dsh-cuigengji-plugin 0.4.2 → 0.4.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.
Files changed (67) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +1 -1
  3. package/cordis.patch.yml +3 -3
  4. package/docs/CHANGELOG.md +15 -0
  5. package/docs/agent-installation.md +19 -12
  6. package/docs/architecture.md +74 -74
  7. package/docs/desktop-migration-20260930.md +26 -26
  8. package/docs/development.md +7 -3
  9. package/docs/getting-started.md +17 -7
  10. package/docs/layout-progress.md +31 -31
  11. package/docs/market-listing.md +5 -3
  12. package/docs/npm-publishing.md +35 -11
  13. package/docs/package-name-migration.md +3 -3
  14. package/docs/planning-board.md +44 -44
  15. package/docs/presets.md +24 -24
  16. package/docs/release-process.md +45 -10
  17. package/docs/tavern-compatibility-audit-20261001.md +109 -109
  18. package/docs/test-report.md +139 -129
  19. package/docs/upgrade.md +3 -1
  20. package/docs/usage-review.md +13 -13
  21. package/lib/client.js +19 -14
  22. package/package.json +19 -14
  23. package/skills/cuigengji-maintenance/SKILL.md +2 -2
  24. package/src/adapters/dsh/prompt.js +36 -36
  25. package/src/adapters/dsh/rpc.js +96 -96
  26. package/src/adapters/dsh/skills.js +17 -17
  27. package/src/adapters/mcp/actions.js +6 -6
  28. package/src/adapters/mcp/client.js +172 -172
  29. package/src/adapters/mcp/server.js +40 -40
  30. package/src/application/bindings/session.js +72 -72
  31. package/src/application/queries/context.js +15 -15
  32. package/src/application/store.js +412 -412
  33. package/src/client/features/chapters/clipboard.ts +27 -27
  34. package/src/client/features/chapters/history.ts +11 -11
  35. package/src/client/features/chapters/index.jsx +118 -118
  36. package/src/client/features/chapters/sync.ts +155 -155
  37. package/src/client/features/chapters/useChapterSync.ts +39 -39
  38. package/src/client/features/context/index.jsx +22 -22
  39. package/src/client/features/memory/index.jsx +73 -73
  40. package/src/client/features/planning/Decorations.tsx +1 -1
  41. package/src/client/features/planning/Pages.tsx +11 -11
  42. package/src/client/features/planning/data.js +16 -16
  43. package/src/client/index.tsx +130 -130
  44. package/src/client/launcher.tsx +78 -78
  45. package/src/client/shared/creation.tsx +34 -34
  46. package/src/client/shared/detail-page.tsx +61 -61
  47. package/src/client/shared/drafts.js +17 -17
  48. package/src/client/shared/evidence.jsx +11 -11
  49. package/src/client/shared/groups.tsx +34 -34
  50. package/src/client/shared/preferences.tsx +104 -104
  51. package/src/client/shared/select-style.ts +25 -25
  52. package/src/client/shared/state.js +95 -95
  53. package/src/client/shared/ui.jsx +19 -19
  54. package/src/client/shared/workspace.tsx +11 -11
  55. package/src/client/style.ts +274 -274
  56. package/src/client/visual.ts +190 -190
  57. package/src/contracts/actions.js +19 -19
  58. package/src/contracts/planning.ts +52 -52
  59. package/src/domain/manuscript.ts +4 -4
  60. package/src/domain/planning/index.ts +113 -113
  61. package/src/domain/planning/model.ts +59 -59
  62. package/src/domain/preset/index.js +89 -89
  63. package/src/index.js +91 -91
  64. package/src/infrastructure/migrations/legacy.js +96 -96
  65. package/src/infrastructure/migrations/tavern.js +78 -78
  66. package/src/infrastructure/persistence/atomic-store.ts +80 -80
  67. package/src/infrastructure/persistence/project-layout.ts +1 -1
