Replace textarea with CodeMirror 6 editor

- Add CodeMirror 6 with oneDark theme and markdown language support
- Bundle via esbuild into public/vendor/ (built on deploy, not committed)
- Add codemirror-entry.mjs as bundle entry point
- Fix SPA fallback to only serve index.html for /, not all paths
- Fix mime type handling by mutating mime-types registry
- Add Write/Preview tabs; preview renders on tab switch only
- Multi-cursor via Shift+Alt+Up/Down; Alt+Click adds cursor
- gitignore package-lock.json and public/vendor/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 15:08:36 +00:00
parent dbef6605c9
commit 6058c22b6c
8 changed files with 100 additions and 897 deletions

View File

@@ -1,5 +1,21 @@
import * as api from './lib/api.mjs';
import { qs, clone, set_text, show, hide } from './lib/dom.mjs';
import {
EditorState,
EditorView,
keymap,
placeholder,
drawSelection,
defaultKeymap,
history as cm_history,
historyKeymap,
indentWithTab,
addCursorAbove,
addCursorBelow,
markdown,
syntaxHighlighting,
oneDark,
} from '/vendor/codemirror-bundle.mjs';
// ---------------------------------------------------------------------------
// State
@@ -818,11 +834,42 @@ function render_edit_view(container) {
tab_bar.appendChild(write_tab);
tab_bar.appendChild(preview_tab);
// Textarea
const textarea = document.createElement('textarea');
textarea.className = 'edit-textarea';
textarea.value = entry?.body ?? '';
textarea.placeholder = 'Body (markdown)';
// CodeMirror editor
const cm_host = document.createElement('div');
cm_host.className = 'edit-cm-host';
const cm_editor = new EditorView({
state: EditorState.create({
doc: entry?.body ?? '',
extensions: [
cm_history(),
drawSelection(),
EditorState.allowMultipleSelections.of(true),
EditorView.clickAddsSelectionRange.of(e => e.altKey),
keymap.of([
{ key: 'Shift-Alt-ArrowUp', run: addCursorAbove },
{ key: 'Shift-Alt-ArrowDown', run: addCursorBelow },
...defaultKeymap,
...historyKeymap,
indentWithTab,
{ key: 'Ctrl-s', mac: 'Cmd-s', run: () => { save_btn.click(); return true; } },
]),
EditorView.lineWrapping,
placeholder('Body (markdown)'),
markdown(),
oneDark,
EditorView.theme({
'&': { height: '100%' },
'.cm-editor.cm-focused': { outline: 'none' },
'.cm-gutters': { display: 'none' },
'.cm-placeholder': { fontStyle: 'italic' },
}),
],
}),
parent: cm_host,
});
function get_editor_value() { return cm_editor.state.doc.toString(); }
// Preview pane
const preview_el = document.createElement('div');
@@ -832,17 +879,17 @@ function render_edit_view(container) {
write_tab.addEventListener('click', () => {
write_tab.classList.add('active');
preview_tab.classList.remove('active');
textarea.hidden = false;
cm_host.hidden = false;
preview_el.hidden = true;
textarea.focus();
cm_editor.focus();
});
preview_tab.addEventListener('click', () => {
preview_tab.classList.add('active');
write_tab.classList.remove('active');
textarea.hidden = true;
cm_host.hidden = true;
preview_el.hidden = false;
const text = textarea.value;
const text = get_editor_value();
if (!text.trim()) {
preview_el.innerHTML = '<em style="color:#555">Nothing to preview.</em>';
} else {
@@ -851,20 +898,10 @@ function render_edit_view(container) {
}
});
// Ctrl+S to save
// Ctrl+S on title input
title_input.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save_btn.click(); }
});
textarea.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save_btn.click(); }
if (e.key === 'Tab') {
e.preventDefault();
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
textarea.value = textarea.value.slice(0, start) + '\t' + textarea.value.slice(end);
textarea.selectionStart = textarea.selectionEnd = start + 1;
}
});
// --- Sidebar ---
const sidebar = document.createElement('div');
@@ -985,7 +1022,7 @@ function render_edit_view(container) {
const editor_pane = document.createElement('div');
editor_pane.className = 'edit-editor-pane';
editor_pane.appendChild(tab_bar);
editor_pane.appendChild(textarea);
editor_pane.appendChild(cm_host);
editor_pane.appendChild(preview_el);
body_area.appendChild(editor_pane);
@@ -994,14 +1031,14 @@ function render_edit_view(container) {
container.appendChild(view);
if (!title_input.value) { title_input.focus(); }
else { textarea.focus(); }
else { cm_editor.focus(); }
// Save handler
save_btn.addEventListener('click', async () => {
const data = {
type: ae.mode === 'new' ? (type_el.value ?? ae.type) : entry.type,
title: title_input.value.trim(),
body: textarea.value.trim(),
body: get_editor_value().trim(),
status: status_sel.value,
priority: priority_sel.value,
tags: tags_input.value.split(',').map(s => s.trim()).filter(Boolean),

View File

@@ -700,23 +700,19 @@ main.edit-mode {
.edit-tab:hover { color: #aaa; background: transparent; }
.edit-tab.active { color: #e0e0e0; border-bottom-color: #5588e0; background: transparent; }
.edit-textarea {
.edit-cm-host {
flex: 1;
resize: none;
padding: 1rem;
background: #111;
border: none;
color: #d0d0d0;
font-size: 13px;
font-family: monospace;
line-height: 1.6;
overflow-y: auto;
overflow: hidden;
min-height: 0;
}
.edit-textarea:focus {
outline: none;
background: #141414;
.edit-cm-host .cm-editor {
height: 100%;
}
.edit-cm-host .cm-scroller {
overflow: auto;
height: 100%;
}
.edit-preview {