feat: CodeMirror integration and bug fixes

This commit addresses several issues related to the migration from TipTap to CodeMirror:

- **CodeMirror Initialization Fixes:**
  - Resolved 'Unrecognized extension value' errors by refactoring  to explicitly import individual CodeMirror extensions instead of relying on . This ensures proper singleton usage and prevents module duplication issues.
  - Updated  and  to utilize these individual extensions.

- **Text Wrapping Enabled:**
  - Added  to the extensions in  and  to enable text wrapping in the CodeMirror editors.

- **Content Saving Fixes:**
  - Corrected content binding for CodeMirror editor instances in various IDAA components:
    -  (description, location_text, attend_text)
    -  (content, notes)
    -  (content)
    -  (description, notes)
    -  (description, notes)
  - Ensured that the  prop of  is correctly bound to the respective state variables in the parent components, and these state variables are initialized with existing content.

- **Save Button Enablement:**
  - Fixed an issue in  where the Save button was not enabling on content changes. The  logic now directly compares the  and  with the original object's content, ensuring reactivity.
This commit is contained in:
Scott Idem
2025-11-18 13:27:42 -05:00
parent e521bea448
commit 95412dd0ad
13 changed files with 540 additions and 337 deletions

View File

@@ -0,0 +1,169 @@
let _cmCache: {
EditorView?: any;
EditorState?: any;
basicSetup?: any;
markdown?: any;
markdownLanguage?: any;
keymap?: any;
defaultKeymap?: any;
history?: any;
historyKeymap?: any;
lineNumbers?: any;
highlightSpecialChars?: any;
drawSelection?: any;
dropCursor?: any;
rectangularSelection?: any;
crosshairCursor?: any;
highlightActiveLine?: any;
highlightActiveLineGutter?: any;
indentWithTab?: any;
indentUnit?: any;
autocompletion?: any;
completionKeymap?: any;
closeBrackets?: any;
closeBracketsKeymap?: any;
searchKeymap?: any;
highlightSelectionMatches?: any;
lintKeymap?: any;
EditorState_allowMultipleSelections?: any;
EditorView_lineWrapping?: any;
EditorView_editable?: any;
EditorView_contentAttributes?: any;
bracketMatching?: any;
foldGutter?: any;
foldKeymap?: any;
indentOnInput?: any;
languages?: any;
oneDark?: any;
placeholderExt?: any;
} | null = null;
// ...existing code...
import { browser } from '$app/environment';
type CMCache = {
EditorView: any;
EditorState: any;
basicSetup?: any;
markdown?: any;
markdownLanguage?: any;
keymap?: any;
defaultKeymap?: any;
history?: any;
historyKeymap?: any;
lineNumbers?: any;
highlightSpecialChars?: any;
drawSelection?: any;
dropCursor?: any;
rectangularSelection?: any;
crosshairCursor?: any;
highlightActiveLine?: any;
highlightActiveLineGutter?: any;
indentWithTab?: any;
indentUnit?: any;
autocompletion?: any;
completionKeymap?: any;
closeBrackets?: any;
closeBracketsKeymap?: any;
searchKeymap?: any;
highlightSelectionMatches?: any;
lintKeymap?: any;
EditorState_allowMultipleSelections?: any;
EditorView_lineWrapping?: any;
EditorView_editable?: any;
EditorView_contentAttributes?: any;
bracketMatching?: any;
foldGutter?: any;
foldKeymap?: any;
indentOnInput?: any;
languages?: any;
oneDark?: any;
placeholderExt?: any;
} | null;
const GLOBAL_KEY = '__cm_singleton_modules_v1';
export async function ensureCodeMirrorModules(): Promise<CMCache> {
if (!browser) return null;
// Use a single global object so HMR/multiple module copies reuse same instance
const globalAny = globalThis as any;
if (globalAny[GLOBAL_KEY]) return globalAny[GLOBAL_KEY] as CMCache;
const [
viewMod,
stateMod,
basicSetupMod,
markdownMod,
commandsMod,
languageMod,
autocompleteMod,
searchMod,
lintMod,
themeOneDarkMod
] = await Promise.all([
import('@codemirror/view'),
import('@codemirror/state'),
import('@codemirror/basic-setup'),
import('@codemirror/lang-markdown'),
import('@codemirror/commands'),
import('@codemirror/language'),
import('@codemirror/autocomplete'),
import('@codemirror/search'),
import('@codemirror/lint'),
import('@codemirror/theme-one-dark')
]);
const cache: CMCache = {
EditorView: viewMod.EditorView,
keymap: viewMod.keymap,
lineNumbers: viewMod.lineNumbers,
highlightSpecialChars: viewMod.highlightSpecialChars,
drawSelection: viewMod.drawSelection,
dropCursor: viewMod.dropCursor,
rectangularSelection: viewMod.rectangularSelection,
crosshairCursor: viewMod.crosshairCursor,
highlightActiveLine: viewMod.highlightActiveLine,
highlightActiveLineGutter: viewMod.highlightActiveLineGutter,
EditorView_lineWrapping: viewMod.EditorView.lineWrapping,
EditorView_editable: viewMod.EditorView.editable,
EditorView_contentAttributes: viewMod.EditorView.contentAttributes,
placeholderExt: viewMod.placeholder,
EditorState: stateMod.EditorState,
EditorState_allowMultipleSelections: stateMod.EditorState.allowMultipleSelections,
EditorState_readOnly: stateMod.EditorState.readOnly,
basicSetup: basicSetupMod?.basicSetup,
markdown: markdownMod?.markdown,
markdownLanguage: markdownMod?.markdownLanguage,
languages: languageMod?.languages, // From @codemirror/language-data, often re-exported by @codemirror/language
defaultKeymap: (commandsMod && commandsMod.defaultKeymap) || [],
history: commandsMod?.history,
historyKeymap: commandsMod?.historyKeymap || [],
indentWithTab: commandsMod?.indentWithTab,
indentUnit: languageMod?.indentUnit,
indentOnInput: languageMod?.indentOnInput,
bracketMatching: languageMod?.bracketMatching,
foldGutter: languageMod?.foldGutter,
foldKeymap: languageMod?.foldKeymap,
autocompletion: autocompleteMod?.autocompletion,
completionKeymap: autocompleteMod?.completionKeymap,
closeBrackets: autocompleteMod?.closeBrackets,
closeBracketsKeymap: autocompleteMod?.closeBracketsKeymap,
searchKeymap: searchMod?.searchKeymap,
highlightSelectionMatches: searchMod?.highlightSelectionMatches,
lintKeymap: lintMod?.lintKeymap,
oneDark: themeOneDarkMod?.oneDark
};
globalAny[GLOBAL_KEY] = cache;
return cache;
}

