@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
@@ -0,0 +1,248 @@
1
+ import { useCallback, useEffect, useMemo, useState, type FormEvent, type RefObject } from "react";
2
+ import type { SourceBlock } from "../../document-model";
3
+ import type { InlineSavedComment, InspectorCommentStatus, PendingCommentsStatus } from "../workbenchTypes";
4
+ import { formatInspectorCommentStatus } from "../workbenchFormatters";
5
+ import { clearInspectorComment, fetchInspectorComments } from "./inspectorModel";
6
+ import { createInspectorCommentDraft, submitInspectorComment, updateInspectorComment } from "./inspectorModel";
7
+ import type { InspectorState, PendingComment } from "./inspectorModel";
8
+ import { getInlineSavedCommentForTarget, resolveInlineSavedComment } from "./inlineCommentModel";
9
+
10
+ export interface UseInspectorCommentsOptions {
11
+ devMode: boolean;
12
+ inspector: InspectorState;
13
+ sourceBlockMap: Record<string, SourceBlock>;
14
+ sourceBlocksByPath: Record<string, SourceBlock[]>;
15
+ sourceContainerRef: RefObject<HTMLDivElement | null>;
16
+ onSelectWorkspacePage: (pageIndex: number, options?: { behavior?: ScrollBehavior }) => void;
17
+ }
18
+
19
+ export interface InspectorComments {
20
+ pendingComments: PendingComment[];
21
+ commentsStatus: PendingCommentsStatus;
22
+ commentsError: string;
23
+ inspectorCommentText: string;
24
+ inspectorCommentStatus: InspectorCommentStatus;
25
+ inspectorCommentStatusMessage: string;
26
+ inspectorCommentDisabled: boolean;
27
+ inlineSavedComments: InlineSavedComment[];
28
+ activeInlineSavedComment: InlineSavedComment | null;
29
+ setInspectorCommentText: (value: string) => void;
30
+ refreshPendingComments: () => Promise<void>;
31
+ clearPendingComment: (id: string) => Promise<void>;
32
+ handleSubmitInspectorComment: (event?: FormEvent<HTMLFormElement>) => Promise<void>;
33
+ handleOpenInlineSavedComment: (comment: InlineSavedComment) => void;
34
+ handleRemoveInlineSavedComment: (comment: InlineSavedComment) => Promise<void>;
35
+ handleSelectPendingComment: (comment: PendingComment) => void;
36
+ }
37
+
38
+ export function useInspectorComments({
39
+ devMode,
40
+ inspector,
41
+ sourceBlockMap,
42
+ sourceBlocksByPath,
43
+ sourceContainerRef,
44
+ onSelectWorkspacePage,
45
+ }: UseInspectorCommentsOptions): InspectorComments {
46
+ const [inspectorCommentText, setInspectorCommentText] = useState("");
47
+ const [inspectorCommentStatus, setInspectorCommentStatus] = useState<InspectorCommentStatus>("idle");
48
+ const [inspectorCommentError, setInspectorCommentError] = useState("");
49
+ const [inlineSavedCommentId, setInlineSavedCommentId] = useState<string | null>(null);
50
+ const [pendingComments, setPendingComments] = useState<PendingComment[]>([]);
51
+ const [commentsStatus, setCommentsStatus] = useState<PendingCommentsStatus>("idle");
52
+ const [commentsError, setCommentsError] = useState("");
53
+
54
+ const inlineSavedComments = useMemo(
55
+ () => pendingComments.flatMap((comment, index) => (
56
+ resolveInlineSavedComment(comment, sourceBlocksByPath)
57
+ .map((inlineComment) => ({ ...inlineComment, markerLabel: String(index + 1) }))
58
+ )),
59
+ [pendingComments, sourceBlocksByPath],
60
+ );
61
+
62
+ const activeInlineSavedComment = getInlineSavedCommentForTarget(
63
+ inlineSavedComments,
64
+ inspector.selectedTarget,
65
+ inlineSavedCommentId,
66
+ );
67
+
68
+ const inspectorCommentDisabled =
69
+ !inspector.selectedBlock || !inspectorCommentText.trim() || inspectorCommentStatus === "submitting";
70
+ const inspectorCommentStatusMessage = formatInspectorCommentStatus(inspectorCommentStatus, inspectorCommentError);
71
+
72
+ const refreshPendingComments = useCallback(async () => {
73
+ if (!devMode) return;
74
+ setCommentsStatus("loading");
75
+ setCommentsError("");
76
+ try {
77
+ const comments = await fetchInspectorComments();
78
+ setPendingComments(comments);
79
+ setCommentsStatus("ready");
80
+ } catch (error) {
81
+ setCommentsStatus("failed");
82
+ setCommentsError(error instanceof Error ? error.message : String(error));
83
+ }
84
+ }, [devMode]);
85
+
86
+ const clearPendingComment = useCallback(async (id: string) => {
87
+ setCommentsStatus("clearing");
88
+ setCommentsError("");
89
+ try {
90
+ await clearInspectorComment({ id });
91
+ setPendingComments((comments) => comments.filter((comment) => comment.id !== id));
92
+ setInlineSavedCommentId((currentId) => (currentId === id ? null : currentId));
93
+ setCommentsStatus("ready");
94
+ } catch (error) {
95
+ setCommentsStatus("failed");
96
+ setCommentsError(error instanceof Error ? error.message : String(error));
97
+ }
98
+ }, []);
99
+
100
+ const handleSubmitInspectorComment = useCallback(async (event?: FormEvent<HTMLFormElement>) => {
101
+ event?.preventDefault();
102
+ if (inspectorCommentDisabled || !inspector.selectedBlock) return;
103
+ setInspectorCommentStatus("submitting");
104
+ setInspectorCommentError("");
105
+ try {
106
+ const note = inspectorCommentText.trim();
107
+ const placement = inspector.selectedTarget?.placement ?? "block";
108
+ if (activeInlineSavedComment) {
109
+ const result = await updateInspectorComment({
110
+ id: activeInlineSavedComment.id,
111
+ note,
112
+ placement,
113
+ });
114
+ setInlineSavedCommentId(result.comment?.id ?? activeInlineSavedComment.id);
115
+ } else {
116
+ const draft = createInspectorCommentDraft({
117
+ block: inspector.selectedBlock,
118
+ entity: inspector.selectedObjectEntity,
119
+ target: inspector.selectedTarget,
120
+ note,
121
+ placement,
122
+ });
123
+ const result = await submitInspectorComment({ draft });
124
+ if (result.comment?.id) {
125
+ setInlineSavedCommentId(result.comment.id);
126
+ }
127
+ }
128
+ setInspectorCommentText("");
129
+ setInspectorCommentStatus("saved");
130
+ void refreshPendingComments();
131
+ } catch (error) {
132
+ setInspectorCommentStatus("failed");
133
+ setInspectorCommentError(error instanceof Error ? error.message : String(error));
134
+ }
135
+ }, [
136
+ activeInlineSavedComment,
137
+ inspector.selectedBlock,
138
+ inspector.selectedObjectEntity,
139
+ inspector.selectedTarget,
140
+ inspector.selectedTarget?.placement,
141
+ inspectorCommentDisabled,
142
+ inspectorCommentText,
143
+ refreshPendingComments,
144
+ ]);
145
+
146
+ const handleOpenInlineSavedComment = useCallback((comment: InlineSavedComment) => {
147
+ setInlineSavedCommentId(comment.id);
148
+ setInspectorCommentText(comment.note);
149
+ setInspectorCommentStatus("idle");
150
+ setInspectorCommentError("");
151
+ }, []);
152
+
153
+ const handleRemoveInlineSavedComment = useCallback(async (comment: InlineSavedComment) => {
154
+ setInspectorCommentStatus("submitting");
155
+ setInspectorCommentError("");
156
+ try {
157
+ await clearInspectorComment({ id: comment.id });
158
+ setPendingComments((comments) => comments.filter((item) => item.id !== comment.id));
159
+ setInlineSavedCommentId((currentId) => (currentId === comment.id ? null : currentId));
160
+ setInspectorCommentText("");
161
+ setInspectorCommentStatus("idle");
162
+ inspector.selectTarget(null);
163
+ void refreshPendingComments();
164
+ } catch (error) {
165
+ setInspectorCommentStatus("failed");
166
+ setInspectorCommentError(error instanceof Error ? error.message : String(error));
167
+ }
168
+ }, [inspector, refreshPendingComments]);
169
+
170
+ const handleSelectPendingComment = useCallback((comment: PendingComment) => {
171
+ const inlineComment = inlineSavedComments.find((item) => item.id === comment.id)
172
+ ?? resolveInlineSavedComment(comment, sourceBlocksByPath)[0];
173
+ if (!inlineComment?.blockId) return;
174
+
175
+ const sourceBlock = sourceBlockMap[inlineComment.blockId];
176
+ if (typeof sourceBlock?.pageIndex === "number") {
177
+ onSelectWorkspacePage(sourceBlock.pageIndex, { behavior: "smooth" });
178
+ }
179
+
180
+ inspector.selectSelection({
181
+ objectId: inlineComment.objectId,
182
+ blockId: inlineComment.blockId,
183
+ placement: inlineComment.placement,
184
+ });
185
+ handleOpenInlineSavedComment(inlineComment);
186
+
187
+ window.requestAnimationFrame(() => {
188
+ const selector = inlineComment.objectId
189
+ ? `[data-openpress-object-id="${cssEscape(inlineComment.objectId)}"]`
190
+ : `[data-openpress-block-id="${cssEscape(inlineComment.blockId!)}"]`;
191
+ sourceContainerRef.current?.querySelector<HTMLElement>(selector)?.scrollIntoView({
192
+ behavior: "smooth",
193
+ block: "center",
194
+ });
195
+ });
196
+ }, [
197
+ handleOpenInlineSavedComment,
198
+ inlineSavedComments,
199
+ inspector,
200
+ onSelectWorkspacePage,
201
+ sourceBlockMap,
202
+ sourceBlocksByPath,
203
+ sourceContainerRef,
204
+ ]);
205
+
206
+ // Reset composer state when the inspector selection changes.
207
+ useEffect(() => {
208
+ setInspectorCommentStatus("idle");
209
+ setInspectorCommentError("");
210
+ setInspectorCommentText("");
211
+ }, [inspector.selectedBlockId, inspector.selectedTarget?.placement]);
212
+
213
+ // Drop the inline saved id if its comment is no longer reachable.
214
+ useEffect(() => {
215
+ if (inlineSavedCommentId && !activeInlineSavedComment) {
216
+ setInlineSavedCommentId(null);
217
+ }
218
+ }, [activeInlineSavedComment, inlineSavedCommentId]);
219
+
220
+ // Initial + dev-mode refresh of pending comments.
221
+ useEffect(() => {
222
+ if (!devMode) return;
223
+ void refreshPendingComments();
224
+ }, [devMode, refreshPendingComments]);
225
+
226
+ return {
227
+ pendingComments,
228
+ commentsStatus,
229
+ commentsError,
230
+ inspectorCommentText,
231
+ inspectorCommentStatus,
232
+ inspectorCommentStatusMessage,
233
+ inspectorCommentDisabled,
234
+ inlineSavedComments,
235
+ activeInlineSavedComment,
236
+ setInspectorCommentText,
237
+ refreshPendingComments,
238
+ clearPendingComment,
239
+ handleSubmitInspectorComment,
240
+ handleOpenInlineSavedComment,
241
+ handleRemoveInlineSavedComment,
242
+ handleSelectPendingComment,
243
+ };
244
+ }
245
+
246
+ function cssEscape(value: string) {
247
+ return globalThis.CSS?.escape ? globalThis.CSS.escape(value) : value.replace(/["\\]/g, "\\$&");
248
+ }
@@ -0,0 +1,41 @@
1
+ import type { ComposerMentionItem } from "./useComposerMentions";
2
+
3
+ export interface MentionSuggestionListProps {
4
+ className: string;
5
+ suggestions: ComposerMentionItem[];
6
+ highlightedIndex: number;
7
+ ariaLabel: string;
8
+ onHighlight: (index: number) => void;
9
+ onSelect: (item: ComposerMentionItem) => void;
10
+ }
11
+
12
+ export function MentionSuggestionList({
13
+ className,
14
+ suggestions,
15
+ highlightedIndex,
16
+ ariaLabel,
17
+ onHighlight,
18
+ onSelect,
19
+ }: MentionSuggestionListProps) {
20
+ if (suggestions.length === 0) return null;
21
+
22
+ return (
23
+ <div className={className} role="listbox" aria-label={ariaLabel}>
24
+ {suggestions.map((item, index) => (
25
+ <button
26
+ type="button"
27
+ role="option"
28
+ aria-selected={index === highlightedIndex}
29
+ data-highlighted={index === highlightedIndex ? "true" : undefined}
30
+ key={`${item.kind}-${item.value}`}
31
+ onMouseDown={(event) => event.preventDefault()}
32
+ onMouseEnter={() => onHighlight(index)}
33
+ onClick={() => onSelect(item)}
34
+ >
35
+ <span>{item.label}</span>
36
+ <small>{item.meta}</small>
37
+ </button>
38
+ ))}
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./useComposerMentions";
2
+ export * from "./MentionSuggestionList";
@@ -1,4 +1,5 @@
1
1
  import { useEffect, useMemo, useState, type KeyboardEvent, type RefObject } from "react";
2
+ import { clampNumber } from "../../shared";
2
3
 
3
4
  export type ComposerMentionItem = {
4
5
  trigger: "@" | "/";
@@ -176,10 +177,6 @@ function mentionMatches(item: ComposerMentionItem, query: string) {
176
177
  || item.meta.toLowerCase().includes(normalizedQuery);
177
178
  }
178
179
 
179
- function clampNumber(value: number, min: number, max: number) {
180
- return Math.min(Math.max(value, min), Math.max(min, max));
181
- }
182
-
183
180
  const MENTION_PREFIX_DEFINITIONS: Array<{ kind: "media" | "chapter" | "section" | "component"; meta: string }> = [
184
181
  { kind: "media", meta: "prefix · images" },
185
182
  { kind: "chapter", meta: "prefix · chapters" },
@@ -0,0 +1 @@
1
+ export { Panel } from "../../shared/Panel";
@@ -0,0 +1,76 @@
1
+ import { Trash2 } from "lucide-react";
2
+ import type { PendingComment } from "../inspector";
3
+ import {
4
+ formatCommentTimestamp,
5
+ formatCommentsCount,
6
+ } from "../workbenchFormatters";
7
+ import type { PendingCommentsStatus } from "../workbenchTypes";
8
+ import { Panel } from "./Panel";
9
+
10
+ export function PendingCommentsPanel({
11
+ comments,
12
+ status,
13
+ error,
14
+ onClear,
15
+ onSelect,
16
+ }: {
17
+ comments: PendingComment[];
18
+ status: PendingCommentsStatus;
19
+ error: string;
20
+ onClear: (id: string) => Promise<void>;
21
+ onSelect?: (comment: PendingComment) => void;
22
+ }) {
23
+ const busy = status === "loading" || status === "clearing";
24
+
25
+ return (
26
+ <Panel
27
+ className="openpress-comments-panel openpress-comments-panel--embedded openpress-panel--compact"
28
+ data-openpress-comments-panel
29
+ aria-label="待處理註解"
30
+ >
31
+ <Panel.Header>
32
+ <div className="openpress-panel-heading-stack">
33
+ <Panel.Kicker>Comments</Panel.Kicker>
34
+ <Panel.Title>待處理註解</Panel.Title>
35
+ <Panel.Description>{formatCommentsCount(comments.length, status)}</Panel.Description>
36
+ </div>
37
+ </Panel.Header>
38
+
39
+ <Panel.Body>
40
+ {error ? <Panel.Error>{error}</Panel.Error> : null}
41
+
42
+ {comments.length === 0 && status !== "loading" ? (
43
+ <Panel.Empty role="status">目前沒有註解</Panel.Empty>
44
+ ) : (
45
+ <ol className="openpress-comments-list" aria-label="待處理註解列表">
46
+ {comments.map((comment) => (
47
+ <li className="openpress-comment-entry" data-openpress-comment-id={comment.id} key={comment.id}>
48
+ <button
49
+ type="button"
50
+ className="openpress-comment-entry__jump"
51
+ onClick={() => onSelect?.(comment)}
52
+ aria-label={`跳到註解 ${comment.id}`}
53
+ >
54
+ <p className="openpress-comment-entry__note" title={comment.note}>{comment.note}</p>
55
+ <p className="openpress-comment-entry__meta">
56
+ <code>{comment.path}:{comment.line}</code>
57
+ {comment.timestamp ? <span>{formatCommentTimestamp(comment.timestamp)}</span> : null}
58
+ </p>
59
+ </button>
60
+ <button
61
+ type="button"
62
+ className="openpress-comment-entry__clear"
63
+ onClick={() => void onClear(comment.id)}
64
+ disabled={busy}
65
+ aria-label={`清除註解 ${comment.id}`}
66
+ >
67
+ <Trash2 aria-hidden="true" />
68
+ </button>
69
+ </li>
70
+ ))}
71
+ </ol>
72
+ )}
73
+ </Panel.Body>
74
+ </Panel>
75
+ );
76
+ }
@@ -0,0 +1,29 @@
1
+ import { Fragment, type ReactNode } from "react";
2
+
3
+ // A WorkbenchPanel is a self-contained renderable slot in the workbench
4
+ // control surface. Each panel owns its own props by capturing them in render;
5
+ // the host (HtmlWorkbench) supplies the list and decides ordering.
6
+ export interface WorkbenchPanel {
7
+ id: string;
8
+ render: () => ReactNode;
9
+ }
10
+
11
+ export interface WorkbenchControlPanelProps {
12
+ panels: WorkbenchPanel[];
13
+ className?: string;
14
+ ariaLabel?: string;
15
+ }
16
+
17
+ export function WorkbenchControlPanel({
18
+ panels,
19
+ className = "openpress-control-panel",
20
+ ariaLabel = "控制面板",
21
+ }: WorkbenchControlPanelProps) {
22
+ return (
23
+ <div className={className} data-openpress-control-panel aria-label={ariaLabel}>
24
+ {panels.map((panel) => (
25
+ <Fragment key={panel.id}>{panel.render()}</Fragment>
26
+ ))}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,3 @@
1
+ export * from "./Panel";
2
+ export * from "./PendingCommentsPanel";
3
+ export * from "./WorkbenchControlPanel";