@open-press/core 0.7.1 → 0.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 (115) hide show
  1. package/engine/commands/dev.mjs +2 -2
  2. package/engine/output/chrome-pdf.mjs +18 -3
  3. package/engine/output/static-server.mjs +39 -0
  4. package/engine/react/comment-endpoint.mjs +13 -39
  5. package/engine/react/comment-marker.mjs +30 -6
  6. package/engine/react/document-entry.mjs +11 -0
  7. package/engine/react/document-export.mjs +30 -5
  8. package/engine/react/http-json.mjs +24 -0
  9. package/engine/react/mdx-compile.mjs +96 -3
  10. package/engine/react/measurement-css.mjs +93 -1
  11. package/engine/react/object-entities.mjs +119 -0
  12. package/engine/react/pipeline/allocate.mjs +10 -7
  13. package/engine/react/pipeline/frame-measurement.mjs +2 -0
  14. package/engine/react/project-asset-endpoint.mjs +6 -24
  15. package/engine/react/source-edit-endpoint.d.mts +10 -0
  16. package/engine/react/source-edit-endpoint.mjs +75 -0
  17. package/engine/react/sources/mdx-resolver.mjs +12 -14
  18. package/engine/react/style-discovery.mjs +1 -4
  19. package/engine/runtime/file-walk.mjs +22 -0
  20. package/engine/runtime/inspection.mjs +1 -20
  21. package/engine/runtime/path-utils.mjs +20 -0
  22. package/engine/runtime/source-text-tools.d.mts +102 -0
  23. package/engine/runtime/source-text-tools.mjs +551 -16
  24. package/engine/runtime/source-workspace.mjs +4 -31
  25. package/package.json +1 -1
  26. package/src/openpress/{App.tsx → app/OpenPressApp.tsx} +25 -12
  27. package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +10 -7
  28. package/src/openpress/app/index.ts +2 -0
  29. package/src/openpress/core/Frame.tsx +9 -11
  30. package/src/openpress/core/FrameContext.tsx +8 -3
  31. package/src/openpress/core/MdxArea.tsx +11 -12
  32. package/src/openpress/core/cn.ts +4 -0
  33. package/src/openpress/core/index.tsx +2 -1
  34. package/src/openpress/core/primitives.tsx +29 -8
  35. package/src/openpress/core/types.ts +8 -0
  36. package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
  37. package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
  38. package/src/openpress/{types.ts → document-model/documentTypes.ts} +42 -0
  39. package/src/openpress/document-model/index.ts +6 -0
  40. package/src/openpress/document-model/objectEntityModel.ts +51 -0
  41. package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
  42. package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
  43. package/src/openpress/manuscript/index.tsx +49 -7
  44. package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
  45. package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
  46. package/src/openpress/reader/index.ts +10 -0
  47. package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
  48. package/src/openpress/reader/readerTypes.ts +4 -0
  49. package/src/openpress/reader/usePageViewportScale.ts +119 -0
  50. package/src/openpress/reader/usePanelState.ts +56 -0
  51. package/src/openpress/reader/useReaderHashSync.ts +61 -0
  52. package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
  53. package/src/openpress/reader/useReaderRuntime.ts +146 -0
  54. package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
  55. package/src/openpress/shared/Panel.tsx +77 -0
  56. package/src/openpress/shared/index.ts +4 -0
  57. package/src/openpress/shared/numberUtils.ts +3 -0
  58. package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
  59. package/src/openpress/workbench/Workbench.tsx +407 -0
  60. package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
  61. package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
  62. package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
  63. package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
  64. package/src/openpress/workbench/actions/index.ts +5 -0
  65. package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
  66. package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
  67. package/src/openpress/workbench/dialog/index.ts +1 -0
  68. package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
  69. package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
  70. package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
  71. package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
  72. package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
  73. package/src/openpress/workbench/document/index.ts +10 -0
  74. package/src/openpress/workbench/index.ts +2 -0
  75. package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
  76. package/src/openpress/workbench/inspector/index.ts +5 -0
  77. package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
  78. package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
  79. package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
  80. package/src/openpress/workbench/inspector/useInspectorComments.ts +248 -0
  81. package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
  82. package/src/openpress/workbench/mentions/index.ts +2 -0
  83. package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
  84. package/src/openpress/workbench/panels/Panel.tsx +1 -0
  85. package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +76 -0
  86. package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
  87. package/src/openpress/workbench/panels/index.ts +3 -0
  88. package/src/openpress/workbench/project/ProjectEntryPanel.tsx +523 -0
  89. package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
  90. package/src/openpress/workbench/project/index.ts +2 -0
  91. package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
  92. package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
  93. package/src/openpress/workbench/shell/index.ts +1 -0
  94. package/src/openpress/workbench/workbenchFormatters.ts +120 -0
  95. package/src/openpress/workbench/workbenchTypes.ts +35 -0
  96. package/src/styles/openpress/print-route.css +0 -2
  97. package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
  98. package/src/styles/openpress/public-viewer.css +25 -320
  99. package/src/styles/openpress/reader-runtime.css +243 -55
  100. package/src/styles/openpress/responsive.css +145 -270
  101. package/src/styles/openpress/workbench-panels.css +214 -178
  102. package/src/styles/openpress/workbench.css +986 -451
  103. package/src/styles/openpress.css +1 -1
  104. package/vite.config.ts +50 -0
  105. package/src/openpress/inspector.ts +0 -282
  106. package/src/openpress/projectWorkspace.tsx +0 -919
  107. package/src/openpress/readerRuntime.ts +0 -230
  108. package/src/openpress/workbench.tsx +0 -1265
  109. package/src/openpress/workbenchTypes.ts +0 -4
  110. /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
  111. /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
  112. /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
  113. /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
  114. /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
  115. /package/src/openpress/{projectSources.ts → workbench/project/projectSourceModel.ts} +0 -0
