feat: rich text input

This commit is contained in:
Timothy J. Baek
2024-10-18 14:55:39 -07:00
parent 988a5e2b8d
commit 670441f548
5 changed files with 615 additions and 137 deletions

View File

@@ -0,0 +1,211 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { EditorState, Plugin } from 'prosemirror-state';
import { EditorView, Decoration, DecorationSet } from 'prosemirror-view';
import { undo, redo, history } from 'prosemirror-history';
import { schema, defaultMarkdownParser, defaultMarkdownSerializer } from 'prosemirror-markdown';
import {
inputRules,
wrappingInputRule,
textblockTypeInputRule,
InputRule
} from 'prosemirror-inputrules'; // Import input rules
import { splitListItem, liftListItem, sinkListItem } from 'prosemirror-schema-list'; // Import from prosemirror-schema-list
import { keymap } from 'prosemirror-keymap';
import { baseKeymap, chainCommands } from 'prosemirror-commands';
import { DOMParser, DOMSerializer, Schema } from 'prosemirror-model';
import { marked } from 'marked'; // Import marked for markdown parsing
export let className = 'input-prose';
export let value = '';
export let placeholder = 'Type here...';
let element: HTMLElement; // Element where ProseMirror will attach
let state;
let view;
// Plugin to add placeholder when the content is empty
function placeholderPlugin(placeholder: string) {
return new Plugin({
props: {
decorations(state) {
const doc = state.doc;
if (
doc.childCount === 1 &&
doc.firstChild.isTextblock &&
doc.firstChild?.textContent === ''
) {
// If there's nothing in the editor, show the placeholder decoration
const decoration = Decoration.node(0, doc.content.size, {
'data-placeholder': placeholder,
class: 'placeholder'
});
return DecorationSet.create(doc, [decoration]);
}
return DecorationSet.empty;
}
}
});
}
// Method to convert markdown content to ProseMirror-compatible document
function markdownToProseMirrorDoc(markdown: string) {
return defaultMarkdownParser.parse(value || '');
}
// Utility function to convert ProseMirror content back to markdown text
function serializeEditorContent(doc) {
return defaultMarkdownSerializer.serialize(doc);
}
// ---- Input Rules ----
// Input rule for heading (e.g., # Headings)
function headingRule(schema) {
return textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading, (match) => ({
level: match[1].length
}));
}
// Input rule for bullet list (e.g., `- item`)
function bulletListRule(schema) {
return wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list);
}
// Input rule for ordered list (e.g., `1. item`)
function orderedListRule(schema) {
return wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, (match) => ({
order: +match[1]
}));
}
// Custom input rules for Bold/Italic (using * or _)
function markInputRule(regexp: RegExp, markType: any) {
return new InputRule(regexp, (state, match, start, end) => {
const { tr } = state;
if (match) {
tr.replaceWith(start, end, schema.text(match[1], [markType.create()]));
}
return tr;
});
}
function boldRule(schema) {
return markInputRule(/\*([^*]+)\*/, schema.marks.strong);
}
function italicRule(schema) {
return markInputRule(/\_([^*]+)\_/, schema.marks.em);
}
// Initialize Editor State and View
function isInList(state) {
const { $from } = state.selection;
return (
$from.parent.type === schema.nodes.paragraph && $from.node(-1).type === schema.nodes.list_item
);
}
function isEmptyListItem(state) {
const { $from } = state.selection;
return isInList(state) && $from.parent.content.size === 0 && $from.node(-1).childCount === 1;
}
function exitList(state, dispatch) {
return liftListItem(schema.nodes.list_item)(state, dispatch);
}
onMount(() => {
const initialDoc = markdownToProseMirrorDoc(value || ''); // Convert the initial content
// const initialDoc =
state = EditorState.create({
doc: initialDoc,
schema,
plugins: [
history(),
placeholderPlugin(placeholder),
inputRules({
rules: [
headingRule(schema), // Handle markdown-style headings (# H1, ## H2, etc.)
bulletListRule(schema), // Handle `-` or `*` input to start bullet list
orderedListRule(schema), // Handle `1.` input to start ordered list
boldRule(schema), // Bold input rule
italicRule(schema) // Italic input rule
]
}),
keymap({
...baseKeymap,
'Mod-z': undo,
'Mod-y': redo,
Enter: chainCommands(
(state, dispatch, view) => {
if (isEmptyListItem(state)) {
return exitList(state, dispatch);
}
return false;
},
(state, dispatch, view) => {
if (isInList(state)) {
return splitListItem(schema.nodes.list_item)(state, dispatch);
}
return false;
},
baseKeymap.Enter
),
// Prevent default tab navigation and provide indent/outdent behavior inside lists:
Tab: (state, dispatch, view) => {
const { $from } = state.selection;
console.log('Tab key pressed', $from.parent, $from.parent.type);
if (isInList(state)) {
return sinkListItem(schema.nodes.list_item)(state, dispatch);
}
return true; // Prevent Tab from moving the focus
},
'Shift-Tab': (state, dispatch, view) => {
const { $from } = state.selection;
console.log('Shift-Tab key pressed', $from.parent, $from.parent.type);
if (isInList(state)) {
return liftListItem(schema.nodes.list_item)(state, dispatch);
}
return true; // Prevent Shift-Tab from moving the focus
}
})
]
});
view = new EditorView(element, {
state,
dispatchTransaction(transaction) {
// Update editor state
let newState = view.state.apply(transaction);
view.updateState(newState);
value = serializeEditorContent(newState.doc); // Convert ProseMirror content to markdown text
}
});
});
// Reinitialize the editor if the value is externally changed (i.e. when `value` is updated)
$: if (view && value !== serializeEditorContent(view.state.doc)) {
const newDoc = markdownToProseMirrorDoc(value || '');
const newState = EditorState.create({
doc: newDoc,
schema,
plugins: view.state.plugins
});
view.updateState(newState);
}
// Destroy ProseMirror instance on unmount
onDestroy(() => {
view?.destroy();
});
</script>
<div bind:this={element} class="relative w-full h-full {className}"></div>

