Files
buboard/src/lib/Editor.svelte
2025-12-01 17:42:42 +01:00

259 lines
6.9 KiB
Svelte

<script lang="ts">
import { Code, Palette, X, Globe, Layers } from 'lucide-svelte';
import { onMount, onDestroy } from 'svelte';
import { EditorState, Compartment } from '@codemirror/state';
import { EditorView, keymap } from '@codemirror/view';
import { html } from '@codemirror/lang-html';
import { css } from '@codemirror/lang-css';
import { defaultKeymap } from '@codemirror/commands';
import { vim } from '@replit/codemirror-vim';
import { emacs } from '@replit/codemirror-emacs';
import { state as appState } from './state.svelte';
import { createTheme } from './theme';
import type { Item } from './types';
type EditMode = 'normal' | 'vim' | 'emacs';
const themeCompartment = new Compartment();
const keymapCompartment = new Compartment();
let { mode = 'item', item, onClose }: { mode?: 'item' | 'global'; item?: Item; onClose: () => void } = $props();
let activeTab = $state<'html' | 'css' | 'shared' | 'app'>(mode === 'item' ? 'html' : 'shared');
let editMode = $state<EditMode>('normal');
function getKeymapExtension(m: EditMode) {
switch (m) {
case 'vim':
return vim();
case 'emacs':
return emacs();
default:
return [];
}
}
function cycleEditMode() {
const modes: EditMode[] = ['normal', 'vim', 'emacs'];
const idx = modes.indexOf(editMode);
editMode = modes[(idx + 1) % modes.length];
}
let htmlContainer: HTMLDivElement;
let cssContainer: HTMLDivElement;
let sharedContainer: HTMLDivElement;
let appContainer: HTMLDivElement;
let htmlEditor: EditorView | null = null;
let cssEditor: EditorView | null = null;
let sharedEditor: EditorView | null = null;
let appEditor: EditorView | null = null;
function createItemEditor(
container: HTMLDivElement,
lang: 'html' | 'css',
initialValue: string
): EditorView {
const extensions = [
keymapCompartment.of(getKeymapExtension(editMode)),
keymap.of(defaultKeymap),
themeCompartment.of(createTheme()),
lang === 'html' ? html() : css(),
EditorView.updateListener.of((update) => {
if (update.docChanged && item) {
const value = update.state.doc.toString();
if (lang === 'html') {
appState.updateItem(item.id, { html: value });
} else {
appState.updateItem(item.id, { css: value });
}
}
})
];
return new EditorView({
state: EditorState.create({ doc: initialValue, extensions }),
parent: container
});
}
function createGlobalEditor(
container: HTMLDivElement,
type: 'shared' | 'app',
initialValue: string
): EditorView {
const extensions = [
keymapCompartment.of(getKeymapExtension(editMode)),
keymap.of(defaultKeymap),
themeCompartment.of(createTheme()),
css(),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const value = update.state.doc.toString();
if (type === 'shared') {
appState.updateSharedCss(value);
} else {
appState.updateAppCss(value);
}
}
})
];
return new EditorView({
state: EditorState.create({ doc: initialValue, extensions }),
parent: container
});
}
onMount(() => {
if (mode === 'item' && item) {
if (htmlContainer) {
htmlEditor = createItemEditor(htmlContainer, 'html', item.html);
}
if (cssContainer) {
cssEditor = createItemEditor(cssContainer, 'css', item.css);
}
}
if (sharedContainer) {
sharedEditor = createGlobalEditor(sharedContainer, 'shared', appState.manifest.sharedCss);
}
if (appContainer) {
appEditor = createGlobalEditor(appContainer, 'app', appState.manifest.appCss);
}
});
onDestroy(() => {
htmlEditor?.destroy();
cssEditor?.destroy();
sharedEditor?.destroy();
appEditor?.destroy();
});
let previousAppCss = $state(appState.manifest.appCss);
$effect(() => {
const currentAppCss = appState.manifest.appCss;
if (currentAppCss !== previousAppCss) {
previousAppCss = currentAppCss;
setTimeout(() => {
const newTheme = createTheme();
htmlEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
cssEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
sharedEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
appEditor?.dispatch({ effects: themeCompartment.reconfigure(newTheme) });
}, 50);
}
});
$effect(() => {
const ext = getKeymapExtension(editMode);
htmlEditor?.dispatch({ effects: keymapCompartment.reconfigure(ext) });
cssEditor?.dispatch({ effects: keymapCompartment.reconfigure(ext) });
sharedEditor?.dispatch({ effects: keymapCompartment.reconfigure(ext) });
appEditor?.dispatch({ effects: keymapCompartment.reconfigure(ext) });
});
</script>
<div class="panel">
<div class="header">
<div class="tabs">
{#if mode === 'item'}
<button class:active={activeTab === 'html'} onclick={() => (activeTab = 'html')} title="HTML"><Code size={12} /></button>
<button class:active={activeTab === 'css'} onclick={() => (activeTab = 'css')} title="Item CSS"><Palette size={12} /></button>
{/if}
<button class:active={activeTab === 'shared'} onclick={() => (activeTab = 'shared')} title="Shared CSS"><Layers size={12} /></button>
<button class:active={activeTab === 'app'} onclick={() => (activeTab = 'app')} title="App CSS"><Globe size={12} /></button>
</div>
<button class="mode" onclick={cycleEditMode} title="Editor mode: {editMode}">{editMode}</button>
<button class="close" onclick={onClose} title="Close"><X size={12} /></button>
</div>
<div class="editor-container">
{#if mode === 'item'}
<div class="editor" class:hidden={activeTab !== 'html'} bind:this={htmlContainer}></div>
<div class="editor" class:hidden={activeTab !== 'css'} bind:this={cssContainer}></div>
{/if}
<div class="editor" class:hidden={activeTab !== 'shared'} bind:this={sharedContainer}></div>
<div class="editor" class:hidden={activeTab !== 'app'} bind:this={appContainer}></div>
</div>
</div>
<style>
.panel {
width: 100%;
height: 100%;
background: var(--surface, #282c34);
display: flex;
flex-direction: column;
border-left: 1px solid var(--border, #333);
}
.header {
display: flex;
background: var(--surface, #282c34);
border-bottom: 1px solid var(--border, #333);
flex-shrink: 0;
}
.tabs {
display: flex;
flex: 1;
}
.tabs button {
padding: 6px 10px;
background: transparent;
border: none;
color: var(--text-dim, #666);
cursor: pointer;
}
.tabs button:hover {
color: var(--text, #fff);
}
.tabs button.active {
color: var(--accent, #4a9eff);
}
.mode {
padding: 6px 8px;
background: transparent;
border: none;
color: var(--text-dim, #666);
cursor: pointer;
font-size: 10px;
text-transform: uppercase;
}
.mode:hover {
color: var(--text, #fff);
}
.close {
padding: 6px 8px;
background: transparent;
border: none;
color: var(--text-dim, #666);
cursor: pointer;
}
.close:hover {
color: var(--text, #fff);
}
.editor-container {
flex: 1;
position: relative;
overflow: hidden;
}
.editor {
position: absolute;
inset: 0;
}
.editor.hidden {
display: none;
}
</style>