@@ -1,919 +0,0 @@
1
- import { useRef, useState, type CSSProperties, type DragEvent } from "react";
2
- import { createPortal } from "react-dom";
3
- import { Check, Component as ComponentIcon, Images, Palette, Pencil, Trash2, UploadCloud, type LucideIcon } from "lucide-react";
4
- import { useComposerMentions, type ComposerMentionItem } from "./composerMentions";
5
- import type { BookmarkItem, BookmarkSubItem, MediaAssetItem } from "./indexes";
6
- import { projectSourceDirectoryPath, PROJECT_SOURCES } from "./projectSources";
7
- import type { BlockSource } from "./types";
8
- import type { DisplayPage } from "./workbenchTypes";
9
-
10
- export const PROJECT_VISUAL_SYSTEM_KEY = "visual-system";
11
- export const PROJECT_IMAGE_GALLERY_KEY = "image-gallery";
12
- export const PROJECT_COMPONENT_LIBRARY_KEY = "component-library";
13
-
14
- export type ProjectComponentUsage = {
15
- count: number;
16
- pageIndexes: number[];
17
- html: string;
18
- previews: ProjectComponentPreview[];
19
- };
20
-
21
- export type ProjectComponentPreview = {
22
- name: string;
23
- html: string;
24
- pageIndex: number;
25
- };
26
-
27
- export type ProjectMentionItem = ComposerMentionItem;
28
-
29
- type UploadedProjectMedia = {
30
- fileName: string;
31
- src: string;
32
- path: string;
33
- mention: string;
34
- };
35
-
36
- type ProjectPanelPreview =
37
- | { kind: "media"; title: string; src: string }
38
- | { kind: "component"; title: string; html: string };
39
-
40
- type ProjectAssetActionStatus = "idle" | "submitting" | "done" | "failed";
41
-
42
- export function createProjectComponentUsages(pages: DisplayPage[]): Map<string, ProjectComponentUsage> {
43
- const usages = new Map<string, ProjectComponentUsage>();
44
- pages.forEach((page) => {
45
- const html = String(page.html ?? "");
46
- const pageIndex = page.pageNumber - 1;
47
- for (const block of extractRenderedComponentBlocks(html)) {
48
- const current = usages.get(block.name) ?? { count: 0, pageIndexes: [], html: block.html, previews: [] };
49
- current.count += 1;
50
- if (!current.html) current.html = block.html;
51
- if (!current.pageIndexes.includes(pageIndex)) current.pageIndexes.push(pageIndex);
52
- current.previews.push({ name: block.name, html: block.html, pageIndex });
53
- usages.set(block.name, current);
54
- }
55
- });
56
- return usages;
57
- }
58
-
59
- export function createProjectMentionItems(
60
- mediaAssets: MediaAssetItem[],
61
- componentUsages: Map<string, ProjectComponentUsage>,
62
- bookmarks: BookmarkItem[] = [],
63
- ): ProjectMentionItem[] {
64
- const referenceItems = createBookmarkMentionItems(bookmarks);
65
-
66
- const mediaItems: ProjectMentionItem[] = mediaAssets.map((item) => ({
67
- trigger: "@",
68
- value: mediaMention(item.fileName),
69
- label: item.fileName,
70
- meta: item.usageCount > 0 ? `media · P${String(item.pageIndex + 1).padStart(2, "0")}` : "media · unused",
71
- kind: "media",
72
- }));
73
-
74
- const componentItems: ProjectMentionItem[] = Array.from(componentUsages.entries())
75
- .sort(([a], [b]) => a.localeCompare(b, "zh-Hant"))
76
- .map(([name, usage]) => ({
77
- trigger: "@",
78
- value: componentMention(name),
79
- label: name,
80
- meta: `component · ${usage.count}`,
81
- kind: "component",
82
- }));
83
-
84
- return [...PROJECT_SKILL_MENTIONS, ...referenceItems, ...mediaItems, ...componentItems];
85
- }
86
-
87
- export function ProjectEntryPanel({
88
- mediaAssets,
89
- componentUsages,
90
- mentionItems,
91
- currentSource,
92
- onInsertMention,
93
- }: {
94
- mediaAssets: MediaAssetItem[];
95
- componentUsages: Map<string, ProjectComponentUsage>;
96
- mentionItems: ProjectMentionItem[];
97
- currentSource: BlockSource | undefined;
98
- onInsertMention: (mention: string) => void;
99
- }) {
100
- const [dragActive, setDragActive] = useState(false);
101
- const [uploadStatus, setUploadStatus] = useState<"idle" | "uploading" | "done" | "failed">("idle");
102
- const [uploadMessage, setUploadMessage] = useState("");
103
- const [uploadedMedia, setUploadedMedia] = useState<UploadedProjectMedia[]>([]);
104
- const [preview, setPreview] = useState<ProjectPanelPreview | null>(null);
105
- const componentItems = Array.from(componentUsages.entries())
106
- .sort(([a], [b]) => a.localeCompare(b, "zh-Hant"))
107
- .slice(0, 8);
108
- const mediaItems = [
109
- ...uploadedMedia.map((item) => ({
110
- fileName: item.fileName,
111
- src: item.src,
112
- mention: item.mention,
113
- meta: "new",
114
- })),
115
- ...mediaAssets.slice(0, 8).map((item) => ({
116
- fileName: item.fileName,
117
- src: item.src,
118
- mention: mediaMention(item.fileName),
119
- meta: item.usageCount > 0 ? `P${String(item.pageIndex + 1).padStart(2, "0")}` : "unused",
120
- })),
121
- ].slice(0, 10);
122
-
123
- const uploadFiles = async (files: FileList | File[]) => {
124
- const selectedFiles = Array.from(files).filter((file) => file.type.startsWith("image/") || isImageFileName(file.name));
125
- if (selectedFiles.length === 0) return;
126
- setUploadStatus("uploading");
127
- setUploadMessage("上傳中");
128
- try {
129
- const uploaded: UploadedProjectMedia[] = [];
130
- for (const file of selectedFiles) {
131
- uploaded.push(await uploadProjectMediaFile(file));
132
- }
133
- setUploadedMedia((items) => [...uploaded, ...items]);
134
- setUploadStatus("done");
135
- setUploadMessage(`${uploaded.length} 張圖片已加入 media`);
136
- if (uploaded[0]) onInsertMention(uploaded[0].mention);
137
- } catch (error) {
138
- setUploadStatus("failed");
139
- setUploadMessage(error instanceof Error ? error.message : String(error));
140
- }
141
- };
142
-
143
- const handleDrop = (event: DragEvent<HTMLLabelElement>) => {
144
- event.preventDefault();
145
- setDragActive(false);
146
- void uploadFiles(event.dataTransfer.files);
147
- };
148
-
149
- return (
150
- <section className="openpress-project-panel openpress-panel-section" aria-label="Project tools">
151
- <section className="openpress-project-tool-block" aria-label="上傳圖片">
152
- <header className="openpress-project-tool-header">
153
- <span>Upload</span>
154
- </header>
155
- <label
156
- className="openpress-project-upload-zone"
157
- data-drag-active={dragActive ? "true" : "false"}
158
- data-upload-status={uploadStatus}
159
- onDragEnter={(event) => {
160
- event.preventDefault();
161
- setDragActive(true);
162
- }}
163
- onDragOver={(event) => event.preventDefault()}
164
- onDragLeave={() => setDragActive(false)}
165
- onDrop={handleDrop}
166
- >
167
- <input
168
- type="file"
169
- accept="image/*"
170
- multiple
171
- onChange={(event) => {
172
- if (event.currentTarget.files) void uploadFiles(event.currentTarget.files);
173
- event.currentTarget.value = "";
174
- }}
175
- />
176
- <UploadCloud aria-hidden="true" />
177
- <span>上傳圖片到 media</span>
178
- <small>{uploadMessage || "拖放或點擊選取,完成後可用 @media mention"}</small>
179
- </label>
180
- </section>
181
-
182
- <section className="openpress-project-tool-block" aria-label="媒體素材">
183
- <header className="openpress-project-tool-header">
184
- <span>Media</span>
185
- </header>
186
- {mediaItems.length > 0 ? (
187
- <div className="openpress-project-asset-list">
188
- {mediaItems.map((item) => (
189
- <button
190
- type="button"
191
- className="openpress-project-asset"
192
- key={`${item.src}-${item.fileName}`}
193
- onClick={() => setPreview({ kind: "media", title: item.fileName, src: item.src })}
194
- >
195
- <span className="openpress-project-asset-thumb">
196
- <img src={item.src} alt="" loading="lazy" />
197
- </span>
198
- <span className="openpress-project-asset-body">
199
- <strong>{item.fileName}</strong>
200
- </span>
201
- </button>
202
- ))}
203
- </div>
204
- ) : (
205
- <p className="openpress-project-tool-empty">尚無圖片</p>
206
- )}
207
- </section>
208
-
209
- <section className="openpress-project-tool-block" aria-label="Components">
210
- <header className="openpress-project-tool-header">
211
- <span>Components</span>
212
- </header>
213
- {componentItems.length > 0 ? (
214
- <div className="openpress-project-component-mention-list">
215
- {componentItems.map(([name, usage]) => (
216
- <button
217
- type="button"
218
- key={name}
219
- onClick={() => setPreview({ kind: "component", title: name, html: usage.html })}
220
- >
221
- <ComponentIcon aria-hidden="true" />
222
- <span>
223
- <strong>{name}</strong>
224
- </span>
225
- </button>
226
- ))}
227
- </div>
228
- ) : (
229
- <p className="openpress-project-tool-empty">尚無 component</p>
230
- )}
231
- </section>
232
-
233
- <section className="openpress-project-tool-block" aria-label="Style tokens">
234
- <header className="openpress-project-tool-header">
235
- <span>Style</span>
236
- </header>
237
- <div className="openpress-project-style-strip" aria-label="色票">
238
- {PROJECT_COLOR_SWATCHES.slice(0, 6).map((item) => (
239
- <span
240
- key={item.token}
241
- title={item.label}
242
- style={{ "--openpress-project-swatch": `var(${item.token})` } as CSSProperties}
243
- />
244
- ))}
245
- </div>
246
- </section>
247
- {preview ? (
248
- <ProjectPreviewDialog
249
- key={`${preview.kind}-${preview.title}`}
250
- preview={preview}
251
- mentionItems={mentionItems}
252
- currentSource={currentSource}
253
- onClose={() => setPreview(null)}
254
- />
255
- ) : null}
256
- </section>
257
- );
258
- }
259
-
260
- function ProjectPreviewDialog({
261
- preview,
262
- mentionItems,
263
- currentSource,
264
- onClose,
265
- }: {
266
- preview: ProjectPanelPreview;
267
- mentionItems: ProjectMentionItem[];
268
- currentSource: BlockSource | undefined;
269
- onClose: () => void;
270
- }) {
271
- const commentTextareaRef = useRef<HTMLTextAreaElement | null>(null);
272
- const [actionMode, setActionMode] = useState<"idle" | "rename" | "delete">("idle");
273
- const [renameValue, setRenameValue] = useState(preview.title);
274
- const [actionStatus, setActionStatus] = useState<ProjectAssetActionStatus>("idle");
275
- const [actionMessage, setActionMessage] = useState("");
276
- const [commentText, setCommentText] = useState("");
277
- const [commentTarget, setCommentTarget] = useState<"current-page" | "asset-source">(
278
- preview.kind === "component" ? "asset-source" : "current-page",
279
- );
280
- const [commentStatus, setCommentStatus] = useState<ProjectAssetActionStatus>("idle");
281
- const [commentMessage, setCommentMessage] = useState("");
282
- const {
283
- activeMention,
284
- handleMentionKeyDown,
285
- highlightedMentionIndex,
286
- mentionSuggestions,
287
- setHighlightedMentionIndex,
288
- setComposerCursor,
289
- syncCursor,
290
- insertMention,
291
- } = useComposerMentions({
292
- text: commentText,
293
- items: mentionItems,
294
- textareaRef: commentTextareaRef,
295
- onTextChange: setCommentText,
296
- maxSuggestions: 8,
297
- });
298
-
299
- if (typeof document === "undefined") return null;
300
-
301
- const resetAction = () => {
302
- setActionMode("idle");
303
- setRenameValue(preview.title);
304
- setActionStatus("idle");
305
- setActionMessage("");
306
- };
307
-
308
- const runRename = async () => {
309
- setActionStatus("submitting");
310
- setActionMessage("");
311
- try {
312
- const result = await submitProjectAssetAction({
313
- action: "rename",
314
- kind: preview.kind,
315
- name: preview.title,
316
- nextName: renameValue,
317
- });
318
- setActionStatus("done");
319
- setActionMessage(`已重新命名,並更新 ${result.referenceCount ?? 0} 個引用。重新整理後會看到最新列表。`);
320
- } catch (error) {
321
- setActionStatus("failed");
322
- setActionMessage(error instanceof Error ? error.message : String(error));
323
- }
324
- };
325
-
326
- const runDelete = async () => {
327
- setActionStatus("submitting");
328
- setActionMessage("");
329
- try {
330
- await submitProjectAssetAction({
331
- action: "delete",
332
- kind: preview.kind,
333
- name: preview.title,
334
- });
335
- setActionStatus("done");
336
- setActionMessage("已刪除。重新整理後會從列表移除。");
337
- } catch (error) {
338
- setActionStatus("failed");
339
- setActionMessage(error instanceof Error ? error.message : String(error));
340
- }
341
- };
342
-
343
- const applyCommentTemplate = (target: "current-page" | "asset-source") => {
344
- setCommentTarget(target);
345
- if (target === "current-page") {
346
- const nextText = `請將 ${assetKindLabel(preview.kind)}「${preview.title}」加入 `;
347
- setCommentText(nextText);
348
- window.requestAnimationFrame(() => {
349
- commentTextareaRef.current?.focus();
350
- commentTextareaRef.current?.setSelectionRange(nextText.length, nextText.length);
351
- setComposerCursor(nextText.length);
352
- });
353
- return;
354
- }
355
- const nextText = `請調整 ${assetKindLabel(preview.kind)}「${preview.title}」的樣式:`;
356
- setCommentText(nextText);
357
- window.requestAnimationFrame(() => {
358
- commentTextareaRef.current?.focus();
359
- commentTextareaRef.current?.setSelectionRange(nextText.length, nextText.length);
360
- setComposerCursor(nextText.length);
361
- });
362
- };
363
-
364
- const submitComment = async () => {
365
- setCommentStatus("submitting");
366
- setCommentMessage("");
367
- try {
368
- await submitProjectAssetAction({
369
- action: "comment",
370
- kind: preview.kind,
371
- name: preview.title,
372
- note: commentText,
373
- commentTarget,
374
- currentSource: currentSource ? { path: currentSource.path, line: 1 } : undefined,
375
- });
376
- setCommentStatus("done");
377
- setCommentMessage("已留下註解。");
378
- setCommentText("");
379
- } catch (error) {
380
- setCommentStatus("failed");
381
- setCommentMessage(error instanceof Error ? error.message : String(error));
382
- }
383
- };
384
-
385
- return createPortal(
386
- <div className="openpress-project-preview-dialog" role="presentation" onClick={onClose}>
387
- <section
388
- role="dialog"
389
- aria-modal="true"
390
- aria-label={preview.title}
391
- className="openpress-project-preview-dialog__panel"
392
- onClick={(event) => event.stopPropagation()}
393
- >
394
- <header>
395
- <h2>{preview.title}</h2>
396
- <div className="openpress-project-preview-dialog__actions" aria-label="資產操作">
397
- <button type="button" onClick={() => setActionMode("rename")} aria-label="重新命名">
398
- <Pencil aria-hidden="true" />
399
- <span>Rename</span>
400
- </button>
401
- <button type="button" onClick={() => setActionMode("delete")} aria-label="刪除">
402
- <Trash2 aria-hidden="true" />
403
- <span>Delete</span>
404
- </button>
405
- </div>
406
- <button type="button" className="openpress-project-preview-dialog__close" aria-label="關閉預覽" onClick={onClose}>×</button>
407
- </header>
408
- <div className="openpress-project-preview-dialog__body" data-preview-kind={preview.kind}>
409
- {preview.kind === "media" ? (
410
- <img src={preview.src} alt="" />
411
- ) : (
412
- <div dangerouslySetInnerHTML={{ __html: preview.html }} />
413
- )}
414
- </div>
415
- <footer className="openpress-project-preview-dialog__footer">
416
- {actionMode === "rename" ? (
417
- <form
418
- className="openpress-project-asset-action-form"
419
- onSubmit={(event) => {
420
- event.preventDefault();
421
- void runRename();
422
- }}
423
- >
424
- <label>
425
- <span>重新命名</span>
426
- <input value={renameValue} onChange={(event) => setRenameValue(event.target.value)} />
427
- </label>
428
- <div>
429
- <button type="button" onClick={resetAction}>取消</button>
430
- <button type="submit" disabled={actionStatus === "submitting"}>
431
- <Check aria-hidden="true" />
432
- <span>{actionStatus === "submitting" ? "處理中" : "確認"}</span>
433
- </button>
434
- </div>
435
- </form>
436
- ) : null}
437
- {actionMode === "delete" ? (
438
- <div className="openpress-project-asset-action-form openpress-project-asset-action-form--delete">
439
- <p>確認刪除「{preview.title}」?如果文件仍有引用,系統會拒絕刪除,避免頁面破版。</p>
440
- <div>
441
- <button type="button" onClick={resetAction}>取消</button>
442
- <button type="button" disabled={actionStatus === "submitting"} onClick={() => void runDelete()}>
443
- <Trash2 aria-hidden="true" />
444
- <span>{actionStatus === "submitting" ? "處理中" : "確認刪除"}</span>
445
- </button>
446
- </div>
447
- </div>
448
- ) : null}
449
- {actionMessage ? (
450
- <p className="openpress-project-asset-action-message" data-status={actionStatus} role="status">
451
- {actionMessage}
452
- </p>
453
- ) : null}
454
-
455
- <section className="openpress-project-preview-comment" aria-label="留下註解">
456
- <div className="openpress-project-preview-comment__shortcuts">
457
- <button type="button" onClick={() => applyCommentTemplate("current-page")}>指定章節引用</button>
458
- {preview.kind === "component" ? (
459
- <button type="button" onClick={() => applyCommentTemplate("asset-source")}>調整樣式</button>
460
- ) : null}
461
- </div>
462
- <div className="openpress-project-preview-comment__composer">
463
- <textarea
464
- ref={commentTextareaRef}
465
- value={commentText}
466
- rows={3}
467
- placeholder="留下註解,輸入 @ 指定章節、子章節、media 或 component..."
468
- onChange={(event) => {
469
- setCommentText(event.target.value);
470
- setComposerCursor(event.target.selectionStart ?? event.target.value.length);
471
- }}
472
- onClick={syncCursor}
473
- onKeyUp={syncCursor}
474
- onKeyDown={handleMentionKeyDown}
475
- />
476
- {mentionSuggestions.length > 0 ? (
477
- <div className="openpress-project-preview-comment__suggestions" role="listbox" aria-label={activeMention?.trigger === "/" ? "Skill suggestions" : "Mention suggestions"}>
478
- {mentionSuggestions.map((item, index) => (
479
- <button
480
- type="button"
481
- role="option"
482
- aria-selected={index === highlightedMentionIndex}
483
- data-highlighted={index === highlightedMentionIndex ? "true" : undefined}
484
- key={`${item.kind}-${item.value}`}
485
- onMouseDown={(event) => event.preventDefault()}
486
- onMouseEnter={() => setHighlightedMentionIndex(index)}
487
- onClick={() => insertMention(item)}
488
- >
489
- <span>{item.label}</span>
490
- <small>{item.meta}</small>
491
- </button>
492
- ))}
493
- </div>
494
- ) : null}
495
- </div>
496
- <div className="openpress-project-preview-comment__bottom">
497
- <select value={commentTarget} onChange={(event) => setCommentTarget(event.target.value as "current-page" | "asset-source")}>
498
- <option value="current-page">文件位置</option>
499
- <option value="asset-source">資產來源</option>
500
- </select>
501
- {commentMessage ? (
502
- <span data-status={commentStatus} role="status">{commentMessage}</span>
503
- ) : null}
504
- <button type="button" disabled={commentStatus === "submitting" || !commentText.trim()} onClick={() => void submitComment()}>
505
- <span>{commentStatus === "submitting" ? "送出中" : "送出"}</span>
506
- </button>
507
- </div>
508
- </section>
509
- </footer>
510
- </section>
511
- </div>,
512
- document.body,
513
- );
514
- }
515
-
516
- export function ProjectWorkspace({
517
- mediaAssets,
518
- componentUsages,
519
- selectedKey,
520
- }: {
521
- mediaAssets: MediaAssetItem[];
522
- componentUsages: Map<string, ProjectComponentUsage>;
523
- selectedKey: string | null;
524
- }) {
525
- if (!selectedKey || selectedKey === PROJECT_VISUAL_SYSTEM_KEY) {
526
- return <ProjectVisualSystem />;
527
- }
528
-
529
- if (selectedKey === PROJECT_IMAGE_GALLERY_KEY) {
530
- return <ProjectImageGallery mediaAssets={mediaAssets} />;
531
- }
532
-
533
- if (selectedKey === PROJECT_COMPONENT_LIBRARY_KEY) {
534
- return <ProjectComponentLibrary usages={componentUsages} />;
535
- }
536
-
537
- return <ProjectVisualSystem />;
538
- }
539
-
540
- function ProjectPanelButton({
541
- icon: Icon,
542
- label,
543
- meta,
544
- active,
545
- onClick,
546
- }: {
547
- icon: LucideIcon;
548
- label: string;
549
- meta: string;
550
- active: boolean;
551
- onClick: () => void;
552
- }) {
553
- return (
554
- <button
555
- type="button"
556
- className={`bookmark-item bookmark-h2 openpress-project-entry${active ? " is-active" : ""}`}
557
- aria-pressed={active}
558
- onClick={onClick}
559
- >
560
- <span className="bookmark-index openpress-project-entry-icon"><Icon aria-hidden="true" /></span>
561
- <span className="bookmark-title">
562
- <span>{label}</span>
563
- <small>{meta}</small>
564
- </span>
565
- </button>
566
- );
567
- }
568
-
569
- function ProjectVisualSystem() {
570
- return (
571
- <section className="openpress-project-workspace" aria-label="專案">
572
- <article className="openpress-project-visual-system" aria-label="Visual System">
573
- <ProjectSectionHeader title="Visual System" minimal />
574
-
575
- <div className="openpress-project-visual-grid">
576
- <section className="openpress-project-visual-card openpress-project-visual-card--typography" aria-label="Typography">
577
- <header>
578
- <span>Typography</span>
579
- <strong>閱讀層級</strong>
580
- </header>
581
- <div className="openpress-project-type-specimen">
582
- <p className="openpress-project-type-kicker">Course Notes</p>
583
- <h2>Data Structures</h2>
584
- <h3>Linked List 與 Tree Traversal</h3>
585
- <h4>Pointer / Node / Recursive Thinking</h4>
586
- <p>以 C/C++ 實作資料結構,整理概念、表示法與操作流程。</p>
587
- <code>struct Node *next = head;</code>
588
- </div>
589
- </section>
590
-
591
- <section className="openpress-project-visual-card" aria-label="Color Palette">
592
- <header>
593
- <span>Palette</span>
594
- <strong>色票配置</strong>
595
- </header>
596
- <div className="openpress-project-swatch-grid">
597
- {PROJECT_COLOR_SWATCHES.map((item) => (
598
- <div className="openpress-project-swatch" key={item.token}>
599
- <span
600
- className="openpress-project-swatch-chip"
601
- style={{ "--openpress-project-swatch": `var(${item.token})` } as CSSProperties}
602
- aria-hidden="true"
603
- />
604
- <strong>{item.label}</strong>
605
- <code>{item.token.replace("--openpress-", "")}</code>
606
- </div>
607
- ))}
608
- </div>
609
- </section>
610
-
611
- <section className="openpress-project-visual-card openpress-project-visual-card--surfaces" aria-label="Surfaces">
612
- <header>
613
- <span>Surfaces</span>
614
- <strong>區塊背景</strong>
615
- </header>
616
- <div className="openpress-project-surface-preview">
617
- <div className="openpress-project-surface-paper">
618
- <span>Page paper</span>
619
- </div>
620
- <div className="openpress-project-surface-block">
621
- <span>Figure / Code block</span>
622
- </div>
623
- </div>
624
- </section>
625
- </div>
626
- </article>
627
- </section>
628
- );
629
- }
630
-
631
- function ProjectComponentLibrary({
632
- usages,
633
- }: {
634
- usages: Map<string, ProjectComponentUsage>;
635
- }) {
636
- const previewItems = createComponentPreviewItems(usages);
637
- return (
638
- <section className="openpress-project-workspace" aria-label="專案">
639
- <article className="openpress-project-component-viewer" aria-label={PROJECT_SOURCES.components.label}>
640
- <ProjectSectionHeader
641
- title="Rendered Components"
642
- description="文件目前實際渲染出的 component、圖表與示意圖狀態。"
643
- stats={[
644
- ["Kinds", String(usages.size)],
645
- ["Renders", String(previewItems.length)],
646
- ]}
647
- />
648
- {previewItems.length > 0 ? (
649
- <div className="openpress-project-component-list" aria-label="rendered content block list">
650
- {previewItems.map((item) => (
651
- <figure className="openpress-project-component-preview-row" key={`${item.name}-${item.index}`}>
652
- <figcaption>
653
- <span>{item.name}</span>
654
- <small>P{String(item.preview.pageIndex + 1).padStart(2, "0")}</small>
655
- </figcaption>
656
- <div
657
- className="openpress-project-component-preview"
658
- dangerouslySetInnerHTML={{ __html: item.preview.html }}
659
- />
660
- </figure>
661
- ))}
662
- </div>
663
- ) : (
664
- <p className="openpress-project-empty">目前文件尚未渲染任何內容區塊。</p>
665
- )}
666
- </article>
667
- </section>
668
- );
669
- }
670
-
671
- function ProjectImageGallery({
672
- mediaAssets,
673
- }: {
674
- mediaAssets: MediaAssetItem[];
675
- }) {
676
- const usedCount = mediaAssets.filter((item) => item.usageCount > 0).length;
677
- const unreferencedAssets = mediaAssets.filter((item) => item.usageCount === 0);
678
- const referencedAssets = mediaAssets.filter((item) => item.usageCount > 0);
679
- return (
680
- <section className="openpress-project-workspace" aria-label="專案">
681
- <article className="openpress-project-gallery-viewer" aria-label="Image Gallery">
682
- <ProjectSectionHeader
683
- title="Media Library"
684
- description={projectSourceDirectoryPath("media")}
685
- stats={[
686
- ["Files", String(mediaAssets.length)],
687
- ["Used", String(usedCount)],
688
- ]}
689
- />
690
- {mediaAssets.length > 0 ? (
691
- <div className="openpress-project-media-sections" aria-label="media gallery">
692
- <ProjectMediaSection title="未引用" assets={unreferencedAssets} />
693
- <ProjectMediaSection title="已引用" assets={referencedAssets} />
694
- </div>
695
- ) : (
696
- <p className="openpress-project-empty">{projectSourceDirectoryPath("media")} 尚未有可預覽素材。</p>
697
- )}
698
- </article>
699
- </section>
700
- );
701
- }
702
-
703
- function ProjectSectionHeader({
704
- title,
705
- description,
706
- stats,
707
- minimal = false,
708
- }: {
709
- title: string;
710
- description?: string;
711
- stats?: Array<[string, string]>;
712
- minimal?: boolean;
713
- }) {
714
- return (
715
- <header className={`openpress-project-section-header${minimal ? " openpress-project-section-header--minimal" : ""}`}>
716
- <div>
717
- <h2>{title}</h2>
718
- {description ? <span>{description}</span> : null}
719
- </div>
720
- {!minimal && stats?.length ? (
721
- <dl>
722
- {stats.map(([label, value]) => (
723
- <div key={label}>
724
- <dt>{label}</dt>
725
- <dd>{value}</dd>
726
- </div>
727
- ))}
728
- </dl>
729
- ) : null}
730
- </header>
731
- );
732
- }
733
-
734
- function ProjectMediaSection({
735
- title,
736
- assets,
737
- }: {
738
- title: string;
739
- assets: MediaAssetItem[];
740
- }) {
741
- return (
742
- <section className="openpress-project-media-section" aria-label={title}>
743
- <header className="openpress-project-media-section-header">
744
- <h3>{title}</h3>
745
- </header>
746
- {assets.length > 0 ? (
747
- <div className="openpress-project-media-gallery">
748
- {assets.map((item) => (
749
- <figure className="openpress-project-media-card" data-unused={item.usageCount === 0 ? "true" : "false"} key={item.id}>
750
- <img src={item.src} alt="" loading="lazy" />
751
- <figcaption>
752
- <strong>{item.fileName}</strong>
753
- </figcaption>
754
- </figure>
755
- ))}
756
- </div>
757
- ) : (
758
- <p className="openpress-project-media-section-empty">沒有{title}圖片。</p>
759
- )}
760
- </section>
761
- );
762
- }
763
-
764
- function createComponentPreviewItems(usages: Map<string, ProjectComponentUsage>) {
765
- return Array.from(usages.entries())
766
- .flatMap(([name, usage]) => usage.previews.map((preview, index) => ({ name, preview, index })))
767
- .filter((item) => Boolean(item.preview.html))
768
- .sort((a, b) => {
769
- const pageDelta = a.preview.pageIndex - b.preview.pageIndex;
770
- return pageDelta || a.name.localeCompare(b.name, "zh-Hant") || a.index - b.index;
771
- });
772
- }
773
-
774
- function extractRenderedComponentBlocks(html: string) {
775
- const blocks: Array<{ name: string; html: string }> = [];
776
- const openTagPattern = /<(figure|section|article|div)\b[^>]*data-openpress-component="([^"]+)"[^>]*>/g;
777
- for (const match of html.matchAll(openTagPattern)) {
778
- const tagName = match[1];
779
- const componentName = match[2];
780
- const start = match.index ?? 0;
781
- const end = findClosingTagEnd(html, tagName, start + match[0].length);
782
- blocks.push({
783
- name: componentName,
784
- html: end > start ? html.slice(start, end) : match[0],
785
- });
786
- }
787
- return blocks;
788
- }
789
-
790
- function findClosingTagEnd(html: string, tagName: string, fromIndex: number) {
791
- const tagPattern = new RegExp(`</?${tagName}\\b[^>]*>`, "gi");
792
- tagPattern.lastIndex = fromIndex;
793
- let depth = 1;
794
- let match: RegExpExecArray | null;
795
- while ((match = tagPattern.exec(html))) {
796
- if (match[0].startsWith("</")) {
797
- depth -= 1;
798
- } else if (!match[0].endsWith("/>")) {
799
- depth += 1;
800
- }
801
- if (depth === 0) return tagPattern.lastIndex;
802
- }
803
- return -1;
804
- }
805
-
806
- function mediaMention(fileName: string) {
807
- return `@media/${fileName}`;
808
- }
809
-
810
- function componentMention(name: string) {
811
- return `@component/${name}`;
812
- }
813
-
814
- function createBookmarkMentionItems(bookmarks: BookmarkItem[]): ProjectMentionItem[] {
815
- return bookmarks
816
- .filter((item) => item.label !== "00")
817
- .flatMap((chapter) => [
818
- bookmarkMentionItem("chapter", chapter),
819
- ...chapter.subs.map((section) => bookmarkMentionItem("section", section)),
820
- ]);
821
- }
822
-
823
- function bookmarkMentionItem(kind: "chapter" | "section", item: BookmarkItem | BookmarkSubItem): ProjectMentionItem {
824
- const label = item.label ? `${item.label} ` : "";
825
- return {
826
- trigger: "@",
827
- value: `@${kind}/${bookmarkMentionSlug(item)}`,
828
- label: `${label}${item.title}`,
829
- meta: `${kind === "chapter" ? "chapter" : "section"} · P${String(item.pageIndex + 1).padStart(2, "0")}`,
830
- kind,
831
- };
832
- }
833
-
834
- function bookmarkMentionSlug(item: BookmarkItem | BookmarkSubItem) {
835
- const parts = [item.label, item.title]
836
- .filter(Boolean)
837
- .map((part) => mentionSlugPart(String(part)));
838
- return parts.filter(Boolean).join("-") || item.id;
839
- }
840
-
841
- function mentionSlugPart(value: string) {
842
- return value
843
- .trim()
844
- .replace(/\s+/g, "-")
845
- .replace(/[^\p{L}\p{N}._-]+/gu, "-")
846
- .replace(/-+/g, "-")
847
- .replace(/^-|-$/g, "");
848
- }
849
-
850
- function isImageFileName(name: string) {
851
- return /\.(png|jpe?g|gif|svg|webp)$/i.test(name);
852
- }
853
-
854
- async function uploadProjectMediaFile(file: File): Promise<UploadedProjectMedia> {
855
- const response = await fetch("/__openpress/media-upload", {
856
- method: "POST",
857
- headers: {
858
- "Content-Type": file.type || "application/octet-stream",
859
- "X-OpenPress-File-Name": encodeURIComponent(file.name),
860
- },
861
- body: file,
862
- });
863
- const result = await response.json().catch(() => null) as { ok?: boolean; asset?: UploadedProjectMedia; message?: string } | null;
864
- if (!response.ok || !result?.ok || !result.asset) {
865
- throw new Error(result?.message ?? `Upload failed with status ${response.status}`);
866
- }
867
- return result.asset;
868
- }
869
-
870
- async function submitProjectAssetAction(body: {
871
- action: "rename" | "delete" | "comment";
872
- kind: "media" | "component";
873
- name: string;
874
- nextName?: string;
875
- note?: string;
876
- commentTarget?: "current-page" | "asset-source";
877
- currentSource?: { path: string; line: number };
878
- }) {
879
- const response = await fetch("/__openpress/project-asset", {
880
- method: "POST",
881
- headers: { "Content-Type": "application/json" },
882
- body: JSON.stringify(body),
883
- });
884
- const result = await response.json().catch(() => null) as {
885
- ok?: boolean;
886
- message?: string;
887
- referenceCount?: number;
888
- fileCount?: number;
889
- } | null;
890
- if (!response.ok || !result?.ok) {
891
- throw new Error(result?.message ?? `Project asset action failed with status ${response.status}`);
892
- }
893
- return result;
894
- }
895
-
896
- function assetKindLabel(kind: "media" | "component") {
897
- return kind === "media" ? "圖片" : "Component";
898
- }
899
-
900
- const PROJECT_SKILL_MENTIONS: ProjectMentionItem[] = [
901
- { trigger: "/", value: "/insert-image", label: "insert-image", meta: "skill", kind: "skill" },
902
- { trigger: "/", value: "/redraw-figure", label: "redraw-figure", meta: "skill", kind: "skill" },
903
- { trigger: "/", value: "/rewrite-section", label: "rewrite-section", meta: "skill", kind: "skill" },
904
- { trigger: "/", value: "/apply-style", label: "apply-style", meta: "skill", kind: "skill" },
905
- { trigger: "/", value: "/fix-code", label: "fix-code", meta: "skill", kind: "skill" },
906
- ];
907
-
908
- const PROJECT_COLOR_SWATCHES = [
909
- { label: "Document", token: "--openpress-color-document" },
910
- { label: "Paper", token: "--openpress-color-paper" },
911
- { label: "Ink", token: "--openpress-color-ink" },
912
- { label: "Body", token: "--openpress-color-body" },
913
- { label: "Muted", token: "--openpress-color-muted" },
914
- { label: "Subtle", token: "--openpress-color-subtle" },
915
- { label: "Line", token: "--openpress-color-line" },
916
- { label: "Block", token: "--openpress-color-block" },
917
- { label: "Info", token: "--openpress-color-info" },
918
- { label: "Green", token: "--openpress-color-green" },
919
- ];