@musnows/scriverse 0.4.7 → 0.4.10

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.
@@ -0,0 +1,57 @@
1
+ export type CropRect = {
2
+ x: number;
3
+ y: number;
4
+ size: number;
5
+ };
6
+
7
+ export type DisplayRect = {
8
+ x: number;
9
+ y: number;
10
+ width: number;
11
+ height: number;
12
+ scale: number;
13
+ };
14
+
15
+ export type CropHandle = "nw" | "ne" | "sw" | "se";
16
+
17
+ export const AVATAR_CROP_OUTPUT_SIZE: number;
18
+ export const AVATAR_CROP_MIN_SIZE: number;
19
+
20
+ export function defaultCropRect(imageWidth: number, imageHeight: number): CropRect;
21
+ export function clampCropRect(
22
+ rect: Partial<CropRect> | null | undefined,
23
+ imageWidth: number,
24
+ imageHeight: number,
25
+ minSize?: number
26
+ ): CropRect;
27
+ export function moveCropRect(
28
+ rect: Partial<CropRect> | null | undefined,
29
+ deltaX: number,
30
+ deltaY: number,
31
+ imageWidth: number,
32
+ imageHeight: number
33
+ ): CropRect;
34
+ export function resizeCropRect(
35
+ rect: Partial<CropRect> | null | undefined,
36
+ handle: CropHandle | string,
37
+ pointerX: number,
38
+ pointerY: number,
39
+ imageWidth: number,
40
+ imageHeight: number
41
+ ): CropRect;
42
+ export function containImageRect(
43
+ imageWidth: number,
44
+ imageHeight: number,
45
+ viewportWidth: number,
46
+ viewportHeight: number
47
+ ): DisplayRect;
48
+ export function mapDisplayPointToImage(
49
+ point: { x: number; y: number },
50
+ displayRect: DisplayRect
51
+ ): { x: number; y: number };
52
+ export function mapImageRectToDisplay(cropRect: CropRect, displayRect: DisplayRect): {
53
+ x: number;
54
+ y: number;
55
+ size: number;
56
+ };
57
+ export function cropOutputSize(cropSize: number, maxSize?: number): number;
@@ -0,0 +1,201 @@
1
+ /** 头像裁剪输出边长上限(像素) */
2
+ export const AVATAR_CROP_OUTPUT_SIZE = 512;
3
+
4
+ /** 选区最小边长(原图像素) */
5
+ export const AVATAR_CROP_MIN_SIZE = 32;
6
+
7
+ /**
8
+ * @typedef {{ x: number, y: number, size: number }} CropRect
9
+ * @typedef {{ x: number, y: number, width: number, height: number, scale: number }} DisplayRect
10
+ */
11
+
12
+ function finitePositive(value) {
13
+ return Number.isFinite(value) && value > 0 ? value : 0;
14
+ }
15
+
16
+ /**
17
+ * 在图片内取居中最大正方形作为默认选区。
18
+ * @param {number} imageWidth
19
+ * @param {number} imageHeight
20
+ * @returns {CropRect}
21
+ */
22
+ export function defaultCropRect(imageWidth, imageHeight) {
23
+ const width = Math.max(0, Math.floor(finitePositive(imageWidth)));
24
+ const height = Math.max(0, Math.floor(finitePositive(imageHeight)));
25
+ const size = Math.min(width, height);
26
+ if (size < 1) return { x: 0, y: 0, size: 0 };
27
+ return {
28
+ x: Math.floor((width - size) / 2),
29
+ y: Math.floor((height - size) / 2),
30
+ size
31
+ };
32
+ }
33
+
34
+ /**
35
+ * 将正方形选区钳制在图片范围内。
36
+ * @param {Partial<CropRect>} rect
37
+ * @param {number} imageWidth
38
+ * @param {number} imageHeight
39
+ * @param {number} [minSize]
40
+ * @returns {CropRect}
41
+ */
42
+ export function clampCropRect(rect, imageWidth, imageHeight, minSize = AVATAR_CROP_MIN_SIZE) {
43
+ const width = Math.max(0, Math.floor(finitePositive(imageWidth)));
44
+ const height = Math.max(0, Math.floor(finitePositive(imageHeight)));
45
+ if (width < 1 || height < 1) return { x: 0, y: 0, size: 0 };
46
+
47
+ const maxSize = Math.min(width, height);
48
+ const floorMin = Math.max(1, Math.min(Math.floor(finitePositive(minSize)) || 1, maxSize));
49
+ let size = Math.floor(finitePositive(rect?.size));
50
+ if (size < floorMin) size = floorMin;
51
+ if (size > maxSize) size = maxSize;
52
+
53
+ let x = Math.floor(Number(rect?.x) || 0);
54
+ let y = Math.floor(Number(rect?.y) || 0);
55
+ if (x < 0) x = 0;
56
+ if (y < 0) y = 0;
57
+ if (x > width - size) x = width - size;
58
+ if (y > height - size) y = height - size;
59
+ return { x, y, size };
60
+ }
61
+
62
+ /**
63
+ * 平移选区。
64
+ * @param {CropRect} rect
65
+ * @param {number} deltaX
66
+ * @param {number} deltaY
67
+ * @param {number} imageWidth
68
+ * @param {number} imageHeight
69
+ * @returns {CropRect}
70
+ */
71
+ export function moveCropRect(rect, deltaX, deltaY, imageWidth, imageHeight) {
72
+ return clampCropRect({
73
+ x: (Number(rect?.x) || 0) + (Number(deltaX) || 0),
74
+ y: (Number(rect?.y) || 0) + (Number(deltaY) || 0),
75
+ size: rect?.size
76
+ }, imageWidth, imageHeight);
77
+ }
78
+
79
+ /**
80
+ * 从四角手柄调整正方形选区,对边角保持固定。
81
+ * @param {CropRect} rect
82
+ * @param {"nw"|"ne"|"sw"|"se"} handle
83
+ * @param {number} pointerX 原图像素坐标
84
+ * @param {number} pointerY 原图像素坐标
85
+ * @param {number} imageWidth
86
+ * @param {number} imageHeight
87
+ * @returns {CropRect}
88
+ */
89
+ export function resizeCropRect(rect, handle, pointerX, pointerY, imageWidth, imageHeight) {
90
+ const current = clampCropRect(rect, imageWidth, imageHeight);
91
+ const left = current.x;
92
+ const top = current.y;
93
+ const right = current.x + current.size;
94
+ const bottom = current.y + current.size;
95
+ const px = Number(pointerX) || 0;
96
+ const py = Number(pointerY) || 0;
97
+
98
+ let nextLeft = left;
99
+ let nextTop = top;
100
+ let nextRight = right;
101
+ let nextBottom = bottom;
102
+
103
+ if (handle === "nw") {
104
+ nextLeft = px;
105
+ nextTop = py;
106
+ } else if (handle === "ne") {
107
+ nextRight = px;
108
+ nextTop = py;
109
+ } else if (handle === "sw") {
110
+ nextLeft = px;
111
+ nextBottom = py;
112
+ } else {
113
+ nextRight = px;
114
+ nextBottom = py;
115
+ }
116
+
117
+ const widthSpan = Math.abs(nextRight - nextLeft);
118
+ const heightSpan = Math.abs(nextBottom - nextTop);
119
+ const size = Math.max(widthSpan, heightSpan);
120
+
121
+ if (handle === "nw") {
122
+ return clampCropRect({ x: right - size, y: bottom - size, size }, imageWidth, imageHeight);
123
+ }
124
+ if (handle === "ne") {
125
+ return clampCropRect({ x: left, y: bottom - size, size }, imageWidth, imageHeight);
126
+ }
127
+ if (handle === "sw") {
128
+ return clampCropRect({ x: right - size, y: top, size }, imageWidth, imageHeight);
129
+ }
130
+ return clampCropRect({ x: left, y: top, size }, imageWidth, imageHeight);
131
+ }
132
+
133
+ /**
134
+ * 计算 object-fit: contain 时图片在视口中的显示矩形。
135
+ * @param {number} imageWidth
136
+ * @param {number} imageHeight
137
+ * @param {number} viewportWidth
138
+ * @param {number} viewportHeight
139
+ * @returns {DisplayRect}
140
+ */
141
+ export function containImageRect(imageWidth, imageHeight, viewportWidth, viewportHeight) {
142
+ const width = finitePositive(imageWidth);
143
+ const height = finitePositive(imageHeight);
144
+ const viewW = finitePositive(viewportWidth);
145
+ const viewH = finitePositive(viewportHeight);
146
+ if (!width || !height || !viewW || !viewH) {
147
+ return { x: 0, y: 0, width: 0, height: 0, scale: 0 };
148
+ }
149
+ const scale = Math.min(viewW / width, viewH / height);
150
+ const displayWidth = width * scale;
151
+ const displayHeight = height * scale;
152
+ return {
153
+ x: (viewW - displayWidth) / 2,
154
+ y: (viewH - displayHeight) / 2,
155
+ width: displayWidth,
156
+ height: displayHeight,
157
+ scale
158
+ };
159
+ }
160
+
161
+ /**
162
+ * 视口坐标映射到原图像素坐标。
163
+ * @param {{ x: number, y: number }} point
164
+ * @param {DisplayRect} displayRect
165
+ * @returns {{ x: number, y: number }}
166
+ */
167
+ export function mapDisplayPointToImage(point, displayRect) {
168
+ const scale = finitePositive(displayRect?.scale);
169
+ if (!scale) return { x: 0, y: 0 };
170
+ return {
171
+ x: ((Number(point?.x) || 0) - (Number(displayRect.x) || 0)) / scale,
172
+ y: ((Number(point?.y) || 0) - (Number(displayRect.y) || 0)) / scale
173
+ };
174
+ }
175
+
176
+ /**
177
+ * 原图选区映射到视口显示矩形。
178
+ * @param {CropRect} cropRect
179
+ * @param {DisplayRect} displayRect
180
+ * @returns {{ x: number, y: number, size: number }}
181
+ */
182
+ export function mapImageRectToDisplay(cropRect, displayRect) {
183
+ const scale = finitePositive(displayRect?.scale);
184
+ return {
185
+ x: (Number(displayRect?.x) || 0) + (Number(cropRect?.x) || 0) * scale,
186
+ y: (Number(displayRect?.y) || 0) + (Number(cropRect?.y) || 0) * scale,
187
+ size: (Number(cropRect?.size) || 0) * scale
188
+ };
189
+ }
190
+
191
+ /**
192
+ * 裁剪输出边长:不超过原选区,也不超过上限。
193
+ * @param {number} cropSize
194
+ * @param {number} [maxSize]
195
+ * @returns {number}
196
+ */
197
+ export function cropOutputSize(cropSize, maxSize = AVATAR_CROP_OUTPUT_SIZE) {
198
+ const size = Math.max(1, Math.floor(finitePositive(cropSize)) || 1);
199
+ const limit = Math.max(1, Math.floor(finitePositive(maxSize)) || AVATAR_CROP_OUTPUT_SIZE);
200
+ return Math.min(size, limit);
201
+ }
@@ -13,3 +13,4 @@ export function providerConnectionLabel(value: unknown): string;
13
13
  export function chapterVersionSourceLabel(value: unknown): string;
