Files
PiCloud/dashboard/src/lib/CodeEditor.svelte
MechaCat02 bef4d34c43 feat(dashboard): CodeEditor readOnly prop
Threads readOnly through to EditorState.readOnly + EditorView.editable so
script-detail can render a viewer-only editor without intercepting
keystrokes upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:29:55 +02:00

120 lines
3.5 KiB
Svelte

<!--
CodeMirror-backed text editor for the dashboard.
Replaces our plain <textarea> with line numbers, syntax highlighting,
bracket matching, search/replace (Ctrl+F), and language-aware
autocomplete. Two-way bound via `value`; the parent treats it the
same as a textarea.
Languages: `rhai` (custom mode, ./rhai-mode.ts) and `json`
(@codemirror/lang-json).
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { basicSetup } from 'codemirror';
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { indentWithTab } from '@codemirror/commands';
import { json as jsonLang } from '@codemirror/lang-json';
import { rhai as rhaiLang } from './rhai-mode';
import { dashboardSyntaxHighlighting, dashboardTheme } from './editor-theme';
type Language = 'rhai' | 'json';
let {
value = $bindable(''),
language = 'rhai' as Language,
placeholder = '',
minHeight = '12rem',
readOnly = false
}: {
value?: string;
language?: Language;
placeholder?: string;
minHeight?: string;
/** When true the editor renders without a cursor and rejects
* keystrokes. Parent-driven `value` changes still apply via
* the dispatch path below — this only blocks user edits.
* Not reactive after mount; re-mount via `{#key}` if needed. */
readOnly?: boolean;
} = $props();
let host: HTMLDivElement | null = null;
let view: EditorView | null = null;
// Guard against the update-listener firing while we're pushing an
// external value into the editor — without this, parent-driven
// `value` changes would echo back through the listener and create a
// dispatch loop in Svelte 5 reactivity.
let pushingFromOutside = false;
function buildExtensions(lang: Language) {
const extensions = [
basicSetup,
lang === 'json' ? jsonLang() : rhaiLang(),
keymap.of([indentWithTab]),
dashboardSyntaxHighlighting,
dashboardTheme,
// readOnly + editable together: readOnly blocks the
// underlying transactions, editable suppresses the caret
// + selection visuals so the user can see it's not
// editable.
EditorState.readOnly.of(readOnly),
EditorView.editable.of(!readOnly),
EditorView.updateListener.of((update) => {
if (update.docChanged && !pushingFromOutside) {
value = update.state.doc.toString();
}
})
];
if (placeholder) extensions.push(cmPlaceholder(placeholder));
return extensions;
}
onMount(() => {
if (!host) return;
view = new EditorView({
state: EditorState.create({
doc: value,
extensions: buildExtensions(language)
}),
parent: host
});
});
onDestroy(() => {
view?.destroy();
view = null;
});
// Push parent-driven `value` updates back into the editor (e.g.
// when the script is reloaded after Save, or "Format JSON" rewrites
// the body). We only dispatch when the document genuinely differs
// from the current `value`.
$effect(() => {
if (!view) return;
const current = view.state.doc.toString();
if (current !== value) {
pushingFromOutside = true;
view.dispatch({
changes: { from: 0, to: current.length, insert: value }
});
pushingFromOutside = false;
}
});
</script>
<div bind:this={host} class="cm-host" style:min-height={minHeight}></div>
<style>
.cm-host :global(.cm-editor) {
height: 100%;
border-radius: 0.375rem;
border: 1px solid #334155;
overflow: hidden;
}
.cm-host :global(.cm-scroller) {
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
}
</style>