@waterwx/dsh-novel-forge 1.7.2 → 1.8.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.
Files changed (48) hide show
  1. package/README.md +1 -1
  2. package/lib/client.js +3788 -600
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1001 -83
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/author-assets.d.ts +25 -0
  7. package/lib/types/client/api.d.ts +18 -0
  8. package/lib/types/client/panel/AdaptModeView.d.ts +4 -0
  9. package/lib/types/client/panel/AuthorAssetsView.d.ts +4 -0
  10. package/lib/types/client/panel/AuthorHome.d.ts +20 -0
  11. package/lib/types/client/panel/ProgressHomeView.d.ts +5 -0
  12. package/lib/types/client/panel/SettingsView.d.ts +14 -0
  13. package/lib/types/client/text.d.ts +9 -0
  14. package/lib/types/engine.d.ts +24 -1
  15. package/lib/types/index.d.ts +8 -0
  16. package/lib/types/protocol.d.ts +206 -10
  17. package/lib/types/shot-language.d.ts +46 -0
  18. package/lib/types/story-beat-language.d.ts +35 -0
  19. package/package.json +4 -2
  20. package/skills/chapter-batch/SKILL.md +81 -0
  21. package/src/author-assets.ts +166 -0
  22. package/src/client/api.ts +41 -0
  23. package/src/client/mount.tsx._new +97 -0
  24. package/src/client/mount.tsx._nf +97 -0
  25. package/src/client/mount.tsx._tmp +97 -0
  26. package/src/client/panel/AdaptModeView.tsx +204 -0
  27. package/src/client/panel/AuthorAssetsView.tsx +206 -0
  28. package/src/client/panel/AuthorHome.tsx +93 -0
  29. package/src/client/panel/CreateBookView.tsx +4 -0
  30. package/src/client/panel/ImportModal.tsx +13 -3
  31. package/src/client/panel/NovelPanel.tsx +48 -16
  32. package/src/client/panel/ProgressHomeView.tsx +87 -0
  33. package/src/client/panel/RunPanel.tsx +1 -1
  34. package/src/client/panel/SceneLibrary.tsx +1 -1
  35. package/src/client/panel/SettingsView.tsx +285 -0
  36. package/src/client/panel/ShelfView.tsx +2 -2
  37. package/src/client/panel/StoryboardTab.tsx +42 -10
  38. package/src/client/panel/panel.module.css +325 -0
  39. package/src/client/sidebar-entry.ts +1 -1
  40. package/src/client/text.ts +28 -0
  41. package/src/engine.ts +256 -14
  42. package/src/index.ts +21 -0
  43. package/src/protocol.ts +218 -10
  44. package/src/routes.ts +204 -7
  45. package/src/run.ts +5 -4
  46. package/src/shot-language.ts +181 -0
  47. package/src/story-beat-language.ts +130 -0
  48. package/lib/types/client/panel/RoleVisualPanel.d.ts +0 -15
