dsh-tiddlywiki 0.28.2 → 0.28.4
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 +2 -0
- package/docs/CHANGELOG.md +2 -0
- package/lib/client.js +52 -28
- package/lib/index.js +63 -3
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/dock-align.ts +121 -0
- package/src/client/knowledge-fab.ts +38 -4
- package/src/client/note-widget.ts +10 -1
- package/src/client/quick-note-dock.ts +5 -80
- package/src/client/settings-page.ts +45 -1
- package/src/client/sidebar-entry.ts +112 -19
- package/src/client/status-cache.ts +2 -0
- package/src/client/styles.ts +25 -1
- package/src/client/wiki-icon.ts +65 -0
- package/src/client/wiki-scope-dock.ts +12 -1
- package/src/host/seed-notes.ts +22 -0
- package/src/host/wiki-registry.ts +47 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tiddlywiki",
|
|
3
3
|
"description": "把 TiddlyWiki 5 变成你和 Agent 共用的本地文档中心(纯文本 + git):你在 DSH 界面里阅读、写作、记日记,Agent 用 15 个 tiddlywiki_* 工具读写同一批笔记;每条笔记都是你能读、能改、能审计、能随时带走的文件——不是自动记忆插件,不做后台抓取。可选一键发布到微信公众号草稿箱。 Turn TiddlyWiki 5 into a document center you and your agent share, in plain text under git — a knowledge base you can read, edit, audit and take with you, not an auto-memory plugin. Mounts via the official dsh plugin system; no DSH source changes.",
|
|
4
|
-
"version": "0.28.
|
|
4
|
+
"version": "0.28.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"dsh",
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Align a `conversation.input.dock` entry with the composer input card.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS (v0.28.2)
|
|
5
|
+
* -------------------------
|
|
6
|
+
* The dock slot renders entries as a full-width flex column that is a SIBLING of
|
|
7
|
+
* the composer, not inside it. The composer's input card is centred and has its
|
|
8
|
+
* own max-width, so an entry's right edge sits far to the right of the input box
|
|
9
|
+
* — visibly misaligned.
|
|
10
|
+
*
|
|
11
|
+
* The fix is to measure the composer card and pad the entry's right side by the
|
|
12
|
+
* difference. That measurement used to live only inside `quick-note-dock.ts`, so
|
|
13
|
+
* the moment a SECOND dock entry was added (the per-session knowledge-base
|
|
14
|
+
* selector) it simply did not align — nobody had published the rule. This module
|
|
15
|
+
* is that rule, written once.
|
|
16
|
+
*
|
|
17
|
+
* HOW IT FINDS THE CARD
|
|
18
|
+
* ---------------------
|
|
19
|
+
* Climb from the entry until an ancestor's subtree contains a wide text field
|
|
20
|
+
* (>300px — the composer input; dock entries' own inputs are small), then take
|
|
21
|
+
* the widest box between that field and its containing column (the visible card).
|
|
22
|
+
*
|
|
23
|
+
* WHEN IT RE-MEASURES
|
|
24
|
+
* -------------------
|
|
25
|
+
* ResizeObserver on the whole ancestor chain up to the conversation root
|
|
26
|
+
* (`[data-phase]`), plus `resize` / `visibilitychange`, plus a short self-healing
|
|
27
|
+
* interval that stops once the layout has measured cleanly three times (or after
|
|
28
|
+
* 20 tries, so a shell we cannot understand never leaks a timer).
|
|
29
|
+
*
|
|
30
|
+
* @module dsh-tiddlywiki/client/dock-align
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** The measurement/padding key, exported so the gate can assert on it. */
|
|
34
|
+
export const DOCK_ALIGN_MIN_INPUT_WIDTH = 300
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Start aligning `wrap` with the composer input card. Returns the disposer; call
|
|
38
|
+
* it from the component's effect cleanup.
|
|
39
|
+
*
|
|
40
|
+
* A React ref is intentionally NOT taken: the caller passes its own element, so
|
|
41
|
+
* this works for any dock entry regardless of how it renders.
|
|
42
|
+
*/
|
|
43
|
+
export function alignDockEntry(wrap: HTMLElement): () => void {
|
|
44
|
+
/** Find the composer input CARD (see the module doc for the algorithm). */
|
|
45
|
+
const findCard = (): HTMLElement | null => {
|
|
46
|
+
let input: Element | null = null
|
|
47
|
+
let column: HTMLElement | null = null
|
|
48
|
+
let node: HTMLElement | null = wrap
|
|
49
|
+
while (node !== null && node !== document.documentElement) {
|
|
50
|
+
node = node.parentElement
|
|
51
|
+
if (node === null) break
|
|
52
|
+
for (const el of node.querySelectorAll('textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
|
|
53
|
+
if (el.getBoundingClientRect().width > DOCK_ALIGN_MIN_INPUT_WIDTH) { input = el; column = node; break }
|
|
54
|
+
}
|
|
55
|
+
if (column !== null) break
|
|
56
|
+
}
|
|
57
|
+
if (input === null || column === null) return null
|
|
58
|
+
const colW = column.getBoundingClientRect().width
|
|
59
|
+
let cur: HTMLElement | null = input as HTMLElement
|
|
60
|
+
let card: HTMLElement | null = null
|
|
61
|
+
let cardW = 0
|
|
62
|
+
while (cur !== null && cur !== column) {
|
|
63
|
+
const w = cur.getBoundingClientRect().width
|
|
64
|
+
if (w > 0 && w < colW - 8 && w >= cardW) { cardW = w; card = cur }
|
|
65
|
+
cur = cur.parentElement
|
|
66
|
+
}
|
|
67
|
+
return card
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Measure once; returns true when the composer card was found (valid measure). */
|
|
71
|
+
const align = (): boolean => {
|
|
72
|
+
const wrapRect = wrap.getBoundingClientRect()
|
|
73
|
+
const card = findCard()
|
|
74
|
+
const right = card !== null ? card.getBoundingClientRect().right : wrapRect.right
|
|
75
|
+
const pad = Math.max(0, wrapRect.right - right)
|
|
76
|
+
if (wrap.style.paddingRight !== `${pad}px`) wrap.style.paddingRight = `${pad}px`
|
|
77
|
+
return card !== null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
align()
|
|
81
|
+
// A sidebar toggle changes the conversation column width → the centred,
|
|
82
|
+
// max-width composer card moves → the padding must be re-measured. Observing
|
|
83
|
+
// only the dock container misses that (it may not resize at all), so observe
|
|
84
|
+
// the whole ancestor chain up to the conversation root.
|
|
85
|
+
const ro = new ResizeObserver(() => { align() })
|
|
86
|
+
let node: Element | null = wrap
|
|
87
|
+
while (node !== null && node !== document.documentElement) {
|
|
88
|
+
ro.observe(node)
|
|
89
|
+
if (node instanceof HTMLElement && node.hasAttribute('data-phase')) break
|
|
90
|
+
node = node.parentElement
|
|
91
|
+
}
|
|
92
|
+
window.addEventListener('resize', align)
|
|
93
|
+
document.addEventListener('visibilitychange', align)
|
|
94
|
+
|
|
95
|
+
// Self-healing fallback for layout changes we cannot observe. Each tick is a
|
|
96
|
+
// few getBoundingClientRect reads. It stops after 3 clean measurements, and
|
|
97
|
+
// gives up after 20 tries so an unrecognisable shell cannot leak a timer.
|
|
98
|
+
let guardHits = 0
|
|
99
|
+
let guardTicks = 0
|
|
100
|
+
const guard = window.setInterval(() => {
|
|
101
|
+
guardTicks++
|
|
102
|
+
if (!align()) {
|
|
103
|
+
if (guardTicks >= 20) window.clearInterval(guard)
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
guardHits++
|
|
107
|
+
if (guardHits >= 3) window.clearInterval(guard)
|
|
108
|
+
}, 1500)
|
|
109
|
+
// The composer card may mount slightly later; re-measure a couple of times.
|
|
110
|
+
const t1 = window.setTimeout(align, 120)
|
|
111
|
+
const t2 = window.setTimeout(align, 600)
|
|
112
|
+
|
|
113
|
+
return () => {
|
|
114
|
+
ro.disconnect()
|
|
115
|
+
window.removeEventListener('resize', align)
|
|
116
|
+
document.removeEventListener('visibilitychange', align)
|
|
117
|
+
window.clearInterval(guard)
|
|
118
|
+
window.clearTimeout(t1)
|
|
119
|
+
window.clearTimeout(t2)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -21,7 +21,7 @@ import type { NoteWidgetHandle } from './note-widget.ts'
|
|
|
21
21
|
import type { SyncController } from './sync-button.ts'
|
|
22
22
|
import { PANEL_RELOAD_EVENT } from './tw-frame.ts'
|
|
23
23
|
import { ROUTE_PREFIX } from './endpoints.ts'
|
|
24
|
-
import { resolveFocusWiki, setFocusWiki } from './wiki-focus.ts'
|
|
24
|
+
import { resolveFocusWiki, setFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
|
|
25
25
|
|
|
26
26
|
import { fetchStatus } from './status-cache.ts'
|
|
27
27
|
import { fetchUiConfig } from './ui-config.ts'
|
|
@@ -91,6 +91,13 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
91
91
|
let tip: HTMLDivElement | undefined
|
|
92
92
|
let panelLabel: HTMLSpanElement | undefined
|
|
93
93
|
let menuOpen = false
|
|
94
|
+
/** 焦点库订阅(多库菜单需要,dispose 时回收)。 */
|
|
95
|
+
let focusOff: (() => void) | undefined
|
|
96
|
+
/**
|
|
97
|
+
* 重画"知识库"分组的选中标记。打开菜单前调用一次 —— 订阅负责切库时即时重画,
|
|
98
|
+
* 这里负责兜住"订阅建立之前的窗口"与外部改动(v0.28.3)。
|
|
99
|
+
*/
|
|
100
|
+
let repaintWikiMenu: (() => void) | undefined
|
|
94
101
|
/** Latest TW health snapshot, merged into the hover tip. */
|
|
95
102
|
let twHealth: TwHealth = { state: 'unknown', text: 'TiddlyWiki 服务…', logs: [] }
|
|
96
103
|
|
|
@@ -196,17 +203,33 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
196
203
|
// 多知识库切换(v0.28.0):只有多于一个库时才长出这一段,单库安装的菜单不变。
|
|
197
204
|
// 点一下 = 把"焦点库"切过去并打开面板——面板会加载那个库自己的 /tw/<id>/。
|
|
198
205
|
if (roster.wikis.length > 1) {
|
|
199
|
-
const focus = resolveFocusWiki(roster.wikis, roster.defaultId)
|
|
200
206
|
const heading = document.createElement('div')
|
|
201
207
|
heading.className = 'dsh-tw-fab-group'
|
|
202
208
|
heading.textContent = '知识库'
|
|
203
209
|
menu.append(heading)
|
|
210
|
+
/** 每个库的菜单项,便于焦点变化时就地刷新选中标记。 */
|
|
211
|
+
const wikiItems = new Map<string, HTMLButtonElement>()
|
|
212
|
+
/**
|
|
213
|
+
* 重画选中标记(v0.28.3)。
|
|
214
|
+
*
|
|
215
|
+
* 原来的实现只在**菜单构建时**算一次 focus,之后无论怎么切都不再更新 ——
|
|
216
|
+
* 于是"切了库、选中项还停在上一个"(作者报障)。这里改成从当前焦点重算,
|
|
217
|
+
* 并在 setFocusWiki 的订阅里重画;菜单关闭时也重画,保证下次打开是对的。
|
|
218
|
+
*/
|
|
219
|
+
const paint = (): void => {
|
|
220
|
+
const current = resolveFocusWiki(roster.wikis, roster.defaultId)
|
|
221
|
+
for (const [id, el] of wikiItems) {
|
|
222
|
+
const isCurrent = id === current
|
|
223
|
+
el.dataset.current = isCurrent ? '1' : '0'
|
|
224
|
+
const wiki = roster.wikis.find((w) => w.id === id)
|
|
225
|
+
el.textContent = `${isCurrent ? '●' : '○'} ${wiki?.label ?? id}`
|
|
226
|
+
}
|
|
227
|
+
}
|
|
204
228
|
for (const wiki of roster.wikis) {
|
|
205
229
|
const item = document.createElement('button')
|
|
206
230
|
item.type = 'button'
|
|
207
231
|
item.className = 'dsh-tw-fab-item'
|
|
208
|
-
item.dataset.current =
|
|
209
|
-
item.textContent = `${wiki.id === focus ? '●' : '○'} ${wiki.label}`
|
|
232
|
+
item.dataset.current = '0'
|
|
210
233
|
item.title = `${wiki.path}${wiki.running ? '' : '(未运行,打开会启动)'}`
|
|
211
234
|
item.addEventListener('click', () => {
|
|
212
235
|
closeMenu()
|
|
@@ -224,8 +247,14 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
224
247
|
}
|
|
225
248
|
state.openPanel()
|
|
226
249
|
})
|
|
250
|
+
wikiItems.set(wiki.id, item)
|
|
227
251
|
menu.append(item)
|
|
228
252
|
}
|
|
253
|
+
paint()
|
|
254
|
+
// 焦点变化(可能来自别的入口:侧边栏某一行、快速笔记卡片)也要跟上。
|
|
255
|
+
focusOff = subscribeFocusWiki(() => { paint() })
|
|
256
|
+
// 每次打开菜单前重画一次,避免因为订阅时机错过而显示旧值。
|
|
257
|
+
repaintWikiMenu = paint
|
|
229
258
|
}
|
|
230
259
|
|
|
231
260
|
panelLabel = document.createElement('span')
|
|
@@ -280,6 +309,9 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
280
309
|
if (menuOpen) { closeMenu(); return }
|
|
281
310
|
menuOpen = true
|
|
282
311
|
menu.hidden = false
|
|
312
|
+
// 每次打开都重画一次选中标记(v0.28.3):切库可能发生在菜单关着的时候
|
|
313
|
+
// (侧边栏入口、快速笔记卡片),不重画就会显示上一次的选中项。
|
|
314
|
+
repaintWikiMenu?.()
|
|
283
315
|
renderDot()
|
|
284
316
|
void refreshTwStatus()
|
|
285
317
|
})
|
|
@@ -307,6 +339,8 @@ export function mountKnowledgeFab(state: PanelState, note: NoteWidgetHandle, syn
|
|
|
307
339
|
|
|
308
340
|
return () => {
|
|
309
341
|
disposed = true
|
|
342
|
+
focusOff?.()
|
|
343
|
+
focusOff = undefined
|
|
310
344
|
document.removeEventListener('click', onDocumentClick, true)
|
|
311
345
|
unsubPanel()
|
|
312
346
|
unsubSync()
|
|
@@ -835,7 +835,16 @@ export function createNoteWidget(): NoteWidgetHandle {
|
|
|
835
835
|
if (disposed) return
|
|
836
836
|
rosterMode = typeof payload?.mode === 'string' ? payload.mode : 'single'
|
|
837
837
|
roster = (Array.isArray(payload?.wikis) ? payload.wikis : []).map((item) => ({ id: item.id, label: item.label, running: item.running }))
|
|
838
|
-
|
|
838
|
+
// 单库:连选择器都不该存在。这里**还有一个**兜底 —— 除 `hidden` 外把 select 禁用、
|
|
839
|
+
// 标签也藏掉,因为"能看见一个没有选项的下拉"比"什么都不显示"糟得多(v0.28.1 修)。
|
|
840
|
+
if (roster.length <= 1) {
|
|
841
|
+
wikiField.hidden = true
|
|
842
|
+
wikiField.style.display = 'none'
|
|
843
|
+
wikiSelect.disabled = true
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
wikiField.style.display = ''
|
|
847
|
+
wikiSelect.disabled = false
|
|
839
848
|
const defaultId = typeof payload?.defaultId === 'string' ? payload.defaultId : undefined
|
|
840
849
|
const followFocus = (): void => {
|
|
841
850
|
if (!targetPicked) targetWiki = resolveFocusWiki(roster, defaultId)
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import * as React from 'react'
|
|
18
18
|
import { NOTE_STATE_EVENT, type NoteWidgetHandle } from './note-widget.ts'
|
|
19
19
|
import { fetchUiConfig } from './ui-config.ts'
|
|
20
|
+
import { alignDockEntry } from './dock-align.ts'
|
|
20
21
|
import { isEditorPopupOpen, isEditorPopupBlank, closeEditorPopup } from './editor-popup.ts'
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -54,89 +55,13 @@ export function createQuickNoteDock(note: NoteWidgetHandle): () => React.ReactEl
|
|
|
54
55
|
window.removeEventListener(NOTE_STATE_EVENT, onState)
|
|
55
56
|
}
|
|
56
57
|
}, [])
|
|
58
|
+
// 与 composer 输入框右缘对齐:测量逻辑已抽到 client/dock-align.ts(v0.28.2),
|
|
59
|
+
// 因为第二个 dock 条目(会话级知识库选择器)加进来时它没有对齐 —— 规则只写在
|
|
60
|
+
// 这里,别人看不见。
|
|
57
61
|
React.useLayoutEffect(() => {
|
|
58
62
|
const wrap = wrapRef.current
|
|
59
63
|
if (wrap === null) return
|
|
60
|
-
|
|
61
|
-
* Find the composer input CARD: climb from the dock entry until an
|
|
62
|
-
* ancestor's subtree contains a wide text field (>300px — the composer
|
|
63
|
-
* input; dock entries' own inputs are small), then take the widest box
|
|
64
|
-
* between that field and its containing column (the visible card).
|
|
65
|
-
*/
|
|
66
|
-
const findCard = (): HTMLElement | null => {
|
|
67
|
-
let input: Element | null = null
|
|
68
|
-
let column: HTMLElement | null = null
|
|
69
|
-
let node: HTMLElement | null = wrap
|
|
70
|
-
while (node !== null && node !== document.documentElement) {
|
|
71
|
-
node = node.parentElement
|
|
72
|
-
if (node === null) break
|
|
73
|
-
for (const el of node.querySelectorAll('textarea, [contenteditable]:not([contenteditable="false"]), [role="textbox"]')) {
|
|
74
|
-
if (el.getBoundingClientRect().width > 300) { input = el; column = node; break }
|
|
75
|
-
}
|
|
76
|
-
if (column !== null) break
|
|
77
|
-
}
|
|
78
|
-
if (input === null || column === null) return null
|
|
79
|
-
const colW = column.getBoundingClientRect().width
|
|
80
|
-
let cur: HTMLElement | null = input as HTMLElement
|
|
81
|
-
let card: HTMLElement | null = null
|
|
82
|
-
let cardW = 0
|
|
83
|
-
while (cur !== null && cur !== column) {
|
|
84
|
-
const w = cur.getBoundingClientRect().width
|
|
85
|
-
if (w > 0 && w < colW - 8 && w >= cardW) { cardW = w; card = cur }
|
|
86
|
-
cur = cur.parentElement
|
|
87
|
-
}
|
|
88
|
-
return card
|
|
89
|
-
}
|
|
90
|
-
/** 测量一次对齐;返回 true = 找到了 composer 输入卡片(测量有效)。 */
|
|
91
|
-
const align = (): boolean => {
|
|
92
|
-
const wrapRect = wrap.getBoundingClientRect()
|
|
93
|
-
const card = findCard()
|
|
94
|
-
const right = card !== null ? card.getBoundingClientRect().right : wrapRect.right
|
|
95
|
-
const pad = Math.max(0, wrapRect.right - right)
|
|
96
|
-
if (wrap.style.paddingRight !== `${pad}px`) wrap.style.paddingRight = `${pad}px`
|
|
97
|
-
return card !== null
|
|
98
|
-
}
|
|
99
|
-
align()
|
|
100
|
-
// 侧边栏开/关会改变对话列宽度 → composer 输入卡片(居中、有 max-width)的
|
|
101
|
-
// 右缘随之移动。旧实现只观察了 dock 容器,侧边栏变化时它不一定触发 resize,
|
|
102
|
-
// 导致按钮停在上次的位置、不再对齐。这里观察「从 dock 条目一直到对话根节点
|
|
103
|
-
// (带 data-phase)的整条祖先链」,任一祖先尺寸变化都会重测对齐。
|
|
104
|
-
const ro = new ResizeObserver(align)
|
|
105
|
-
let node: Element | null = wrap
|
|
106
|
-
while (node !== null && node !== document.documentElement) {
|
|
107
|
-
ro.observe(node)
|
|
108
|
-
if (node instanceof HTMLElement && node.hasAttribute('data-phase')) break
|
|
109
|
-
node = node.parentElement
|
|
110
|
-
}
|
|
111
|
-
window.addEventListener('resize', align)
|
|
112
|
-
document.addEventListener('visibilitychange', align)
|
|
113
|
-
// 自愈兜底:任何未观测到的布局变化(侧边栏切换、插件重渲染等)也会在
|
|
114
|
-
// 1.5s 内被纠正;每帧只是几次 getBoundingClientRect 读取,开销可忽略。
|
|
115
|
-
// 但常驻定时器没有必要:连续 3 次测量成功(找到 composer 输入卡片)即认为
|
|
116
|
-
// 布局已稳定,清掉 interval;即使一直测不到(异常 shell),也在 20 次后
|
|
117
|
-
// 强制收手,之后由 ResizeObserver + resize + visibilitychange 覆盖。
|
|
118
|
-
let guardHits = 0
|
|
119
|
-
let guardTicks = 0
|
|
120
|
-
const guard = window.setInterval(() => {
|
|
121
|
-
guardTicks++
|
|
122
|
-
if (!align()) {
|
|
123
|
-
if (guardTicks >= 20) window.clearInterval(guard)
|
|
124
|
-
return
|
|
125
|
-
}
|
|
126
|
-
guardHits++
|
|
127
|
-
if (guardHits >= 3) window.clearInterval(guard)
|
|
128
|
-
}, 1500)
|
|
129
|
-
// The composer card may mount slightly later; re-measure a couple of times.
|
|
130
|
-
const t1 = window.setTimeout(align, 120)
|
|
131
|
-
const t2 = window.setTimeout(align, 600)
|
|
132
|
-
return () => {
|
|
133
|
-
ro.disconnect()
|
|
134
|
-
window.removeEventListener('resize', align)
|
|
135
|
-
document.removeEventListener('visibilitychange', align)
|
|
136
|
-
window.clearInterval(guard)
|
|
137
|
-
window.clearTimeout(t1)
|
|
138
|
-
window.clearTimeout(t2)
|
|
139
|
-
}
|
|
64
|
+
return alignDockEntry(wrap)
|
|
140
65
|
}, [])
|
|
141
66
|
const native = mode === 'native'
|
|
142
67
|
const label = native ? '快速笔记' : (open ? '快速笔记(已打开)' : '快速笔记')
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
import * as React from 'react'
|
|
25
25
|
import { toast } from './toast.ts'
|
|
26
26
|
import { invalidateUiConfig } from './ui-config.ts'
|
|
27
|
+
import { ICON_NAMES } from './wiki-icon.ts'
|
|
27
28
|
import {
|
|
28
29
|
ADMIN_CONFIG_ENDPOINT as CONFIG_ENDPOINT,
|
|
29
30
|
ADMIN_INFO_ENDPOINT as INFO_ENDPOINT,
|
|
@@ -943,8 +944,28 @@ interface WikiListItemView {
|
|
|
943
944
|
autostart: boolean
|
|
944
945
|
running: boolean
|
|
945
946
|
status: string
|
|
947
|
+
/** Per-wiki entry icon (v0.28.4): a curated name or a short emoji. */
|
|
948
|
+
icon?: string
|
|
946
949
|
}
|
|
947
950
|
|
|
951
|
+
/**
|
|
952
|
+
* Icon picker labels (v0.28.4). The NAMES come from `wiki-icon.ts`, so the set
|
|
953
|
+
* the picker offers is exactly the set the sidebar can render.
|
|
954
|
+
*/
|
|
955
|
+
const ICON_LABELS: Record<string, string> = {
|
|
956
|
+
book: '📖 书(book)',
|
|
957
|
+
briefcase: '💼 公文包(briefcase)',
|
|
958
|
+
home: '🏠 房子(home)',
|
|
959
|
+
notebook: '📓 笔记本(notebook)',
|
|
960
|
+
flask: '🧪 实验(flask)',
|
|
961
|
+
globe: '🌐 地球(globe)',
|
|
962
|
+
star: '⭐ 星标(star)',
|
|
963
|
+
archive: '🗄 归档(archive)',
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/** A few emoji that read well at sidebar size and are easy to tell apart. */
|
|
967
|
+
const EMOJI_CHOICES = ['📚', '💼', '🏠', '✍️', '🧪', '🌱', '🎯', '🗂️']
|
|
968
|
+
|
|
948
969
|
/** `GET`/`POST /dsh-tiddlywiki/admin/wikis` payload (v0.28.0). */
|
|
949
970
|
interface WikisView {
|
|
950
971
|
ok?: boolean
|
|
@@ -1111,6 +1132,29 @@ function renderWikiListSection(body: HTMLElement, isDisposed: () => boolean, ref
|
|
|
1111
1132
|
const ok = window.confirm(`把「${wiki.label}」移出清单?\n\n**目录与内容不会被删除**(仍在 ${wiki.path}),只是插件不再管理它。`)
|
|
1112
1133
|
if (ok) void post({ action: 'remove', id: wiki.id }, '移出中…')
|
|
1113
1134
|
})
|
|
1135
|
+
// 图标选择(v0.28.4):下拉里是内置图形 + 一组常用 emoji;选「默认」即清除自定义。
|
|
1136
|
+
// 它和 label 一样属于"库的身份",所以在列表里改,而不是塞进常规配置区。
|
|
1137
|
+
const iconPicker = make('select', 'dsh-tw-settings-icon-select')
|
|
1138
|
+
iconPicker.setAttribute('aria-label', `「${wiki.label}」的入口图标`)
|
|
1139
|
+
const currentIcon = typeof wiki.icon === 'string' ? wiki.icon : ''
|
|
1140
|
+
const options: Array<[string, string]> = [['', '默认图标']]
|
|
1141
|
+
for (const name of ICON_NAMES) options.push([name, ICON_LABELS[name] ?? name])
|
|
1142
|
+
for (const emoji of EMOJI_CHOICES) options.push([emoji, emoji])
|
|
1143
|
+
// 当前值不在候选里(用户填了自己的 emoji):保留它,别让下拉把它抹掉。
|
|
1144
|
+
if (currentIcon.length > 0 && !options.some(([v]) => v === currentIcon)) options.push([currentIcon, currentIcon])
|
|
1145
|
+
for (const [value, text] of options) {
|
|
1146
|
+
const opt = document.createElement('option')
|
|
1147
|
+
opt.value = value
|
|
1148
|
+
opt.textContent = text
|
|
1149
|
+
iconPicker.append(opt)
|
|
1150
|
+
}
|
|
1151
|
+
iconPicker.value = currentIcon
|
|
1152
|
+
iconPicker.addEventListener('change', () => {
|
|
1153
|
+
const next = iconPicker.value
|
|
1154
|
+
void post(next.length > 0
|
|
1155
|
+
? { action: 'update', wiki: { ...wiki, icon: next } }
|
|
1156
|
+
: { action: 'update', wiki: { ...wiki, icon: '' } }, '保存中…')
|
|
1157
|
+
})
|
|
1114
1158
|
// 「改目录」只在多库模式出现(v0.28.1):单库模式下这件事由「知识库位置」负责
|
|
1115
1159
|
// (它写 wiki 之外的指针文件),两处并存会让用户不知道以哪个为准。
|
|
1116
1160
|
if (view.mode === 'multi') {
|
|
@@ -1132,7 +1176,7 @@ function renderWikiListSection(body: HTMLElement, isDisposed: () => boolean, ref
|
|
|
1132
1176
|
})
|
|
1133
1177
|
actions.append(move)
|
|
1134
1178
|
}
|
|
1135
|
-
actions.append(configure, power, makeDefault, visibility, autostart, remove)
|
|
1179
|
+
actions.append(configure, power, makeDefault, visibility, autostart, iconPicker, remove)
|
|
1136
1180
|
row.append(actions)
|
|
1137
1181
|
list.append(row)
|
|
1138
1182
|
}
|
|
@@ -11,12 +11,13 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { PanelState } from './state.ts'
|
|
13
13
|
import { fetchUiConfig, subscribeUiConfig } from './ui-config.ts'
|
|
14
|
+
import { fetchStatus } from './status-cache.ts'
|
|
15
|
+
import { resolveFocusWiki, setFocusWiki, subscribeFocusWiki } from './wiki-focus.ts'
|
|
16
|
+
import { applyWikiIcon } from './wiki-icon.ts'
|
|
14
17
|
|
|
15
18
|
/** Stable data attribute identifying this entry row. */
|
|
16
19
|
export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
|
|
17
20
|
|
|
18
|
-
/** Inline icon: a wiki page with a TiddlyWiki-style "T" (nav-icon look). */
|
|
19
|
-
const ICON = '<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 2.5h8a1 1 0 0 1 1 1v9a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-9a1 1 0 0 1 1-1z"/><path d="M6 6h4M6 8.5h2.5"/></svg>'
|
|
20
21
|
|
|
21
22
|
/** Family entries from sibling plugins, kept in a stable relative order. */
|
|
22
23
|
const FAMILY_SELECTOR = '[data-dsh-tw-entry], [data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]'
|
|
@@ -46,22 +47,38 @@ function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
|
46
47
|
return buttons.find(button => !button.matches(ENTRY_SELECTOR) && /新会话|新建会话|new session/i.test(button.textContent ?? ''))
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Build one entry row (a detached button; inserted once the shell is up).
|
|
52
|
+
*
|
|
53
|
+
* `wikiId` is optional and only used in multi-wiki mode: clicking the row focuses
|
|
54
|
+
* that knowledge base before toggling the panel, so the row a user clicks is the
|
|
55
|
+
* wiki they get. Single-wiki installs pass nothing and behave exactly as before.
|
|
56
|
+
*/
|
|
57
|
+
function createEntry(
|
|
58
|
+
state: PanelState,
|
|
59
|
+
label: string,
|
|
60
|
+
wikiId?: string,
|
|
61
|
+
icon?: string,
|
|
62
|
+
): { entry: HTMLButtonElement; labelEl: HTMLSpanElement; iconEl: HTMLSpanElement } {
|
|
51
63
|
const entry = document.createElement('button')
|
|
52
64
|
entry.type = 'button'
|
|
53
65
|
entry.dataset.dshTwEntry = ''
|
|
54
66
|
entry.className = 'dsh-tw-entry'
|
|
55
|
-
entry.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
if (wikiId !== undefined) entry.dataset.wiki = wikiId
|
|
68
|
+
entry.setAttribute('aria-label', `TiddlyWiki 知识库:${label}`)
|
|
69
|
+
const iconEl = document.createElement('span')
|
|
70
|
+
iconEl.className = 'dsh-tw-entry-icon'
|
|
71
|
+
applyWikiIcon(iconEl, icon)
|
|
59
72
|
const labelEl = document.createElement('span')
|
|
60
73
|
labelEl.className = 'dsh-tw-entry-label'
|
|
61
74
|
labelEl.textContent = label
|
|
62
|
-
entry.append(
|
|
63
|
-
entry.addEventListener('click', () => {
|
|
64
|
-
|
|
75
|
+
entry.append(iconEl, labelEl)
|
|
76
|
+
entry.addEventListener('click', () => {
|
|
77
|
+
// 多库:先把这个库设为「焦点库」,面板随后加载它自己的 /tw/<id>/。
|
|
78
|
+
if (wikiId !== undefined) setFocusWiki(wikiId)
|
|
79
|
+
state.toggle()
|
|
80
|
+
})
|
|
81
|
+
return { entry, labelEl, iconEl }
|
|
65
82
|
}
|
|
66
83
|
|
|
67
84
|
/** Re-insert the entry before the whole family block (stable ordering). */
|
|
@@ -98,7 +115,10 @@ function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
|
|
|
98
115
|
* @returns disposer removing the entry and its observers.
|
|
99
116
|
*/
|
|
100
117
|
export function mountSidebarEntry(state: PanelState): () => void {
|
|
101
|
-
|
|
118
|
+
/** 多库:每个**在运行**的库一个入口行,用自己的显示名(作者 2026-09-28 要求)。 */
|
|
119
|
+
const rows = new Map<string, { entry: HTMLButtonElement; labelEl: HTMLSpanElement; iconEl: HTMLSpanElement }>()
|
|
120
|
+
const { entry, labelEl, iconEl } = createEntry(state, 'TiddlyWiki')
|
|
121
|
+
rows.set('', { entry, labelEl, iconEl })
|
|
102
122
|
let disposed = false
|
|
103
123
|
/**
|
|
104
124
|
* 自定义显示名:/status 返回 ui.sidebarLabel(设置页「侧边栏入口显示名称」)。
|
|
@@ -114,6 +134,62 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
114
134
|
}
|
|
115
135
|
void applyLabel()
|
|
116
136
|
const unsubscribeLabel = subscribeUiConfig(() => { void applyLabel() })
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 多库:给**每个在运行**的库挂一个入口行(v0.28.2,作者要求"有多少个 wiki 启动了就按
|
|
140
|
+
* 各自的展示名称显示入口")。
|
|
141
|
+
*
|
|
142
|
+
* 为什么只列「在运行」的:一个入口行点了就该能打开那个库;没在跑的库点开会先卡在启动
|
|
143
|
+
* 上。没在跑的库仍可从右下角「知识库」菜单启动(那里会把它们列全,并标注"未运行")。
|
|
144
|
+
*
|
|
145
|
+
* 单库模式(名册 ≤1):沿用**原来那一个**行与 ui.sidebarLabel —— 行为逐字不变。
|
|
146
|
+
*/
|
|
147
|
+
const applyRoster = async (): Promise<void> => {
|
|
148
|
+
const payload = await fetchStatus()
|
|
149
|
+
if (disposed) return
|
|
150
|
+
const list = Array.isArray(payload?.wikis) ? payload.wikis : []
|
|
151
|
+
const running = list.filter((w) => w.running)
|
|
152
|
+
// 单库:不建额外行,标签仍归 ui.sidebarLabel 管。
|
|
153
|
+
if (running.length <= 1) {
|
|
154
|
+
for (const [key, row] of rows) {
|
|
155
|
+
if (key === '') continue
|
|
156
|
+
row.entry.remove()
|
|
157
|
+
rows.delete(key)
|
|
158
|
+
}
|
|
159
|
+
entry.hidden = false
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
// 多库:默认那行让位给每个库自己的行(否则会出现一个没有库名的第 4 行)。
|
|
163
|
+
entry.hidden = true
|
|
164
|
+
const wanted = new Set(running.map((w) => w.id))
|
|
165
|
+
// 移除已不在运行/已删除的库
|
|
166
|
+
for (const [key, row] of rows) {
|
|
167
|
+
if (key === '' || wanted.has(key)) continue
|
|
168
|
+
row.entry.remove()
|
|
169
|
+
rows.delete(key)
|
|
170
|
+
}
|
|
171
|
+
// 新增/更新
|
|
172
|
+
for (const w of running) {
|
|
173
|
+
let row = rows.get(w.id)
|
|
174
|
+
if (row === undefined) {
|
|
175
|
+
row = createEntry(state, w.label, w.id, w.icon)
|
|
176
|
+
rows.set(w.id, row)
|
|
177
|
+
}
|
|
178
|
+
row.labelEl.textContent = w.label
|
|
179
|
+
// 图标每次同步(v0.28.4):在设置页改完图标,10s 内的轮询就会把它换过来。
|
|
180
|
+
applyWikiIcon(row.iconEl, w.icon)
|
|
181
|
+
row.entry.setAttribute('aria-label', `TiddlyWiki 知识库:${w.label}`)
|
|
182
|
+
// 当前焦点库高亮
|
|
183
|
+
const focused = resolveFocusWiki(running, payload?.defaultId) === w.id
|
|
184
|
+
if (focused) row.entry.dataset.focus = 'true'
|
|
185
|
+
else delete row.entry.dataset.focus
|
|
186
|
+
}
|
|
187
|
+
// 让放置逻辑把新行插进去(复用同一个 root)
|
|
188
|
+
if (root !== undefined) { placed = false; tryPlace() }
|
|
189
|
+
}
|
|
190
|
+
void applyRoster()
|
|
191
|
+
const unsubscribeFocus = subscribeFocusWiki(() => { void applyRoster() })
|
|
192
|
+
const rosterTimer = window.setInterval(() => { void applyRoster() }, 10_000)
|
|
117
193
|
let root: HTMLElement | undefined
|
|
118
194
|
let placed = false
|
|
119
195
|
/** 兜底轮询定时器:只在「尚未放置」或「shell 重建了 root」时运行。 */
|
|
@@ -135,6 +211,10 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
135
211
|
}, 2_000)
|
|
136
212
|
}
|
|
137
213
|
|
|
214
|
+
/** 当前应该出现在侧边栏里的行(隐藏的默认行不算)。 */
|
|
215
|
+
const activeRows = (): HTMLButtonElement[] =>
|
|
216
|
+
[...rows.values()].map((r) => r.entry).filter((el) => !el.hidden)
|
|
217
|
+
|
|
138
218
|
const tryPlace = (): void => {
|
|
139
219
|
if (root !== undefined && !root.isConnected) {
|
|
140
220
|
rootObserver.disconnect()
|
|
@@ -144,7 +224,7 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
144
224
|
startRetry()
|
|
145
225
|
}
|
|
146
226
|
if (placed) {
|
|
147
|
-
if (document.body.contains(
|
|
227
|
+
if (activeRows().every((el) => document.body.contains(el))) return
|
|
148
228
|
rootObserver.disconnect()
|
|
149
229
|
root = undefined
|
|
150
230
|
placed = false
|
|
@@ -152,7 +232,12 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
152
232
|
}
|
|
153
233
|
root ??= sidebarRoot()
|
|
154
234
|
if (root === undefined) return
|
|
155
|
-
|
|
235
|
+
// 逐行放置:多库时每一行都要插进侧边栏(顺序按 rows 的插入序)。
|
|
236
|
+
let any = false
|
|
237
|
+
for (const el of activeRows()) {
|
|
238
|
+
if (placeEntry(root, el)) any = true
|
|
239
|
+
}
|
|
240
|
+
placed = any
|
|
156
241
|
if (placed) {
|
|
157
242
|
// 放置成功即停掉兜底轮询:后续自愈由 rootObserver/waitObserver 负责。
|
|
158
243
|
stopRetry()
|
|
@@ -164,7 +249,7 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
164
249
|
const waitObserver = new MutationObserver(() => { tryPlace() })
|
|
165
250
|
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
166
251
|
|
|
167
|
-
// Self-heal: re-insert in the same frame when a re-render displaces the
|
|
252
|
+
// Self-heal: re-insert in the same frame when a re-render displaces the rows.
|
|
168
253
|
const rootObserver = new MutationObserver(() => {
|
|
169
254
|
if (root === undefined || !root.isConnected) {
|
|
170
255
|
placed = false
|
|
@@ -172,7 +257,9 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
172
257
|
tryPlace()
|
|
173
258
|
return
|
|
174
259
|
}
|
|
175
|
-
|
|
260
|
+
for (const el of activeRows()) {
|
|
261
|
+
if (!root.contains(el)) placeEntry(root, el)
|
|
262
|
+
}
|
|
176
263
|
})
|
|
177
264
|
|
|
178
265
|
// Belt-and-braces: a late shell mount with no further mutations still heals.
|
|
@@ -180,8 +267,11 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
180
267
|
startRetry()
|
|
181
268
|
|
|
182
269
|
const syncActive = (): void => {
|
|
183
|
-
|
|
184
|
-
|
|
270
|
+
// 面板是共享的:所有行一起高亮/取消,用户看到的"打开中"状态才一致。
|
|
271
|
+
for (const el of activeRows()) {
|
|
272
|
+
if (state.isOpen()) el.dataset.active = 'true'
|
|
273
|
+
else delete el.dataset.active
|
|
274
|
+
}
|
|
185
275
|
}
|
|
186
276
|
const unsubscribe = state.subscribe(syncActive)
|
|
187
277
|
syncActive()
|
|
@@ -190,10 +280,13 @@ export function mountSidebarEntry(state: PanelState): () => void {
|
|
|
190
280
|
return () => {
|
|
191
281
|
disposed = true
|
|
192
282
|
clearInterval(retry)
|
|
283
|
+
window.clearInterval(rosterTimer)
|
|
193
284
|
waitObserver.disconnect()
|
|
194
285
|
rootObserver.disconnect()
|
|
195
286
|
unsubscribe()
|
|
196
287
|
unsubscribeLabel()
|
|
197
|
-
|
|
288
|
+
unsubscribeFocus()
|
|
289
|
+
for (const row of rows.values()) row.entry.remove()
|
|
290
|
+
rows.clear()
|
|
198
291
|
}
|
|
199
292
|
}
|