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/README.md +258 -212
- package/lib/client.bundle.js +25740 -436
- package/lib/client.js +25740 -436
- package/package.json +7 -1
- package/src/client/markdown-editor.ts +123 -0
- package/src/client/note-widget.ts +36 -76
- package/src/client/styles.ts +30 -53
- package/src/client/markdown.ts +0 -137
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.
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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 {
|
|
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
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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:
|
|
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
|
? ``
|
|
265
236
|
: `[${name}](${payload.url})`
|
|
266
|
-
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
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
|
-
|
|
411
|
-
const text =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
470
|
+
const widget = buildWidget()
|
|
471
|
+
root = widget.root
|
|
472
|
+
editorView = widget.editorView
|
|
513
473
|
})()
|
|
514
474
|
return disposer
|
|
515
475
|
}
|
package/src/client/styles.ts
CHANGED
|
@@ -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
|
-
|
|
133
|
+
/* 标题与标签上下分行:标题一行,标签 chips+输入框独占一行,
|
|
134
|
+
避免标签增多时把并排的标题栏一起撑高。 */
|
|
135
|
+
.dsh-tw-note-fields { display: flex; flex-direction: column; gap: 8px; }
|
|
134
136
|
.dsh-tw-note-fields input {
|
|
135
|
-
|
|
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-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
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 .
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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 .
|
|
163
|
-
|
|
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
|
-
|
|
168
|
-
.dsh-tw-note-drop .
|
|
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;
|
|
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;
|
package/src/client/markdown.ts
DELETED
|
@@ -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, '&')
|
|
24
|
-
.replace(/</g, '<')
|
|
25
|
-
.replace(/>/g, '>')
|
|
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"></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
|
-
}
|