dsh-tiddlywiki 0.2.0 → 0.3.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.2.0",
4
+ "version": "0.3.0",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -1,121 +1,121 @@
1
- /**
2
- * Floating popup iframe that loads TiddlyWiki's NATIVE editor for a draft
3
- * (quick-note "✏️ 在 TW 中编辑"). A small draggable + resizable overlay with its
4
- * own iframe pointed at `twUrl#<draftTitle>`: the fragment-only navigation
5
- * triggers TW's hashchange, which opens the draft in the story, and because the
6
- * draft tiddler carries `draft.of` the story renders the native EditTemplate.
7
- *
8
- * Independent of the center panel — a separate floating window so the user can
9
- * edit a note without leaving the chat context.
10
- *
11
- * @module dsh-tiddlywiki/client/editor-popup
12
- */
13
-
14
- let root: HTMLDivElement | undefined
15
- let frame: HTMLIFrameElement | undefined
16
- let titleEl: HTMLSpanElement | undefined
17
-
18
- /** Open (create on first use) the popup and load `url` (twUrl#draftTitle). */
19
- export function openEditorPopup(url: string, label: string): void {
20
- ensurePopup()
21
- if (root === undefined || frame === undefined) return
22
- if (titleEl !== undefined) titleEl.textContent = `TiddlyWiki 编辑器 · ${label}`
23
- root.style.display = ''
24
- // Same-base fragment navigation reloads nothing; a changed base (restart on a
25
- // new port) reloads the app and TW still opens the draft from the hash.
26
- frame.src = url
27
- }
28
-
29
- /** Remove the popup DOM entirely (plugin dispose). */
30
- export function disposeEditorPopup(): void {
31
- root?.remove()
32
- root = undefined
33
- frame = undefined
34
- titleEl = undefined
35
- }
36
-
37
- function ensurePopup(): void {
38
- if (root !== undefined && frame !== undefined) return
39
-
40
- root = document.createElement('div')
41
- root.className = 'dsh-tw-editor-popup'
42
- root.style.display = 'none'
43
-
44
- const bar = document.createElement('div')
45
- bar.className = 'dsh-tw-editor-bar'
46
- titleEl = document.createElement('span')
47
- titleEl.className = 'dsh-tw-editor-title'
48
- titleEl.textContent = 'TiddlyWiki 编辑器'
49
- const close = document.createElement('button')
50
- close.type = 'button'
51
- close.className = 'dsh-tw-editor-close'
52
- close.textContent = '✕'
53
- close.title = '关闭'
54
- bar.append(titleEl, close)
55
-
56
- frame = document.createElement('iframe')
57
- frame.className = 'dsh-tw-editor-frame'
58
- frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups')
59
- frame.title = 'TiddlyWiki 编辑器'
60
-
61
- const resize = document.createElement('div')
62
- resize.className = 'dsh-tw-editor-resize'
63
- resize.title = '拖拽调整大小'
64
-
65
- root.append(bar, frame, resize)
66
- document.body.append(root)
67
-
68
- close.addEventListener('click', () => {
69
- if (root !== undefined) root.style.display = 'none'
70
- })
71
-
72
- // Drag by the title bar (un-center by setting explicit left/top + margin 0).
73
- bar.addEventListener('mousedown', (event) => {
74
- if (event.button !== 0 || root === undefined) return
75
- event.preventDefault()
76
- const rect = root.getBoundingClientRect()
77
- const startX = event.clientX
78
- const startY = event.clientY
79
- const baseLeft = rect.left
80
- const baseTop = rect.top
81
- const onMove = (ev: MouseEvent): void => {
82
- if (root === undefined) return
83
- root.style.left = `${baseLeft + ev.clientX - startX}px`
84
- root.style.top = `${baseTop + ev.clientY - startY}px`
85
- root.style.margin = '0'
86
- root.style.right = 'auto'
87
- root.style.bottom = 'auto'
88
- }
89
- const onUp = (): void => {
90
- window.removeEventListener('mousemove', onMove)
91
- window.removeEventListener('mouseup', onUp)
92
- }
93
- window.addEventListener('mousemove', onMove)
94
- window.addEventListener('mouseup', onUp)
95
- })
96
-
97
- // Resize from the bottom-right corner.
98
- resize.addEventListener('mousedown', (event) => {
99
- if (event.button !== 0 || root === undefined) return
100
- event.preventDefault()
101
- event.stopPropagation()
102
- const rect = root.getBoundingClientRect()
103
- const startX = event.clientX
104
- const startY = event.clientY
105
- const baseW = rect.width
106
- const baseH = rect.height
107
- const onMove = (ev: MouseEvent): void => {
108
- if (root === undefined) return
109
- root.style.width = `${Math.max(360, baseW + ev.clientX - startX)}px`
110
- root.style.height = `${Math.max(260, baseH + ev.clientY - startY)}px`
111
- root.style.right = 'auto'
112
- root.style.bottom = 'auto'
113
- }
114
- const onUp = (): void => {
115
- window.removeEventListener('mousemove', onMove)
116
- window.removeEventListener('mouseup', onUp)
117
- }
118
- window.addEventListener('mousemove', onMove)
119
- window.addEventListener('mouseup', onUp)
120
- })
121
- }
1
+ /**
2
+ * Floating popup iframe that loads TiddlyWiki's NATIVE editor for a draft
3
+ * (quick-note "✏️ 在 TW 中编辑"). A small draggable + resizable overlay with its
4
+ * own iframe pointed at `twUrl#<draftTitle>`: the fragment-only navigation
5
+ * triggers TW's hashchange, which opens the draft in the story, and because the
6
+ * draft tiddler carries `draft.of` the story renders the native EditTemplate.
7
+ *
8
+ * Independent of the center panel — a separate floating window so the user can
9
+ * edit a note without leaving the chat context.
10
+ *
11
+ * @module dsh-tiddlywiki/client/editor-popup
12
+ */
13
+
14
+ let root: HTMLDivElement | undefined
15
+ let frame: HTMLIFrameElement | undefined
16
+ let titleEl: HTMLSpanElement | undefined
17
+
18
+ /** Open (create on first use) the popup and load `url` (twUrl#draftTitle). */
19
+ export function openEditorPopup(url: string, label: string): void {
20
+ ensurePopup()
21
+ if (root === undefined || frame === undefined) return
22
+ if (titleEl !== undefined) titleEl.textContent = `TiddlyWiki 编辑器 · ${label}`
23
+ root.style.display = ''
24
+ // Same-base fragment navigation reloads nothing; a changed base (restart on a
25
+ // new port) reloads the app and TW still opens the draft from the hash.
26
+ frame.src = url
27
+ }
28
+
29
+ /** Remove the popup DOM entirely (plugin dispose). */
30
+ export function disposeEditorPopup(): void {
31
+ root?.remove()
32
+ root = undefined
33
+ frame = undefined
34
+ titleEl = undefined
35
+ }
36
+
37
+ function ensurePopup(): void {
38
+ if (root !== undefined && frame !== undefined) return
39
+
40
+ root = document.createElement('div')
41
+ root.className = 'dsh-tw-editor-popup'
42
+ root.style.display = 'none'
43
+
44
+ const bar = document.createElement('div')
45
+ bar.className = 'dsh-tw-editor-bar'
46
+ titleEl = document.createElement('span')
47
+ titleEl.className = 'dsh-tw-editor-title'
48
+ titleEl.textContent = 'TiddlyWiki 编辑器'
49
+ const close = document.createElement('button')
50
+ close.type = 'button'
51
+ close.className = 'dsh-tw-editor-close'
52
+ close.textContent = '✕'
53
+ close.title = '关闭'
54
+ bar.append(titleEl, close)
55
+
56
+ frame = document.createElement('iframe')
57
+ frame.className = 'dsh-tw-editor-frame'
58
+ frame.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms allow-popups')
59
+ frame.title = 'TiddlyWiki 编辑器'
60
+
61
+ const resize = document.createElement('div')
62
+ resize.className = 'dsh-tw-editor-resize'
63
+ resize.title = '拖拽调整大小'
64
+
65
+ root.append(bar, frame, resize)
66
+ document.body.append(root)
67
+
68
+ close.addEventListener('click', () => {
69
+ if (root !== undefined) root.style.display = 'none'
70
+ })
71
+
72
+ // Drag by the title bar (un-center by setting explicit left/top + margin 0).
73
+ bar.addEventListener('mousedown', (event) => {
74
+ if (event.button !== 0 || root === undefined) return
75
+ event.preventDefault()
76
+ const rect = root.getBoundingClientRect()
77
+ const startX = event.clientX
78
+ const startY = event.clientY
79
+ const baseLeft = rect.left
80
+ const baseTop = rect.top
81
+ const onMove = (ev: MouseEvent): void => {
82
+ if (root === undefined) return
83
+ root.style.left = `${baseLeft + ev.clientX - startX}px`
84
+ root.style.top = `${baseTop + ev.clientY - startY}px`
85
+ root.style.margin = '0'
86
+ root.style.right = 'auto'
87
+ root.style.bottom = 'auto'
88
+ }
89
+ const onUp = (): void => {
90
+ window.removeEventListener('mousemove', onMove)
91
+ window.removeEventListener('mouseup', onUp)
92
+ }
93
+ window.addEventListener('mousemove', onMove)
94
+ window.addEventListener('mouseup', onUp)
95
+ })
96
+
97
+ // Resize from the bottom-right corner.
98
+ resize.addEventListener('mousedown', (event) => {
99
+ if (event.button !== 0 || root === undefined) return
100
+ event.preventDefault()
101
+ event.stopPropagation()
102
+ const rect = root.getBoundingClientRect()
103
+ const startX = event.clientX
104
+ const startY = event.clientY
105
+ const baseW = rect.width
106
+ const baseH = rect.height
107
+ const onMove = (ev: MouseEvent): void => {
108
+ if (root === undefined) return
109
+ root.style.width = `${Math.max(360, baseW + ev.clientX - startX)}px`
110
+ root.style.height = `${Math.max(260, baseH + ev.clientY - startY)}px`
111
+ root.style.right = 'auto'
112
+ root.style.bottom = 'auto'
113
+ }
114
+ const onUp = (): void => {
115
+ window.removeEventListener('mousemove', onMove)
116
+ window.removeEventListener('mouseup', onUp)
117
+ }
118
+ window.addEventListener('mousemove', onMove)
119
+ window.addEventListener('mouseup', onUp)
120
+ })
121
+ }
@@ -16,6 +16,7 @@ import { PanelState } from './state.ts'
16
16
  import { mountSidebarEntry } from './sidebar-entry.ts'
17
17
  import { mountPanel } from './panel.ts'
18
18
  import { mountNoteWidget } from './note-widget.ts'
19
+ import { mountSyncButton } from './sync-button.ts'
19
20
  import { disposeEditorPopup } from './editor-popup.ts'
20
21
  import { SettingsSection } from './settings-page.ts'
21
22
 
@@ -50,6 +51,7 @@ export function apply(ctx: ClientContextFace): void {
50
51
  disposers.push(mountSidebarEntry(state))
51
52
  disposers.push(mountPanel(state))
52
53
  disposers.push(mountNoteWidget())
54
+ disposers.push(mountSyncButton())
53
55
  disposers.push(disposeEditorPopup)
54
56
  } catch (error) {
55
57
  // DOM failures degrade the plugin, never the GUI.
@@ -0,0 +1,137 @@
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
+ }