@@ -0,0 +1,166 @@
1
+ /**
2
+ * 作者资产库/总数据(跨书)— 按作者维度聚合的可复用资产。
3
+ * 与书架同惯例持久化到 ~/.dsh/dsh-novel-forge-author-assets.json。
4
+ * 提供读取/持久化/新增或更新/删除,以及「导入默认」:把书架书的写作资产/角色 +
5
+ * 内置全局库(题材/反AI规则/风格模板/推进模式)批量沉淀成资产条目(默认库)。
6
+ */
7
+
8
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
9
+ import { homedir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { randomBytes } from 'node:crypto'
12
+ import type { AuthorAssetLibrary, AuthorStyleAsset, GenreNode, AntiAiRule, StyleTemplate, ProgressionMode } from './protocol.ts'
13
+ import { BUILTIN_ANTI_AI_RULES, BUILTIN_GENRE_LIBRARY, BUILTIN_PROGRESSION_MODES, BUILTIN_STYLE_TEMPLATES } from './assets.ts'
14
+ import { loadBookshelf } from './bookshelf.ts'
15
+ import { loadProject } from './engine.ts'
16
+
17
+ /** 作者资产库配置文件路径。 */
18
+ export function authorAssetsFile(): string {
19
+ return join(homedir(), '.dsh', 'dsh-novel-forge-author-assets.json')
20
+ }
21
+
22
+ /** 默认空资产库。 */
23
+ function defaultLibrary(): AuthorAssetLibrary {
24
+ return { version: 1, items: [] }
25
+ }
26
+
27
+ /** 读取作者资产库(不存在/损坏则返回空库,不抛错)。 */
28
+ export function loadAuthorAssets(): AuthorAssetLibrary {
29
+ const file = authorAssetsFile()
30
+ if (!existsSync(file)) return defaultLibrary()
31
+ try {
32
+ let raw = readFileSync(file, 'utf8')
33
+ if (raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1)
34
+ const parsed = JSON.parse(raw) as Partial<AuthorAssetLibrary>
35
+ if (!Array.isArray(parsed.items)) return defaultLibrary()
36
+ return { version: 1, items: parsed.items }
37
+ } catch {
38
+ return defaultLibrary()
39
+ }
40
+ }
41
+
42
+ /** 持久化作者资产库。 */
43
+ export function saveAuthorAssets(library: AuthorAssetLibrary): void {
44
+ const file = authorAssetsFile()
45
+ mkdirSync(join(homedir(), '.dsh'), { recursive: true })
46
+ writeFileSync(file, JSON.stringify(library, null, 2), 'utf8')
47
+ }
48
+
49
+ /** 新建一条资产(自动生成 id 与时间戳)。 */
50
+ export function createAuthorAsset(input: Omit<AuthorStyleAsset, 'id' | 'createdAt' | 'updatedAt'>): AuthorStyleAsset {
51
+ const now = new Date().toISOString()
52
+ const asset: AuthorStyleAsset = {
53
+ ...input,
54
+ id: 'aa-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex'),
55
+ sourceBooks: input.sourceBooks ?? [],
56
+ tags: input.tags ?? [],
57
+ createdAt: now,
58
+ updatedAt: now,
59
+ }
60
+ const library = loadAuthorAssets()
61
+ library.items.push(asset)
62
+ saveAuthorAssets(library)
63
+ return asset
64
+ }
65
+
66
+ /** 按 id 更新一条资产;不存在则追加。 */
67
+ export function upsertAuthorAsset(input: AuthorStyleAsset): AuthorAssetLibrary {
68
+ const library = loadAuthorAssets()
69
+ const idx = library.items.findIndex(a => a.id === input.id)
70
+ const now = new Date().toISOString()
71
+ if (idx >= 0) {
72
+ const prev = library.items[idx]
73
+ if (prev !== undefined) {
74
+ library.items[idx] = {
75
+ ...prev,
76
+ ...input,
77
+ id: prev.id,
78
+ createdAt: prev.createdAt,
79
+ updatedAt: now,
80
+ }
81
+ }
82
+ } else {
83
+ library.items.push({ ...input, createdAt: input.createdAt !== '' ? input.createdAt : now, updatedAt: now })
84
+ }
85
+ saveAuthorAssets(library)
86
+ return library
87
+ }
88
+
89
+ /** 删除一条资产;返回更新后的资产库。 */
90
+ export function removeAuthorAsset(id: string): AuthorAssetLibrary {
91
+ const library = loadAuthorAssets()
92
+ library.items = library.items.filter(a => a.id !== id)
93
+ saveAuthorAssets(library)
94
+ return library
95
+ }
96
+
97
+ /**
98
+ * 导入默认:把书架所有书的写作资产/角色 + 内置全局库批量沉淀到作者资产库。
99
+ * 按 `kind:name` 去重(已存在则跳过),不覆盖用户已有条目。
100
+ * @returns 更新后的资产库。
101
+ */
102
+ export function importDefaultAuthorAssets(): AuthorAssetLibrary {
103
+ const library = loadAuthorAssets()
104
+ const seen = new Set(library.items.map(a => a.kind + ':' + a.name))
105
+ const now = new Date().toISOString()
106
+ const push = (kind: AuthorStyleAsset['kind'], name: string, summary: string, content: string, tags: string[], sourceBooks: string[], structured?: Record<string, unknown>): void => {
107
+ const key = kind + ':' + name.trim()
108
+ if (name.trim() === '' || seen.has(key)) return
109
+ seen.add(key)
110
+ library.items.push({
111
+ id: 'aa-' + Date.now().toString(36) + '-' + randomBytes(3).toString('hex'),
112
+ kind, name: name.trim(), summary: summary.trim(), content: content.trim(),
113
+ sourceBooks, tags, structured, createdAt: now, updatedAt: now,
114
+ });
115
+ };
116
+
117
+ // 1) 内置全局库
118
+ for (const g of flattenGenres(BUILTIN_GENRE_LIBRARY)) {
119
+ push('genre', g.name, g.description, g.description, ['内置', '题材'], [], { description: g.description, children: (g.children ?? []).map(c => c.name) });
120
+ }
121
+ for (const r of BUILTIN_ANTI_AI_RULES) {
122
+ push('antiAi', r.name, r.avoid, '避免:' + r.avoid + '\n修正:' + r.fix, ['内置', '反AI'], [], { avoid: r.avoid, fix: r.fix, detectPatterns: r.detectPatterns });
123
+ }
124
+ for (const t of BUILTIN_STYLE_TEMPLATES) {
125
+ push('style', t.name, t.description,
126
+ '分类:' + t.category + '\n适用:' + t.applicableGenres.join('、') + '\n叙述:' + t.proseRules.join(';') + '\n台词:' + t.dialogueRules.join(';') + '\n语言:' + t.languageRules.join(';') + '\n节奏:' + t.rhythmRules.join(';'),
127
+ ['内置', '风格模板'], [], { category: t.category, applicableGenres: t.applicableGenres });
128
+ }
129
+ for (const p of BUILTIN_PROGRESSION_MODES) {
130
+ push('progression', p.name, p.driver, '驱动:' + p.driver + '\n期待:' + p.readerExpectation + '\n兑现:' + p.payoffs.join(';') + '\n风险:' + p.risks.join(';'), ['内置', '推进'], [], { driver: p.driver, primary: p.primary });
131
+ }
132
+
133
+ // 2) 书架里的书:写作资产 + 角色模板
134
+ const shelf = loadBookshelf()
135
+ for (const book of shelf.books) {
136
+ const project = loadProject(book.outputDir)
137
+ if (project === undefined) continue
138
+ const src = [project.bookName]
139
+ const assets = project.assets
140
+ if (assets !== undefined) {
141
+ if (assets.genre !== undefined) push('genre', assets.genre.name, assets.genre.description, assets.genre.description, ['书', '题材'], src, { description: assets.genre.description });
142
+ if (assets.primaryProgression !== undefined) push('progression', assets.primaryProgression.name, assets.primaryProgression.driver, '驱动:' + assets.primaryProgression.driver + '\n期待:' + assets.primaryProgression.readerExpectation, src, [], { driver: assets.primaryProgression.driver, primary: true });
143
+ for (const p of assets.auxiliaryProgressions ?? []) push('progression', p.name, p.driver, '驱动:' + p.driver + '\n期待:' + p.readerExpectation, src, [], { driver: p.driver, primary: false });
144
+ for (const r of assets.antiAiRules ?? []) push('antiAi', r.name, r.avoid, '避免:' + r.avoid + '\n修正:' + r.fix, src, [], { avoid: r.avoid, fix: r.fix, detectPatterns: r.detectPatterns });
145
+ for (const s of assets.styleAssets ?? []) push('style', s.name, s.proseRules.join(';'),
146
+ '叙述:' + s.proseRules.join(';') + '\n台词:' + s.dialogueRules.join(';') + '\n描写:' + s.descriptionRules.join(';') + '\n边界:' + s.boundaries.join(';'), src, [], { sourceText: s.sourceText });
147
+ }
148
+ for (const role of project.roles ?? []) {
149
+ const persona = ('身份:' + role.identity + '\n性格:' + role.traits.join('、') + '\n目标:' + role.goals + '\n关系:' + role.relations.join('、') + '\n成长线:' + (role.arc.length > 0 ? role.arc.join(' > ') : '—') + '\n知情度:' + role.knowledge.join('、')).trim();
150
+ push('roleTemplate', role.name, role.identity, persona, src, [], { roleLabel: role.roleLabel, traits: role.traits, relations: role.relations, arc: role.arc, knowledge: role.knowledge });
151
+ }
152
+ }
153
+
154
+ saveAuthorAssets(library)
155
+ return library
156
+ }
157
+
158
+ /** 拍平题材库(含子题材,父级名称并入 tag)。 */
159
+ function flattenGenres(nodes: GenreNode[], parentName?: string): GenreNode[] {
160
+ const out: GenreNode[] = []
161
+ for (const n of nodes) {
162
+ out.push({ ...n, name: parentName !== undefined ? parentName + '/' + n.name : n.name });
163
+ if (n.children.length > 0) out.push(...flattenGenres(n.children, parentName !== undefined ? parentName + '/' + n.name : n.name));
164
+ }
165
+ return out
166
+ }
package/src/client/api.ts CHANGED
@@ -461,4 +461,45 @@ export class NovelApi {
461
461
  async assistantClear(): Promise<{ ok: boolean }> {
462
462
  return postJson<{ ok: boolean }>(NOVEL_API.assistantClear, {})
463
463
  }
464
+
465
+ /** 作者资产库/总数据:读取跨书可复用资产。 */
466
+ async authorAssets(): Promise<import('../protocol.ts').AuthorAssetsResponse> {
467
+ const response = await fetch(NOVEL_API.authorAssets)
468
+ return readJson<import('../protocol.ts').AuthorAssetsResponse>(response)
469
+ }
470
+
471
+ /** 作者资产库:新增/更新一条资产(upsert by id)。 */
472
+ async authorAssetsUpsert(asset: import('../protocol.ts').AuthorStyleAsset): Promise<import('../protocol.ts').AuthorAssetsResponse> {
473
+ return postJson<import('../protocol.ts').AuthorAssetsResponse>(NOVEL_API.authorAssetsUpsert, { asset })
474
+ }
475
+
476
+ /** 作者资产库:删除一条资产。 */
477
+ async authorAssetsRemove(id: string): Promise<import('../protocol.ts').AuthorAssetsResponse> {
478
+ return postJson<import('../protocol.ts').AuthorAssetsResponse>(NOVEL_API.authorAssetsRemove, { id })
479
+ }
480
+
481
+ /** 作者资产库:导入默认(书架书的写作资产/角色 + 内置全局库)批量沉淀。 */
482
+ async authorAssetsImportDefault(): Promise<import('../protocol.ts').AuthorAssetsResponse> {
483
+ return postJson<import('../protocol.ts').AuthorAssetsResponse>(NOVEL_API.authorAssetsImportDefault, {})
484
+ }
485
+
486
+ /** 改编模式 P0:上传全文 → 原文设定卡片 / 可改范围矩阵。 */
487
+ async adaptAnalyze(text: string, filePath?: string): Promise<import('../protocol.ts').AdaptAnalyzeResponse> {
488
+ return postJson<import('../protocol.ts').AdaptAnalyzeResponse>(NOVEL_API.adaptAnalyze, { text, filePath })
489
+ }
490
+
491
+ /** 改编模式 P1:确认要改的维度 → 生成映射表/改编规则/联动影响清单。 */
492
+ async adaptPropose(req: import('../protocol.ts').AdaptProposeRequest): Promise<import('../protocol.ts').AdaptProposeResponse> {
493
+ return postJson<import('../protocol.ts').AdaptProposeResponse>(NOVEL_API.adaptPropose, req)
494
+ }
495
+
496
+ /** 改编模式 P2:执行术语替换(全局替换 + 命中统计 + 改编文本预览)。 */
497
+ async adaptExecute(req: import('../protocol.ts').AdaptExecuteRequest): Promise<import('../protocol.ts').AdaptExecuteResponse> {
498
+ return postJson<import('../protocol.ts').AdaptExecuteResponse>(NOVEL_API.adaptExecute, req)
499
+ }
500
+
501
+ /** 主题自定义背景:上传图片,服务端存盘并返回可访问 URL。 */
502
+ async themeBackgroundUpload(dataUrl: string): Promise<{ url: string }> {
503
+ return postJson<{ url: string }>(NOVEL_API.themeBackgroundUpload, { dataUrl })
504
+ }
464
505
  }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Panel view mounting — mirrors the family plugins: a container appended
