@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.
- package/engine/commands/dev.mjs +2 -2
- package/engine/output/chrome-pdf.mjs +18 -3
- package/engine/output/static-server.mjs +39 -0
- package/engine/react/comment-endpoint.mjs +13 -39
- package/engine/react/comment-marker.mjs +30 -6
- package/engine/react/document-entry.mjs +11 -0
- package/engine/react/document-export.mjs +30 -5
- package/engine/react/http-json.mjs +24 -0
- package/engine/react/mdx-compile.mjs +96 -3
- package/engine/react/measurement-css.mjs +93 -1
- package/engine/react/object-entities.mjs +119 -0
- package/engine/react/pipeline/allocate.mjs +10 -7
- package/engine/react/pipeline/frame-measurement.mjs +2 -0
- package/engine/react/project-asset-endpoint.mjs +6 -24
- package/engine/react/source-edit-endpoint.d.mts +10 -0
- package/engine/react/source-edit-endpoint.mjs +75 -0
- package/engine/react/sources/mdx-resolver.mjs +12 -14
- package/engine/react/style-discovery.mjs +1 -4
- package/engine/runtime/file-walk.mjs +22 -0
- package/engine/runtime/inspection.mjs +1 -20
- package/engine/runtime/path-utils.mjs +20 -0
- package/engine/runtime/source-text-tools.d.mts +102 -0
- package/engine/runtime/source-text-tools.mjs +551 -16
- package/engine/runtime/source-workspace.mjs +4 -31
- package/package.json +1 -1
- package/src/openpress/{App.tsx → app/OpenPressApp.tsx} +25 -12
- package/src/openpress/{renderer.tsx → app/OpenPressRuntime.tsx} +10 -7
- package/src/openpress/app/index.ts +2 -0
- package/src/openpress/core/Frame.tsx +9 -11
- package/src/openpress/core/FrameContext.tsx +8 -3
- package/src/openpress/core/MdxArea.tsx +11 -12
- package/src/openpress/core/cn.ts +4 -0
- package/src/openpress/core/index.tsx +2 -1
- package/src/openpress/core/primitives.tsx +29 -8
- package/src/openpress/core/types.ts +8 -0
- package/src/openpress/{anchorMap.ts → document-model/anchorMapModel.ts} +1 -1
- package/src/openpress/{indexes.ts → document-model/documentIndexes.ts} +1 -1
- package/src/openpress/{types.ts → document-model/documentTypes.ts} +42 -0
- package/src/openpress/document-model/index.ts +6 -0
- package/src/openpress/document-model/objectEntityModel.ts +51 -0
- package/src/openpress/{projectIdentity.ts → document-model/projectIdentityModel.ts} +1 -1
- package/src/openpress/{reactDocumentMetadata.ts → document-model/reactDocumentMetadataModel.ts} +1 -1
- package/src/openpress/manuscript/index.tsx +49 -7
- package/src/openpress/{publicPage.tsx → reader/PublicReaderPage.tsx} +31 -51
- package/src/openpress/{workbenchPanels.tsx → reader/ReaderNavigationPanel.tsx} +6 -5
- package/src/openpress/reader/index.ts +10 -0
- package/src/openpress/reader/pageViewportScaleModel.ts +73 -0
- package/src/openpress/reader/readerTypes.ts +4 -0
- package/src/openpress/reader/usePageViewportScale.ts +119 -0
- package/src/openpress/reader/usePanelState.ts +56 -0
- package/src/openpress/reader/useReaderHashSync.ts +61 -0
- package/src/openpress/reader/useReaderKeyboardNav.ts +48 -0
- package/src/openpress/reader/useReaderRuntime.ts +146 -0
- package/src/openpress/reader/useReaderScrollAnchor.ts +64 -0
- package/src/openpress/shared/Panel.tsx +77 -0
- package/src/openpress/shared/index.ts +4 -0
- package/src/openpress/shared/numberUtils.ts +3 -0
- package/src/openpress/{runtimeMode.ts → shared/runtimeMode.ts} +0 -11
- package/src/openpress/workbench/Workbench.tsx +407 -0
- package/src/openpress/workbench/actions/DeploymentControl.tsx +157 -0
- package/src/openpress/workbench/actions/PageZoomControl.tsx +182 -0
- package/src/openpress/workbench/actions/SearchControl.tsx +345 -0
- package/src/openpress/workbench/actions/deploymentStatusModel.ts +112 -0
- package/src/openpress/workbench/actions/index.ts +5 -0
- package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +136 -0
- package/src/openpress/workbench/dialog/WorkbenchDialog.tsx +72 -0
- package/src/openpress/workbench/dialog/index.ts +1 -0
- package/src/openpress/workbench/document/components/DocumentPanel.tsx +127 -0
- package/src/openpress/workbench/document/components/InlineSourceEditorLayer.tsx +207 -0
- package/src/openpress/workbench/document/components/ReaderStage.tsx +9 -0
- package/src/openpress/workbench/document/hooks/useDocumentWorkbenchModel.ts +34 -0
- package/src/openpress/workbench/document/hooks/useInlineDocumentEditor.ts +525 -0
- package/src/openpress/workbench/document/index.ts +10 -0
- package/src/openpress/workbench/index.ts +2 -0
- package/src/openpress/workbench/inspector/InlineInspectorLayer.tsx +459 -0
- package/src/openpress/workbench/inspector/index.ts +5 -0
- package/src/openpress/workbench/inspector/inlineCommentModel.ts +125 -0
- package/src/openpress/workbench/inspector/inspectorGeometryModel.ts +160 -0
- package/src/openpress/workbench/inspector/inspectorModel.ts +408 -0
- package/src/openpress/workbench/inspector/useInspectorComments.ts +248 -0
- package/src/openpress/workbench/mentions/MentionSuggestionList.tsx +41 -0
- package/src/openpress/workbench/mentions/index.ts +2 -0
- package/src/openpress/{composerMentions.ts → workbench/mentions/useComposerMentions.ts} +1 -4
- package/src/openpress/workbench/panels/Panel.tsx +1 -0
- package/src/openpress/workbench/panels/PendingCommentsPanel.tsx +76 -0
- package/src/openpress/workbench/panels/WorkbenchControlPanel.tsx +29 -0
- package/src/openpress/workbench/panels/index.ts +3 -0
- package/src/openpress/workbench/project/ProjectEntryPanel.tsx +523 -0
- package/src/openpress/workbench/project/ProjectPreviewDialog.tsx +35 -0
- package/src/openpress/workbench/project/index.ts +2 -0
- package/src/openpress/workbench/project/projectPreviewTypes.ts +11 -0
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +167 -0
- package/src/openpress/workbench/shell/index.ts +1 -0
- package/src/openpress/workbench/workbenchFormatters.ts +120 -0
- package/src/openpress/workbench/workbenchTypes.ts +35 -0
- package/src/styles/openpress/print-route.css +0 -2
- package/src/styles/openpress/{project-workspace.css → project-preview-panel.css} +13 -407
- package/src/styles/openpress/public-viewer.css +25 -320
- package/src/styles/openpress/reader-runtime.css +243 -55
- package/src/styles/openpress/responsive.css +145 -270
- package/src/styles/openpress/workbench-panels.css +214 -178
- package/src/styles/openpress/workbench.css +986 -451
- package/src/styles/openpress.css +1 -1
- package/vite.config.ts +50 -0
- package/src/openpress/inspector.ts +0 -282
- package/src/openpress/projectWorkspace.tsx +0 -919
- package/src/openpress/readerRuntime.ts +0 -230
- package/src/openpress/workbench.tsx +0 -1265
- package/src/openpress/workbenchTypes.ts +0 -4
- /package/src/openpress/{readerPageRegistry.ts → reader/readerPageRegistry.ts} +0 -0
- /package/src/openpress/{pageRoute.ts → reader/readerPageRoute.ts} +0 -0
- /package/src/openpress/{readerScroll.ts → reader/readerScroll.ts} +0 -0
- /package/src/openpress/{readerState.ts → reader/readerStateModel.ts} +0 -0
- /package/src/openpress/{frameScheduler.ts → shared/frameScheduler.ts} +0 -0
- /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
|
-
];
|