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/LICENSE +21 -21
- package/README.md +12 -6
- package/cordis.patch.yml +13 -13
- package/lib/client.bundle.js +2173 -1652
- package/lib/client.js +2183 -1662
- package/lib/index.js +2424 -2263
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/client/editor-popup.ts +121 -121
- package/src/client/index.ts +2 -0
- package/src/client/markdown.ts +137 -0
- package/src/client/note-widget.ts +515 -373
- package/src/client/panel.ts +51 -25
- package/src/client/settings-page.ts +32 -2
- package/src/client/sidebar-entry.ts +148 -148
- package/src/client/state.ts +39 -39
- package/src/client/styles.ts +111 -28
- package/src/client/sync-button.ts +188 -0
- package/src/client/toast.ts +22 -22
- package/src/host/admin.ts +408 -408
- package/src/host/config.ts +4 -2
- package/src/host/git.ts +13 -3
- package/src/host/routes.ts +151 -1
- package/src/host/seed-notes.ts +79 -122
- package/src/host/tools.ts +28 -1
- package/src/host/tw-api.ts +157 -157
- package/src/host/wiki.ts +287 -287
- package/src/index.ts +13 -12
- package/src/sdk.ts +198 -198
package/src/client/panel.ts
CHANGED
|
@@ -79,6 +79,7 @@ interface StatusPayload {
|
|
|
79
79
|
wikiPath?: string
|
|
80
80
|
error?: string
|
|
81
81
|
note?: { tag?: string }
|
|
82
|
+
ui?: { showPanelStatus?: boolean }
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
function conversationColumn(): HTMLElement | undefined {
|
|
@@ -115,6 +116,8 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
115
116
|
let frameArea: HTMLDivElement | undefined
|
|
116
117
|
let errorArea: HTMLDivElement | undefined
|
|
117
118
|
let chip: HTMLSpanElement | undefined
|
|
119
|
+
let statusDot: HTMLSpanElement | undefined
|
|
120
|
+
let statusFloater: HTMLDivElement | undefined
|
|
118
121
|
let refreshTimer: number | undefined
|
|
119
122
|
let refreshAttempts = 0
|
|
120
123
|
|
|
@@ -123,27 +126,6 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
123
126
|
view.dataset.dshTwView = ''
|
|
124
127
|
view.className = 'dsh-tw-view'
|
|
125
128
|
|
|
126
|
-
const bar = document.createElement('div')
|
|
127
|
-
bar.className = 'dsh-tw-panel-bar'
|
|
128
|
-
const title = document.createElement('span')
|
|
129
|
-
title.className = 'dsh-tw-panel-title'
|
|
130
|
-
title.textContent = 'TiddlyWiki 知识库'
|
|
131
|
-
chip = document.createElement('span')
|
|
132
|
-
chip.className = 'dsh-tw-status-chip'
|
|
133
|
-
chip.dataset.state = 'unknown'
|
|
134
|
-
chip.textContent = '—'
|
|
135
|
-
const reload = document.createElement('button')
|
|
136
|
-
reload.type = 'button'
|
|
137
|
-
reload.textContent = '重载'
|
|
138
|
-
reload.addEventListener('click', () => {
|
|
139
|
-
if (iframe !== undefined && !iframe.hidden) iframe.src = iframe.src
|
|
140
|
-
})
|
|
141
|
-
const refresh = document.createElement('button')
|
|
142
|
-
refresh.type = 'button'
|
|
143
|
-
refresh.textContent = '状态'
|
|
144
|
-
refresh.addEventListener('click', () => { void doRefresh() })
|
|
145
|
-
bar.append(title, chip, refresh, reload)
|
|
146
|
-
|
|
147
129
|
frameArea = document.createElement('div')
|
|
148
130
|
frameArea.className = 'dsh-tw-panel-frame-wrap'
|
|
149
131
|
frameArea.style.cssText = 'flex:1;min-height:0;display:flex;flex-direction:column'
|
|
@@ -157,14 +139,50 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
157
139
|
errorArea.className = 'dsh-tw-panel-error'
|
|
158
140
|
errorArea.hidden = true
|
|
159
141
|
|
|
160
|
-
view.append(
|
|
142
|
+
view.append(frameArea, errorArea)
|
|
161
143
|
return view
|
|
162
144
|
}
|
|
163
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Bottom-right floating status/reload buttons (same visual language as the
|
|
148
|
+
* quick-note toggle) — the top panel bar is gone, so TW's menubar reaches
|
|
149
|
+
* the very top of the panel and clears the host's top-right buttons. Mount
|
|
150
|
+
* is gated by `ui.showPanelStatus` (settings page toggle).
|
|
151
|
+
*/
|
|
152
|
+
const buildStatusFloater = (): HTMLDivElement => {
|
|
153
|
+
const wrap = document.createElement('div')
|
|
154
|
+
wrap.className = 'dsh-tw-panel-status'
|
|
155
|
+
|
|
156
|
+
const statusBtn = document.createElement('button')
|
|
157
|
+
statusBtn.type = 'button'
|
|
158
|
+
statusBtn.className = 'dsh-tw-panel-status-btn'
|
|
159
|
+
statusBtn.title = '刷新状态'
|
|
160
|
+
statusDot = document.createElement('span')
|
|
161
|
+
statusDot.className = 'dsh-tw-status-dot'
|
|
162
|
+
statusDot.dataset.state = 'unknown'
|
|
163
|
+
chip = document.createElement('span')
|
|
164
|
+
chip.className = 'dsh-tw-panel-status-text'
|
|
165
|
+
chip.textContent = '—'
|
|
166
|
+
statusBtn.append(statusDot, chip)
|
|
167
|
+
statusBtn.addEventListener('click', () => { void doRefresh() })
|
|
168
|
+
|
|
169
|
+
const reloadBtn = document.createElement('button')
|
|
170
|
+
reloadBtn.type = 'button'
|
|
171
|
+
reloadBtn.className = 'dsh-tw-panel-status-btn'
|
|
172
|
+
reloadBtn.textContent = '重载'
|
|
173
|
+
reloadBtn.title = '重载 TiddlyWiki 面板'
|
|
174
|
+
reloadBtn.addEventListener('click', () => {
|
|
175
|
+
if (iframe !== undefined && !iframe.hidden) iframe.src = iframe.src
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
wrap.append(statusBtn, reloadBtn)
|
|
179
|
+
document.body.append(wrap)
|
|
180
|
+
return wrap
|
|
181
|
+
}
|
|
182
|
+
|
|
164
183
|
const setChip = (stateName: string, text: string): void => {
|
|
165
|
-
if (
|
|
166
|
-
chip.
|
|
167
|
-
chip.textContent = text
|
|
184
|
+
if (statusDot !== undefined) statusDot.dataset.state = stateName
|
|
185
|
+
if (chip !== undefined) chip.textContent = text
|
|
168
186
|
}
|
|
169
187
|
|
|
170
188
|
/** Pin the overlay to the center column's current viewport rect. */
|
|
@@ -329,6 +347,13 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
329
347
|
ensure()
|
|
330
348
|
applyActive()
|
|
331
349
|
|
|
350
|
+
// Bottom-right status/reload buttons, gated by ui.showPanelStatus.
|
|
351
|
+
void (async () => {
|
|
352
|
+
const payload = await fetchStatus()
|
|
353
|
+
if (payload?.ui?.showPanelStatus === false) return
|
|
354
|
+
statusFloater = buildStatusFloater()
|
|
355
|
+
})()
|
|
356
|
+
|
|
332
357
|
return () => {
|
|
333
358
|
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
|
334
359
|
window.clearInterval(syncInterval)
|
|
@@ -341,5 +366,6 @@ export function mountPanel(state: PanelState): () => void {
|
|
|
341
366
|
unsubscribe()
|
|
342
367
|
document.documentElement.removeAttribute(ACTIVE_ATTR)
|
|
343
368
|
container?.remove()
|
|
369
|
+
statusFloater?.remove()
|
|
344
370
|
}
|
|
345
371
|
}
|
|
@@ -23,6 +23,7 @@ const STATE_ENDPOINT = '/dsh-tiddlywiki/admin/state'
|
|
|
23
23
|
const INFO_ENDPOINT = '/dsh-tiddlywiki/admin/info'
|
|
24
24
|
const CONFIG_ENDPOINT = '/dsh-tiddlywiki/admin/config'
|
|
25
25
|
const RESTART_ENDPOINT = '/dsh-tiddlywiki/admin/restart'
|
|
26
|
+
const SYNC_ENDPOINT = '/dsh-tiddlywiki/sync'
|
|
26
27
|
|
|
27
28
|
interface CatalogEntry {
|
|
28
29
|
name: string
|
|
@@ -119,6 +120,34 @@ function renderStatus(row: HTMLElement, state: AdminState, refresh: () => Promis
|
|
|
119
120
|
state.git?.dirty === true ? '有未提交改动' : '',
|
|
120
121
|
].filter(Boolean).join(' · ')
|
|
121
122
|
const label = make('span', 'dsh-tw-settings-muted', info)
|
|
123
|
+
const sync = make('button', 'dsh-tw-settings-btn', '同步')
|
|
124
|
+
sync.type = 'button'
|
|
125
|
+
sync.title = 'git 同步(pull → commit → push)'
|
|
126
|
+
sync.addEventListener('click', () => {
|
|
127
|
+
sync.disabled = true
|
|
128
|
+
sync.textContent = '同步中…'
|
|
129
|
+
void (async () => {
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(SYNC_ENDPOINT, { method: 'POST', signal: AbortSignal.timeout(120_000) })
|
|
132
|
+
const payload = (await res.json().catch(() => null)) as { ok?: boolean; message?: string; error?: string; changed?: boolean; restarted?: boolean; restartError?: string } | null
|
|
133
|
+
if (!res.ok || payload?.ok !== true) toast(`同步失败:${payload?.error ?? payload?.message ?? `HTTP ${res.status}`}`)
|
|
134
|
+
else {
|
|
135
|
+
let detail = ''
|
|
136
|
+
if (payload.changed === true) {
|
|
137
|
+
detail += payload.restarted === true ? ',TW 已重启' : ',TW 未自动重启'
|
|
138
|
+
if (payload.restartError) detail += `(${payload.restartError})`
|
|
139
|
+
}
|
|
140
|
+
toast(`同步完成:${payload.message ?? 'OK'}${detail}`)
|
|
141
|
+
}
|
|
142
|
+
} catch (err) {
|
|
143
|
+
toast(`同步失败:${err instanceof Error ? err.message : String(err)}`)
|
|
144
|
+
} finally {
|
|
145
|
+
sync.disabled = false
|
|
146
|
+
sync.textContent = '同步'
|
|
147
|
+
void refresh()
|
|
148
|
+
}
|
|
149
|
+
})()
|
|
150
|
+
})
|
|
122
151
|
const restart = make('button', 'dsh-tw-settings-btn', '重启 TW')
|
|
123
152
|
restart.type = 'button'
|
|
124
153
|
restart.addEventListener('click', () => {
|
|
@@ -137,7 +166,7 @@ function renderStatus(row: HTMLElement, state: AdminState, refresh: () => Promis
|
|
|
137
166
|
}
|
|
138
167
|
})()
|
|
139
168
|
})
|
|
140
|
-
row.append(chip, label, restart)
|
|
169
|
+
row.append(chip, label, sync, restart)
|
|
141
170
|
}
|
|
142
171
|
|
|
143
172
|
/** Config section: fields bound to effective config, changed-only save. */
|
|
@@ -182,7 +211,8 @@ function renderConfigSection(body: HTMLElement, config: Record<string, unknown>,
|
|
|
182
211
|
textField('git.remote', 'git 远端(空=仅本地)', typeof git.remote === 'string' ? git.remote : '')
|
|
183
212
|
textField('git.branch', 'git 分支', typeof git.branch === 'string' ? git.branch : 'main')
|
|
184
213
|
checkField('ui.showQuickNote', '显示「快速笔记」悬浮按钮', ui.showQuickNote !== false)
|
|
185
|
-
checkField('ui.
|
|
214
|
+
checkField('ui.showPanelStatus', '显示 TW 面板右下角「状态/重载」悬浮按钮', ui.showPanelStatus !== false)
|
|
215
|
+
checkField('ui.showSyncButton', '显示右下角「同步」悬浮按钮', ui.showSyncButton !== false)
|
|
186
216
|
// 界面语言在下方「语言管理」区块设置(config 的 uiLanguage 仅供启动时自动应用)。
|
|
187
217
|
|
|
188
218
|
const save = make('button', 'dsh-tw-settings-btn dsh-tw-settings-primary', '保存配置')
|
|
@@ -1,148 +1,148 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sidebar entry injection — structure ported from dsh-taskboard's
|
|
3
|
-
* sidebar-entry.ts (verified live in this shell): scope to the sidebar root,
|
|
4
|
-
* find the New Session button, and insert the entry as a direct child of that
|
|
5
|
-
* root next to the family block. A body-level MutationObserver self-heals
|
|
6
|
-
* React re-renders; a slow timer covers shells that mount late without
|
|
7
|
-
* further mutations. The row is plain DOM so it never disturbs the shell's
|
|
8
|
-
* reconciliation.
|
|
9
|
-
*
|
|
10
|
-
* @module dsh-tiddlywiki/client/sidebar-entry
|
|
11
|
-
*/
|
|
12
|
-
import type { PanelState } from './state.ts'
|
|
13
|
-
|
|
14
|
-
/** Stable data attribute identifying this entry row. */
|
|
15
|
-
export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
|
|
16
|
-
|
|
17
|
-
/** Inline icon: a wiki page with a TiddlyWiki-style "T" (nav-icon look). */
|
|
18
|
-
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>'
|
|
19
|
-
|
|
20
|
-
/** Family entries from sibling plugins, kept in a stable relative order. */
|
|
21
|
-
const FAMILY_SELECTOR = '[data-dsh-tw-entry], [data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]'
|
|
22
|
-
|
|
23
|
-
/** Find the sidebar shell root element, or undefined while not yet mounted. */
|
|
24
|
-
function sidebarRoot(): HTMLElement | undefined {
|
|
25
|
-
const column = document.querySelector<HTMLElement>(
|
|
26
|
-
'[data-pane="sidebar"], [class*="sidebarCol"], .dshDesktopUpstreamSidebar, .dshDesktopSidebarSurface',
|
|
27
|
-
)
|
|
28
|
-
if (column === null) return undefined
|
|
29
|
-
const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
|
|
30
|
-
return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** The New Session button inside the sidebar root. */
|
|
34
|
-
function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
35
|
-
const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
|
|
36
|
-
if (nested !== null) return nested
|
|
37
|
-
for (const child of root.children) {
|
|
38
|
-
if (child instanceof HTMLButtonElement && !child.matches(ENTRY_SELECTOR)) return child
|
|
39
|
-
}
|
|
40
|
-
const byAria = root.querySelector<HTMLButtonElement>(
|
|
41
|
-
'button[aria-label="新建会话"], button[aria-label="New Session"], button[aria-label*="新会话"], button[aria-label*="new session" i]',
|
|
42
|
-
)
|
|
43
|
-
if (byAria !== null) return byAria
|
|
44
|
-
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('button'))
|
|
45
|
-
return buttons.find(button => !button.matches(ENTRY_SELECTOR) && /新会话|新建会话|new session/i.test(button.textContent ?? ''))
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** Build the entry row (a detached button; insert once the shell is up). */
|
|
49
|
-
function createEntry(state: PanelState): HTMLButtonElement {
|
|
50
|
-
const entry = document.createElement('button')
|
|
51
|
-
entry.type = 'button'
|
|
52
|
-
entry.dataset.dshTwEntry = ''
|
|
53
|
-
entry.className = 'dsh-tw-entry'
|
|
54
|
-
entry.setAttribute('aria-label', 'TiddlyWiki 知识库')
|
|
55
|
-
entry.innerHTML = `<span class="dsh-tw-entry-icon">${ICON}</span><span class="dsh-tw-entry-label">TiddlyWiki</span>`
|
|
56
|
-
entry.addEventListener('click', () => { state.toggle() })
|
|
57
|
-
return entry
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Re-insert the entry before the whole family block (stable ordering). */
|
|
61
|
-
function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
|
|
62
|
-
const button = newSessionButton(root)
|
|
63
|
-
if (button === undefined) return false
|
|
64
|
-
if (entry.parentElement !== root) {
|
|
65
|
-
const row = button.closest('[class*="logoRow"]')
|
|
66
|
-
const base = (row !== null && row.parentElement === root) ? row : button
|
|
67
|
-
const family = Array.from(root.children).filter(
|
|
68
|
-
(el): el is HTMLElement => el instanceof HTMLElement && el.matches(FAMILY_SELECTOR),
|
|
69
|
-
)
|
|
70
|
-
const anchor = family.length > 0 ? (family[0] ?? null) : (base.nextElementSibling ?? null)
|
|
71
|
-
root.insertBefore(entry, anchor)
|
|
72
|
-
}
|
|
73
|
-
return true
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Debug counters (window.__twDebug) — evidence if the entry fails to appear. */
|
|
77
|
-
interface TwDebug { attempts: number; found: boolean; placed: boolean }
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Mount the sidebar entry, waiting for the shell and self-healing on later
|
|
81
|
-
* re-renders.
|
|
82
|
-
* @param state - the shared panel state the entry toggles.
|
|
83
|
-
* @returns disposer removing the entry and its observers.
|
|
84
|
-
*/
|
|
85
|
-
export function mountSidebarEntry(state: PanelState): () => void {
|
|
86
|
-
const entry = createEntry(state)
|
|
87
|
-
const debug: TwDebug = { attempts: 0, found: false, placed: false }
|
|
88
|
-
const host = globalThis.location?.hostname
|
|
89
|
-
if (host === 'localhost' || host === '127.0.0.1') {
|
|
90
|
-
;(window as unknown as { __twDebug?: TwDebug }).__twDebug = debug
|
|
91
|
-
}
|
|
92
|
-
let root: HTMLElement | undefined
|
|
93
|
-
let placed = false
|
|
94
|
-
|
|
95
|
-
const tryPlace = (): void => {
|
|
96
|
-
debug.attempts++
|
|
97
|
-
if (root !== undefined && !root.isConnected) {
|
|
98
|
-
rootObserver.disconnect()
|
|
99
|
-
root = undefined
|
|
100
|
-
placed = false
|
|
101
|
-
}
|
|
102
|
-
if (placed) {
|
|
103
|
-
if (document.body.contains(entry)) return
|
|
104
|
-
rootObserver.disconnect()
|
|
105
|
-
root = undefined
|
|
106
|
-
placed = false
|
|
107
|
-
}
|
|
108
|
-
root ??= sidebarRoot()
|
|
109
|
-
if (root === undefined) return
|
|
110
|
-
debug.found = newSessionButton(root) !== undefined
|
|
111
|
-
placed = placeEntry(root, entry)
|
|
112
|
-
debug.placed = placed
|
|
113
|
-
if (placed) rootObserver.observe(root, { childList: true, subtree: true })
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Body-level watcher as the whole-rebuild fallback.
|
|
117
|
-
const waitObserver = new MutationObserver(() => { tryPlace() })
|
|
118
|
-
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
119
|
-
|
|
120
|
-
// Self-heal: re-insert in the same frame when a re-render displaces the row.
|
|
121
|
-
const rootObserver = new MutationObserver(() => {
|
|
122
|
-
if (root === undefined || !root.isConnected) {
|
|
123
|
-
placed = false
|
|
124
|
-
tryPlace()
|
|
125
|
-
return
|
|
126
|
-
}
|
|
127
|
-
if (!root.contains(entry)) placed = placeEntry(root, entry)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
// Belt-and-braces: a late shell mount with no further mutations still heals.
|
|
131
|
-
const retry = setInterval(() => { tryPlace() }, 2_000)
|
|
132
|
-
|
|
133
|
-
const syncActive = (): void => {
|
|
134
|
-
if (state.isOpen()) entry.dataset.active = 'true'
|
|
135
|
-
else delete entry.dataset.active
|
|
136
|
-
}
|
|
137
|
-
const unsubscribe = state.subscribe(syncActive)
|
|
138
|
-
syncActive()
|
|
139
|
-
tryPlace()
|
|
140
|
-
|
|
141
|
-
return () => {
|
|
142
|
-
clearInterval(retry)
|
|
143
|
-
waitObserver.disconnect()
|
|
144
|
-
rootObserver.disconnect()
|
|
145
|
-
unsubscribe()
|
|
146
|
-
entry.remove()
|
|
147
|
-
}
|
|
148
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Sidebar entry injection — structure ported from dsh-taskboard's
|
|
3
|
+
* sidebar-entry.ts (verified live in this shell): scope to the sidebar root,
|
|
4
|
+
* find the New Session button, and insert the entry as a direct child of that
|
|
5
|
+
* root next to the family block. A body-level MutationObserver self-heals
|
|
6
|
+
* React re-renders; a slow timer covers shells that mount late without
|
|
7
|
+
* further mutations. The row is plain DOM so it never disturbs the shell's
|
|
8
|
+
* reconciliation.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-tiddlywiki/client/sidebar-entry
|
|
11
|
+
*/
|
|
12
|
+
import type { PanelState } from './state.ts'
|
|
13
|
+
|
|
14
|
+
/** Stable data attribute identifying this entry row. */
|
|
15
|
+
export const ENTRY_SELECTOR = '[data-dsh-tw-entry]'
|
|
16
|
+
|
|
17
|
+
/** Inline icon: a wiki page with a TiddlyWiki-style "T" (nav-icon look). */
|
|
18
|
+
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>'
|
|
19
|
+
|
|
20
|
+
/** Family entries from sibling plugins, kept in a stable relative order. */
|
|
21
|
+
const FAMILY_SELECTOR = '[data-dsh-tw-entry], [data-dsh-atb-entry], [data-dsh-taskboard-entry], [data-dsh-ssh-entry]'
|
|
22
|
+
|
|
23
|
+
/** Find the sidebar shell root element, or undefined while not yet mounted. */
|
|
24
|
+
function sidebarRoot(): HTMLElement | undefined {
|
|
25
|
+
const column = document.querySelector<HTMLElement>(
|
|
26
|
+
'[data-pane="sidebar"], [class*="sidebarCol"], .dshDesktopUpstreamSidebar, .dshDesktopSidebarSurface',
|
|
27
|
+
)
|
|
28
|
+
if (column === null) return undefined
|
|
29
|
+
const logoOwner = column.querySelector<HTMLElement>('[class*="logoRow"]')?.parentElement
|
|
30
|
+
return logoOwner ?? (column.firstElementChild as HTMLElement | undefined)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The New Session button inside the sidebar root. */
|
|
34
|
+
function newSessionButton(root: HTMLElement): HTMLButtonElement | undefined {
|
|
35
|
+
const nested = root.querySelector<HTMLButtonElement>('button[class*="newSession"]')
|
|
36
|
+
if (nested !== null) return nested
|
|
37
|
+
for (const child of root.children) {
|
|
38
|
+
if (child instanceof HTMLButtonElement && !child.matches(ENTRY_SELECTOR)) return child
|
|
39
|
+
}
|
|
40
|
+
const byAria = root.querySelector<HTMLButtonElement>(
|
|
41
|
+
'button[aria-label="新建会话"], button[aria-label="New Session"], button[aria-label*="新会话"], button[aria-label*="new session" i]',
|
|
42
|
+
)
|
|
43
|
+
if (byAria !== null) return byAria
|
|
44
|
+
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('button'))
|
|
45
|
+
return buttons.find(button => !button.matches(ENTRY_SELECTOR) && /新会话|新建会话|new session/i.test(button.textContent ?? ''))
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Build the entry row (a detached button; insert once the shell is up). */
|
|
49
|
+
function createEntry(state: PanelState): HTMLButtonElement {
|
|
50
|
+
const entry = document.createElement('button')
|
|
51
|
+
entry.type = 'button'
|
|
52
|
+
entry.dataset.dshTwEntry = ''
|
|
53
|
+
entry.className = 'dsh-tw-entry'
|
|
54
|
+
entry.setAttribute('aria-label', 'TiddlyWiki 知识库')
|
|
55
|
+
entry.innerHTML = `<span class="dsh-tw-entry-icon">${ICON}</span><span class="dsh-tw-entry-label">TiddlyWiki</span>`
|
|
56
|
+
entry.addEventListener('click', () => { state.toggle() })
|
|
57
|
+
return entry
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Re-insert the entry before the whole family block (stable ordering). */
|
|
61
|
+
function placeEntry(root: HTMLElement, entry: HTMLButtonElement): boolean {
|
|
62
|
+
const button = newSessionButton(root)
|
|
63
|
+
if (button === undefined) return false
|
|
64
|
+
if (entry.parentElement !== root) {
|
|
65
|
+
const row = button.closest('[class*="logoRow"]')
|
|
66
|
+
const base = (row !== null && row.parentElement === root) ? row : button
|
|
67
|
+
const family = Array.from(root.children).filter(
|
|
68
|
+
(el): el is HTMLElement => el instanceof HTMLElement && el.matches(FAMILY_SELECTOR),
|
|
69
|
+
)
|
|
70
|
+
const anchor = family.length > 0 ? (family[0] ?? null) : (base.nextElementSibling ?? null)
|
|
71
|
+
root.insertBefore(entry, anchor)
|
|
72
|
+
}
|
|
73
|
+
return true
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Debug counters (window.__twDebug) — evidence if the entry fails to appear. */
|
|
77
|
+
interface TwDebug { attempts: number; found: boolean; placed: boolean }
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mount the sidebar entry, waiting for the shell and self-healing on later
|
|
81
|
+
* re-renders.
|
|
82
|
+
* @param state - the shared panel state the entry toggles.
|
|
83
|
+
* @returns disposer removing the entry and its observers.
|
|
84
|
+
*/
|
|
85
|
+
export function mountSidebarEntry(state: PanelState): () => void {
|
|
86
|
+
const entry = createEntry(state)
|
|
87
|
+
const debug: TwDebug = { attempts: 0, found: false, placed: false }
|
|
88
|
+
const host = globalThis.location?.hostname
|
|
89
|
+
if (host === 'localhost' || host === '127.0.0.1') {
|
|
90
|
+
;(window as unknown as { __twDebug?: TwDebug }).__twDebug = debug
|
|
91
|
+
}
|
|
92
|
+
let root: HTMLElement | undefined
|
|
93
|
+
let placed = false
|
|
94
|
+
|
|
95
|
+
const tryPlace = (): void => {
|
|
96
|
+
debug.attempts++
|
|
97
|
+
if (root !== undefined && !root.isConnected) {
|
|
98
|
+
rootObserver.disconnect()
|
|
99
|
+
root = undefined
|
|
100
|
+
placed = false
|
|
101
|
+
}
|
|
102
|
+
if (placed) {
|
|
103
|
+
if (document.body.contains(entry)) return
|
|
104
|
+
rootObserver.disconnect()
|
|
105
|
+
root = undefined
|
|
106
|
+
placed = false
|
|
107
|
+
}
|
|
108
|
+
root ??= sidebarRoot()
|
|
109
|
+
if (root === undefined) return
|
|
110
|
+
debug.found = newSessionButton(root) !== undefined
|
|
111
|
+
placed = placeEntry(root, entry)
|
|
112
|
+
debug.placed = placed
|
|
113
|
+
if (placed) rootObserver.observe(root, { childList: true, subtree: true })
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Body-level watcher as the whole-rebuild fallback.
|
|
117
|
+
const waitObserver = new MutationObserver(() => { tryPlace() })
|
|
118
|
+
waitObserver.observe(document.body, { childList: true, subtree: true })
|
|
119
|
+
|
|
120
|
+
// Self-heal: re-insert in the same frame when a re-render displaces the row.
|
|
121
|
+
const rootObserver = new MutationObserver(() => {
|
|
122
|
+
if (root === undefined || !root.isConnected) {
|
|
123
|
+
placed = false
|
|
124
|
+
tryPlace()
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
if (!root.contains(entry)) placed = placeEntry(root, entry)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Belt-and-braces: a late shell mount with no further mutations still heals.
|
|
131
|
+
const retry = setInterval(() => { tryPlace() }, 2_000)
|
|
132
|
+
|
|
133
|
+
const syncActive = (): void => {
|
|
134
|
+
if (state.isOpen()) entry.dataset.active = 'true'
|
|
135
|
+
else delete entry.dataset.active
|
|
136
|
+
}
|
|
137
|
+
const unsubscribe = state.subscribe(syncActive)
|
|
138
|
+
syncActive()
|
|
139
|
+
tryPlace()
|
|
140
|
+
|
|
141
|
+
return () => {
|
|
142
|
+
clearInterval(retry)
|
|
143
|
+
waitObserver.disconnect()
|
|
144
|
+
rootObserver.disconnect()
|
|
145
|
+
unsubscribe()
|
|
146
|
+
entry.remove()
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/client/state.ts
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tiny pub/sub panel state shared by the sidebar entry and the center panel.
|
|
3
|
-
*
|
|
4
|
-
* @module dsh-tiddlywiki/client/state
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export type PanelListener = (open: boolean) => void
|
|
8
|
-
|
|
9
|
-
export class PanelState {
|
|
10
|
-
private open = false
|
|
11
|
-
private readonly listeners = new Set<PanelListener>()
|
|
12
|
-
|
|
13
|
-
isOpen(): boolean {
|
|
14
|
-
return this.open
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
toggle(): void {
|
|
18
|
-
this.set(!this.open)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
openPanel(): void {
|
|
22
|
-
this.set(true)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
closePanel(): void {
|
|
26
|
-
this.set(false)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
set(value: boolean): void {
|
|
30
|
-
if (this.open === value) return
|
|
31
|
-
this.open = value
|
|
32
|
-
for (const listener of [...this.listeners]) listener(value)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
subscribe(listener: PanelListener): () => void {
|
|
36
|
-
this.listeners.add(listener)
|
|
37
|
-
return () => { this.listeners.delete(listener) }
|
|
38
|
-
}
|
|
39
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tiny pub/sub panel state shared by the sidebar entry and the center panel.
|
|
3
|
+
*
|
|
4
|
+
* @module dsh-tiddlywiki/client/state
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type PanelListener = (open: boolean) => void
|
|
8
|
+
|
|
9
|
+
export class PanelState {
|
|
10
|
+
private open = false
|
|
11
|
+
private readonly listeners = new Set<PanelListener>()
|
|
12
|
+
|
|
13
|
+
isOpen(): boolean {
|
|
14
|
+
return this.open
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
toggle(): void {
|
|
18
|
+
this.set(!this.open)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
openPanel(): void {
|
|
22
|
+
this.set(true)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
closePanel(): void {
|
|
26
|
+
this.set(false)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
set(value: boolean): void {
|
|
30
|
+
if (this.open === value) return
|
|
31
|
+
this.open = value
|
|
32
|
+
for (const listener of [...this.listeners]) listener(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
subscribe(listener: PanelListener): () => void {
|
|
36
|
+
this.listeners.add(listener)
|
|
37
|
+
return () => { this.listeners.delete(listener) }
|
|
38
|
+
}
|
|
39
|
+
}
|