14
14
  export function occurrenceRoleLabel(value: unknown): string;
15
15
  export function searchResultTypeLabel(value: unknown): string;
16
+ export function characterStateFieldLabel(value: unknown): string;
@@ -75,3 +75,11 @@ export function occurrenceRoleLabel(value) {
75
75
  export function searchResultTypeLabel(value) {
76
76
  return enumLabel({ chapter: "章节", setting: "设定", character: "角色", race: "种族", organization: "组织" }, value, "其他资料");
77
77
  }
78
+
79
+ export function characterStateFieldLabel(value) {
80
+ const normalized = String(value ?? "").trim();
81
+ return enumLabel({
82
+ location: "位置",
83
+ condition: "状况"
84
+ }, normalized, normalized || "未命名字段");
85
+ }
@@ -10,7 +10,7 @@
10
10
  <link rel="icon" href="/icon.svg?v=20260712" type="image/svg+xml">
11
11
  <link rel="manifest" href="/site.webmanifest">
12
12
  <link rel="stylesheet" href="/vendor/vditor/dist/index.css?v=3.11.2">
13
- <link rel="stylesheet" href="/styles.css?v=20260725-mobile-workspace-unify">
13
+ <link rel="stylesheet" href="/styles.css?v=20260725-module-count-vditor-left-panel">
14
14
  </head>
15
15
  <body class="auth-pending">
16
16
  <section id="auth-view" class="auth-view hidden" aria-labelledby="auth-title">
@@ -105,40 +105,42 @@
105
105
 
106
106
  <aside class="left-panel">
107
107
  <div id="left-panel-resize" class="panel-resize-handle" role="separator" aria-label="调整作品侧栏宽度" aria-orientation="vertical" tabindex="0"></div>
108
- <div class="left-actions">
109
- <div class="left-primary-actions">
110
- <label id="import-file-button" class="file-button" aria-label="导入 TXT / DOCX">
111
- <span class="import-file-label import-file-label-full" aria-hidden="true">导入 TXT / DOCX</span>
112
- <span class="import-file-label import-file-label-compact" aria-hidden="true">导入TXT/DOCX</span>
113
- <span class="import-file-label import-file-label-short" aria-hidden="true">导入</span>
114
- <input id="import-file" type="file" accept=".txt,.docx">
115
- </label>
116
- <button id="left-panel-toggle" class="panel-collapse-button" type="button" aria-label="收起作品侧栏" aria-expanded="true">‹</button>
108
+ <div class="left-panel-body">
109
+ <div class="left-actions">
110
+ <div class="left-primary-actions">
111
+ <label id="import-file-button" class="file-button" aria-label="导入 TXT / DOCX">
112
+ <span class="import-file-label import-file-label-full" aria-hidden="true">导入 TXT / DOCX</span>
113
+ <span class="import-file-label import-file-label-compact" aria-hidden="true">导入TXT/DOCX</span>
114
+ <span class="import-file-label import-file-label-short" aria-hidden="true">导入</span>
115
+ <input id="import-file" type="file" accept=".txt,.docx">
116
+ </label>
117
+ <button id="left-panel-toggle" class="panel-collapse-button" type="button" aria-label="收起作品侧栏" aria-expanded="true">‹</button>
118
+ </div>
117
119
  </div>
118
- </div>
119
- <nav id="module-nav" class="module-nav" aria-label="作品模块">
120
- <button type="button" data-module="editor" class="active"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>正文</button>
121
- <button type="button" data-module="settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>设定库</button>
122
- <button type="button" data-module="characters"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>角色</button>
123
- <button type="button" data-module="organizations"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>组织</button>
124
- <button type="button" data-module="timeline"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>时间轴</button>
125
- <button type="button" data-module="relationships"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.59 13.51 6.83 3.98"/><path d="m15.41 6.51-6.82 3.98"/></svg>关系</button>
126
- <button type="button" data-module="outlines"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg><span class="nav-label">大纲/伏笔</span></button>
127
- <button class="ai-analysis-entry" type="button" data-module="tasks"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>AI 分析</button>
128
- <button id="module-more-button" type="button" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg><span class="nav-label">更多</span></button>
129
- <button class="module-nav-secondary hidden" type="button" data-module="races"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg>种族</button>
130
- <button class="module-nav-secondary hidden" type="button" data-module="reviews"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></svg>审核</button>
131
- <button class="module-nav-secondary hidden" type="button" data-module="ai-settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>AI 设置</button>
132
- <button class="module-nav-secondary hidden" type="button" data-work-settings><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/></svg>作品设置</button>
133
- </nav>
134
- <div class="panel-heading">
135
- <span>作品目录</span>
136
- <div class="panel-heading-actions">
137
- <span id="chapter-count">0 章</span>
138
- <button id="new-volume-button" class="add-button" type="button" aria-label="新建分卷" title="新建分卷">+</button>
120
+ <nav id="module-nav" class="module-nav" aria-label="作品模块">
121
+ <button type="button" data-module="editor" class="active"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>正文</button>
122
+ <button type="button" data-module="settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m16 6 4 14"/><path d="M12 6v14"/><path d="M8 8v12"/><path d="M4 4v16"/></svg>设定库</button>
123
+ <button type="button" data-module="characters"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>角色</button>
124
+ <button type="button" data-module="organizations"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>组织</button>
125
+ <button type="button" data-module="timeline"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>时间轴</button>
126
+ <button type="button" data-module="relationships"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="m8.59 13.51 6.83 3.98"/><path d="m15.41 6.51-6.82 3.98"/></svg>关系</button>
127
+ <button type="button" data-module="races"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="4" r="2"/><circle cx="18" cy="8" r="2"/><circle cx="20" cy="16" r="2"/><path d="M9 10a5 5 0 0 1 5 5v3.5a3.5 3.5 0 0 1-6.84 1.045Q6.52 17.48 4.46 16.84A3.5 3.5 0 0 1 5.5 10Z"/></svg>种族</button>
128
+ <button class="ai-analysis-entry" type="button" data-module="tasks"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/></svg>AI 分析</button>
129
+ <button id="module-more-button" type="button" aria-expanded="false"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg><span class="nav-label">更多</span></button>
130
+ <button class="module-nav-secondary hidden" type="button" data-module="outlines"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="m3 17 2 2 4-4"/><path d="m3 7 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg><span class="nav-label">大纲/伏笔</span></button>
131
+ <button class="module-nav-secondary hidden" type="button" data-module="reviews"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/></svg>审核</button>
132
+ <button class="module-nav-secondary hidden" type="button" data-module="ai-settings"><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/></svg>AI 设置</button>
133
+ <button class="module-nav-secondary hidden" type="button" data-work-settings><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2Z"/><circle cx="12" cy="12" r="3"/></svg>作品设置</button>
134
+ </nav>
135
+ <div class="panel-heading">
136
+ <span>作品目录</span>
137
+ <div class="panel-heading-actions">
138
+ <span id="chapter-count">0 章</span>
139
+ <button id="new-volume-button" class="add-button" type="button" aria-label="新建分卷" title="新建分卷">+</button>
140
+ </div>
139
141
  </div>
142
+ <div id="novel-tree" class="novel-tree empty-copy">创建或选择作品后开始写作</div>
140
143
  </div>
141
- <div id="novel-tree" class="novel-tree empty-copy">创建或选择作品后开始写作</div>
142
144
  </aside>
143
145
 
144
146
  <main id="main-panel" class="main-panel">
@@ -202,6 +204,7 @@
202
204
  <span id="chapter-stats" class="chapter-stats">0 字 · v0</span>
203
205
  <button id="insight-button" class="ghost-button" type="button">章节概览</button>
204
206
  <button id="versions-button" class="ghost-button" type="button">版本</button>
207
+ <button id="chapter-edit-button" class="primary-button hidden" type="button">编辑</button>
205
208
  <button id="tidy-blank-lines-button" class="ghost-button" type="button">整理空行</button>
206
209
  <button id="save-button" class="primary-button" type="button">保存正文</button>
207
210
  </div>
@@ -590,7 +593,7 @@
590
593
  <header class="account-settings-section-header"><h3 id="profile-settings-title">个人资料</h3><p>用于作品协作和历史记录中的作者标识。</p></header>
591
594
  <div class="avatar-settings">
592
595
  <div id="profile-avatar-preview" class="user-avatar profile-avatar-preview" role="img" aria-label="当前头像"><span class="user-avatar-fallback">作</span></div>
593
- <div class="avatar-settings-copy"><strong>头像图片</strong><small>支持 PNG、JPEG、WebP,文件不超过 5 MB,尺寸不超过 4096 × 4096 像素。</small></div>
596
+ <div class="avatar-settings-copy"><strong>头像图片</strong><small>支持 PNG、JPEG、WebP,文件不超过 5 MB。选择后可框选正方形选区再裁剪上传。</small></div>
594
597
  <div class="avatar-settings-actions"><button id="avatar-upload-button" class="ghost-button" type="button">上传头像</button><button id="avatar-remove-button" class="ghost-button hidden" type="button">移除头像</button></div>
595
598
  </div>
596
599
  <form id="profile-form" class="account-settings-form"><label>显示名称<input id="profile-display-name" name="displayName" maxlength="80" required></label><button class="primary-button" type="submit">保存名称</button></form>
@@ -613,6 +616,37 @@
613
616
  </div>
614
617
  </dialog>
615
618
 
619
+ <dialog id="avatar-crop-dialog" class="dialog avatar-crop-dialog" aria-labelledby="avatar-crop-dialog-title" aria-describedby="avatar-crop-dialog-description">
620
+ <div class="dialog-header">
621
+ <div>
622
+ <span class="eyebrow">个人账户</span>
623
+ <h2 id="avatar-crop-dialog-title">裁剪头像</h2>
624
+ <p id="avatar-crop-dialog-description" class="dialog-header-meta">拖动选区或四角手柄调整范围,圆形预览即最终显示效果。</p>
625
+ </div>
626
+ <button id="avatar-crop-close" class="dialog-close" aria-label="关闭裁剪" type="button">×</button>
627
+ </div>
628
+ <div class="avatar-crop-body">
629
+ <div id="avatar-crop-stage" class="avatar-crop-stage" tabindex="0" role="img" aria-label="头像裁剪选区">
630
+ <img id="avatar-crop-image" class="avatar-crop-image" alt="">
631
+ <div id="avatar-crop-shade" class="avatar-crop-shade" aria-hidden="true"></div>
632
+ <div id="avatar-crop-selection" class="avatar-crop-selection" hidden>
633
+ <button type="button" class="avatar-crop-handle" data-avatar-crop-handle="nw" aria-label="调整左上角"></button>
634
+ <button type="button" class="avatar-crop-handle" data-avatar-crop-handle="ne" aria-label="调整右上角"></button>
635
+ <button type="button" class="avatar-crop-handle" data-avatar-crop-handle="sw" aria-label="调整左下角"></button>
636
+ <button type="button" class="avatar-crop-handle" data-avatar-crop-handle="se" aria-label="调整右下角"></button>
637
+ </div>
638
+ </div>
639
+ <aside class="avatar-crop-side">
640
+ <div id="avatar-crop-preview" class="user-avatar avatar-crop-preview" role="img" aria-label="裁剪预览"></div>
641
+ <p>预览</p>
642
+ </aside>
643
+ </div>
644
+ <div class="dialog-actions">
645
+ <button id="avatar-crop-cancel" class="ghost-button" type="button">取消</button>
646
+ <button id="avatar-crop-confirm" class="primary-button" type="button">确认裁剪</button>
647
+ </div>
648
+ </dialog>
649
+
616
650
  <dialog id="members-dialog" class="dialog wide-dialog" aria-labelledby="members-dialog-title">
617
651
  <div class="dialog-header"><div><span id="members-dialog-eyebrow" class="eyebrow">作品权限</span><h2 id="members-dialog-title">成员模块权限</h2></div><button id="members-dialog-close" class="dialog-close" aria-label="关闭" type="button">×</button></div>
618
652
  <div class="access-dialog-body">
@@ -699,6 +733,6 @@
699
733
  <div id="auth-loading" class="auth-loading" role="status" aria-label="正在载入工作台"></div>
700
734
  <script id="vditorIconScript" src="/vendor/vditor/dist/js/icons/ant.js?v=3.11.2"></script>
701
735
  <script src="/vendor/vditor/dist/index.min.js?v=3.11.2"></script>
702
- <script type="module" src="/app.js?v=20260725-mobile-workspace-unify"></script>
736
+ <script type="module" src="/app.js?v=20260725-collab-refresh-left-panel-resize"></script>
703
737
  </body>
704
738
  </html>
@@ -199,6 +199,22 @@ export function renderMarkdown(value) {
199
199
  }
200
200
  flushQuote();
201
201
  if (!line.trim()) {
202
+ // CommonMark 松散列表:同类型列表项之间的空行不结束列表,
203
+ // 否则每个条目会各自生成 <ol>/<ul>,有序序号会全部显示为 1。
204
+ if (list) {
205
+ let nextIndex = lineIndex + 1;
206
+ while (nextIndex < lines.length && !lines[nextIndex].trim()) nextIndex += 1;
207
+ if (nextIndex < lines.length) {
208
+ const nextListItem = lines[nextIndex].match(/^(\s*)([-+*]|\d+\.)\s+(.+)$/u);
209
+ if (nextListItem) {
210
+ const nextTag = /\d+\./u.test(nextListItem[2]) ? "ol" : "ul";
211
+ if (nextTag === list.tag) {
212
+ flushParagraph();
213
+ continue;
214
+ }
215
+ }
216
+ }
217
+ }
202
218
  flushBlocks();
203
219
  continue;
204
220
  }