dsh-tiddlywiki 0.16.13 → 0.16.14

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 with filters/recent/tags/batch/rename + git sync & conflict resolve), a full TiddlyWiki editor embedded in the GUI center column (same-origin proxy, works over Tailscale/LAN/domain), a quick-note card with draft auto-save & recent-notes picker behind a single knowledge FAB, and git-based sync with auto-commit. Mounts via the official dsh plugin system — no DSH source changes.",
4
- "version": "0.16.13",
4
+ "version": "0.16.14",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "dsh",
@@ -1,10 +1,10 @@
1
- /**
2
- * Same-origin DSH endpoints the client calls. The authoritative route table
3
- * lives in src/host/routes.ts (see AGENTS.md §1「DSH 路由」) — this is just the
4
- * client-side mirror, so the path string exists once per endpoint instead of
5
- * being re-typed in every widget.
6
- *
7
- * @module dsh-tiddlywiki/client/endpoints
8
- */
9
- export const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
10
- export const GET_ENDPOINT = '/dsh-tiddlywiki/get'
1
+ /**
2
+ * Same-origin DSH endpoints the client calls. The authoritative route table
3
+ * lives in src/host/routes.ts (see AGENTS.md §1「DSH 路由」) — this is just the
4
+ * client-side mirror, so the path string exists once per endpoint instead of
5
+ * being re-typed in every widget.
6
+ *
7
+ * @module dsh-tiddlywiki/client/endpoints
8
+ */
9
+ export const STATUS_ENDPOINT = '/dsh-tiddlywiki/status'
10
+ export const GET_ENDPOINT = '/dsh-tiddlywiki/get'
@@ -1,128 +1,128 @@
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
- /** Called after every doc change (used for draft auto-save). */
72
- onChange?: () => void
73
- }
74
-
75
- /**
76
- * Build a CodeMirror 6 Markdown editor inside `.dsh-tw-note-editor`.
77
- * GFM base language (strikethrough, tables, task lists, autolinks) — strictly
78
- * more coverage than the old regex highlighter.
79
- */
80
- export function buildMarkdownEditor(opts: MarkdownEditorOptions = {}): MarkdownEditor {
81
- const wrap = document.createElement('div')
82
- wrap.className = 'dsh-tw-note-editor'
83
-
84
- const view = new EditorView({
85
- parent: wrap,
86
- state: EditorState.create({
87
- doc: '',
88
- extensions: [
89
- EditorView.lineWrapping,
90
- history(),
91
- markdown({ base: markdownLanguage }),
92
- syntaxHighlighting(mdHighlight),
93
- drawSelection(),
94
- placeholder(opts.placeholder ?? ''),
95
- keymap.of([
96
- ...defaultKeymap,
97
- ...historyKeymap,
98
- { key: 'Mod-Enter', run: () => { opts.onSave?.(); return true } },
99
- ]),
100
- EditorView.updateListener.of((update) => {
101
- if (update.docChanged) opts.onChange?.()
102
- }),
103
- ],
104
- }),
105
- })
106
-
107
- return {
108
- el: wrap,
109
- view,
110
- getValue: () => view.state.doc.toString(),
111
- setValue(value: string) {
112
- view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
113
- },
114
- insertAtCaret(markdownLine: string) {
115
- const { from } = view.state.selection.main
116
- const line = view.state.doc.lineAt(from)
117
- const atLineStart = from === line.from
118
- const insert = `${atLineStart ? '' : '\n'}${markdownLine}\n`
119
- view.dispatch({
120
- changes: { from, insert },
121
- selection: { anchor: from + insert.length },
122
- scrollIntoView: true,
123
- })
124
- view.focus()
125
- },
126
- focus: () => view.focus(),
127
- }
128
- }
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
+ /** Called after every doc change (used for draft auto-save). */
72
+ onChange?: () => void
73
+ }
74
+
75
+ /**
76
+ * Build a CodeMirror 6 Markdown editor inside `.dsh-tw-note-editor`.
77
+ * GFM base language (strikethrough, tables, task lists, autolinks) — strictly
78
+ * more coverage than the old regex highlighter.
79
+ */
80
+ export function buildMarkdownEditor(opts: MarkdownEditorOptions = {}): MarkdownEditor {
81
+ const wrap = document.createElement('div')
82
+ wrap.className = 'dsh-tw-note-editor'
83
+
84
+ const view = new EditorView({
85
+ parent: wrap,
86
+ state: EditorState.create({
87
+ doc: '',
88
+ extensions: [
89
+ EditorView.lineWrapping,
90
+ history(),
91
+ markdown({ base: markdownLanguage }),
92
+ syntaxHighlighting(mdHighlight),
93
+ drawSelection(),
94
+ placeholder(opts.placeholder ?? ''),
95
+ keymap.of([
96
+ ...defaultKeymap,
97
+ ...historyKeymap,
98
+ { key: 'Mod-Enter', run: () => { opts.onSave?.(); return true } },
99
+ ]),
100
+ EditorView.updateListener.of((update) => {
101
+ if (update.docChanged) opts.onChange?.()
102
+ }),
103
+ ],
104
+ }),
105
+ })
106
+
107
+ return {
108
+ el: wrap,
109
+ view,
110
+ getValue: () => view.state.doc.toString(),
111
+ setValue(value: string) {
112
+ view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
113
+ },
114
+ insertAtCaret(markdownLine: string) {
115
+ const { from } = view.state.selection.main
116
+ const line = view.state.doc.lineAt(from)
117
+ const atLineStart = from === line.from
118
+ const insert = `${atLineStart ? '' : '\n'}${markdownLine}\n`
119
+ view.dispatch({
120
+ changes: { from, insert },
121
+ selection: { anchor: from + insert.length },
122
+ scrollIntoView: true,
123
+ })
124
+ view.focus()
125
+ },
126
+ focus: () => view.focus(),
127
+ }
128
+ }
@@ -1,133 +1,133 @@
1
- /**
2
- * Quick-note button above the chat input box (需求: 输入框上方快速笔记按钮).
3
- *
4
- * Registers into the shell's `conversation.input.dock` slot — the sanctioned
5
- * "Full-width entries above the composer card" seat that sibling plugins
6
- * (todo / cost-meter / goal / queue / git-graph) already use. Entries render as
7
- * a flex column, so this strip stacks with theirs and can never overlap or
8
- * occlude anything. Gated by `ui.showQuickNoteDock` (default on), honored at
9
- * registration time in client/index.ts.
10
- *
11
- * Clicking opens the floating quick-note card (open-only; the card itself can
12
- * only be dismissed with its ✕ button). While the card is open the button
13
- * highlights, driven by the card's `dsh-tw-note-state` CustomEvent.
14
- *
15
- * @module dsh-tiddlywiki/client/quick-note-dock
16
- */
17
- import * as React from 'react'
18
- import { NOTE_STATE_EVENT, type NoteWidgetHandle } from './note-widget.ts'
19
-
20
- /**
21
- * Build the dock entry component bound to one note-widget handle. Called once
22
- * per client mount; the returned component is what the slot renders.
23
- *
24
- * 与输入框对齐:mount 后(含 resize / 窗口变化)测量 composer 输入卡片
25
- * (Lexical contenteditable 所在、有边框圆角的可见「输入框」盒子)的右缘,
26
- * 把横条 paddingRight 设为差值,让按钮右缘与输入框右缘对齐——而不是悬在整列
27
- * 最右端。注意 dock 槽位会把各条目包在一个容器里、与输入栏是兄弟节点,所以
28
- * 必须逐级向上爬祖先才能找到输入框。
29
- */
30
- export function createQuickNoteDock(note: NoteWidgetHandle): () => React.ReactElement {
31
- return function QuickNoteDock() {
32
- const [open, setOpen] = React.useState(false)
33
- const wrapRef = React.useRef<HTMLDivElement | null>(null)
34
- const btnRef = React.useRef<HTMLButtonElement | null>(null)
35
- React.useEffect(() => {
36
- const onState = (event: Event): void => {
37
- const detail = (event as CustomEvent<{ open?: boolean }>).detail
38
- setOpen(detail?.open === true)
39
- }
40
- window.addEventListener(NOTE_STATE_EVENT, onState)
41
- return () => window.removeEventListener(NOTE_STATE_EVENT, onState)
42
- }, [])
43
- React.useLayoutEffect(() => {
44
- const wrap = wrapRef.current
45
- if (wrap === null) return
46
- /**
47
- * Find the composer input CARD: climb from the dock entry until an
48
- * ancestor's subtree contains a wide text field (>300px — the composer
49
- * input; dock entries' own inputs are small), then take the widest box
50
- * between that field and its containing column (the visible card).
51
- */
52
- const findCard = (): HTMLElement | null => {
53
- let input: Element | null = null
54
- let column: HTMLElement | null = null
55
- let node: HTMLElement | null = wrap
56
- while (node !== null && node !== document.documentElement) {
57
- node = node.parentElement
58
- if (node === null) break
59
- for (const el of node.querySelectorAll('textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
60
- if (el.getBoundingClientRect().width > 300) { input = el; column = node; break }
61
- }
62
- if (column !== null) break
63
- }
64
- if (input === null || column === null) return null
65
- const colW = column.getBoundingClientRect().width
66
- let cur: HTMLElement | null = input as HTMLElement
67
- let card: HTMLElement | null = null
68
- let cardW = 0
69
- while (cur !== null && cur !== column) {
70
- const w = cur.getBoundingClientRect().width
71
- if (w > 0 && w < colW - 8 && w >= cardW) { cardW = w; card = cur }
72
- cur = cur.parentElement
73
- }
74
- return card
75
- }
76
- const align = (): void => {
77
- const wrapRect = wrap.getBoundingClientRect()
78
- const card = findCard()
79
- const right = card !== null ? card.getBoundingClientRect().right : wrapRect.right
80
- const pad = Math.max(0, wrapRect.right - right)
81
- if (wrap.style.paddingRight !== `${pad}px`) wrap.style.paddingRight = `${pad}px`
82
- }
83
- align()
84
- // 侧边栏开/关会改变对话列宽度 → composer 输入卡片(居中、有 max-width)的
85
- // 右缘随之移动。旧实现只观察了 dock 容器,侧边栏变化时它不一定触发 resize,
86
- // 导致按钮停在上次的位置、不再对齐。这里观察「从 dock 条目一直到对话根节点
87
- // (带 data-phase)的整条祖先链」,任一祖先尺寸变化都会重测对齐。
88
- const ro = new ResizeObserver(align)
89
- let node: Element | null = wrap
90
- while (node !== null && node !== document.documentElement) {
91
- ro.observe(node)
92
- if (node instanceof HTMLElement && node.hasAttribute('data-phase')) break
93
- node = node.parentElement
94
- }
95
- window.addEventListener('resize', align)
96
- document.addEventListener('visibilitychange', align)
97
- // 自愈兜底:任何未观测到的布局变化(侧边栏切换、插件重渲染等)也会在
98
- // 1.5s 内被纠正;每帧只是几次 getBoundingClientRect 读取,开销可忽略。
99
- const guard = window.setInterval(align, 1500)
100
- // The composer card may mount slightly later; re-measure a couple of times.
101
- const t1 = window.setTimeout(align, 120)
102
- const t2 = window.setTimeout(align, 600)
103
- return () => {
104
- ro.disconnect()
105
- window.removeEventListener('resize', align)
106
- document.removeEventListener('visibilitychange', align)
107
- window.clearInterval(guard)
108
- window.clearTimeout(t1)
109
- window.clearTimeout(t2)
110
- }
111
- }, [])
112
- return React.createElement(
113
- 'div',
114
- { ref: wrapRef, className: 'dsh-tw-dock-note' },
115
- React.createElement(
116
- 'button',
117
- {
118
- ref: btnRef,
119
- type: 'button',
120
- className: open ? 'dsh-tw-dock-note-btn dsh-tw-dock-note-btn-active' : 'dsh-tw-dock-note-btn',
121
- title: open ? '快速笔记已打开(点此按钮或卡片右上角 ✕ 收起)' : '打开快速笔记(在按钮上方弹出,可拖动标题栏移动)',
122
- onClick: () => {
123
- // 开关:打开时再次点击即收起;打开时在按钮上方弹出(✕ 亦可关闭)。
124
- if (note.isOpen()) note.close()
125
- else void note.open(btnRef.current ?? undefined)
126
- },
127
- },
128
- React.createElement('span', { className: 'dsh-tw-dock-note-icon', 'aria-hidden': 'true' }, '📝'),
129
- React.createElement('span', { className: 'dsh-tw-dock-note-label' }, open ? '快速笔记(已打开)' : '快速笔记'),
130
- ),
131
- )
132
- }
133
- }
1
+ /**
2
+ * Quick-note button above the chat input box (需求: 输入框上方快速笔记按钮).
3
+ *
4
+ * Registers into the shell's `conversation.input.dock` slot — the sanctioned
5
+ * "Full-width entries above the composer card" seat that sibling plugins
6
+ * (todo / cost-meter / goal / queue / git-graph) already use. Entries render as
7
+ * a flex column, so this strip stacks with theirs and can never overlap or
8
+ * occlude anything. Gated by `ui.showQuickNoteDock` (default on), honored at
9
+ * registration time in client/index.ts.
10
+ *
11
+ * Clicking opens the floating quick-note card (open-only; the card itself can
12
+ * only be dismissed with its ✕ button). While the card is open the button
13
+ * highlights, driven by the card's `dsh-tw-note-state` CustomEvent.
14
+ *
15
+ * @module dsh-tiddlywiki/client/quick-note-dock
16
+ */
17
+ import * as React from 'react'
18
+ import { NOTE_STATE_EVENT, type NoteWidgetHandle } from './note-widget.ts'
19
+
20
+ /**
21
+ * Build the dock entry component bound to one note-widget handle. Called once
22
+ * per client mount; the returned component is what the slot renders.
23
+ *
24
+ * 与输入框对齐:mount 后(含 resize / 窗口变化)测量 composer 输入卡片
25
+ * (Lexical contenteditable 所在、有边框圆角的可见「输入框」盒子)的右缘,
26
+ * 把横条 paddingRight 设为差值,让按钮右缘与输入框右缘对齐——而不是悬在整列
27
+ * 最右端。注意 dock 槽位会把各条目包在一个容器里、与输入栏是兄弟节点,所以
28
+ * 必须逐级向上爬祖先才能找到输入框。
29
+ */
30
+ export function createQuickNoteDock(note: NoteWidgetHandle): () => React.ReactElement {
31
+ return function QuickNoteDock() {
32
+ const [open, setOpen] = React.useState(false)
33
+ const wrapRef = React.useRef<HTMLDivElement | null>(null)
34
+ const btnRef = React.useRef<HTMLButtonElement | null>(null)
35
+ React.useEffect(() => {
36
+ const onState = (event: Event): void => {
37
+ const detail = (event as CustomEvent<{ open?: boolean }>).detail
38
+ setOpen(detail?.open === true)
39
+ }
40
+ window.addEventListener(NOTE_STATE_EVENT, onState)
41
+ return () => window.removeEventListener(NOTE_STATE_EVENT, onState)
42
+ }, [])
43
+ React.useLayoutEffect(() => {
44
+ const wrap = wrapRef.current
45
+ if (wrap === null) return
46
+ /**
47
+ * Find the composer input CARD: climb from the dock entry until an
48
+ * ancestor's subtree contains a wide text field (>300px — the composer
49
+ * input; dock entries' own inputs are small), then take the widest box
50
+ * between that field and its containing column (the visible card).
51
+ */
52
+ const findCard = (): HTMLElement | null => {
53
+ let input: Element | null = null
54
+ let column: HTMLElement | null = null
55
+ let node: HTMLElement | null = wrap
56
+ while (node !== null && node !== document.documentElement) {
57
+ node = node.parentElement
58
+ if (node === null) break
59
+ for (const el of node.querySelectorAll('textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
60
+ if (el.getBoundingClientRect().width > 300) { input = el; column = node; break }
61
+ }
62
+ if (column !== null) break
63
+ }
64
+ if (input === null || column === null) return null
65
+ const colW = column.getBoundingClientRect().width
66
+ let cur: HTMLElement | null = input as HTMLElement
67
+ let card: HTMLElement | null = null
68
+ let cardW = 0
69
+ while (cur !== null && cur !== column) {
70
+ const w = cur.getBoundingClientRect().width
71
+ if (w > 0 && w < colW - 8 && w >= cardW) { cardW = w; card = cur }
72
+ cur = cur.parentElement
73
+ }
74
+ return card
75
+ }
76
+ const align = (): void => {
77
+ const wrapRect = wrap.getBoundingClientRect()
78
+ const card = findCard()
79
+ const right = card !== null ? card.getBoundingClientRect().right : wrapRect.right
80
+ const pad = Math.max(0, wrapRect.right - right)
81
+ if (wrap.style.paddingRight !== `${pad}px`) wrap.style.paddingRight = `${pad}px`
82
+ }
83
+ align()
84
+ // 侧边栏开/关会改变对话列宽度 → composer 输入卡片(居中、有 max-width)的
85
+ // 右缘随之移动。旧实现只观察了 dock 容器,侧边栏变化时它不一定触发 resize,
86
+ // 导致按钮停在上次的位置、不再对齐。这里观察「从 dock 条目一直到对话根节点
87
+ // (带 data-phase)的整条祖先链」,任一祖先尺寸变化都会重测对齐。
88
+ const ro = new ResizeObserver(align)
89
+ let node: Element | null = wrap
90
+ while (node !== null && node !== document.documentElement) {
91
+ ro.observe(node)
92
+ if (node instanceof HTMLElement && node.hasAttribute('data-phase')) break
93
+ node = node.parentElement
94
+ }
95
+ window.addEventListener('resize', align)
96
+ document.addEventListener('visibilitychange', align)
97
+ // 自愈兜底:任何未观测到的布局变化(侧边栏切换、插件重渲染等)也会在
98
+ // 1.5s 内被纠正;每帧只是几次 getBoundingClientRect 读取,开销可忽略。
99
+ const guard = window.setInterval(align, 1500)
100
+ // The composer card may mount slightly later; re-measure a couple of times.
101
+ const t1 = window.setTimeout(align, 120)
102
+ const t2 = window.setTimeout(align, 600)
103
+ return () => {
104
+ ro.disconnect()
105
+ window.removeEventListener('resize', align)
106
+ document.removeEventListener('visibilitychange', align)
107
+ window.clearInterval(guard)
108
+ window.clearTimeout(t1)
109
+ window.clearTimeout(t2)
110
+ }
111
+ }, [])
112
+ return React.createElement(
113
+ 'div',
114
+ { ref: wrapRef, className: 'dsh-tw-dock-note' },
115
+ React.createElement(
116
+ 'button',
117
+ {
118
+ ref: btnRef,
119
+ type: 'button',
120
+ className: open ? 'dsh-tw-dock-note-btn dsh-tw-dock-note-btn-active' : 'dsh-tw-dock-note-btn',
121
+ title: open ? '快速笔记已打开(点此按钮或卡片右上角 ✕ 收起)' : '打开快速笔记(在按钮上方弹出,可拖动标题栏移动)',
122
+ onClick: () => {
123
+ // 开关:打开时再次点击即收起;打开时在按钮上方弹出(✕ 亦可关闭)。
124
+ if (note.isOpen()) note.close()
125
+ else void note.open(btnRef.current ?? undefined)
126
+ },
127
+ },
128
+ React.createElement('span', { className: 'dsh-tw-dock-note-icon', 'aria-hidden': 'true' }, '📝'),
129
+ React.createElement('span', { className: 'dsh-tw-dock-note-label' }, open ? '快速笔记(已打开)' : '快速笔记'),
130
+ ),
131
+ )
132
+ }
133
+ }