3
+ * inside the conversation grid item, hidden while inactive; toggling is a
4
+ * data attribute on <html>, with cross-plugin activation events.
5
+ */
6
+ import { createRoot, type Root } from 'react-dom/client'
7
+ import type { NovelApi } from './api.ts'
8
+ import type { PanelController } from './panel/controller.ts'
9
+ import { NovelPanel } from './panel/NovelPanel.tsx'
10
+ import css from './panel/panel.module.css'
11
+
12
+ /** The injected panel container. */
13
+ export const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
14
+
15
+ const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
16
+ const ACTIVE_ATTR = 'data-dsh-novelforge-active'
17
+ /** Sibling panels' activation attributes (evicted when this panel opens). */
18
+ const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
19
+ /** Cross-plugin activation event; detail is the activating panel name. */
20
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
21
+ const PANEL_NAME = 'novelforge'
22
+
23
+ /** Find the center column, or undefined while the frame is not mounted. */
24
+ function conversationColumn(): HTMLElement | undefined {
25
+ return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
26
+ }
27
+
28
+ /**
29
+ * Mount the panel React tree into the center column and bind visibility to
30
+ * the controller.
31
+ */
32
+ export function mountPanel(controller: PanelController, api: NovelApi): () => void {
33
+ let root: Root | undefined
34
+ let container: HTMLDivElement | undefined
35
+
36
+ const ensure = (): void => {
37
+ if (container !== undefined) {
38
+ if (container.isConnected) return
39
+ root?.unmount()
40
+ root = undefined
41
+ container.remove()
42
+ container = undefined
43
+ }
44
+ const column = conversationColumn()
45
+ if (column === undefined) return
46
+ container = document.createElement('div')
47
+ container.dataset.dshNovelforgeView = 'true'
48
+ container.className = css.view
49
+ column.appendChild(container)
50
+ root = createRoot(container)
51
+ root.render(<NovelPanel controller={controller} api={api} />)
52
+ }
53
+
54
+ const waitObserver = new MutationObserver(() => { ensure() })
55
+ waitObserver.observe(document.body, { childList: true, subtree: true })
56
+
57
+ const applyActive = (): void => {
58
+ if (controller.getSnapshot().panelOpen) {
59
+ for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
60
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
61
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
62
+ } else {
63
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
64
+ }
65
+ }
66
+ const onOtherActivate = (event: Event): void => {
67
+ const detail = (event as CustomEvent).detail
68
+ if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
69
+ controller.close()
70
+ }
71
+ }
72
+ // Jump out on sidebar context clicks.
73
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
74
+ const onClickSidebarRow = (event: MouseEvent): void => {
75
+ if (!controller.getSnapshot().panelOpen) return
76
+ const target = event.target as HTMLElement | null
77
+ if (target === null) return
78
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
79
+ }
80
+ document.addEventListener('click', onClickSidebarRow, true)
81
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
82
+ const unsubscribe = controller.subscribe(applyActive)
83
+ applyActive()
84
+ ensure()
85
+
86
+ return () => {
87
+ document.removeEventListener('click', onClickSidebarRow, true)
88
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
89
+ waitObserver.disconnect()
90
+ unsubscribe()
91
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
92
+ root?.unmount()
93
+ root = undefined
94
+ container?.remove()
95
+ container = undefined
96
+ }
97
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Panel view mounting — mirrors the family plugins: a container appended
3
+ * inside the conversation grid item, hidden while inactive; toggling is a
4
+ * data attribute on <html>, with cross-plugin activation events.
5
+ */
6
+ import { createRoot, type Root } from 'react-dom/client'
7
+ import type { NovelApi } from './api.ts'
8
+ import type { PanelController } from './panel/controller.ts'
9
+ import { NovelPanel } from './panel/NovelPanel.tsx'
10
+ import css from './panel/panel.module.css'
11
+
12
+ /** The injected panel container. */
13
+ export const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
14
+
15
+ const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
16
+ const ACTIVE_ATTR = 'data-dsh-novelforge-active'
17
+ /** Sibling panels' activation attributes (evicted when this panel opens). */
18
+ const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
19
+ /** Cross-plugin activation event; detail is the activating panel name. */
20
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
21
+ const PANEL_NAME = 'novelforge'
22
+
23
+ /** Find the center column, or undefined while the frame is not mounted. */
24
+ function conversationColumn(): HTMLElement | undefined {
25
+ return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
26
+ }
27
+
28
+ /**
29
+ * Mount the panel React tree into the center column and bind visibility to
30
+ * the controller.
31
+ */
32
+ export function mountPanel(controller: PanelController, api: NovelApi): () => void {
33
+ let root: Root | undefined
34
+ let container: HTMLDivElement | undefined
35
+
36
+ const ensure = (): void => {
37
+ if (container !== undefined) {
38
+ if (container.isConnected) return
39
+ root?.unmount()
40
+ root = undefined
41
+ container.remove()
42
+ container = undefined
43
+ }
44
+ const column = conversationColumn()
45
+ if (column === undefined) return
46
+ container = document.createElement('div')
47
+ container.dataset.dshNovelforgeView = 'true'
48
+ container.className = css.view
49
+ column.appendChild(container)
50
+ root = createRoot(container)
51
+ root.render(<NovelPanel controller={controller} api={api} />)
52
+ }
53
+
54
+ const waitObserver = new MutationObserver(() => { ensure() })
55
+ waitObserver.observe(document.body, { childList: true, subtree: true })
56
+
57
+ const applyActive = (): void => {
58
+ if (controller.getSnapshot().panelOpen) {
59
+ for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
60
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
61
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
62
+ } else {
63
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
64
+ }
65
+ }
66
+ const onOtherActivate = (event: Event): void => {
67
+ const detail = (event as CustomEvent).detail
68
+ if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
69
+ controller.close()
70
+ }
71
+ }
72
+ // Jump out on sidebar context clicks.
73
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
74
+ const onClickSidebarRow = (event: MouseEvent): void => {
75
+ if (!controller.getSnapshot().panelOpen) return
76
+ const target = event.target as HTMLElement | null
77
+ if (target === null) return
78
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
79
+ }
80
+ document.addEventListener('click', onClickSidebarRow, true)
81
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
82
+ const unsubscribe = controller.subscribe(applyActive)
83
+ applyActive()
84
+ ensure()
85
+
86
+ return () => {
87
+ document.removeEventListener('click', onClickSidebarRow, true)
88
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
89
+ waitObserver.disconnect()
90
+ unsubscribe()
91
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
92
+ root?.unmount()
93
+ root = undefined
94
+ container?.remove()
95
+ container = undefined
96
+ }
97
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Panel view mounting — mirrors the family plugins: a container appended
3
+ * inside the conversation grid item, hidden while inactive; toggling is a
4
+ * data attribute on <html>, with cross-plugin activation events.
5
+ */
6
+ import { createRoot, type Root } from 'react-dom/client'
7
+ import type { NovelApi } from './api.ts'
8
+ import type { PanelController } from './panel/controller.ts'
9
+ import { NovelPanel } from './panel/NovelPanel.tsx'
10
+ import css from './panel/panel.module.css'
11
+
12
+ /** The injected panel container. */
13
+ export const PANEL_VIEW_SELECTOR = '[data-dsh-novelforge-view]'
14
+
15
+ const CONVERSATION_COLUMN_SELECTOR = '[data-pane="conversation"], [class*="centerCol"]'
16
+ const ACTIVE_ATTR = 'data-dsh-novelforge-active'
17
+ /** Sibling panels' activation attributes (evicted when this panel opens). */
18
+ const OTHER_ACTIVE_ATTRS = ['data-dsh-taskboard-active', 'data-dsh-ssh-active']
19
+ /** Cross-plugin activation event; detail is the activating panel name. */
20
+ const ACTIVATE_EVENT = 'dsh-panel-activate'
21
+ const PANEL_NAME = 'novelforge'
22
+
23
+ /** Find the center column, or undefined while the frame is not mounted. */
24
+ function conversationColumn(): HTMLElement | undefined {
25
+ return document.querySelector<HTMLElement>(CONVERSATION_COLUMN_SELECTOR) ?? undefined
26
+ }
27
+
28
+ /**
29
+ * Mount the panel React tree into the center column and bind visibility to
30
+ * the controller.
31
+ */
32
+ export function mountPanel(controller: PanelController, api: NovelApi): () => void {
33
+ let root: Root | undefined
34
+ let container: HTMLDivElement | undefined
35
+
36
+ const ensure = (): void => {
37
+ if (container !== undefined) {
38
+ if (container.isConnected) return
39
+ root?.unmount()
40
+ root = undefined
41
+ container.remove()
42
+ container = undefined
43
+ }
44
+ const column = conversationColumn()
45
+ if (column === undefined) return
46
+ container = document.createElement('div')
47
+ container.dataset.dshNovelforgeView = 'true'
48
+ container.className = css.view
49
+ column.appendChild(container)
50
+ root = createRoot(container)
51
+ root.render(<NovelPanel controller={controller} api={api} />)
52
+ }
53
+
54
+ const waitObserver = new MutationObserver(() => { ensure() })
55
+ waitObserver.observe(document.body, { childList: true, subtree: true })
56
+
57
+ const applyActive = (): void => {
58
+ if (controller.getSnapshot().panelOpen) {
59
+ for (const attr of OTHER_ACTIVE_ATTRS) document.documentElement.removeAttribute(attr)
60
+ document.documentElement.setAttribute(ACTIVE_ATTR, '')
61
+ document.dispatchEvent(new CustomEvent(ACTIVATE_EVENT, { detail: PANEL_NAME }))
62
+ } else {
63
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
64
+ }
65
+ }
66
+ const onOtherActivate = (event: Event): void => {
67
+ const detail = (event as CustomEvent).detail
68
+ if ((detail === 'taskboard' || detail === 'ssh') && controller.getSnapshot().panelOpen) {
69
+ controller.close()
70
+ }
71
+ }
72
+ // Jump out on sidebar context clicks.
73
+ const SIDEBAR_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="searchResultWorkspace"], [class*="newSession"]'
74
+ const onClickSidebarRow = (event: MouseEvent): void => {
75
+ if (!controller.getSnapshot().panelOpen) return
76
+ const target = event.target as HTMLElement | null
77
+ if (target === null) return
78
+ if (target.closest(SIDEBAR_ROW_SELECTOR) !== null) controller.close()
79
+ }
80
+ document.addEventListener('click', onClickSidebarRow, true)
81
+ document.addEventListener(ACTIVATE_EVENT, onOtherActivate)
82
+ const unsubscribe = controller.subscribe(applyActive)
83
+ applyActive()
84
+ ensure()
85
+
86
+ return () => {
87
+ document.removeEventListener('click', onClickSidebarRow, true)
88
+ document.removeEventListener(ACTIVATE_EVENT, onOtherActivate)
89
+ waitObserver.disconnect()
90
+ unsubscribe()
91
+ document.documentElement.removeAttribute(ACTIVE_ATTR)
92
+ root?.unmount()
93
+ root = undefined
94
+ container?.remove()
95
+ container = undefined
96
+ }
97
+ }