View File

@@ -21,20 +21,19 @@
updateKnowledgeById
} from '$lib/apis/knowledge';
import Spinner from '$lib/components/common/Spinner.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Files from './Collection/Files.svelte';
import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte';
import AddContentModal from './Collection/AddTextContentModal.svelte';
import { transcribeAudio } from '$lib/apis/audio';
import { blobToFile } from '$lib/utils';
import { processFile } from '$lib/apis/retrieval';
import Spinner from '$lib/components/common/Spinner.svelte';
import Files from './Collection/Files.svelte';
import AddFilesPlaceholder from '$lib/components/AddFilesPlaceholder.svelte';
import AddContentMenu from './Collection/AddContentMenu.svelte';
import AddTextContentModal from './Collection/AddTextContentModal.svelte';
import SyncConfirmDialog from '../../common/ConfirmDialog.svelte';
import RichTextInput from '$lib/components/common/RichTextInput.svelte';
let largeScreen = true;
type Knowledge = {
@@ -552,157 +551,159 @@
}}
/>
<div class="flex flex-col w-full max-h-[100dvh] h-full">
<div class="flex flex-col mb-2 flex-1 overflow-auto h-0">
{#if id && knowledge}
<div class="flex flex-row h-0 flex-1 overflow-auto">
<div
class=" {largeScreen
? 'flex-shrink-0'
: 'flex-1'} flex py-2.5 w-80 rounded-2xl border border-gray-50 dark:border-gray-850"
>
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
<div class="w-full h-full flex flex-col">
<div class=" px-3">
<div class="flex">
<div class=" self-center ml-1 mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Collection')}
on:focus={() => {
selectedFileId = null;
<div class="flex flex-col w-full h-full max-h-[100dvh]">
{#if id && knowledge}
<div class="flex flex-row flex-1 h-full max-h-full pb-2.5">
<div
class=" {largeScreen
? 'flex-shrink-0'
: 'flex-1'} flex py-2.5 w-80 rounded-2xl border border-gray-50 dark:border-gray-850"
>
<div class=" flex flex-col w-full space-x-2 rounded-lg h-full">
<div class="w-full h-full flex flex-col">
<div class=" px-3">
<div class="flex">
<div class=" self-center ml-1 mr-3">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4"
>
<path
fill-rule="evenodd"
d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-none bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Collection')}
on:focus={() => {
selectedFileId = null;
}}
/>
<div>
<AddContentMenu
on:upload={(e) => {
if (e.detail.type === 'directory') {
uploadDirectoryHandler();
} else if (e.detail.type === 'text') {
showAddTextContentModal = true;
} else {
document.getElementById('files-input').click();
}
}}
on:sync={(e) => {
showSyncConfirmModal = true;
}}
/>
<div>
<AddContentMenu
on:upload={(e) => {
if (e.detail.type === 'directory') {
uploadDirectoryHandler();
} else if (e.detail.type === 'text') {
showAddTextContentModal = true;
} else {
document.getElementById('files-input').click();
}
}}
on:sync={(e) => {
showSyncConfirmModal = true;
}}
/>
</div>
</div>
<hr class=" mt-2 mb-1 border-gray-50 dark:border-gray-850" />
</div>
{#if filteredItems.length > 0}
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files
files={filteredItems}
{selectedFileId}
on:click={(e) => {
selectedFileId = selectedFileId === e.detail ? null : e.detail;
}}
on:delete={(e) => {
console.log(e.detail);
selectedFileId = null;
deleteFileHandler(e.detail);
}}
/>
</div>
{:else}
<div class="m-auto text-gray-500 text-xs">{$i18n.t('No content found')}</div>
{/if}
<hr class=" mt-2 mb-1 border-gray-50 dark:border-gray-850" />
</div>
{#if filteredItems.length > 0}
<div class=" flex overflow-y-auto h-full w-full scrollbar-hidden text-xs">
<Files
files={filteredItems}
{selectedFileId}
on:click={(e) => {
selectedFileId = selectedFileId === e.detail ? null : e.detail;
}}
on:delete={(e) => {
console.log(e.detail);
selectedFileId = null;
deleteFileHandler(e.detail);
}}
/>
</div>
{:else}
<div class="m-auto text-gray-500 text-xs">{$i18n.t('No content found')}</div>
{/if}
</div>
</div>
</div>
{#if largeScreen}
<div class="flex-1 flex justify-start max-h-full overflow-hidden pl-3">
{#if selectedFile}
<div class=" flex flex-col w-full h-full">
<div class=" flex-shrink-0 mb-2 flex items-center">
<div class=" flex-1 text-xl line-clamp-1">
{selectedFile?.meta?.name}
</div>
<div>
<button
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
on:click={() => {
updateFileContentHandler();
}}
>
{$i18n.t('Save')}
</button>
</div>
{#if largeScreen}
<div class="flex-1 flex justify-start h-full max-h-full pl-3">
{#if selectedFile}
<div class=" flex flex-col w-full h-full max-h-full">
<div class="flex-shrink-0 mb-2 flex items-center">
<div class=" flex-1 text-xl line-clamp-1">
{selectedFile?.meta?.name}
</div>
<div class=" flex-grow">
<textarea
class=" w-full h-full resize-none rounded-xl py-4 px-4 text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none"
<div>
<button
class="self-center w-fit text-sm py-1 px-2.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-lg"
on:click={() => {
updateFileContentHandler();
}}
>
{$i18n.t('Save')}
</button>
</div>
</div>
<div
class=" flex-1 w-full h-full max-h-full py-2.5 px-3.5 rounded-xl text-sm bg-gray-50 dark:text-gray-300 dark:bg-gray-850 outline-none overflow-y-auto scrollbar-hidden"
>
{#key selectedFile.id}
<RichTextInput
className="input-prose-sm"
bind:value={selectedFile.data.content}
placeholder={$i18n.t('Add content here')}
/>
</div>
{/key}
</div>
{:else}
<div class="m-auto pb-32">
<div>
<div class=" flex w-full mt-1 mb-3.5">
<div class="flex-1">
<div class="flex items-center justify-between w-full px-0.5 mb-1">
<div class="w-full">
<input
type="text"
class="text-center w-full font-medium text-3xl font-primary bg-transparent outline-none"
bind:value={knowledge.name}
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
</div>
<div class="flex w-full px-1">
</div>
{:else}
<div class="m-auto pb-32">
<div>
<div class=" flex w-full mt-1 mb-3.5">
<div class="flex-1">
<div class="flex items-center justify-between w-full px-0.5 mb-1">
<div class="w-full">
<input
type="text"
class="text-center w-full text-gray-500 bg-transparent outline-none"
bind:value={knowledge.description}
class="text-center w-full font-medium text-3xl font-primary bg-transparent outline-none"
bind:value={knowledge.name}
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
</div>
<div class="flex w-full px-1">
<input
type="text"
class="text-center w-full text-gray-500 bg-transparent outline-none"
bind:value={knowledge.description}
on:input={() => {
changeDebounceHandler();
}}
/>
</div>
</div>
</div>
<div class=" mt-2 text-center text-sm text-gray-200 dark:text-gray-700 w-full">
{$i18n.t('Select a file to view or drag and drop a file to upload')}
</div>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<Spinner />
{/if}
</div>
<div class=" mt-2 text-center text-sm text-gray-200 dark:text-gray-700 w-full">
{$i18n.t('Select a file to view or drag and drop a file to upload')}
</div>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<Spinner />
{/if}
</div>