242 lines
8.5 KiB
Svelte
242 lines
8.5 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* AE_Comp_Editor_CodeMirror.svelte
|
|
* Consolidated CodeMirror 6 Editor for Aether Platform.
|
|
* Combines technical power with a user-friendly Markdown toolbar.
|
|
* Uses strictly snake_case and Svelte 5 Runes.
|
|
*/
|
|
import { onMount, onDestroy, untrack } from 'svelte';
|
|
import { browser } from '$app/environment';
|
|
import { ensureCodeMirrorModules } from './codemirror_modules';
|
|
import type { key_val } from '$lib/stores/ae_stores';
|
|
|
|
// Icons (Standardized to Lucide where possible, or FontAwesome placeholders)
|
|
import { Bold, Italic, List, Code, Maximize2 } from 'lucide-svelte';
|
|
|
|
interface Props {
|
|
content?: string;
|
|
new_content?: string;
|
|
placeholder?: string;
|
|
theme_mode?: 'light' | 'dark';
|
|
language?: 'markdown' | 'json' | 'html' | 'javascript';
|
|
readonly?: boolean;
|
|
show_line_numbers?: boolean;
|
|
wrap_lines?: boolean;
|
|
show_toolbar?: boolean;
|
|
editor_view?: any; // $bindable for external control
|
|
class_li?: string;
|
|
}
|
|
|
|
let {
|
|
content = $bindable(''),
|
|
new_content = $bindable(''),
|
|
placeholder = 'Start typing...',
|
|
theme_mode = 'light',
|
|
language = 'markdown',
|
|
readonly = false,
|
|
show_line_numbers = false,
|
|
wrap_lines = true,
|
|
show_toolbar = true,
|
|
editor_view = $bindable(),
|
|
class_li = ''
|
|
}: Props = $props();
|
|
|
|
let editor_container: HTMLDivElement | undefined = $state();
|
|
let cm: any = $state(); // CodeMirror modules cache
|
|
|
|
async function create_editor() {
|
|
if (!browser) return;
|
|
|
|
cm = await ensureCodeMirrorModules();
|
|
if (!cm) return;
|
|
|
|
// Cleanup existing instance if HMR or remount occurs
|
|
if (editor_view) {
|
|
editor_view.destroy();
|
|
editor_view = null;
|
|
}
|
|
|
|
const extensions = [
|
|
cm.highlightSpecialChars(),
|
|
cm.history(),
|
|
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(),
|
|
|
|
// Keymaps
|
|
cm.keymap.of([
|
|
...cm.defaultKeymap,
|
|
...cm.searchKeymap,
|
|
...cm.historyKeymap,
|
|
...cm.foldKeymap,
|
|
...cm.completionKeymap,
|
|
...cm.lintKeymap
|
|
]),
|
|
|
|
// Language Support
|
|
language === 'markdown' ? cm.markdown({ base: cm.markdownLanguage }) : null,
|
|
language === 'json' ? cm.languages.find((l: any) => l.name === 'json')?.load() : null,
|
|
|
|
// Theme & Behavior
|
|
theme_mode === 'dark' ? cm.oneDark : cm.EditorView.baseTheme(),
|
|
cm.EditorView.contentAttributes.of({ spellcheck: 'true' }),
|
|
readonly ? cm.EditorState.readOnly.of(true) : cm.EditorView.editable.of(true),
|
|
show_line_numbers ? cm.lineNumbers() : null,
|
|
wrap_lines ? cm.EditorView_lineWrapping : null,
|
|
placeholder ? cm.placeholderExt(placeholder) : null,
|
|
|
|
// Sync back to Svelte
|
|
cm.EditorView.updateListener.of((update: any) => {
|
|
if (update.docChanged) {
|
|
const doc_str = update.state.doc.toString();
|
|
content = doc_str;
|
|
new_content = doc_str;
|
|
}
|
|
})
|
|
].filter(Boolean);
|
|
|
|
if (!editor_container) return;
|
|
|
|
editor_view = new cm.EditorView({
|
|
state: cm.EditorState.create({
|
|
doc: content ?? '',
|
|
extensions
|
|
}),
|
|
parent: editor_container as HTMLElement
|
|
});
|
|
}
|
|
|
|
// Initialize on mount
|
|
onMount(async () => {
|
|
await create_editor();
|
|
});
|
|
|
|
// Cleanup on destroy
|
|
onDestroy(() => {
|
|
if (editor_view) editor_view.destroy();
|
|
});
|
|
|
|
// Reactive update if content is changed from outside (e.g. API load)
|
|
$effect(() => {
|
|
if (editor_view && content !== editor_view.state.doc.toString()) {
|
|
untrack(() => {
|
|
editor_view.dispatch({
|
|
changes: { from: 0, to: editor_view.state.doc.length, insert: content || '' }
|
|
});
|
|
});
|
|
}
|
|
});
|
|
|
|
// *** Toolbar Helpers
|
|
const wrap_selection = (before: string, after: string = before) => {
|
|
if (!editor_view) return;
|
|
const state = editor_view.state;
|
|
const changes = state.changeByRange((range: any) => {
|
|
const is_wrapped =
|
|
state.sliceDoc(range.from - before.length, range.from) === before &&
|
|
state.sliceDoc(range.to, range.to + after.length) === after;
|
|
|
|
if (is_wrapped) {
|
|
return {
|
|
changes: [
|
|
{ from: range.from - before.length, to: range.from, insert: '' },
|
|
{ from: range.to, to: range.to + after.length, insert: '' }
|
|
],
|
|
range: cm.EditorSelection.range(
|
|
range.from - before.length,
|
|
range.to - before.length
|
|
)
|
|
};
|
|
}
|
|
|
|
return {
|
|
changes: [
|
|
{ from: range.from, insert: before },
|
|
{ from: range.to, insert: after }
|
|
],
|
|
range: cm.EditorSelection.range(
|
|
range.from + before.length,
|
|
range.to + before.length
|
|
)
|
|
};
|
|
});
|
|
editor_view.dispatch(changes);
|
|
editor_view.focus();
|
|
};
|
|
|
|
const toggle_list = () => {
|
|
if (!editor_view) return;
|
|
const state = editor_view.state;
|
|
const changes = state.changeByRange((range: any) => {
|
|
const line = state.doc.lineAt(range.from);
|
|
const has_list = line.text.startsWith('- ');
|
|
|
|
if (has_list) {
|
|
return {
|
|
changes: [{ from: line.from, to: line.from + 2, insert: '' }],
|
|
range: cm.EditorSelection.range(range.from - 2, range.to - 2)
|
|
};
|
|
}
|
|
|
|
return {
|
|
changes: [{ from: line.from, insert: '- ' }],
|
|
range: cm.EditorSelection.range(range.from + 2, range.to + 2)
|
|
};
|
|
});
|
|
editor_view.dispatch(changes);
|
|
editor_view.focus();
|
|
};
|
|
</script>
|
|
|
|
<div class="ae__comp__codemirror_editor flex flex-col border border-surface-500/20 rounded-container overflow-hidden h-full {class_li}">
|
|
{#if show_toolbar && !readonly}
|
|
<div class="toolbar flex flex-wrap gap-1 p-1 bg-surface-50 dark:bg-surface-900 border-b border-surface-500/20">
|
|
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={() => wrap_selection('**')} title="Bold">
|
|
<Bold size="14" />
|
|
</button>
|
|
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={() => wrap_selection('*')} title="Italic">
|
|
<Italic size="14" />
|
|
</button>
|
|
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={toggle_list} title="Bullet List">
|
|
<List size="14" />
|
|
</button>
|
|
<button type="button" class="btn btn-sm variant-soft hover:variant-filled-primary" onclick={() => wrap_selection('`')} title="Inline Code">
|
|
<Code size="14" />
|
|
</button>
|
|
|
|
<div class="ml-auto flex gap-1">
|
|
<span class="text-[10px] opacity-50 self-center uppercase font-bold mr-2">{language}</span>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div
|
|
bind:this={editor_container}
|
|
class="grow overflow-auto bg-white dark:bg-black/20"
|
|
style="min-height: 100px;"
|
|
></div>
|
|
</div>
|
|
|
|
<style lang="postcss">
|
|
.ae__comp__codemirror_editor :global(.cm-editor) {
|
|
height: 100%;
|
|
outline: none !important;
|
|
}
|
|
.ae__comp__codemirror_editor :global(.cm-scroller) {
|
|
font-family: theme('fontFamily.mono');
|
|
font-size: 0.875rem;
|
|
}
|
|
/* Hide the focus outline from CM6 default theme */
|
|
.ae__comp__codemirror_editor :global(.cm-focused) {
|
|
outline: none !important;
|
|
}
|
|
</style>
|