@open-press/core 1.1.4 → 1.2.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/cli.mjs +2 -2
- package/engine/commands/_shared.mjs +80 -9
- package/engine/commands/image.mjs +9 -3
- package/engine/commands/pdf.mjs +4 -1
- package/engine/output/chrome-pdf.mjs +102 -0
- package/engine/output/static-server.mjs +48 -8
- package/engine/react/document-export.mjs +22 -0
- package/package.json +1 -1
- package/src/openpress/app/OpenPressApp.tsx +1 -0
- package/src/openpress/app/OpenPressRuntime.tsx +59 -5
- package/src/openpress/reader/PublicReaderPage.tsx +163 -74
- package/src/openpress/reader/SlidePresentationPage.tsx +8 -3
- package/src/openpress/reader/usePanelState.ts +14 -5
- package/src/openpress/shared/index.ts +1 -0
- package/src/openpress/shared/staticSearch.ts +174 -0
- package/src/openpress/workbench/Workbench.tsx +19 -16
- package/src/openpress/workbench/actions/SearchControl.tsx +32 -43
- package/src/openpress/workbench/actions/useDeploymentWorkbench.ts +14 -3
- package/src/openpress/workbench/inspector/useInspectorComments.ts +6 -6
- package/src/openpress/workbench/shell/WorkbenchShell.tsx +44 -18
- package/vite.config.ts +50 -8
|
@@ -51,8 +51,9 @@ export function HtmlWorkbench({
|
|
|
51
51
|
document,
|
|
52
52
|
pages,
|
|
53
53
|
style,
|
|
54
|
-
|
|
54
|
+
workspaceMode,
|
|
55
55
|
deploymentInfo,
|
|
56
|
+
pressSlug = null,
|
|
56
57
|
onDocumentRefresh,
|
|
57
58
|
onBackToWorkspace,
|
|
58
59
|
onOpenPresentation,
|
|
@@ -61,8 +62,12 @@ export function HtmlWorkbench({
|
|
|
61
62
|
document: ReaderDocument;
|
|
62
63
|
pages: Array<HtmlPageBlock>;
|
|
63
64
|
style: CSSProperties;
|
|
64
|
-
|
|
65
|
+
workspaceMode: boolean;
|
|
65
66
|
deploymentInfo: DeploymentInfo;
|
|
67
|
+
// Active Press slug — threaded down to useDeploymentWorkbench so the
|
|
68
|
+
// local PDF export endpoint can pick the right Press in multi-Press
|
|
69
|
+
// workspaces. Null when the workspace is at the gallery root.
|
|
70
|
+
pressSlug?: string | null;
|
|
66
71
|
onDocumentRefresh?: () => void | Promise<void>;
|
|
67
72
|
onBackToWorkspace?: () => void;
|
|
68
73
|
onOpenPresentation?: (pageIndex: number) => void;
|
|
@@ -83,7 +88,7 @@ export function HtmlWorkbench({
|
|
|
83
88
|
sourceBlocksByPath,
|
|
84
89
|
projectMentionItems,
|
|
85
90
|
} = useDocumentWorkbenchModel(document, displayPages);
|
|
86
|
-
const inspector = useInspector(document, { enabled:
|
|
91
|
+
const inspector = useInspector(document, { enabled: workspaceMode });
|
|
87
92
|
const reader = useReaderRuntime({
|
|
88
93
|
pageCount: Math.max(displayPages.length, 1),
|
|
89
94
|
leftPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT,
|
|
@@ -96,7 +101,7 @@ export function HtmlWorkbench({
|
|
|
96
101
|
pageCount: displayPages.length,
|
|
97
102
|
layoutMode: pageLayoutMode,
|
|
98
103
|
});
|
|
99
|
-
const deployment = useDeploymentWorkbench({ deploymentInfo });
|
|
104
|
+
const deployment = useDeploymentWorkbench({ deploymentInfo, pressSlug });
|
|
100
105
|
const [inlineEditStatus, setInlineEditStatus] = useState<InlineDocumentEditStatus>({ state: "idle" });
|
|
101
106
|
const [sourceEditorTarget, setSourceEditorTarget] = useState<InlineDocumentSourceTarget | null>(null);
|
|
102
107
|
|
|
@@ -117,7 +122,7 @@ export function HtmlWorkbench({
|
|
|
117
122
|
// text cursor active would (a) show the I-beam instead of the inspector
|
|
118
123
|
// crosshair, (b) allow accidental text selection that paints the whole
|
|
119
124
|
// page (notably covers) with the browser ::selection color.
|
|
120
|
-
const inlineEditEnabled =
|
|
125
|
+
const inlineEditEnabled = workspaceMode && !inspector.inspectorMode;
|
|
121
126
|
useInlineDocumentEditor({
|
|
122
127
|
enabled: inlineEditEnabled,
|
|
123
128
|
sourceContainerRef,
|
|
@@ -146,7 +151,7 @@ export function HtmlWorkbench({
|
|
|
146
151
|
};
|
|
147
152
|
|
|
148
153
|
const comments = useInspectorComments({
|
|
149
|
-
|
|
154
|
+
workspaceMode,
|
|
150
155
|
inspector,
|
|
151
156
|
sourceBlockMap,
|
|
152
157
|
sourceBlocksByPath,
|
|
@@ -324,13 +329,13 @@ export function HtmlWorkbench({
|
|
|
324
329
|
/>
|
|
325
330
|
</div>
|
|
326
331
|
<div className="openpress-workbench-toolbar__group openpress-workbench-toolbar__group--right" aria-label="工作台狀態與發布">
|
|
327
|
-
{
|
|
332
|
+
{workspaceMode ? (
|
|
328
333
|
<SearchControl
|
|
329
334
|
sourceBlocksByPath={sourceBlocksByPath}
|
|
330
335
|
onSelectPage={selectWorkspacePage}
|
|
331
336
|
/>
|
|
332
337
|
) : null}
|
|
333
|
-
{
|
|
338
|
+
{workspaceMode && editStatusMessage ? (
|
|
334
339
|
<span
|
|
335
340
|
className="openpress-dev-edit-status openpress-dev-edit-status--toolbar"
|
|
336
341
|
data-openpress-edit-status={inlineEditStatus.state}
|
|
@@ -341,7 +346,7 @@ export function HtmlWorkbench({
|
|
|
341
346
|
<span>{editStatusMessage}</span>
|
|
342
347
|
</span>
|
|
343
348
|
) : null}
|
|
344
|
-
{
|
|
349
|
+
{workspaceMode ? (
|
|
345
350
|
<button
|
|
346
351
|
type="button"
|
|
347
352
|
className="openpress-workbench-toolbar-action"
|
|
@@ -359,7 +364,7 @@ export function HtmlWorkbench({
|
|
|
359
364
|
<span className="openpress-dev-inspector-status">{inspectorSelectionLabel}</span>
|
|
360
365
|
</button>
|
|
361
366
|
) : null}
|
|
362
|
-
{
|
|
367
|
+
{workspaceMode && inspector.inspectorMode ? (
|
|
363
368
|
<span
|
|
364
369
|
className="openpress-dev-inspector-status"
|
|
365
370
|
role="status"
|
|
@@ -391,7 +396,7 @@ export function HtmlWorkbench({
|
|
|
391
396
|
deployment.pdfStatusMessage,
|
|
392
397
|
deployment.pdfToolbarExpanded,
|
|
393
398
|
deployment.status,
|
|
394
|
-
|
|
399
|
+
workspaceMode,
|
|
395
400
|
editStatusMessage,
|
|
396
401
|
inlineEditStatus.state,
|
|
397
402
|
inspector.inspectorMode,
|
|
@@ -418,7 +423,6 @@ export function HtmlWorkbench({
|
|
|
418
423
|
return (
|
|
419
424
|
<WorkbenchShell
|
|
420
425
|
style={style}
|
|
421
|
-
devMode={devMode}
|
|
422
426
|
viewMode={viewMode}
|
|
423
427
|
pressType={pressType}
|
|
424
428
|
presentationMode={false}
|
|
@@ -491,15 +495,14 @@ export function HtmlWorkbench({
|
|
|
491
495
|
<PublicPage
|
|
492
496
|
pages={displayPages}
|
|
493
497
|
currentPageIndex={reader.currentPageIndex}
|
|
494
|
-
devMode={devMode}
|
|
495
498
|
sourceContainerRef={sourceContainerRef}
|
|
496
499
|
registerPage={reader.registerPage}
|
|
497
|
-
exposeSourceData={
|
|
500
|
+
exposeSourceData={workspaceMode}
|
|
498
501
|
inspector={inspector}
|
|
499
502
|
onInternalAnchorNavigate={selectWorkspaceAnchor}
|
|
500
503
|
pageLayoutMode={pageLayoutMode}
|
|
501
504
|
/>
|
|
502
|
-
{
|
|
505
|
+
{workspaceMode ? (
|
|
503
506
|
<InlineInspectorLayer
|
|
504
507
|
sourceContainerRef={sourceContainerRef}
|
|
505
508
|
inspector={inspector}
|
|
@@ -508,7 +511,7 @@ export function HtmlWorkbench({
|
|
|
508
511
|
geometryVersion={`${pageViewport.scaleMode}:${pageViewport.scale}:${pageLayoutMode}`}
|
|
509
512
|
/>
|
|
510
513
|
) : null}
|
|
511
|
-
{
|
|
514
|
+
{workspaceMode ? (
|
|
512
515
|
<InlineSourceEditorLayer
|
|
513
516
|
target={sourceEditorTarget}
|
|
514
517
|
onClose={() => setSourceEditorTarget(null)}
|
|
@@ -1,43 +1,15 @@
|
|
|
1
1
|
import { useCallback, useEffect, useId, useMemo, useRef, useState, type FormEvent } from "react";
|
|
2
2
|
import { FileText, Loader2, Search } from "lucide-react";
|
|
3
3
|
import type { SourceBlock } from "../../document-model";
|
|
4
|
+
import type { SearchReport, SearchScope } from "../../shared";
|
|
4
5
|
import { WorkbenchDialog } from "../dialog";
|
|
5
6
|
|
|
6
|
-
type SearchScope = "content" | "all";
|
|
7
7
|
type SearchStatus = "idle" | "loading" | "success" | "error";
|
|
8
8
|
const SEARCH_SCOPE: SearchScope = "all";
|
|
9
9
|
const LIVE_SEARCH_DEBOUNCE_MS = 280;
|
|
10
10
|
|
|
11
|
-
type SearchFile =
|
|
12
|
-
|
|
13
|
-
file: string;
|
|
14
|
-
path: string;
|
|
15
|
-
matchCount: number;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
type SearchMatch = {
|
|
19
|
-
id: string;
|
|
20
|
-
scope: string;
|
|
21
|
-
file: string;
|
|
22
|
-
path: string;
|
|
23
|
-
line: number;
|
|
24
|
-
column: number;
|
|
25
|
-
index: number;
|
|
26
|
-
text: string;
|
|
27
|
-
preview: string;
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
type SearchReport = {
|
|
31
|
-
ok?: boolean;
|
|
32
|
-
kind: "search";
|
|
33
|
-
query: string;
|
|
34
|
-
scope: SearchScope;
|
|
35
|
-
caseSensitive: boolean;
|
|
36
|
-
matchCount: number;
|
|
37
|
-
files: Array<SearchFile>;
|
|
38
|
-
matches: Array<SearchMatch>;
|
|
39
|
-
message?: string;
|
|
40
|
-
};
|
|
11
|
+
type SearchFile = SearchReport["files"][number];
|
|
12
|
+
type SearchMatch = SearchReport["matches"][number];
|
|
41
13
|
|
|
42
14
|
type SearchJumpTarget = {
|
|
43
15
|
blockId: string;
|
|
@@ -45,12 +17,37 @@ type SearchJumpTarget = {
|
|
|
45
17
|
pageNumber: number;
|
|
46
18
|
};
|
|
47
19
|
|
|
20
|
+
export interface SearchControlSearcherArgs {
|
|
21
|
+
query: string;
|
|
22
|
+
scope: SearchScope;
|
|
23
|
+
signal: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type SearchControlSearcher = (args: SearchControlSearcherArgs) => Promise<SearchReport>;
|
|
27
|
+
|
|
28
|
+
// Default searcher: hits the dev-only /__openpress/search endpoint.
|
|
29
|
+
// Public deploys override this via the `searcher` prop with a static
|
|
30
|
+
// in-browser searcher backed by /openpress/search-corpus.json.
|
|
31
|
+
async function liveSearcher({ query, scope, signal }: SearchControlSearcherArgs): Promise<SearchReport> {
|
|
32
|
+
const params = new URLSearchParams();
|
|
33
|
+
params.set("q", query);
|
|
34
|
+
params.set("scope", scope);
|
|
35
|
+
const response = await fetch(`/__openpress/search?${params.toString()}`, { cache: "no-store", signal });
|
|
36
|
+
const data = (await response.json().catch(() => null)) as (Partial<SearchReport> & { message?: string }) | null;
|
|
37
|
+
if (!response.ok || data?.ok === false || !isSearchReport(data)) {
|
|
38
|
+
throw new Error(data?.message ?? "搜尋失敗。");
|
|
39
|
+
}
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
|
|
48
43
|
export function SearchControl({
|
|
49
44
|
sourceBlocksByPath = {},
|
|
50
45
|
onSelectPage,
|
|
46
|
+
searcher = liveSearcher,
|
|
51
47
|
}: {
|
|
52
48
|
sourceBlocksByPath?: Record<string, SourceBlock[]>;
|
|
53
49
|
onSelectPage?: (pageIndex: number, options?: { behavior?: ScrollBehavior }) => void;
|
|
50
|
+
searcher?: SearchControlSearcher;
|
|
54
51
|
}) {
|
|
55
52
|
const titleId = useId();
|
|
56
53
|
const [open, setOpen] = useState(false);
|
|
@@ -98,26 +95,18 @@ export function SearchControl({
|
|
|
98
95
|
setError("");
|
|
99
96
|
|
|
100
97
|
try {
|
|
101
|
-
const
|
|
102
|
-
params.set("q", trimmedQuery);
|
|
103
|
-
params.set("scope", SEARCH_SCOPE);
|
|
104
|
-
const response = await fetch(`/__openpress/search?${params.toString()}`, {
|
|
105
|
-
cache: "no-store",
|
|
106
|
-
signal: controller.signal,
|
|
107
|
-
});
|
|
108
|
-
const data = await response.json().catch(() => null) as (Partial<SearchReport> & { message?: string }) | null;
|
|
98
|
+
const data = await searcher({ query: trimmedQuery, scope: SEARCH_SCOPE, signal: controller.signal });
|
|
109
99
|
if (controller.signal.aborted || requestId !== searchRequestIdRef.current) return;
|
|
110
|
-
if (!
|
|
111
|
-
throw new Error(data?.message ?? "搜尋失敗。");
|
|
112
|
-
}
|
|
100
|
+
if (!isSearchReport(data)) throw new Error("搜尋失敗。");
|
|
113
101
|
setReport(data);
|
|
114
102
|
setStatus("success");
|
|
115
103
|
} catch (searchError) {
|
|
116
104
|
if (controller.signal.aborted || requestId !== searchRequestIdRef.current) return;
|
|
105
|
+
if (searchError instanceof DOMException && searchError.name === "AbortError") return;
|
|
117
106
|
setError(searchError instanceof Error ? searchError.message : String(searchError));
|
|
118
107
|
setStatus("error");
|
|
119
108
|
}
|
|
120
|
-
}, []);
|
|
109
|
+
}, [searcher]);
|
|
121
110
|
|
|
122
111
|
useEffect(() => {
|
|
123
112
|
if (!open) return undefined;
|
|
@@ -6,6 +6,12 @@ import { parseDeployError, workbenchPdfButtonText, workbenchPdfStatusMessage } f
|
|
|
6
6
|
|
|
7
7
|
export interface UseDeploymentWorkbenchOptions {
|
|
8
8
|
deploymentInfo: DeploymentInfo;
|
|
9
|
+
// Active Press slug — when present the local PDF export endpoint
|
|
10
|
+
// tells the CLI to export this Press (open-press pdf . --press <slug>)
|
|
11
|
+
// instead of defaulting to the first Press. Empty / null means the
|
|
12
|
+
// workspace has only one Press, or the workbench is at the gallery
|
|
13
|
+
// root, and the CLI default is correct.
|
|
14
|
+
pressSlug?: string | null;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
export interface DeploymentWorkbench {
|
|
@@ -22,7 +28,7 @@ export interface DeploymentWorkbench {
|
|
|
22
28
|
handleOpenWorkbenchPdf: () => void;
|
|
23
29
|
}
|
|
24
30
|
|
|
25
|
-
export function useDeploymentWorkbench({ deploymentInfo }: UseDeploymentWorkbenchOptions): DeploymentWorkbench {
|
|
31
|
+
export function useDeploymentWorkbench({ deploymentInfo, pressSlug = null }: UseDeploymentWorkbenchOptions): DeploymentWorkbench {
|
|
26
32
|
const [status, setStatus] = useState<DeployStatus>("idle");
|
|
27
33
|
const [pdfActionStatus, setPdfActionStatus] = useState<PdfActionStatus>("idle");
|
|
28
34
|
const [currentDeploymentInfo, setCurrentDeploymentInfo] = useState(deploymentInfo);
|
|
@@ -96,7 +102,12 @@ export function useDeploymentWorkbench({ deploymentInfo }: UseDeploymentWorkbenc
|
|
|
96
102
|
if (pdfActionStatus === "generating") return;
|
|
97
103
|
setPdfActionStatus("generating");
|
|
98
104
|
try {
|
|
99
|
-
const
|
|
105
|
+
const requestBody = pressSlug ? { press: pressSlug } : {};
|
|
106
|
+
const response = await fetch("/__openpress/local-pdf-export", {
|
|
107
|
+
method: "POST",
|
|
108
|
+
headers: { "Content-Type": "application/json" },
|
|
109
|
+
body: JSON.stringify(requestBody),
|
|
110
|
+
});
|
|
100
111
|
if (!response.ok) {
|
|
101
112
|
const text = await response.text().catch(() => "");
|
|
102
113
|
throw new Error(text || `Local PDF export failed with status ${response.status}`);
|
|
@@ -109,7 +120,7 @@ export function useDeploymentWorkbench({ deploymentInfo }: UseDeploymentWorkbenc
|
|
|
109
120
|
console.error("OpenPress local PDF export failed", error);
|
|
110
121
|
setPdfActionStatus("failed");
|
|
111
122
|
}
|
|
112
|
-
}, [pdfActionStatus]);
|
|
123
|
+
}, [pdfActionStatus, pressSlug]);
|
|
113
124
|
|
|
114
125
|
const handleOpenWorkbenchPdf = useCallback(() => {
|
|
115
126
|
if (localDeployEnabled) {
|
|
@@ -8,7 +8,7 @@ import type { InspectorState, PendingComment } from "./inspectorModel";
|
|
|
8
8
|
import { getInlineSavedCommentForTarget, resolveInlineSavedComment } from "./inlineCommentModel";
|
|
9
9
|
|
|
10
10
|
export interface UseInspectorCommentsOptions {
|
|
11
|
-
|
|
11
|
+
workspaceMode: boolean;
|
|
12
12
|
inspector: InspectorState;
|
|
13
13
|
sourceBlockMap: Record<string, SourceBlock>;
|
|
14
14
|
sourceBlocksByPath: Record<string, SourceBlock[]>;
|
|
@@ -36,7 +36,7 @@ export interface InspectorComments {
|
|
|
36
36
|
}
|
|
37
37
|
|
|
38
38
|
export function useInspectorComments({
|
|
39
|
-
|
|
39
|
+
workspaceMode,
|
|
40
40
|
inspector,
|
|
41
41
|
sourceBlockMap,
|
|
42
42
|
sourceBlocksByPath,
|
|
@@ -76,7 +76,7 @@ export function useInspectorComments({
|
|
|
76
76
|
);
|
|
77
77
|
|
|
78
78
|
const refreshPendingComments = useCallback(async () => {
|
|
79
|
-
if (!
|
|
79
|
+
if (!workspaceMode) return;
|
|
80
80
|
setCommentsStatus("loading");
|
|
81
81
|
setCommentsError("");
|
|
82
82
|
try {
|
|
@@ -87,7 +87,7 @@ export function useInspectorComments({
|
|
|
87
87
|
setCommentsStatus("failed");
|
|
88
88
|
setCommentsError(error instanceof Error ? error.message : String(error));
|
|
89
89
|
}
|
|
90
|
-
}, [
|
|
90
|
+
}, [workspaceMode]);
|
|
91
91
|
|
|
92
92
|
const clearPendingComment = useCallback(async (id: string) => {
|
|
93
93
|
setCommentsStatus("clearing");
|
|
@@ -225,9 +225,9 @@ export function useInspectorComments({
|
|
|
225
225
|
|
|
226
226
|
// Initial + dev-mode refresh of pending comments.
|
|
227
227
|
useEffect(() => {
|
|
228
|
-
if (!
|
|
228
|
+
if (!workspaceMode) return;
|
|
229
229
|
void refreshPendingComments();
|
|
230
|
-
}, [
|
|
230
|
+
}, [workspaceMode, refreshPendingComments]);
|
|
231
231
|
|
|
232
232
|
return {
|
|
233
233
|
pendingComments,
|
|
@@ -6,6 +6,7 @@ type WorkbenchShellContextValue = {
|
|
|
6
6
|
rightPanelOpen: boolean;
|
|
7
7
|
onToggleLeftPanel: () => void;
|
|
8
8
|
onToggleRightPanel: () => void;
|
|
9
|
+
withRightPanel: boolean;
|
|
9
10
|
};
|
|
10
11
|
|
|
11
12
|
const WorkbenchShellContext = createContext<WorkbenchShellContextValue | null>(null);
|
|
@@ -18,7 +19,6 @@ function useWorkbenchShell() {
|
|
|
18
19
|
|
|
19
20
|
function WorkbenchShellRoot({
|
|
20
21
|
style,
|
|
21
|
-
devMode,
|
|
22
22
|
viewMode,
|
|
23
23
|
pressType = "pages",
|
|
24
24
|
presentationMode = false,
|
|
@@ -28,10 +28,11 @@ function WorkbenchShellRoot({
|
|
|
28
28
|
rightPanelOpen,
|
|
29
29
|
onToggleLeftPanel,
|
|
30
30
|
onToggleRightPanel,
|
|
31
|
+
withRightPanel = true,
|
|
32
|
+
publicViewer = false,
|
|
31
33
|
children,
|
|
32
34
|
}: {
|
|
33
35
|
style: CSSProperties;
|
|
34
|
-
devMode: boolean;
|
|
35
36
|
viewMode: string;
|
|
36
37
|
pressType?: string;
|
|
37
38
|
presentationMode?: boolean;
|
|
@@ -41,20 +42,42 @@ function WorkbenchShellRoot({
|
|
|
41
42
|
rightPanelOpen: boolean;
|
|
42
43
|
onToggleLeftPanel: () => void;
|
|
43
44
|
onToggleRightPanel: () => void;
|
|
45
|
+
// When false the toolbar omits the right-panel toggle button and the
|
|
46
|
+
// shell grid runs without a right column. Used by the public viewer
|
|
47
|
+
// where the right panel currently has no content (comments + project
|
|
48
|
+
// entry are workbench-only).
|
|
49
|
+
withRightPanel?: boolean;
|
|
50
|
+
// Marks the outer <main> with `data-openpress-public-viewer` so CSS
|
|
51
|
+
// and external integrations can target the public reading surface.
|
|
52
|
+
publicViewer?: boolean;
|
|
44
53
|
children: ReactNode;
|
|
45
54
|
}) {
|
|
46
|
-
const
|
|
47
|
-
const
|
|
55
|
+
const effectiveRightOpen = withRightPanel ? rightPanelOpen : false;
|
|
56
|
+
const scrimOpen = leftPanelOpen || effectiveRightOpen;
|
|
57
|
+
const handleScrimClick = effectiveRightOpen ? onToggleRightPanel : onToggleLeftPanel;
|
|
48
58
|
const shellClassName = [
|
|
49
59
|
"reader-app openpress-reader-app openpress-public-viewer openpress-dev-public-viewer openpress-workbench-shell is-ready",
|
|
50
60
|
leftPanelOpen ? "" : "is-closed-left",
|
|
51
|
-
|
|
61
|
+
effectiveRightOpen ? "" : "is-closed-right",
|
|
62
|
+
withRightPanel ? "" : "openpress-workbench-shell--no-right-panel",
|
|
52
63
|
presentationMode ? "is-presentation-mode" : "",
|
|
53
64
|
].filter(Boolean).join(" ");
|
|
54
65
|
|
|
55
66
|
return (
|
|
56
|
-
<WorkbenchShellContext.Provider
|
|
57
|
-
|
|
67
|
+
<WorkbenchShellContext.Provider
|
|
68
|
+
value={{
|
|
69
|
+
leftPanelOpen,
|
|
70
|
+
rightPanelOpen: effectiveRightOpen,
|
|
71
|
+
onToggleLeftPanel,
|
|
72
|
+
onToggleRightPanel,
|
|
73
|
+
withRightPanel,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
<main
|
|
77
|
+
className="openpress-workbench"
|
|
78
|
+
style={style}
|
|
79
|
+
data-openpress-public-viewer={publicViewer ? "true" : undefined}
|
|
80
|
+
>
|
|
58
81
|
<div
|
|
59
82
|
className={shellClassName}
|
|
60
83
|
data-openpress-react-runtime="true"
|
|
@@ -82,6 +105,7 @@ export function WorkbenchToolbar({ children }: { children: ReactNode }) {
|
|
|
82
105
|
rightPanelOpen,
|
|
83
106
|
onToggleLeftPanel,
|
|
84
107
|
onToggleRightPanel,
|
|
108
|
+
withRightPanel,
|
|
85
109
|
} = useWorkbenchShell();
|
|
86
110
|
const LeftIcon = leftPanelOpen ? PanelLeftClose : PanelLeftOpen;
|
|
87
111
|
const RightIcon = rightPanelOpen ? PanelRightClose : PanelRightOpen;
|
|
@@ -109,17 +133,19 @@ export function WorkbenchToolbar({ children }: { children: ReactNode }) {
|
|
|
109
133
|
<div className="openpress-workbench-toolbar__content">
|
|
110
134
|
{children}
|
|
111
135
|
</div>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
136
|
+
{withRightPanel ? (
|
|
137
|
+
<button
|
|
138
|
+
type="button"
|
|
139
|
+
className="openpress-workbench-toolbar-panel-toggle"
|
|
140
|
+
data-openpress-toggle-right-panel
|
|
141
|
+
data-openpress-panel-open={rightPanelOpen ? "true" : "false"}
|
|
142
|
+
aria-label={rightLabel}
|
|
143
|
+
title={rightLabel}
|
|
144
|
+
onClick={onToggleRightPanel}
|
|
145
|
+
>
|
|
146
|
+
<RightIcon aria-hidden="true" />
|
|
147
|
+
</button>
|
|
148
|
+
) : null}
|
|
123
149
|
</header>
|
|
124
150
|
);
|
|
125
151
|
}
|
package/vite.config.ts
CHANGED
|
@@ -233,13 +233,18 @@ async function handleLocalPdfExportRequest(req: IncomingMessage, res: ServerResp
|
|
|
233
233
|
return;
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
-
const
|
|
237
|
-
const
|
|
236
|
+
const body = await readJsonRequestBody(req);
|
|
237
|
+
const slug = normalizePressSlug(body?.press);
|
|
238
|
+
const result = await runLocalPdfExport(slug);
|
|
239
|
+
const pdfPath = pressPdfAbsolutePath(slug);
|
|
240
|
+
const exists = await fileExists(pdfPath);
|
|
241
|
+
const cliArgs = slug ? ["pdf", ".", "--press", slug] : ["pdf", "."];
|
|
242
|
+
const pdfUrl = `/__openpress/local-pdf-file?${slug ? `press=${encodeURIComponent(slug)}&` : ""}ts=${Date.now()}`;
|
|
238
243
|
writeJson(res, result.code === 0 && exists ? 200 : 500, {
|
|
239
244
|
ok: result.code === 0 && exists,
|
|
240
245
|
code: result.code,
|
|
241
|
-
pdf:
|
|
242
|
-
command: openpressCliCommand(
|
|
246
|
+
pdf: pdfUrl,
|
|
247
|
+
command: openpressCliCommand(cliArgs),
|
|
243
248
|
stdout: result.stdout,
|
|
244
249
|
stderr: result.stderr,
|
|
245
250
|
});
|
|
@@ -251,11 +256,15 @@ async function handleLocalPdfFileRequest(req: IncomingMessage, res: ServerRespon
|
|
|
251
256
|
return;
|
|
252
257
|
}
|
|
253
258
|
|
|
259
|
+
const requestUrl = new URL(req.url ?? "/", "http://localhost");
|
|
260
|
+
const slug = normalizePressSlug(requestUrl.searchParams.get("press"));
|
|
261
|
+
const pdfPath = pressPdfAbsolutePath(slug);
|
|
262
|
+
const filename = pressFilename(openpressConfig.pdf.filename, slug);
|
|
254
263
|
try {
|
|
255
|
-
const body = await fs.readFile(
|
|
264
|
+
const body = await fs.readFile(pdfPath);
|
|
256
265
|
res.writeHead(200, {
|
|
257
266
|
"Content-Type": "application/pdf",
|
|
258
|
-
"Content-Disposition": `inline; filename="${
|
|
267
|
+
"Content-Disposition": `inline; filename="${filename}"`,
|
|
259
268
|
"Cache-Control": "no-store",
|
|
260
269
|
});
|
|
261
270
|
res.end(body);
|
|
@@ -264,6 +273,37 @@ async function handleLocalPdfFileRequest(req: IncomingMessage, res: ServerRespon
|
|
|
264
273
|
}
|
|
265
274
|
}
|
|
266
275
|
|
|
276
|
+
function normalizePressSlug(value: unknown): string {
|
|
277
|
+
if (typeof value !== "string") return "";
|
|
278
|
+
return value.trim().replace(/^\/+|\/+$/g, "");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function pressFilename(baseFilename: string, slug: string): string {
|
|
282
|
+
if (!slug) return baseFilename;
|
|
283
|
+
const ext = path.extname(baseFilename);
|
|
284
|
+
const stem = ext ? baseFilename.slice(0, -ext.length) : baseFilename;
|
|
285
|
+
return `${stem}-${slug}${ext}`;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function pressPdfAbsolutePath(slug: string): string {
|
|
289
|
+
return path.join(openpressConfig.outputDir, pressFilename(openpressConfig.pdf.filename, slug));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function readJsonRequestBody(req: IncomingMessage): Promise<{ press?: unknown } | null> {
|
|
293
|
+
try {
|
|
294
|
+
const chunks: Buffer[] = [];
|
|
295
|
+
for await (const chunk of req) {
|
|
296
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer));
|
|
297
|
+
}
|
|
298
|
+
if (chunks.length === 0) return null;
|
|
299
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
300
|
+
if (!text.trim()) return null;
|
|
301
|
+
return JSON.parse(text);
|
|
302
|
+
} catch {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
267
307
|
async function handleLocalStatusRequest(req: IncomingMessage, res: ServerResponse) {
|
|
268
308
|
if (req.method !== "GET") {
|
|
269
309
|
writeJson(res, 405, { ok: false, message: "Status endpoint requires GET." });
|
|
@@ -355,9 +395,11 @@ async function handleLocalDeployRequest(req: IncomingMessage, res: ServerRespons
|
|
|
355
395
|
});
|
|
356
396
|
}
|
|
357
397
|
|
|
358
|
-
function runLocalPdfExport() {
|
|
398
|
+
function runLocalPdfExport(slug = "") {
|
|
399
|
+
const args = [openpressCliPath, "pdf", "."];
|
|
400
|
+
if (slug) args.push("--press", slug);
|
|
359
401
|
return new Promise<{ code: number; stdout: string; stderr: string }>((resolve) => {
|
|
360
|
-
const child = spawn("node",
|
|
402
|
+
const child = spawn("node", args, {
|
|
361
403
|
cwd: workspaceRoot,
|
|
362
404
|
shell: false,
|
|
363
405
|
});
|