View File

@@ -1,80 +1,130 @@
<script lang="ts">
import CodeMirror from 'svelte-codemirror-editor';
import { EditorView } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { onMount, onDestroy } from 'svelte';
import { browser } from '$app/environment';
import { ensureCodeMirrorModules } from './codemirror_modules';
export let content: string = '';
export let placeholder: string = 'Start typing...';
export let content: string = '';
export let placeholder: string = 'Start typing...';
let editor: EditorView;
let editor_container: HTMLDivElement;
let editor_view: any;
const wrapSelection = (before: string, after: string = before) => {
if (!editor) return;
const state = editor.state;
const changes = state.changeByRange((range) => {
const isAlreadyWrapped =
state.sliceDoc(range.from - before.length, range.from) === before &&
state.sliceDoc(range.to, range.to + after.length) === after;
async function createEditor() {
if (!browser) return;
const cm = await ensureCodeMirrorModules();
if (!cm) return;
if (isAlreadyWrapped) {
return {
changes: [
{ from: range.from - before.length, to: range.from, insert: '' },
{ from: range.to, to: range.to + after.length, insert: '' }
],
range: EditorState.range(range.from - before.length, range.to - before.length)
};
}
// If an existing instance exists (HMR / remount), destroy it first
if (editor_view && typeof editor_view.destroy === 'function') {
try { editor_view.destroy(); } catch (e) { /* ignore */ }
editor_view = null;
}
return {
changes: [
{ from: range.from, insert: before },
{ from: range.to, insert: after }
],
range: EditorState.range(range.from + before.length, range.to + before.length)
};
});
editor.dispatch(changes);
editor.focus();
};
// Build extensions defensively
const extensions = [
cm.lineNumbers(),
cm.highlightSpecialChars(),
cm.history(),
cm.foldGutter(),
cm.drawSelection(),
cm.dropCursor(),
cm.EditorState_allowMultipleSelections.of(true),
cm.indentOnInput(),
cm.bracketMatching(),
cm.closeBrackets(),
cm.autocompletion(),
cm.rectangularSelection(),
cm.crosshairCursor(),
cm.highlightActiveLine(),
cm.highlightActiveLineGutter(),
cm.EditorView_lineWrapping,
cm.keymap.of([
...cm.defaultKeymap,
...cm.searchKeymap,
...cm.historyKeymap,
...cm.foldKeymap,
...cm.completionKeymap,
...cm.lintKeymap
]),
cm.markdown ? cm.markdown({ base: cm.markdownLanguage }) : null,
cm.EditorView && cm.EditorView.updateListener ? cm.EditorView.updateListener.of((update: any) => {
if (update.docChanged) content = update.state.doc.toString();
}) : null
].filter(Boolean);
const insertList = () => {
if (!editor) return;
const state = editor.state;
const changes = state.changeByRange((range) => {
const line = state.doc.lineAt(range.from);
return {
changes: [{ from: line.from, insert: '- ' }],
range: EditorState.range(range.from + 2, range.to + 2)
};
});
editor.dispatch(changes);
editor.focus();
};
// Create editor
editor_view = new cm.EditorView({
state: cm.EditorState.create({
doc: content ?? '',
extensions
}),
parent: editor_container
});
}
onMount(async () => {
// ensure it's created only in browser and after modules resolved
await createEditor();
});
onDestroy(() => {
if (editor_view && typeof editor_view.destroy === 'function') {
try { editor_view.destroy(); } catch (e) { /* ignore */ }
editor_view = null;
}
});
// Helper functions using the live editor_view (unchanged)
const wrapSelection = (before: string, after: string = before) => {
if (!editor_view) return;
const state = editor_view.state;
const changes = state.changeByRange((range: any) => {
const isAlreadyWrapped =
state.sliceDoc(range.from - before.length, range.from) === before &&
state.sliceDoc(range.to, range.to + after.length) === after;
if (isAlreadyWrapped) {
return {
changes: [
{ from: range.from - before.length, to: range.from, insert: '' },
{ from: range.to, to: range.to + after.length, insert: '' }
],
range: (state as any).constructor.range(range.from - before.length, range.to - before.length)
};
}
return {
changes: [
{ from: range.from, insert: before },
{ from: range.to, insert: after }
],
range: (state as any).constructor.range(range.from + before.length, range.to + before.length)
};
});
editor_view.dispatch(changes);
editor_view.focus();
};
const insertList = () => {
if (!editor_view) return;
const state = editor_view.state;
const changes = state.changeByRange((range: any) => {
const line = state.doc.lineAt(range.from);
return {
changes: [{ from: line.from, insert: '- ' }],
range: (state as any).constructor.range(range.from + 2, range.to + 2)
};
});
editor_view.dispatch(changes);
editor_view.focus();
};
</script>
<div class="codemirror-wrapper border rounded">
<div class="toolbar p-1 bg-gray-100 border-b">
<button on:click={() => wrapSelection('**')} class="px-2 py-1 rounded hover:bg-gray-200"
><b>B</b></button
>
<button on:click={() => wrapSelection('*')} class="px-2 py-1 rounded hover:bg-gray-200"
><i>I</i></button
>
<button on:click={insertList} class="px-2 py-1 rounded hover:bg-gray-200">List</button>
</div>
<Codemirror
bind:value={content}
bind:view={editor}
{placeholder}
styles={{
'&': {
height: '100%',
minHeight: '150px'
},
'.cm-scroller': {
overflow: 'auto'
}
}}
/>
</div>
<div class="toolbar p-1 bg-gray-100 border-b">
<button on:click={() => wrapSelection('**')} class="px-2 py-1 rounded hover:bg-gray-200"><b>B</b></button>
<button on:click={() => wrapSelection('*')} class="px-2 py-1 rounded hover:bg-gray-200"><i>I</i></button>
<button on:click={insertList} class="px-2 py-1 rounded hover:bg-gray-200">List</button>
</div>
<div bind:this={editor_container} class="h-full min-h-[150px] overflow-auto"></div>
</div>

View File

@@ -1,15 +1,21 @@
<script lang="ts">
import ElementCodemirrorEditor from './element_codemirror_editor.svelte';
import ElementCodemirrorEditor from './element_codemirror_editor.svelte';
import { browser } from '$app/environment';
export let html_text: string = '';
export let placeholder: string = 'Type your text here...';
export let classes: string = '';
export let html_text: string = '';
export let placeholder: string = 'Type your text here...';
export let classes: string = '';
</script>
<div class="block w-full h-full {classes}">
<ElementCodemirrorEditor
class="p-1 transition-all duration-1000"
bind:content={html_text}
{placeholder}
/>
</div>
{#if browser}
<ElementCodemirrorEditor
class="p-1 transition-all duration-1000"
bind:content={html_text}
{placeholder}
/>
{:else}
<!-- server / prerender placeholder to avoid SSR loading CM -->
<div class="p-2 text-sm text-surface-600-400">Editor (client only)</div>
{/if}
</div>