dsh-tiddlywiki 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-tiddlywiki",
3
3
  "description": "TiddlyWiki 5 as the DSH persistent knowledge base: tiddlywiki_* agent tools (search/get/put/delete + git sync), a full TiddlyWiki editor embedded in the GUI center column, a floating quick-note widget in the chat area, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.3.1",
4
+ "version": "0.4.0",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -60,6 +60,12 @@
60
60
  "tiddlywiki": "^5.4.1"
61
61
  },
62
62
  "devDependencies": {
63
+ "@codemirror/commands": "^6.11.0",
64
+ "@codemirror/lang-markdown": "^6.5.2",
65
+ "@codemirror/language": "^6.12.4",
66
+ "@codemirror/state": "^6.7.2",
67
+ "@codemirror/view": "^6.43.10",
68
+ "@lezer/highlight": "^1.2.3",
63
69
  "@types/node": "^22.20.1",
64
70
  "@types/react": "^19.1.0",
65
71
  "react": "^19.1.0",
@@ -0,0 +1,123 @@
1
+ /**
2
+ * CodeMirror 6 Markdown editor for the quick-note widget (replaces the
3
+ * hand-rolled regex highlighter in the old markdown.ts — real Lezer syntax
4
+ * tree, proper GFM coverage, undo/redo, and a native editing surface).
5
+ *
6
+ * The widget stays a plain-DOM product: CodeMirror is framework-agnostic, so
7
+ * no React is pulled in. The editor is built into a wrapper `<div>`
8
+ * (`.dsh-tw-note-editor`) that the widget can append, drag-drop onto, and
9
+ * re-read via `getValue()`/`setValue()`.
10
+ *
11
+ * Theming follows the DSH design tokens (`--dsw-alias-*`) through a
12
+ * `HighlightStyle` that maps Markdown tags onto the same palette the old
13
+ * `.md-*` rules used, so light/dark both look right.
14
+ *
15
+ * @module dsh-tiddlywiki/client/markdown-editor
16
+ */
17
+ import { EditorView, keymap, placeholder, drawSelection } from '@codemirror/view'
18
+ import { EditorState } from '@codemirror/state'
19
+ import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
20
+ import { syntaxHighlighting, HighlightStyle } from '@codemirror/language'
21
+ import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
22
+ import { tags as t } from '@lezer/highlight'
23
+
24
+ /** Design-token aliases (same values as styles.ts / the old .md-* rules). */
25
+ const BRAND = 'var(--dsw-alias-brand-primary, #3e63dd)'
26
+ const BRAND_DIM = 'color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 55%, transparent)'
27
+ const SECONDARY = 'var(--dsw-alias-label-secondary, #888)'
28
+ const DIMMED = 'var(--dsw-alias-label-dimmed, #999)'
29
+ const MONO = 'ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace'
30
+ const CODE_BG = 'color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 12%, transparent)'
31
+ const CODE_BLOCK_BG = 'color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent)'
32
+
33
+ /** Markdown token → design-token style map (mirrors the old .md-* palette). */
34
+ const mdHighlight = HighlightStyle.define([
35
+ // Headings (# … ######) — brand + bold.
36
+ { tag: [t.heading1, t.heading2, t.heading3, t.heading4, t.heading5, t.heading6], color: BRAND, fontWeight: '700' },
37
+ // Markers: #, >, -, *, `, [ ] etc.
38
+ { tag: t.processingInstruction, color: BRAND_DIM },
39
+ // Inline code + fenced code text — monospace, tinted background.
40
+ { tag: t.monospace, fontFamily: MONO, backgroundColor: CODE_BG, borderRadius: '4px', padding: '0 3px' },
41
+ // Code block content (inside a fence) gets a slightly wider tint.
42
+ { tag: t.content, fontFamily: MONO, backgroundColor: CODE_BLOCK_BG },
43
+ { tag: t.strong, fontWeight: '700' },
44
+ { tag: t.emphasis, fontStyle: 'italic' },
45
+ { tag: t.strikethrough, textDecoration: 'line-through', opacity: '.75' },
46
+ { tag: t.link, color: BRAND, textDecoration: 'underline' },
47
+ { tag: t.url, color: BRAND_DIM, textDecoration: 'underline dotted' },
48
+ { tag: t.quote, fontStyle: 'italic', color: SECONDARY },
49
+ { tag: t.contentSeparator, color: DIMMED, textDecoration: 'line-through' },
50
+ { tag: t.comment, color: DIMMED },
51
+ ])
52
+
53
+ /** Public editor surface the quick-note widget consumes. */
54
+ export interface MarkdownEditor {
55
+ /** Wrapper element (append/drag-target). */
56
+ el: HTMLDivElement
57
+ /** The CodeMirror EditorView (advanced use / future extensions). */
58
+ view: EditorView
59
+ getValue(): string
60
+ setValue(value: string): void
61
+ /** Insert a Markdown line at the current caret (used by file upload). */
62
+ insertAtCaret(markdown: string): void
63
+ focus(): void
64
+ }
65
+
66
+ export interface MarkdownEditorOptions {
67
+ /** Placeholder text shown when the doc is empty. */
68
+ placeholder?: string
69
+ /** Called on Ctrl/Cmd+Enter (wired after save is defined by the widget). */
70
+ onSave?: () => void
71
+ }
72
+
73
+ /**
74
+ * Build a CodeMirror 6 Markdown editor inside `.dsh-tw-note-editor`.
75
+ * GFM base language (strikethrough, tables, task lists, autolinks) — strictly
76
+ * more coverage than the old regex highlighter.
77
+ */
78
+ export function buildMarkdownEditor(opts: MarkdownEditorOptions = {}): MarkdownEditor {
79
+ const wrap = document.createElement('div')
80
+ wrap.className = 'dsh-tw-note-editor'
81
+
82
+ const view = new EditorView({
83
+ parent: wrap,
84
+ state: EditorState.create({
85
+ doc: '',
86
+ extensions: [
87
+ EditorView.lineWrapping,
88
+ history(),
89
+ markdown({ base: markdownLanguage }),
90
+ syntaxHighlighting(mdHighlight),
91
+ drawSelection(),
92
+ placeholder(opts.placeholder ?? ''),
93
+ keymap.of([
94
+ ...defaultKeymap,
95
+ ...historyKeymap,
96
+ { key: 'Mod-Enter', run: () => { opts.onSave?.(); return true } },
97
+ ]),
98
+ ],
99
+ }),
100
+ })
101
+
102
+ return {
103
+ el: wrap,
104
+ view,
105
+ getValue: () => view.state.doc.toString(),
106
+ setValue(value: string) {
107
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
108
+ },
109
+ insertAtCaret(markdownLine: string) {
110
+ const { from } = view.state.selection.main
111
+ const line = view.state.doc.lineAt(from)
112
+ const atLineStart = from === line.from
113
+ const insert = `${atLineStart ? '' : '\n'}${markdownLine}\n`
114
+ view.dispatch({
115
+ changes: { from, insert },
116
+ selection: { anchor: from + insert.length },
117
+ scrollIntoView: true,
118
+ })
119
+ view.focus()
120
+ },
121
+ focus: () => view.focus(),
122
+ }
123
+ }
@@ -14,9 +14,10 @@
14
14
  * (GET /dsh-tiddlywiki/tags). Enter/`,` commits the typed value; Backspace on
15
15
  * an empty field removes the last chip; the dropdown filters on typing.
16
16
  *
17
- * Markdown editing: a "highlighted textarea" overlay (a `<pre>` rendered
18
- * behind a transparent-text `<textarea>`, see markdown.ts) so Markdown is
19
- * syntax-highlighted as you type — no dependencies, perfect alignment.
17
+ * Markdown editing: a CodeMirror 6 editor (see markdown-editor.ts) with real
18
+ * Lezer-based syntax highlighting (GFM: headings, lists, code, links, tables,
19
+ * task lists, strikethrough…) plus undo/redo, wired into the same
20
+ * `.dsh-tw-note-editor` surface the widget reads/writes.
20
21
  *
21
22
  * File upload: the body accepts files via the 📎 button or drag-and-drop;
22
23
  * each file is POSTed raw to /dsh-tiddlywiki/upload (saved under the wiki's
@@ -27,7 +28,8 @@
27
28
  */
28
29
  import { toast } from './toast.ts'
29
30
  import { openEditorPopup } from './editor-popup.ts'
30
- import { highlightMarkdown } from './markdown.ts'
31
+ import { buildMarkdownEditor, type MarkdownEditor } from './markdown-editor.ts'
32
+ import type { EditorView } from '@codemirror/view'
31
33
 
32
34
  const NOTE_ENDPOINT = '/dsh-tiddlywiki/note'
33
35
  const EDIT_ENDPOINT = '/dsh-tiddlywiki/edit'
@@ -195,54 +197,23 @@ function buildTagEditor(): {
195
197
  }
196
198
 
197
199
  /**
198
- * Markdown editor: a `<pre>` overlay (highlighted by markdown.ts) rendered
199
- * behind a transparent-text `<textarea>` with identical metrics. Scroll is
200
- * mirrored; on every input the overlay is re-rendered. The text layer stays
201
- * exactly aligned with the highlight layer because the highlighter never
202
- * changes the user's characters.
200
+ * Build the CodeMirror 6 Markdown editor for the quick-note widget. The editor
201
+ * lives inside `.dsh-tw-note-editor`; the widget reads/writes through
202
+ * getValue/setValue and inserts uploaded files at the caret via insertAtCaret.
203
+ * Mod-Enter (save) is routed through `onSave`.
203
204
  */
204
- function buildEditor(): {
205
- el: HTMLDivElement
206
- textarea: HTMLTextAreaElement
207
- render: () => void
208
- focus: () => void
209
- } {
210
- const wrap = document.createElement('div')
211
- wrap.className = 'dsh-tw-note-editor'
212
-
213
- const hl = document.createElement('pre')
214
- hl.className = 'dsh-tw-note-hl'
215
- hl.setAttribute('aria-hidden', 'true')
216
-
217
- const textarea = document.createElement('textarea')
218
- textarea.className = 'dsh-tw-note-text'
219
- textarea.placeholder = '写点东西… Markdown 高亮,可 📎/拖入文件\nCtrl+Enter 保存'
220
-
221
- wrap.append(hl, textarea)
222
-
223
- const render = (): void => {
224
- hl.innerHTML = `${highlightMarkdown(textarea.value)}\n`
225
- hl.scrollTop = textarea.scrollTop
226
- hl.scrollLeft = textarea.scrollLeft
227
- }
228
-
229
- const syncScroll = (): void => {
230
- hl.scrollTop = textarea.scrollTop
231
- hl.scrollLeft = textarea.scrollLeft
232
- }
233
-
234
- textarea.addEventListener('input', render)
235
- textarea.addEventListener('scroll', syncScroll)
236
- render()
237
-
238
- return { el: wrap, textarea, render, focus: () => textarea.focus() }
205
+ function buildEditor(onSave: () => void): MarkdownEditor {
206
+ return buildMarkdownEditor({
207
+ placeholder: '写点东西… Markdown 高亮,可 📎/拖入文件\nCtrl+Enter 保存',
208
+ onSave,
209
+ })
239
210
  }
240
211
 
241
212
  /**
242
213
  * Upload one file to the wiki (raw body, name in ?name=), then insert a
243
214
  * Markdown image/link line at the caret of the given editor.
244
215
  */
245
- async function uploadInto(file: File, editor: { textarea: HTMLTextAreaElement; render: () => void }): Promise<void> {
216
+ async function uploadInto(file: File, editor: MarkdownEditor): Promise<void> {
246
217
  if (file.size > MAX_UPLOAD_BYTES) {
247
218
  toast(`文件过大(≤ ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`)
248
219
  return
@@ -263,26 +234,15 @@ async function uploadInto(file: File, editor: { textarea: HTMLTextAreaElement; r
263
234
  const markdown = file.type.startsWith('image/')
264
235
  ? `![${name}](${payload.url})`
265
236
  : `[${name}](${payload.url})`
266
- const ta = editor.textarea
267
- const start = ta.selectionStart ?? ta.value.length
268
- const end = ta.selectionEnd ?? start
269
- const before = ta.value.slice(0, start)
270
- const after = ta.value.slice(end)
271
- const needsLead = start > 0 && !before.endsWith('\n')
272
- const insert = `${needsLead ? '\n' : ''}${markdown}\n`
273
- ta.value = `${before}${insert}${after}`
274
- const caret = (before + insert).length
275
- ta.setSelectionRange(caret, caret)
276
- ta.focus()
277
- editor.render()
237
+ editor.insertAtCaret(markdown)
278
238
  toast(`已上传「${name}」并插入链接`)
279
239
  } catch (err) {
280
240
  toast(`上传失败:${err instanceof Error ? err.message : String(err)}`)
281
241
  }
282
242
  }
283
243
 
284
- /** Build the whole widget DOM and wire it up (returns the appended root). */
285
- function buildWidget(): HTMLDivElement {
244
+ /** Build the whole widget DOM and wire it up (returns root + editor view). */
245
+ function buildWidget(): { root: HTMLDivElement; editorView: EditorView } {
286
246
  const root = document.createElement('div')
287
247
  root.className = 'dsh-tw-note'
288
248
 
@@ -310,8 +270,10 @@ function buildWidget(): HTMLDivElement {
310
270
  const tagEditor = buildTagEditor()
311
271
  fields.append(titleInput, tagEditor.el)
312
272
 
313
- const editor = buildEditor()
314
- const textarea = editor.textarea
273
+ // Mod-Enter save routes through doSave, which is assigned below (the editor
274
+ // keymap only runs after the widget is fully wired, so this is safe).
275
+ let doSave: () => Promise<void> | undefined
276
+ const editor = buildEditor(() => { void doSave?.() })
315
277
 
316
278
  // ── file upload (button + drag & drop onto the editor) ────────────────
317
279
  const uploadBtn = document.createElement('button')
@@ -407,8 +369,8 @@ function buildWidget(): HTMLDivElement {
407
369
  toggle.addEventListener('click', () => { void (opened ? close() : open()) })
408
370
  closeBtn.addEventListener('click', close)
409
371
 
410
- const doSave = async (): Promise<void> => {
411
- const text = textarea.value.trim()
372
+ doSave = async (): Promise<void> => {
373
+ const text = editor.getValue().trim()
412
374
  if (text.length === 0) {
413
375
  toast('内容为空,未保存')
414
376
  return
@@ -431,8 +393,7 @@ function buildWidget(): HTMLDivElement {
431
393
  toast(`保存失败:${payload?.error ?? `HTTP ${res.status}`}`)
432
394
  return
433
395
  }
434
- textarea.value = ''
435
- editor.render()
396
+ editor.setValue('')
436
397
  resetTitle()
437
398
  toast(`已保存「${payload.title ?? titleInput.value}」`)
438
399
  close()
@@ -444,18 +405,12 @@ function buildWidget(): HTMLDivElement {
444
405
  }
445
406
  }
446
407
 
447
- save.addEventListener('click', () => { void doSave() })
448
- textarea.addEventListener('keydown', (event) => {
449
- if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
450
- event.preventDefault()
451
- void doSave()
452
- }
453
- })
408
+ save.addEventListener('click', () => { void doSave?.() })
454
409
 
455
410
  /** Save (if non-empty) and open the tiddler in TW's native editor. */
456
411
  const doEdit = async (): Promise<void> => {
457
412
  const title = titleInput.value.trim().length > 0 ? titleInput.value.trim() : timestampTitle()
458
- const text = textarea.value
413
+ const text = editor.getValue()
459
414
  const tags = tagEditor.getTags()
460
415
  edit.disabled = true
461
416
  edit.textContent = '打开中…'
@@ -489,19 +444,22 @@ function buildWidget(): HTMLDivElement {
489
444
 
490
445
  edit.addEventListener('click', () => { void doEdit() })
491
446
 
492
- return root
447
+ return { root, editorView: editor.view }
493
448
  }
494
449
 
495
450
  /**
496
451
  * Mount the floating quick-note widget. Fetches /status first: when
497
452
  * `ui.showQuickNote` is off the widget is never created (no DOM side effects).
498
- * Returns a disposer that removes it.
453
+ * Returns a disposer that removes it (and destroys the CodeMirror view).
499
454
  */
500
455
  export function mountNoteWidget(): () => void {
501
456
  let disposed = false
502
457
  let root: HTMLDivElement | undefined
458
+ let editorView: EditorView | undefined
503
459
  const disposer = (): void => {
504
460
  disposed = true
461
+ // CodeMirror holds a ResizeObserver + timers; destroy it, not just the DOM.
462
+ editorView?.destroy()
505
463
  root?.remove()
506
464
  const toastEl = document.querySelector<HTMLElement>('.dsh-tw-toast')
507
465
  toastEl?.remove()
@@ -509,7 +467,9 @@ export function mountNoteWidget(): () => void {
509
467
  void (async () => {
510
468
  const ui = await fetchUiOptions()
511
469
  if (disposed || !ui.showQuickNote) return
512
- root = buildWidget()
470
+ const widget = buildWidget()
471
+ root = widget.root
472
+ editorView = widget.editorView
513
473
  })()
514
474
  return disposer
515
475
  }
@@ -130,75 +130,52 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
130
130
  font: inherit; font-size: 15px; line-height: 1; padding: 2px 6px; border-radius: 6px; cursor: pointer;
131
131
  }
132
132
  .dsh-tw-note-close:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(128,128,128,.12)); color: var(--dsw-alias-label-primary, #222); }
133
- .dsh-tw-note-fields { display: flex; gap: 8px; }
133
+ /* 标题与标签上下分行:标题一行,标签 chips+输入框独占一行,
134
+ 避免标签增多时把并排的标题栏一起撑高。 */
135
+ .dsh-tw-note-fields { display: flex; flex-direction: column; gap: 8px; }
134
136
  .dsh-tw-note-fields input {
135
- flex: 1; min-width: 0; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
137
+ width: 100%; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18));
136
138
  border-radius: 8px; padding: 6px 9px; font: inherit; font-size: 12px;
137
139
  background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, inherit);
138
140
  transition: border-color 120ms ease, box-shadow 120ms ease;
139
141
  }
140
- .dsh-tw-note-fields input:focus, .dsh-tw-note-text:focus {
142
+ .dsh-tw-note-fields input:focus, .dsh-tw-note-editor .cm-editor.cm-focused {
141
143
  outline: none; border-color: var(--dsw-alias-brand-primary, #3e63dd);
142
144
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
143
145
  }
144
- .dsh-tw-note-text::placeholder { color: var(--dsw-alias-label-dimmed, #999); }
145
- /* ── Markdown editor (highlighted-textarea overlay) ────────────────
146
- The <pre> highlight layer and the transparent <textarea> share identical
147
- metrics (border/padding/font/line-height/white-space) so lines align 1:1. */
146
+ /* ── Markdown editor (CodeMirror 6, see markdown-editor.ts) ──────────
147
+ The .cm-editor box carries the border/radius/min-height the old textarea
148
+ had; font metrics live on the scroller so lines/selection stay aligned. */
148
149
  .dsh-tw-note-editor { position: relative; min-width: 0; }
149
- .dsh-tw-note-editor pre,
150
- .dsh-tw-note-editor textarea {
151
- margin: 0; box-sizing: border-box; width: 100%;
150
+ .dsh-tw-note-editor .cm-editor {
152
151
  border: 1px solid var(--dsw-alias-border-l2, rgba(0,0,0,.18)); border-radius: 8px;
153
- padding: 8px 9px; font: inherit; font-size: 13px; line-height: 1.5; min-height: 120px;
154
- white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; tab-size: 4;
152
+ background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #222);
153
+ font-size: 13px; min-height: 120px;
155
154
  transition: border-color 120ms ease, box-shadow 120ms ease;
156
155
  }
157
- .dsh-tw-note-editor .dsh-tw-note-hl {
158
- position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 0;
159
- background: var(--dsw-alias-bg-layer-1, #fff); color: var(--dsw-alias-label-primary, #222);
160
- pointer-events: none; overflow: hidden;
156
+ .dsh-tw-note-editor .cm-scroller {
157
+ font-family: inherit; font-size: 13px; line-height: 1.5;
158
+ min-height: 120px; overflow: auto;
159
+ }
160
+ .dsh-tw-note-editor .cm-content {
161
+ caret-color: var(--dsw-alias-label-primary, #222);
162
+ padding: 8px 9px;
163
+ }
164
+ .dsh-tw-note-editor .cm-placeholder { color: var(--dsw-alias-label-dimmed, #999); }
165
+ .dsh-tw-note-editor .cm-line { padding: 0; }
166
+ /* Selection + active line follow the theme. */
167
+ .dsh-tw-note-editor .cm-editor .cm-selectionBackground,
168
+ .dsh-tw-note-editor .cm-editor.cm-focused .cm-selectionBackground {
169
+ background: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 22%, transparent) !important;
161
170
  }
162
- .dsh-tw-note-editor .dsh-tw-note-text {
163
- position: relative; z-index: 1; resize: vertical;
164
- background: transparent; color: transparent; caret-color: var(--dsw-alias-label-primary, #222);
165
- overflow: auto;
171
+ .dsh-tw-note-editor .cm-editor .cm-activeLine {
172
+ background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent);
166
173
  }
167
- .dsh-tw-note-editor .dsh-tw-note-text:focus { outline: none; }
168
- .dsh-tw-note-drop .dsh-tw-note-text,
169
- .dsh-tw-note-drop .dsh-tw-note-hl {
174
+ /* Drag-over highlight (file upload) targets the CodeMirror box now. */
175
+ .dsh-tw-note-drop .cm-editor {
170
176
  border-color: var(--dsw-alias-brand-primary, #3e63dd);
171
177
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 25%, transparent);
172
178
  }
173
- /* ── markdown highlight tokens (colors only — never size/height, so the
174
- overlay keeps line-for-line alignment with the invisible textarea) ── */
175
- .dsh-tw-note-hl .md-heading { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
176
- .dsh-tw-note-hl .md-hash { font-weight: 700; color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 55%, transparent); }
177
- .dsh-tw-note-hl .md-code,
178
- .dsh-tw-note-hl .md-code-block,
179
- .dsh-tw-note-hl .md-fence {
180
- font-family: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace;
181
- }
182
- .dsh-tw-note-hl .md-code {
183
- background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 14%, transparent);
184
- border-radius: 4px; padding: 0 3px;
185
- }
186
- .dsh-tw-note-hl .md-code-block,
187
- .dsh-tw-note-hl .md-fence {
188
- display: block; background: color-mix(in srgb, var(--dsw-alias-label-secondary, #888) 8%, transparent);
189
- }
190
- .dsh-tw-note-hl .md-bold { font-weight: 700; }
191
- .dsh-tw-note-hl .md-italic { font-style: italic; }
192
- .dsh-tw-note-hl .md-strike { text-decoration: line-through; opacity: .75; }
193
- .dsh-tw-note-hl .md-link { color: var(--dsw-alias-brand-primary, #3e63dd); text-decoration: underline; }
194
- .dsh-tw-note-hl .md-url { color: color-mix(in srgb, var(--dsw-alias-brand-primary, #3e63dd) 70%, #999); text-decoration: underline dotted; }
195
- .dsh-tw-note-hl .md-image { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
196
- .dsh-tw-note-hl .md-bullet { font-weight: 700; color: var(--dsw-alias-brand-primary, #3e63dd); }
197
- .dsh-tw-note-hl .md-number { font-weight: 700; color: var(--dsw-alias-state-warning-primary, #d9822b); }
198
- .dsh-tw-note-hl .md-quote { font-style: italic; color: var(--dsw-alias-label-secondary, #888); }
199
- .dsh-tw-note-hl .md-hr { color: var(--dsw-alias-label-dimmed, #999); text-decoration: line-through; }
200
- .dsh-tw-note-hl .md-task { color: var(--dsw-alias-label-secondary, #888); }
201
- .dsh-tw-note-hl .md-task-checked { color: var(--dsw-alias-state-success-primary, #3eaa5f); font-weight: 600; }
202
179
  .dsh-tw-note-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
203
180
  .dsh-tw-note-foot-left, .dsh-tw-note-foot-right { display: flex; align-items: center; gap: 8px; }
204
181
  .dsh-tw-note-hint { font-size: 11px; color: var(--dsw-alias-label-dimmed, #999); }
@@ -228,7 +205,7 @@ html[data-dsh-tw-active] .dshDesktopConversationSurface > :not([data-dsh-tw-view
228
205
  .dsh-tw-note-edit:active:not(:disabled) { transform: scale(.96); }
229
206
  .dsh-tw-note-edit:disabled { opacity: .55; cursor: default; }
230
207
  /* ── Multi-tag chip editor (quick-note tags) ─────────────────────────────── */
231
- .dsh-tw-note-tags { position: relative; flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
208
+ .dsh-tw-note-tags { position: relative; width: 100%; display: flex; flex-direction: column; gap: 4px; }
232
209
  .dsh-tw-note-chips { display: flex; flex-wrap: wrap; gap: 4px; }
233
210
  .dsh-tw-note-tagchip {
234
211
  display: inline-flex; align-items: center; gap: 4px;
@@ -1,137 +0,0 @@
1
- /**
2
- * Lightweight Markdown syntax highlighter for the quick-note editor.
3
- *
4
- * The editor is the classic "highlighted textarea": a `<pre>` rendered BEHIND
5
- * a transparent-text `<textarea>` with identical metrics. To keep the two
6
- * layers perfectly aligned we NEVER change the user's text — every token is
7
- * only wrapped in a colored `<span class="md-*">`, so each line keeps its
8
- * exact characters and column widths. Leading whitespace (indentation) is
9
- * preserved as-is so list/heading/quote alignment survives.
10
- *
11
- * Coverage is deliberately pragmatic (a scratch-note editor, not CommonMark):
12
- * ATX headings, fenced code blocks, blockquotes, bullet + ordered + task
13
- * lists, horizontal rules, inline code, bold, italic, strikethrough, links
14
- * and images. HTML is escaped first, so the output is always safe to set as
15
- * `innerHTML` (no raw HTML from the user is ever executed).
16
- *
17
- * @module dsh-tiddlywiki/client/markdown
18
- */
19
-
20
- /** Escape a raw source fragment for safe insertion into the overlay HTML. */
21
- export function escapeHtml(value: string): string {
22
- return value
23
- .replace(/&/g, '&amp;')
24
- .replace(/</g, '&lt;')
25
- .replace(/>/g, '&gt;')
26
- }
27
-
28
- /**
29
- * Inline token highlighting on ALREADY-ESCAPED text. Inline code spans are
30
- * first pulled out into placeholders so later passes (links/bold/italic) can
31
- * never reach inside them, then restored.
32
- */
33
- function highlightInline(escaped: string): string {
34
- // Inline code spans are pulled out first so later passes never reach inside.
35
- const codeTokens: string[] = []
36
- let s = escaped.replace(/(`+)([^`]+?)\1/g, (_all, t: string, code: string) => {
37
- codeTokens.push(`<span class="md-code">${t}${code}${t}</span>`)
38
- return `\u0000${codeTokens.length - 1}\u0000`
39
- })
40
-
41
- // Images and links: keep the literal markdown text, color the pieces.
42
- s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_a, alt: string, url: string) =>
43
- `<span class="md-image">![${alt}](</span><span class="md-url">${url}</span><span class="md-image">)</span>`)
44
- s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_a, text: string, url: string) =>
45
- `<span class="md-link">[${text}](</span><span class="md-url">${url}</span><span class="md-link">)</span>`)
46
-
47
- // Bold spans are also protected (placeholders) so the later italic pass
48
- // cannot chew into their `**` delimiters (which are also single `*`).
49
- const boldTokens: string[] = []
50
- s = s.replace(/(\*\*|__)([^*_]+?)\1/g, (_a, d: string, text: string) => {
51
- boldTokens.push(`<span class="md-bold">${d}${text}${d}</span>`)
52
- return `\u0001${boldTokens.length - 1}\u0001`
53
- })
54
-
55
- s = s.replace(/(\*|_)([^*_]+?)\1/g, '<span class="md-italic">$1$2$1</span>')
56
- s = s.replace(/~~([^~]+?)~~/g, '<span class="md-strike">~~$1~~</span>')
57
-
58
- return s
59
- .replace(/\u0001(\d+)\u0001/g, (_a, i: string) => boldTokens[Number(i)] ?? '')
60
- .replace(/\u0000(\d+)\u0000/g, (_a, i: string) => codeTokens[Number(i)] ?? '')
61
- }
62
-
63
- /**
64
- * Match and return groups as a plain string array ('' for absent groups), or
65
- * null when the pattern does not match. Avoids noUncheckedIndexedAccess
66
- * friction on RegExpMatchArray indexing.
67
- */
68
- function groups(re: RegExp, value: string): string[] | null {
69
- const m = value.match(re)
70
- if (m === null) return null
71
- return Array.from(m, (g) => g ?? '')
72
- }
73
-
74
- /** Highlight one non-fence line (leading whitespace preserved verbatim). */
75
- function highlightLine(raw: string): string {
76
- const leadMatch = groups(/^(\s*)(.*)$/, raw)
77
- const lead = leadMatch === null ? '' : leadMatch[1]!
78
- const body = leadMatch === null ? raw : leadMatch[2]!
79
- if (body.length === 0) return raw
80
-
81
- // ATX heading: ## 标题
82
- const heading = groups(/^(#{1,6})(\s+)(.*)$/, body)
83
- if (heading !== null) {
84
- return `${lead}<span class="md-heading">${escapeHtml(heading[1]!)}${escapeHtml(heading[2]!)}</span>${highlightInline(escapeHtml(heading[3]!))}`
85
- }
86
- // Horizontal rule
87
- if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(body)) {
88
- return `${lead}<span class="md-hr">${escapeHtml(body)}</span>`
89
- }
90
- // Blockquote: > 内容
91
- const quote = groups(/^(>)( ?)(.*)$/, body)
92
- if (quote !== null) {
93
- return `${lead}<span class="md-quote">${escapeHtml(quote[1]!)}</span>${escapeHtml(quote[2]!)}${highlightInline(escapeHtml(quote[3]!))}`
94
- }
95
- // Task list item: - [x] 内容 / - [ ] 内容
96
- const task = groups(/^([-*+])(\s+)(\[[ xX]\])(\s+)(.*)$/, body)
97
- if (task !== null) {
98
- const checked = /\[[xX]\]/.test(task[3]!)
99
- return `${lead}<span class="md-bullet">${escapeHtml(task[1]!)}</span>${escapeHtml(task[2]!)}<span class="md-task${checked ? ' md-task-checked' : ''}">${escapeHtml(task[3]!)}</span>${escapeHtml(task[4]!)}${highlightInline(escapeHtml(task[5]!))}`
100
- }
101
- // Bullet list item
102
- const bullet = groups(/^([-*+])(\s+)(.*)$/, body)
103
- if (bullet !== null) {
104
- return `${lead}<span class="md-bullet">${escapeHtml(bullet[1]!)}</span>${escapeHtml(bullet[2]!)}${highlightInline(escapeHtml(bullet[3]!))}`
105
- }
106
- // Ordered list item: 1. 内容
107
- const ordered = groups(/^(\d+)([.)])(\s+)(.*)$/, body)
108
- if (ordered !== null) {
109
- return `${lead}<span class="md-number">${escapeHtml(ordered[1]!)}${escapeHtml(ordered[2]!)}</span>${escapeHtml(ordered[3]!)}${highlightInline(escapeHtml(ordered[4]!))}`
110
- }
111
- // Plain inline line
112
- return `${lead}${highlightInline(escapeHtml(body))}`
113
- }
114
-
115
- /**
116
- * Highlight full Markdown source into safe `<span>`-wrapped HTML. Line count
117
- * and per-line text (including whitespace) are preserved exactly.
118
- */
119
- export function highlightMarkdown(src: string): string {
120
- const lines = src.split('\n')
121
- const out: string[] = []
122
- let inFence = false
123
- for (const rawLine of lines) {
124
- const fence = rawLine.match(/^\s*(```|~~~)\s*([\w-]*)\s*$/)
125
- if (fence !== null) {
126
- inFence = !inFence
127
- out.push(`<span class="md-fence">${escapeHtml(rawLine)}</span>`)
128
- continue
129
- }
130
- if (inFence) {
131
- out.push(`<span class="md-code-block">${escapeHtml(rawLine)}</span>`)
132
- continue
133
- }
134
- out.push(highlightLine(rawLine))
135
- }
136
- return out.join('\n')
137
- }