@@ -1,104 +1,104 @@
1
- import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
2
- import manifest from '../../../package.json' with { type: 'json' };
3
- import aboutSticker from '../assets/haoye.png';
4
-
5
- export interface Preferences {
6
- fontSize: number; leading: number; directory: boolean;
7
- planningView: 'canvas' | 'list'; memoryView: 'canvas' | 'list'; motion: 'system' | 'reduce';
8
- }
9
- export const defaults: Preferences = { fontSize: 18, leading: 1.95, directory: true, planningView: 'canvas', memoryView: 'list', motion: 'system' };
10
- const key = 'cuigengji:workbench:preferences:v1';
11
- function read(): Preferences {
12
- try {
13
- const p = JSON.parse(localStorage.getItem(key) || '{}');
14
- return {fontSize: [16,18,20,22].includes(p.fontSize) ? p.fontSize : defaults.fontSize,
15
- leading: [1.7,1.95,2.2].includes(p.leading) ? p.leading : defaults.leading,
16
- directory: typeof p.directory === 'boolean' ? p.directory : defaults.directory,
17
- planningView: p.planningView === 'list' ? 'list' : 'canvas', memoryView: p.memoryView === 'canvas' ? 'canvas' : 'list',
18
- motion: p.motion === 'reduce' ? 'reduce' : 'system'};
19
- } catch { return defaults; }
20
- }
21
- const Context = createContext({ preferences: defaults, update: (_: Partial<Preferences>) => {}, error: '' });
22
- export const useWorkbenchPreferences = () => useContext(Context);
23
- export function PreferencesProvider({children}: {children: React.ReactNode}) {
24
- const [preferences, set] = useState(read), [error, setError] = useState('');
25
- const update = (next: Partial<Preferences>) => set(previous => {
26
- const value = {...previous, ...next};
27
- try {localStorage.setItem(key, JSON.stringify(value)); setError('');}
28
- catch {setError('当前浏览器无法保存偏好,本次会话仍然生效。');}
29
- return value;
30
- });
31
- useEffect(() => {const sync = (e: StorageEvent) => {if(e.key === key) set(read());}; window.addEventListener('storage', sync); return () => window.removeEventListener('storage', sync);}, []);
32
- return <Context.Provider value={{preferences, update, error}}>{children}</Context.Provider>;
33
- }
34
- export function Modal({title, close, children, className = ''}: {title: string; close: () => void; children: React.ReactNode; className?: string}) {
35
- const ref = useRef<HTMLDialogElement>(null);
36
- useEffect(() => {const previous = document.activeElement as HTMLElement | null; ref.current?.showModal(); return () => {previous?.focus();};}, []);
37
- return <dialog className={`utility-modal ${className}`} ref={ref} aria-label={title} onCancel={e => {e.preventDefault();close();}}>
38
- <header className="utility-head"><h2>{title}</h2><button type="button" aria-label={`关闭${title}`} onClick={close}>×</button></header>
39
- <div className="utility-content">{children}</div>
40
- </dialog>;
41
- }
42
- export function PreferencesDialog({close, novelTitle, onPreset}: {close: () => void; novelTitle?: string; onPreset?: () => void}) {
43
- const {preferences:p, update, error} = useWorkbenchPreferences();
44
- return <Modal title="设置" close={close}>
45
- {onPreset && <section className="preference-section"><div className="preference-work-heading"><h3>当前作品</h3><small title={novelTitle}>{novelTitle}</small></div><button className="preference-action" onClick={onPreset} aria-label="写作预设"><span><strong>写作预设</strong><small>叙述风格、表达习惯,以及希望 AI 遵守的写作要求</small></span><span aria-hidden="true">›</span></button></section>}
46
- <section className="preference-section"><h3>正文显示</h3><div className="split">
47
- <label>字号<select aria-label="默认正文字号" value={p.fontSize} onChange={e=>update({fontSize:Number(e.target.value)})}>{[16,18,20,22].map(n=><option key={n} value={n}>{n}</option>)}</select></label>
48
- <label>行距<select aria-label="正文行距" value={p.leading} onChange={e=>update({leading:Number(e.target.value)})}><option value={1.7}>紧凑</option><option value={1.95}>适中</option><option value={2.2}>宽松</option></select></label></div>
49
- <div className="reading-preview"><small>阅读预览</small><p style={{fontSize:p.fontSize,lineHeight:p.leading}}>雨停以后,街道重新安静下来。窗边的灯仍亮着,故事还在继续。</p></div></section>
50
- <section className="preference-section"><h3>打开工作台时</h3><label className="preference-row">默认展开目录<input type="checkbox" checked={p.directory} onChange={e=>update({directory:e.target.checked})}/></label>
51
- <label className="preference-row">规划默认视图<select value={p.planningView} onChange={e=>update({planningView:e.target.value as Preferences['planningView']})}><option value="canvas">流程图</option><option value="list">列表</option></select></label>
52
- <label className="preference-row">设定默认视图<select value={p.memoryView} onChange={e=>update({memoryView:e.target.value as Preferences['memoryView']})}><option value="list">列表</option><option value="canvas">关系图</option></select></label><p className="muted">下次进入对应页面时生效。</p></section>
53
- <section className="preference-section"><label className="preference-row">动画效果<select value={p.motion} onChange={e=>update({motion:e.target.value as Preferences['motion']})}><option value="system">跟随系统</option><option value="reduce">减少动态效果</option></select></label></section>
54
- <footer className="utility-footer"><button onClick={()=>update(defaults)}>恢复默认设置</button><small role="status">{error || '偏好自动保存在此浏览器'}</small></footer>
55
- </Modal>;
56
- }
57
- export function ProseDisplay() {
58
- const {preferences:p, update} = useWorkbenchPreferences();
59
- return <details className="menu prose-display"><summary aria-label="正文显示设置"><span aria-hidden="true">Aa</span>显示</summary><div className="menu-panel">
60
- <label>字号<select aria-label="正文字号" value={p.fontSize} onChange={event=>update({fontSize:Number(event.target.value)})}>{[16,18,20,22].map(value=><option key={value} value={value}>{value}</option>)}</select></label>
61
- <label>行距<select aria-label="当前正文行距" value={p.leading} onChange={event=>update({leading:Number(event.target.value)})}><option value={1.7}>紧凑</option><option value={1.95}>适中</option><option value={2.2}>宽松</option></select></label>
62
- </div></details>;
63
- }
64
- export function AboutDialog({close}: {close: () => void}) {
65
- const [copyStatus, setCopyStatus] = useState('');
66
- const groupNumber = useRef<HTMLElement>(null);
67
- const copyGroup = async () => {
68
- try {
69
- await navigator.clipboard.writeText('1026457465');
70
- setCopyStatus('群号已复制');
71
- } catch {
72
- const selection = window.getSelection();
73
- if (groupNumber.current && selection) {
74
- const range = document.createRange();
75
- range.selectNodeContents(groupNumber.current);
76
- selection.removeAllRanges(); selection.addRange(range);
77
- }
78
- setCopyStatus('可选中群号,按 Ctrl+C 复制。');
79
- }
80
- };
81
- return <Modal title="关于催更姬" close={close} className="about-modal">
82
- <section className="about-overview">
83
- <div>
84
- <div className="about-identity"><h2>催更姬</h2><span className="about-version">v{manifest.version}</span></div>
85
- <p className="about-tagline">让大肥鱼成为你的小说写作助手。</p>
86
- </div>
87
- <img className="about-sticker" src={aboutSticker} width={384} height={384} alt="好耶!紫发少女的欢呼贴纸"/>
88
- <p className="about-description">为 DSH 打造的小说写作插件。把正文、情节规划、人物与世界设定放在同一个工作台,让你和 AI 一起讨论、整理并推进故事。</p>
89
- </section>
90
- <div className="about-contacts" aria-label="交流与支持">
91
- <button type="button" className="about-contact" onClick={copyGroup} aria-label="复制交流群号 1026457465" title="复制群号">
92
- <span className="about-contact-label">交流群</span><strong ref={groupNumber}>1026457465</strong><span className="about-contact-mark" aria-hidden="true">{copyStatus === '群号已复制' ? '✓' : '⧉'}</span>
93
- </button>
94
- <a className="about-contact" href="https://ifdian.net/a/cuigengji" target="_blank" rel="noopener noreferrer">
95
- <span className="about-contact-label">爱发电</span><strong>支持催更姬</strong><span className="about-contact-mark" aria-hidden="true">↗</span>
96
- </a>
97
- <a className="about-contact" href="https://github.com/mistnest/dsh-cuigengji-plugin" target="_blank" rel="noopener noreferrer">
98
- <span className="about-contact-label">GitHub</span><strong>查看开源项目</strong><span className="about-contact-mark" aria-hidden="true">↗</span>
99
- </a>
100
- </div>
101
- <p className="about-copy-status" role="status">{copyStatus}</p>
102
- <footer className="about-welcome"><p>欢迎交流、合作与投喂。</p><p>每一份支持都会化作开发所需的 Token,让催更姬更新得更快一点。</p></footer>
103
- </Modal>;
104
- }
1
+ import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
2
+ import manifest from '../../../package.json' with { type: 'json' };
3
+ import aboutSticker from '../assets/haoye.png';
4
+
5
+ export interface Preferences {
6
+ fontSize: number; leading: number; directory: boolean;
7
+ planningView: 'canvas' | 'list'; memoryView: 'canvas' | 'list'; motion: 'system' | 'reduce';
8
+ }
9
+ export const defaults: Preferences = { fontSize: 18, leading: 1.95, directory: true, planningView: 'canvas', memoryView: 'list', motion: 'system' };
10
+ const key = 'cuigengji:workbench:preferences:v1';
11
+ function read(): Preferences {
12
+ try {
13
+ const p = JSON.parse(localStorage.getItem(key) || '{}');
14
+ return {fontSize: [16,18,20,22].includes(p.fontSize) ? p.fontSize : defaults.fontSize,
15
+ leading: [1.7,1.95,2.2].includes(p.leading) ? p.leading : defaults.leading,
16
+ directory: typeof p.directory === 'boolean' ? p.directory : defaults.directory,
17
+ planningView: p.planningView === 'list' ? 'list' : 'canvas', memoryView: p.memoryView === 'canvas' ? 'canvas' : 'list',
18
+ motion: p.motion === 'reduce' ? 'reduce' : 'system'};
19
+ } catch { return defaults; }
20
+ }
21
+ const Context = createContext({ preferences: defaults, update: (_: Partial<Preferences>) => {}, error: '' });
22
+ export const useWorkbenchPreferences = () => useContext(Context);
23
+ export function PreferencesProvider({children}: {children: React.ReactNode}) {
24
+ const [preferences, set] = useState(read), [error, setError] = useState('');
25
+ const update = (next: Partial<Preferences>) => set(previous => {
26
+ const value = {...previous, ...next};
27
+ try {localStorage.setItem(key, JSON.stringify(value)); setError('');}
28
+ catch {setError('当前浏览器无法保存偏好,本次会话仍然生效。');}
29
+ return value;
30
+ });
31
+ useEffect(() => {const sync = (e: StorageEvent) => {if(e.key === key) set(read());}; window.addEventListener('storage', sync); return () => window.removeEventListener('storage', sync);}, []);
32
+ return <Context.Provider value={{preferences, update, error}}>{children}</Context.Provider>;
33
+ }
34
+ export function Modal({title, close, children, className = ''}: {title: string; close: () => void; children: React.ReactNode; className?: string}) {
35
+ const ref = useRef<HTMLDialogElement>(null);
36
+ useEffect(() => {const previous = document.activeElement as HTMLElement | null; ref.current?.showModal(); return () => {previous?.focus();};}, []);
37
+ return <dialog className={`utility-modal ${className}`} ref={ref} aria-label={title} onCancel={e => {e.preventDefault();close();}}>
38
+ <header className="utility-head"><h2>{title}</h2><button type="button" aria-label={`关闭${title}`} onClick={close}>×</button></header>
39
+ <div className="utility-content">{children}</div>
40
+ </dialog>;
41
+ }
42
+ export function PreferencesDialog({close, novelTitle, onPreset}: {close: () => void; novelTitle?: string; onPreset?: () => void}) {
43
+ const {preferences:p, update, error} = useWorkbenchPreferences();
44
+ return <Modal title="设置" close={close}>
45
+ {onPreset && <section className="preference-section"><div className="preference-work-heading"><h3>当前作品</h3><small title={novelTitle}>{novelTitle}</small></div><button className="preference-action" onClick={onPreset} aria-label="写作预设"><span><strong>写作预设</strong><small>叙述风格、表达习惯,以及希望 AI 遵守的写作要求</small></span><span aria-hidden="true">›</span></button></section>}
46
+ <section className="preference-section"><h3>正文显示</h3><div className="split">
47
+ <label>字号<select aria-label="默认正文字号" value={p.fontSize} onChange={e=>update({fontSize:Number(e.target.value)})}>{[16,18,20,22].map(n=><option key={n} value={n}>{n}</option>)}</select></label>
48
+ <label>行距<select aria-label="正文行距" value={p.leading} onChange={e=>update({leading:Number(e.target.value)})}><option value={1.7}>紧凑</option><option value={1.95}>适中</option><option value={2.2}>宽松</option></select></label></div>
49
+ <div className="reading-preview"><small>阅读预览</small><p style={{fontSize:p.fontSize,lineHeight:p.leading}}>雨停以后,街道重新安静下来。窗边的灯仍亮着,故事还在继续。</p></div></section>
50
+ <section className="preference-section"><h3>打开工作台时</h3><label className="preference-row">默认展开目录<input type="checkbox" checked={p.directory} onChange={e=>update({directory:e.target.checked})}/></label>
51
+ <label className="preference-row">规划默认视图<select value={p.planningView} onChange={e=>update({planningView:e.target.value as Preferences['planningView']})}><option value="canvas">流程图</option><option value="list">列表</option></select></label>
52
+ <label className="preference-row">设定默认视图<select value={p.memoryView} onChange={e=>update({memoryView:e.target.value as Preferences['memoryView']})}><option value="list">列表</option><option value="canvas">关系图</option></select></label><p className="muted">下次进入对应页面时生效。</p></section>
53
+ <section className="preference-section"><label className="preference-row">动画效果<select value={p.motion} onChange={e=>update({motion:e.target.value as Preferences['motion']})}><option value="system">跟随系统</option><option value="reduce">减少动态效果</option></select></label></section>
54
+ <footer className="utility-footer"><button onClick={()=>update(defaults)}>恢复默认设置</button><small role="status">{error || '偏好自动保存在此浏览器'}</small></footer>
55
+ </Modal>;
56
+ }
57
+ export function ProseDisplay() {
58
+ const {preferences:p, update} = useWorkbenchPreferences();
59
+ return <details className="menu prose-display"><summary aria-label="正文显示设置"><span aria-hidden="true">Aa</span>显示</summary><div className="menu-panel">
60
+ <label>字号<select aria-label="正文字号" value={p.fontSize} onChange={event=>update({fontSize:Number(event.target.value)})}>{[16,18,20,22].map(value=><option key={value} value={value}>{value}</option>)}</select></label>
61
+ <label>行距<select aria-label="当前正文行距" value={p.leading} onChange={event=>update({leading:Number(event.target.value)})}><option value={1.7}>紧凑</option><option value={1.95}>适中</option><option value={2.2}>宽松</option></select></label>
62
+ </div></details>;
63
+ }
64
+ export function AboutDialog({close}: {close: () => void}) {
65
+ const [copyStatus, setCopyStatus] = useState('');
66
+ const groupNumber = useRef<HTMLElement>(null);
67
+ const copyGroup = async () => {
68
+ try {
69
+ await navigator.clipboard.writeText('1026457465');
70
+ setCopyStatus('群号已复制');
71
+ } catch {
72
+ const selection = window.getSelection();
73
+ if (groupNumber.current && selection) {
74
+ const range = document.createRange();
75
+ range.selectNodeContents(groupNumber.current);
76
+ selection.removeAllRanges(); selection.addRange(range);
77
+ }
78
+ setCopyStatus('可选中群号,按 Ctrl+C 复制。');
79
+ }
80
+ };
81
+ return <Modal title="关于催更姬" close={close} className="about-modal">
82
+ <section className="about-overview">
83
+ <div>
84
+ <div className="about-identity"><h2>催更姬</h2><span className="about-version">v{manifest.version}</span></div>
85
+ <p className="about-tagline">让大肥鱼成为你的小说写作助手。</p>
86
+ </div>
87
+ <img className="about-sticker" src={aboutSticker} width={384} height={384} alt="好耶!紫发少女的欢呼贴纸"/>
88
+ <p className="about-description">为 DSH 打造的小说写作插件。把正文、情节规划、人物与世界设定放在同一个工作台,让你和 AI 一起讨论、整理并推进故事。</p>
89
+ </section>
90
+ <div className="about-contacts" aria-label="交流与支持">
91
+ <button type="button" className="about-contact" onClick={copyGroup} aria-label="复制交流群号 1026457465" title="复制群号">
92
+ <span className="about-contact-label">交流群</span><strong ref={groupNumber}>1026457465</strong><span className="about-contact-mark" aria-hidden="true">{copyStatus === '群号已复制' ? '✓' : '⧉'}</span>
93
+ </button>
94
+ <a className="about-contact" href="https://ifdian.net/a/cuigengji" target="_blank" rel="noopener noreferrer">
95
+ <span className="about-contact-label">爱发电</span><strong>支持催更姬</strong><span className="about-contact-mark" aria-hidden="true">↗</span>
96
+ </a>
97
+ <a className="about-contact" href="https://github.com/mistnest/dsh-cuigengji-plugin" target="_blank" rel="noopener noreferrer">
98
+ <span className="about-contact-label">GitHub</span><strong>查看开源项目</strong><span className="about-contact-mark" aria-hidden="true">↗</span>
99
+ </a>
100
+ </div>
101
+ <p className="about-copy-status" role="status">{copyStatus}</p>
102
+ <footer className="about-welcome"><p>欢迎交流、合作与投喂。</p><p>每一份支持都会化作开发所需的 Token,让催更姬更新得更快一点。</p></footer>
103
+ </Modal>;
104
+ }
@@ -1,25 +1,25 @@
1
- /** Native customizable selects keep form, keyboard and accessibility semantics. */
2
- export const selectStyles = `
3
- .cuigengji select {cursor:pointer;min-height:34px;line-height:1.4;background-color:var(--cg-paper);}
4
- .cuigengji select:disabled {cursor:default;opacity:.5;}
5
- .cuigengji select:not(:disabled):hover {border-color:color-mix(in srgb,var(--cg-accent) 45%,var(--cg-line));}
6
- .cuigengji select option {font:inherit;background:var(--cg-paper);color:var(--cg-ink);}
7
- @supports (appearance:base-select) {
8
- .cuigengji select,.cuigengji select::picker(select) {appearance:base-select;}
9
- .cuigengji select {display:inline-flex;align-items:center;gap:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
10
- .cuigengji select::picker-icon {content:'';width:10px;height:6px;border:0;background:currentColor;clip-path:polygon(0 0,50% 65%,100% 0,100% 35%,50% 100%,0 35%);rotate:0deg;flex:none;margin:0 1px 0 auto;opacity:.6;transition:rotate 120ms;}
11
- .cuigengji select:open::picker-icon {rotate:180deg;}
12
- .cuigengji select::picker(select) {font:400 13px/1.5 var(--dsw-font-family,system-ui,'Microsoft YaHei',sans-serif);color:var(--cg-ink);background:var(--cg-paper);border:1px solid var(--cg-line);border-radius:12px;padding:5px;margin-block:6px;min-width:anchor-size(width);max-width:calc(100vw - 24px);max-height:min(320px,60dvh);box-shadow:0 8px 28px #0000001c,0 2px 6px #0000000a;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;}
13
- .cuigengji select option {display:flex;align-items:center;gap:12px;min-height:36px;padding:8px 12px;border-radius:7px;white-space:normal;overflow-wrap:anywhere;cursor:pointer;}
14
- .cuigengji select option:checked {color:var(--cg-accent);background:var(--cg-wash);font-weight:600;}
15
- .cuigengji select option:hover,.cuigengji select option:focus {background:color-mix(in srgb,var(--cg-accent) 12%,var(--cg-paper));outline:none;}
16
- .cuigengji select option::checkmark {order:2;margin-left:auto;color:var(--cg-accent);font-size:12px;}
17
- .cuigengji select option:disabled {opacity:.45;cursor:default;}
18
- .cuigengji .filter-control select {appearance:base-select;padding-right:2px;min-height:0;}
19
- .cuigengji .filter-chevron {display:none;}
20
- .cuigengji .book-picker {padding:7px 10px;border:1px solid transparent;border-radius:8px;background:transparent;font-weight:600;}
21
- .cuigengji .book-picker:hover,.cuigengji .book-picker:open {background:var(--cg-wash);border-color:var(--cg-line);}
22
- }
23
- @media (prefers-reduced-motion:reduce) {.cuigengji select::picker-icon {transition:none;}}
24
- .cuigengji[data-motion=reduce] select::picker-icon {transition:none;}
25
- `;
1
+ /** Native customizable selects keep form, keyboard and accessibility semantics. */
2
+ export const selectStyles = `
3
+ .cuigengji select {cursor:pointer;min-height:34px;line-height:1.4;background-color:var(--cg-paper);}
4
+ .cuigengji select:disabled {cursor:default;opacity:.5;}
5
+ .cuigengji select:not(:disabled):hover {border-color:color-mix(in srgb,var(--cg-accent) 45%,var(--cg-line));}
6
+ .cuigengji select option {font:inherit;background:var(--cg-paper);color:var(--cg-ink);}
7
+ @supports (appearance:base-select) {
8
+ .cuigengji select,.cuigengji select::picker(select) {appearance:base-select;}
9
+ .cuigengji select {display:inline-flex;align-items:center;gap:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
10
+ .cuigengji select::picker-icon {content:'';width:10px;height:6px;border:0;background:currentColor;clip-path:polygon(0 0,50% 65%,100% 0,100% 35%,50% 100%,0 35%);rotate:0deg;flex:none;margin:0 1px 0 auto;opacity:.6;transition:rotate 120ms;}
11
+ .cuigengji select:open::picker-icon {rotate:180deg;}
12
+ .cuigengji select::picker(select) {font:400 13px/1.5 var(--dsw-font-family,system-ui,'Microsoft YaHei',sans-serif);color:var(--cg-ink);background:var(--cg-paper);border:1px solid var(--cg-line);border-radius:12px;padding:5px;margin-block:6px;min-width:anchor-size(width);max-width:calc(100vw - 24px);max-height:min(320px,60dvh);box-shadow:0 8px 28px #0000001c,0 2px 6px #0000000a;overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;}
13
+ .cuigengji select option {display:flex;align-items:center;gap:12px;min-height:36px;padding:8px 12px;border-radius:7px;white-space:normal;overflow-wrap:anywhere;cursor:pointer;}
14
+ .cuigengji select option:checked {color:var(--cg-accent);background:var(--cg-wash);font-weight:600;}
15
+ .cuigengji select option:hover,.cuigengji select option:focus {background:color-mix(in srgb,var(--cg-accent) 12%,var(--cg-paper));outline:none;}
16
+ .cuigengji select option::checkmark {order:2;margin-left:auto;color:var(--cg-accent);font-size:12px;}
17
+ .cuigengji select option:disabled {opacity:.45;cursor:default;}
18
+ .cuigengji .filter-control select {appearance:base-select;padding-right:2px;min-height:0;}
19
+ .cuigengji .filter-chevron {display:none;}
20
+ .cuigengji .book-picker {padding:7px 10px;border:1px solid transparent;border-radius:8px;background:transparent;font-weight:600;}
21
+ .cuigengji .book-picker:hover,.cuigengji .book-picker:open {background:var(--cg-wash);border-color:var(--cg-line);}
22
+ }
23
+ @media (prefers-reduced-motion:reduce) {.cuigengji select::picker-icon {transition:none;}}
24
+ .cuigengji[data-motion=reduce] select::picker-icon {transition:none;}
25
+ `;
@@ -1,95 +1,95 @@
1
- import { draftKey, restoreDraft, persistDraft, rebaseDraft } from './drafts.js';
2
- import { createContext, useContext, useEffect, useRef, useState } from 'react';
3
- import {useWorkbenchRefresh} from './refresh.tsx';
4
- const memoryDrafts = new Map();
5
- function initialDraft(key, entityKey, initial) {
6
- if (memoryDrafts.has(key)) return memoryDrafts.get(key);
7
- if (typeof localStorage === 'undefined') return {base:initial,value:initial};
8
- // One-time recovery of the previous chapter-only draft format.
9
- const match = /^([^:]+):chapter:(.+)$/.exec(entityKey);
10
- try {
11
- if (match && localStorage.getItem(key) === null && !localStorage.getItem(`${key}:migrated`)) {
12
- const old = JSON.parse(localStorage.getItem(`cuigengji:draft:${match[1]}:${match[2]}`));
13
- if (old?.base && typeof old.content === 'string') {
14
- persistDraft(localStorage,key,{base:old.base,value:{...old.base,content:old.content,title:old.title??old.base.title,volumeId:old.volumeId??old.base.volumeId}});
15
- }
16
- localStorage.setItem(`${key}:migrated`,'true');
17
- }
18
- } catch {}
19
- return restoreDraft(localStorage,key,initial);
20
- }
21
- export const SessionScope = createContext('local');
22
- export function readLocal(key, fallback) {
23
- try { const value = localStorage.getItem(key); return value === null ? fallback : JSON.parse(value); } catch { return fallback; }
24
- }
25
- export function usePreference(name, fallback) {
26
- const scope = useContext(SessionScope);
27
- const key = `cuigengji:ui:${scope}:${name}`;
28
- const [value, setValue] = useState(() => readLocal(key, fallback));
29
- const update = next => setValue(previous => {
30
- const value = typeof next === 'function' ? next(previous) : next;
31
- try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
32
- return value;
33
- });
34
- return [value, update];
35
- }
36
- // Components using this hook mount only after the server value is available.
37
- // The server revision is retained in the draft; restoration never adopts a newer CAS base.
38
- export function useDraft(entityKey, initial) {
39
- const scope = useContext(SessionScope);
40
- const key = draftKey(scope, entityKey);
41
- const [state, setState] = useState(() => initialDraft(key,entityKey,initial));
42
- const [cacheError, setCacheError] = useState('');
43
- const current = useRef(state); current.current = state;
44
- const dirty = JSON.stringify(state.value) !== JSON.stringify(state.base);
45
- const persist = next => {
46
- if (JSON.stringify(next.value) === JSON.stringify(next.base)) memoryDrafts.delete(key);
47
- else memoryDrafts.set(key, next);
48
- try {
49
- persistDraft(localStorage, key, next);
50
- setCacheError('');
51
- } catch { setCacheError('本地草稿缓存失败,请先保存再离开。'); }
52
- };
53
- const change = next => {
54
- const value = typeof next === 'function' ? next(current.current.value) : next;
55
- const updated = { ...current.current, value };
56
- current.current = updated; setState(updated); persist(updated);
57
- };
58
- const accept = value => { const next = { base: value, value }; current.current = next; setState(next); persist(next); };
59
- useEffect(() => {
60
- const warn = event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } };
61
- window.addEventListener('beforeunload', warn);
62
- return () => window.removeEventListener('beforeunload', warn);
63
- }, [dirty]);
64
- const rebase = server => { const next = rebaseDraft(current.current, server); current.current = next; setState(next); persist(next); };
65
- return { value: state.value, base: state.base, change, accept, rebase, dirty, cacheError };
66
- }
67
- export function useResource(loader, deps) {
68
- const refreshRevision = useWorkbenchRefresh().revision;
69
- const [state, setState] = useState({ value: null, error: '', loading: true });
70
- const [retry, setRetry] = useState(0);
71
- useEffect(() => {
72
- let live = true;
73
- setState(s => ({ ...s, loading: true, error: '' }));
74
- Promise.resolve().then(loader).then(value => live && setState({ value, loading: false, error: '' }), error => live && setState(s => ({ ...s, loading: false, error: error.message || String(error) })));
75
- return () => { live = false; };
76
- }, [...deps, retry, refreshRevision]);
77
- return { ...state, retry: () => setRetry(n => n + 1) };
78
- }
79
-
80
- export function useScrollPosition(name) {
81
- const scope = useContext(SessionScope);
82
- const ref = useRef(null);
83
- const key = `cuigengji:scroll:${scope}:${name}`;
84
- useEffect(() => {
85
- const element = ref.current;
86
- if (!element) return;
87
- element.scrollTop = readLocal(key, 0);
88
- let timer;
89
- const save = () => { try { localStorage.setItem(key, JSON.stringify(element.scrollTop)); } catch {} };
90
- const scroll = () => { clearTimeout(timer); timer = setTimeout(save, 150); };
91
- element.addEventListener('scroll', scroll);
92
- return () => { clearTimeout(timer); save(); element.removeEventListener('scroll', scroll); };
93
- }, [key]);
94
- return ref;
95
- }
1
+ import { draftKey, restoreDraft, persistDraft, rebaseDraft } from './drafts.js';
2
+ import { createContext, useContext, useEffect, useRef, useState } from 'react';
3
+ import {useWorkbenchRefresh} from './refresh.tsx';
4
+ const memoryDrafts = new Map();
5
+ function initialDraft(key, entityKey, initial) {
6
+ if (memoryDrafts.has(key)) return memoryDrafts.get(key);
7
+ if (typeof localStorage === 'undefined') return {base:initial,value:initial};
8
+ // One-time recovery of the previous chapter-only draft format.
9
+ const match = /^([^:]+):chapter:(.+)$/.exec(entityKey);
10
+ try {
11
+ if (match && localStorage.getItem(key) === null && !localStorage.getItem(`${key}:migrated`)) {
12
+ const old = JSON.parse(localStorage.getItem(`cuigengji:draft:${match[1]}:${match[2]}`));
13
+ if (old?.base && typeof old.content === 'string') {
14
+ persistDraft(localStorage,key,{base:old.base,value:{...old.base,content:old.content,title:old.title??old.base.title,volumeId:old.volumeId??old.base.volumeId}});
15
+ }
16
+ localStorage.setItem(`${key}:migrated`,'true');
17
+ }
18
+ } catch {}
19
+ return restoreDraft(localStorage,key,initial);
20
+ }
21
+ export const SessionScope = createContext('local');
22
+ export function readLocal(key, fallback) {
23
+ try { const value = localStorage.getItem(key); return value === null ? fallback : JSON.parse(value); } catch { return fallback; }
24
+ }
25
+ export function usePreference(name, fallback) {
26
+ const scope = useContext(SessionScope);
27
+ const key = `cuigengji:ui:${scope}:${name}`;
28
+ const [value, setValue] = useState(() => readLocal(key, fallback));
29
+ const update = next => setValue(previous => {
30
+ const value = typeof next === 'function' ? next(previous) : next;
31
+ try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
32
+ return value;
33
+ });
34
+ return [value, update];
35
+ }
36
+ // Components using this hook mount only after the server value is available.
37
+ // The server revision is retained in the draft; restoration never adopts a newer CAS base.
38
+ export function useDraft(entityKey, initial) {
39
+ const scope = useContext(SessionScope);
40
+ const key = draftKey(scope, entityKey);
41
+ const [state, setState] = useState(() => initialDraft(key,entityKey,initial));
42
+ const [cacheError, setCacheError] = useState('');
43
+ const current = useRef(state); current.current = state;
44
+ const dirty = JSON.stringify(state.value) !== JSON.stringify(state.base);
45
+ const persist = next => {
46
+ if (JSON.stringify(next.value) === JSON.stringify(next.base)) memoryDrafts.delete(key);
47
+ else memoryDrafts.set(key, next);
48
+ try {
49
+ persistDraft(localStorage, key, next);
50
+ setCacheError('');
51
+ } catch { setCacheError('本地草稿缓存失败,请先保存再离开。'); }
52
+ };
53
+ const change = next => {
54
+ const value = typeof next === 'function' ? next(current.current.value) : next;
55
+ const updated = { ...current.current, value };
56
+ current.current = updated; setState(updated); persist(updated);
57
+ };
58
+ const accept = value => { const next = { base: value, value }; current.current = next; setState(next); persist(next); };
59
+ useEffect(() => {
60
+ const warn = event => { if (dirty) { event.preventDefault(); event.returnValue = ''; } };
61
+ window.addEventListener('beforeunload', warn);
62
+ return () => window.removeEventListener('beforeunload', warn);
63
+ }, [dirty]);
64
+ const rebase = server => { const next = rebaseDraft(current.current, server); current.current = next; setState(next); persist(next); };
65
+ return { value: state.value, base: state.base, change, accept, rebase, dirty, cacheError };
66
+ }
67
+ export function useResource(loader, deps) {
68
+ const refreshRevision = useWorkbenchRefresh().revision;
69
+ const [state, setState] = useState({ value: null, error: '', loading: true });
70
+ const [retry, setRetry] = useState(0);
71
+ useEffect(() => {
72
+ let live = true;
73
+ setState(s => ({ ...s, loading: true, error: '' }));
74
+ Promise.resolve().then(loader).then(value => live && setState({ value, loading: false, error: '' }), error => live && setState(s => ({ ...s, loading: false, error: error.message || String(error) })));
75
+ return () => { live = false; };
76
+ }, [...deps, retry, refreshRevision]);
77
+ return { ...state, retry: () => setRetry(n => n + 1) };
78
+ }
79
+
80
+ export function useScrollPosition(name) {
81
+ const scope = useContext(SessionScope);
82
+ const ref = useRef(null);
83
+ const key = `cuigengji:scroll:${scope}:${name}`;
84
+ useEffect(() => {
85
+ const element = ref.current;
86
+ if (!element) return;
87
+ element.scrollTop = readLocal(key, 0);
88
+ let timer;
89
+ const save = () => { try { localStorage.setItem(key, JSON.stringify(element.scrollTop)); } catch {} };
90
+ const scroll = () => { clearTimeout(timer); timer = setTimeout(save, 150); };
91
+ element.addEventListener('scroll', scroll);
92
+ return () => { clearTimeout(timer); save(); element.removeEventListener('scroll', scroll); };
93
+ }, [key]);
94
+ return ref;
95
+ }
@@ -1,19 +1,19 @@
1
- import React from 'react';
2
- export function ResourceState({ resource, children }) {
3
- if (resource.loading && resource.value === null) return <p className="empty" role="status">正在读取…</p>;
4
- return <>{resource.error&&<div className="notice error" role="alert">{resource.error}<button onClick={resource.retry}>重试</button></div>}{children}</>;
5
- }
6
- // Deliberately small Markdown reader: React escapes all text, no HTML or unsafe links.
7
- export function ReadingText({ text = '' }) {
8
- return <div className="reading-text">{text.split(/\n\s*\n/).map((block, i) => {
9
- const heading = /^(#{1,3})\s+(.+)$/.exec(block);
10
- if (heading) return <h3 key={i}>{heading[2]}</h3>;
11
- return <p key={i}>{block.split(/(\*\*[^*]+\*\*)/g).map((part,j) => part.startsWith('**') && part.endsWith('**') ? <strong key={j}>{part.slice(2,-2)}</strong> : part)}</p>;
12
- })}</div>;
13
- }
14
- export function SaveBar({ dirty, busy, error, invalid, onSave, children, label = '保存修改' }) {
15
- return <footer className="savebar"><div role="status" className={error || invalid ? 'error-text' : 'muted'}>{error || invalid || (dirty ? '本地草稿 · 尚未保存到作品' : '已保存')}{children}</div><button className="primary" disabled={!dirty || busy || !!invalid} onClick={onSave}>{busy ? '请稍候…' : label}</button></footer>;
16
- }
17
- export function saveShortcut(event, save) {
18
- if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); event.stopPropagation(); save(); }
19
- }
1
+ import React from 'react';
2
+ export function ResourceState({ resource, children }) {
3
+ if (resource.loading && resource.value === null) return <p className="empty" role="status">正在读取…</p>;
4
+ return <>{resource.error&&<div className="notice error" role="alert">{resource.error}<button onClick={resource.retry}>重试</button></div>}{children}</>;
5
+ }
6
+ // Deliberately small Markdown reader: React escapes all text, no HTML or unsafe links.
7
+ export function ReadingText({ text = '' }) {
8
+ return <div className="reading-text">{text.split(/\n\s*\n/).map((block, i) => {
9
+ const heading = /^(#{1,3})\s+(.+)$/.exec(block);
10
+ if (heading) return <h3 key={i}>{heading[2]}</h3>;
11
+ return <p key={i}>{block.split(/(\*\*[^*]+\*\*)/g).map((part,j) => part.startsWith('**') && part.endsWith('**') ? <strong key={j}>{part.slice(2,-2)}</strong> : part)}</p>;
12
+ })}</div>;
13
+ }
14
+ export function SaveBar({ dirty, busy, error, invalid, onSave, children, label = '保存修改' }) {
15
+ return <footer className="savebar"><div role="status" className={error || invalid ? 'error-text' : 'muted'}>{error || invalid || (dirty ? '本地草稿 · 尚未保存到作品' : '已保存')}{children}</div><button className="primary" disabled={!dirty || busy || !!invalid} onClick={onSave}>{busy ? '请稍候…' : label}</button></footer>;
16
+ }
17
+ export function saveShortcut(event, save) {
18
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { event.preventDefault(); event.stopPropagation(); save(); }
19
+ }
@@ -1,16 +1,16 @@
1
1
  import React, {useLayoutEffect, useRef, useState} from 'react';
2
- export function DocumentTextarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
3
- const ref = useRef<HTMLTextAreaElement>(null);
4
- const fit = () => {const el=ref.current;if(el){el.style.height='0px';el.style.height=`${el.scrollHeight}px`;}};
5
- useLayoutEffect(fit,[props.value]);
6
- useLayoutEffect(()=>{const el=ref.current;if(!el)return;let width=0;const observer=new ResizeObserver(entries=>{const next=entries[0].contentRect.width;if(next!==width){width=next;fit();}});observer.observe(el);return()=>observer.disconnect();},[]);
7
- return <textarea {...props} ref={ref} style={{...props.style,overflow:'hidden',resize:'none'}}/>;
8
- }
9
- export function ViewSwitch({value,change,graph}: {value:string;change:(value:string)=>void;graph:string}) {
10
- return <div className="segmented" aria-label="显示方式"><button aria-pressed={value==='list'} onClick={()=>change('list')}>列表</button><button aria-pressed={value==='canvas'} onClick={()=>change('canvas')}>{graph}</button></div>;
11
- }
2
+ export function DocumentTextarea(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
3
+ const ref = useRef<HTMLTextAreaElement>(null);
4
+ const fit = () => {const el=ref.current;if(el){el.style.height='0px';el.style.height=`${el.scrollHeight}px`;}};
5
+ useLayoutEffect(fit,[props.value]);
6
+ useLayoutEffect(()=>{const el=ref.current;if(!el)return;let width=0;const observer=new ResizeObserver(entries=>{const next=entries[0].contentRect.width;if(next!==width){width=next;fit();}});observer.observe(el);return()=>observer.disconnect();},[]);
7
+ return <textarea {...props} ref={ref} style={{...props.style,overflow:'hidden',resize:'none'}}/>;
8
+ }
9
+ export function ViewSwitch({value,change,graph}: {value:string;change:(value:string)=>void;graph:string}) {
10
+ return <div className="segmented" aria-label="显示方式"><button aria-pressed={value==='list'} onClick={()=>change('list')}>列表</button><button aria-pressed={value==='canvas'} onClick={()=>change('canvas')}>{graph}</button></div>;
11
+ }
12
12
  export function FilterControl({label,value,change,children}: {label:string;value:string;change:(v:string)=>void;children:React.ReactNode}) {
13
- return <label className={`filter-control ${value?'is-active':''}`}><span className="filter-prefix">{label}</span><select aria-label={label} value={value} onChange={e=>change(e.target.value)}>{children}</select><span className="filter-chevron" aria-hidden="true"/></label>;
13
+ return <label className={`filter-control ${value?'is-active':''}`}><span className="filter-prefix">{label}</span><select aria-label={label} value={value} onChange={e=>change(e.target.value)}>{children}</select><span className="filter-chevron" aria-hidden="true"/></label>;
14
14
  }
15
15
  export function SearchControl({label,value,change}:{label:string;value:string;change:(value:string)=>void}) {
16
16
  const [open,setOpen]=useState(false);