dsh-tiddlywiki 0.3.0 → 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,79 +1,85 @@
1
- {
2
- "name": "dsh-tiddlywiki",
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.0",
5
- "type": "module",
6
- "keywords": [
7
- "dsh",
8
- "dsh-plugin",
9
- "tiddlywiki",
10
- "knowledge-base",
11
- "note-taking",
12
- "notes",
13
- "wiki",
14
- "git-sync",
15
- "agent-tools",
16
- "plugin"
17
- ],
18
- "author": {
19
- "name": "bbqisbbq",
20
- "url": "https://github.com/bbqisbbq"
21
- },
22
- "main": "lib/index.js",
23
- "exports": {
24
- ".": "./lib/index.js",
25
- "./client": "./lib/client.js",
26
- "./package.json": "./package.json"
27
- },
28
- "dsh": {
29
- "bundle": {
30
- "patch": "./cordis.patch.yml"
31
- },
32
- "client": {
33
- "inject": [
34
- "slots"
35
- ],
36
- "platform": "web"
37
- }
38
- },
39
- "files": [
40
- "lib/**/*.js",
41
- "lib/**/*.js.map",
42
- "src",
43
- "cordis.patch.yml",
44
- "LICENSE",
45
- "README.md"
46
- ],
47
- "license": "MIT",
48
- "homepage": "https://github.com/bbqisbbq/dsh-tiddlywiki#readme",
49
- "repository": {
50
- "type": "git",
51
- "url": "https://github.com/bbqisbbq/dsh-tiddlywiki.git"
52
- },
53
- "bugs": {
54
- "url": "https://github.com/bbqisbbq/dsh-tiddlywiki/issues"
55
- },
56
- "engines": {
57
- "node": ">=22"
58
- },
59
- "dependencies": {
60
- "tiddlywiki": "^5.4.1"
61
- },
62
- "devDependencies": {
63
- "@types/node": "^22.20.1",
64
- "@types/react": "^19.1.0",
65
- "react": "^19.1.0",
66
- "tsdown": "0.22.2",
67
- "tsx": "^4.19.2",
68
- "typescript": "~5.7.2",
69
- "unrun": "*"
70
- },
71
- "scripts": {
72
- "build": "node scripts/clean-lib.mjs && npm run build:host && npm run build:client",
73
- "build:host": "tsdown -c tsdown.host.config.ts",
74
- "build:client": "tsdown -c tsdown.client.config.ts && node scripts/wrap-client.mjs",
75
- "watch": "tsdown -c tsdown.host.config.ts --watch",
76
- "typecheck": "tsc --noEmit",
77
- "selftest": "node scripts/selftest.mjs"
78
- }
79
- }
1
+ {
2
+ "name": "dsh-tiddlywiki",
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.4.0",
5
+ "type": "module",
6
+ "keywords": [
7
+ "dsh",
8
+ "dsh-plugin",
9
+ "tiddlywiki",
10
+ "knowledge-base",
11
+ "note-taking",
12
+ "notes",
13
+ "wiki",
14
+ "git-sync",
15
+ "agent-tools",
16
+ "plugin"
17
+ ],
18
+ "author": {
19
+ "name": "bbqisbbq",
20
+ "url": "https://github.com/bbqisbbq"
21
+ },
22
+ "main": "lib/index.js",
23
+ "exports": {
24
+ ".": "./lib/index.js",
25
+ "./client": "./lib/client.js",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "dsh": {
29
+ "bundle": {
30
+ "patch": "./cordis.patch.yml"
31
+ },
32
+ "client": {
33
+ "inject": [
34
+ "slots"
35
+ ],
36
+ "platform": "web"
37
+ }
38
+ },
39
+ "files": [
40
+ "lib/**/*.js",
41
+ "lib/**/*.js.map",
42
+ "src",
43
+ "cordis.patch.yml",
44
+ "LICENSE",
45
+ "README.md"
46
+ ],
47
+ "license": "MIT",
48
+ "homepage": "https://github.com/bbqisbbq/dsh-tiddlywiki#readme",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "https://github.com/bbqisbbq/dsh-tiddlywiki.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://github.com/bbqisbbq/dsh-tiddlywiki/issues"
55
+ },
56
+ "engines": {
57
+ "node": ">=22"
58
+ },
59
+ "dependencies": {
60
+ "tiddlywiki": "^5.4.1"
61
+ },
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",
69
+ "@types/node": "^22.20.1",
70
+ "@types/react": "^19.1.0",
71
+ "react": "^19.1.0",
72
+ "tsdown": "0.22.2",
73
+ "tsx": "^4.19.2",
74
+ "typescript": "~5.7.2",
75
+ "unrun": "*"
76
+ },
77
+ "scripts": {
78
+ "build": "node scripts/clean-lib.mjs && npm run build:host && npm run build:client",
79
+ "build:host": "tsdown -c tsdown.host.config.ts",
80
+ "build:client": "tsdown -c tsdown.client.config.ts && node scripts/wrap-client.mjs",
81
+ "watch": "tsdown -c tsdown.host.config.ts --watch",
82
+ "typecheck": "tsc --noEmit",
83
+ "selftest": "node scripts/selftest.mjs"
84
+ }
85
+ }
@@ -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
  }