@olenbetong/synergi-react 2.3.9 → 2.5.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/es/components/FilePond.d.ts +1 -1
- package/es/components/FilePond.js +1 -1
- package/es/components/ImageEditor/index.d.ts +2 -2
- package/es/components/ImageEditor/index.js +1 -1
- package/es/components/MergeProgressDialog/index.d.ts +8 -0
- package/es/components/MergeProgressDialog/index.js +136 -0
- package/es/components/PdfViewer/PdfViewer.js +3 -27
- package/es/components/Toolbar/Toolbar.css +124 -0
- package/es/components/Toolbar/index.d.ts +15 -0
- package/es/components/Toolbar/index.js +64 -0
- package/es/index.d.ts +4 -2
- package/es/index.js +4 -2
- package/package.json +2 -2
- package/src/components/FilePond.tsx +1 -1
- package/src/components/ImageEditor/index.ts +2 -2
- package/src/components/MergeProgressDialog/index.tsx +249 -0
- package/src/components/PdfViewer/PdfViewer.tsx +3 -24
- package/src/components/Toolbar/Toolbar.css +124 -0
- package/src/components/Toolbar/index.tsx +132 -0
- package/src/index.ts +11 -2
|
@@ -4,7 +4,7 @@ import "filepond/dist/filepond.css";
|
|
|
4
4
|
import type { DataObject } from "@olenbetong/appframe-data";
|
|
5
5
|
import { type FilePondErrorDescription, type FilePondFile, type FilePondOptions, FileStatus, registerPlugin } from "filepond";
|
|
6
6
|
import { type RefObject } from "react";
|
|
7
|
-
export {
|
|
7
|
+
export { FileStatus, registerPlugin };
|
|
8
8
|
type FilePondProps<T extends Record<string, unknown>> = React.HTMLProps<HTMLInputElement> & {
|
|
9
9
|
dataObject: DataObject<T>;
|
|
10
10
|
fields: Partial<T>;
|
|
@@ -10,7 +10,7 @@ import FilePondPluginImagePreview from "filepond-plugin-image-preview";
|
|
|
10
10
|
import FilePondPluginImageResize from "filepond-plugin-image-resize";
|
|
11
11
|
import FilePondPluginImageTransform from "filepond-plugin-image-transform";
|
|
12
12
|
import { useEffect, useMemo, useRef } from "react";
|
|
13
|
-
export {
|
|
13
|
+
export { FileStatus, registerPlugin };
|
|
14
14
|
registerPlugin(FilePondPluginImageExifOrientation, FilePondPluginImageResize, FilePondPluginImagePreview, FilePondPluginImageTransform);
|
|
15
15
|
let isMostLikelyMobile = navigator.userAgent.match(/Android/i) ||
|
|
16
16
|
navigator.userAgent.match(/iPhone/i) ||
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { ImageEditor } from "./ImageEditor.js";
|
|
2
|
-
export type { ImageEditorRef } from "./ImageEditor.js";
|
|
3
1
|
export { defaultPlugins, defaultViewPlugin } from "./defaultPlugins.js";
|
|
2
|
+
export type { ImageEditorRef } from "./ImageEditor.js";
|
|
3
|
+
export { ImageEditor } from "./ImageEditor.js";
|
|
4
4
|
export { cropPlugin } from "./plugins/cropPlugin/index.js";
|
|
5
5
|
export { rotateCcwPlugin, rotateCwPlugin } from "./plugins/rotatePlugin.js";
|
|
6
6
|
export type { ActionPlugin, EditAction, Plugin, ToolPlugin, ViewPlugin } from "./types.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { ImageEditor } from "./ImageEditor.js";
|
|
2
1
|
export { defaultPlugins, defaultViewPlugin } from "./defaultPlugins.js";
|
|
2
|
+
export { ImageEditor } from "./ImageEditor.js";
|
|
3
3
|
export { cropPlugin } from "./plugins/cropPlugin/index.js";
|
|
4
4
|
export { rotateCcwPlugin, rotateCwPlugin } from "./plugins/rotatePlugin.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type MergeProgressDialogProps = {
|
|
2
|
+
open: boolean;
|
|
3
|
+
onClose: () => void;
|
|
4
|
+
/** Called once when the dialog opens; should return the merger ID for the start route */
|
|
5
|
+
getMergerId: () => Promise<string | number>;
|
|
6
|
+
mergeType?: "pdf" | "zip";
|
|
7
|
+
};
|
|
8
|
+
export declare function MergeProgressDialog({ open, onClose, getMergerId, mergeType }: MergeProgressDialogProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Button, DialogActions, LinearProgress, Link, Typography } from "@mui/material";
|
|
3
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
4
|
+
import { ResponsiveDialog } from "@olenbetong/appframe-mui";
|
|
5
|
+
import { useEffect, useRef, useState } from "react";
|
|
6
|
+
function formatRemaining(ms) {
|
|
7
|
+
if (ms < 60_000)
|
|
8
|
+
return `~${Math.ceil(ms / 1000)}s`;
|
|
9
|
+
let mins = Math.floor(ms / 60_000);
|
|
10
|
+
let secs = Math.ceil((ms % 60_000) / 1000);
|
|
11
|
+
return secs > 0 ? `~${mins}m ${secs}s` : `~${mins}m`;
|
|
12
|
+
}
|
|
13
|
+
export function MergeProgressDialog({ open, onClose, getMergerId, mergeType = "pdf" }) {
|
|
14
|
+
let [phase, setPhase] = useState({ kind: "preparing" });
|
|
15
|
+
let [now, setNow] = useState(Date.now);
|
|
16
|
+
let esRef = useRef(null);
|
|
17
|
+
let doneRef = useRef(false);
|
|
18
|
+
// Tick every second while processing to recompute the time estimate
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (phase.kind !== "processing")
|
|
21
|
+
return;
|
|
22
|
+
let timer = setInterval(() => setNow(Date.now()), 1000);
|
|
23
|
+
return () => clearInterval(timer);
|
|
24
|
+
}, [phase.kind]);
|
|
25
|
+
// Start the merge job each time the dialog opens
|
|
26
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only re-runs on open change
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!open)
|
|
29
|
+
return;
|
|
30
|
+
setPhase({ kind: "preparing" });
|
|
31
|
+
doneRef.current = false;
|
|
32
|
+
let cancelled = false;
|
|
33
|
+
let es = null;
|
|
34
|
+
async function start() {
|
|
35
|
+
try {
|
|
36
|
+
let mergerId = await getMergerId();
|
|
37
|
+
if (cancelled)
|
|
38
|
+
return;
|
|
39
|
+
let response = await fetch(`/api/ob/merger/start/${mergerId}/${mergeType}`, { method: "POST" });
|
|
40
|
+
if (!response.ok)
|
|
41
|
+
throw new Error(`${response.status} ${response.statusText}`);
|
|
42
|
+
let { jobId } = await response.json();
|
|
43
|
+
if (cancelled)
|
|
44
|
+
return;
|
|
45
|
+
es = new EventSource(`/api/ob/merger/events/${jobId}`);
|
|
46
|
+
esRef.current = es;
|
|
47
|
+
es.addEventListener("progress", (e) => {
|
|
48
|
+
let d = JSON.parse(e.data);
|
|
49
|
+
setPhase((prev) => ({
|
|
50
|
+
kind: "processing",
|
|
51
|
+
completed: d.completed,
|
|
52
|
+
total: d.total,
|
|
53
|
+
currentFile: d.currentFile ?? "",
|
|
54
|
+
startedAt: prev.kind === "processing" ? prev.startedAt : Date.now(),
|
|
55
|
+
}));
|
|
56
|
+
setNow(Date.now());
|
|
57
|
+
});
|
|
58
|
+
es.addEventListener("done", (e) => {
|
|
59
|
+
doneRef.current = true;
|
|
60
|
+
es?.close();
|
|
61
|
+
let d = JSON.parse(e.data);
|
|
62
|
+
setPhase({ kind: "completed", jobId, fileName: d.fileName ?? "" });
|
|
63
|
+
window.open(`/api/ob/merger/download/${jobId}`);
|
|
64
|
+
});
|
|
65
|
+
// Catches both server-sent "event: error" and connection errors.
|
|
66
|
+
// After a "done" event the server closes the stream, which also fires error —
|
|
67
|
+
// doneRef guards against treating that as a failure.
|
|
68
|
+
es.addEventListener("error", (e) => {
|
|
69
|
+
if (doneRef.current)
|
|
70
|
+
return;
|
|
71
|
+
es?.close();
|
|
72
|
+
let message = getLocalizedString("Unknown error");
|
|
73
|
+
let data = e.data;
|
|
74
|
+
if (data) {
|
|
75
|
+
try {
|
|
76
|
+
message = JSON.parse(data).message ?? message;
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
}
|
|
80
|
+
setPhase({ kind: "failed", error: message });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (!cancelled) {
|
|
85
|
+
setPhase({ kind: "failed", error: err?.message ?? String(err) });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
start();
|
|
90
|
+
return () => {
|
|
91
|
+
cancelled = true;
|
|
92
|
+
es?.close();
|
|
93
|
+
esRef.current = null;
|
|
94
|
+
};
|
|
95
|
+
}, [open]);
|
|
96
|
+
function handleClose() {
|
|
97
|
+
esRef.current?.close();
|
|
98
|
+
esRef.current = null;
|
|
99
|
+
onClose();
|
|
100
|
+
}
|
|
101
|
+
let pct = 0;
|
|
102
|
+
let remaining = null;
|
|
103
|
+
if (phase.kind === "processing" && phase.total > 0) {
|
|
104
|
+
pct = Math.round((phase.completed / phase.total) * 100);
|
|
105
|
+
if (phase.completed > 0) {
|
|
106
|
+
let elapsed = now - phase.startedAt;
|
|
107
|
+
let msPerFile = elapsed / phase.completed;
|
|
108
|
+
let remainingMs = msPerFile * (phase.total - phase.completed);
|
|
109
|
+
remaining = formatRemaining(remainingMs);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
let title = phase.kind === "completed"
|
|
113
|
+
? getLocalizedString("Merge complete")
|
|
114
|
+
: phase.kind === "failed"
|
|
115
|
+
? getLocalizedString("Merge failed")
|
|
116
|
+
: getLocalizedString("Merging drawings");
|
|
117
|
+
let canClose = phase.kind === "completed" || phase.kind === "failed";
|
|
118
|
+
return (_jsxs(ResponsiveDialog, { open: open, onClose: canClose ? handleClose : undefined, title: title, maxWidth: "sm", fullWidth: true, slotProps: {
|
|
119
|
+
transition: {
|
|
120
|
+
mountOnEnter: true,
|
|
121
|
+
unmountOnExit: true,
|
|
122
|
+
},
|
|
123
|
+
}, children: [_jsxs(Box, { sx: { px: 3, pb: 1 }, children: [(phase.kind === "preparing" || phase.kind === "processing") && (_jsx(LinearProgress, { variant: phase.kind === "preparing" || phase.total === 0 ? "indeterminate" : "determinate", value: pct, sx: { mb: 1.5 } })), phase.kind === "preparing" && (_jsx(Typography, { variant: "body2", sx: {
|
|
124
|
+
color: "text.secondary",
|
|
125
|
+
}, children: getLocalizedString("Starting merge job…") })), phase.kind === "processing" && (_jsxs(_Fragment, { children: [_jsxs(Typography, { variant: "body2", children: [phase.completed, " / ", phase.total, " ", getLocalizedString("files"), remaining && (_jsxs(Typography, { component: "span", variant: "body2", sx: {
|
|
126
|
+
color: "text.secondary",
|
|
127
|
+
}, children: [" ", "\u2014 ", remaining] }))] }), phase.currentFile && (_jsx(Typography, { variant: "body2", noWrap: true, title: phase.currentFile, sx: {
|
|
128
|
+
color: "text.secondary",
|
|
129
|
+
mt: 0.5,
|
|
130
|
+
}, children: phase.currentFile }))] })), phase.kind === "completed" && (_jsxs(_Fragment, { children: [_jsx(Typography, { variant: "body2", sx: {
|
|
131
|
+
color: "success.main",
|
|
132
|
+
}, children: getLocalizedString("Download started. You can close this dialog.") }), _jsxs(Typography, { variant: "body2", sx: {
|
|
133
|
+
color: "text.secondary",
|
|
134
|
+
mt: 1,
|
|
135
|
+
}, children: [getLocalizedString("If the file did not open automatically, "), " ", _jsx(Link, { href: `/api/ob/merger/download/${phase.jobId}`, target: "_blank", rel: "noopener noreferrer", children: getLocalizedString("click here to open it") }), "."] })] })), phase.kind === "failed" && (_jsx(Typography, { variant: "body2", color: "error", children: phase.error }))] }), _jsx(DialogActions, { children: _jsx(Button, { onClick: handleClose, disabled: !canClose, children: getLocalizedString("Close") }) })] }));
|
|
136
|
+
}
|
|
@@ -1,34 +1,10 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import rawPdfWorkerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?raw";
|
|
3
2
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
3
|
import { Document, Page, pdfjs } from "react-pdf";
|
|
5
4
|
import "./PdfViewer.css";
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
if (opts.workerPort && typeof opts.workerPort === "object" && typeof opts.workerPort.terminate === "function") {
|
|
10
|
-
try {
|
|
11
|
-
opts.workerPort.terminate();
|
|
12
|
-
}
|
|
13
|
-
catch { }
|
|
14
|
-
}
|
|
15
|
-
// Remove the property entirely to avoid type checks on undefined
|
|
16
|
-
try {
|
|
17
|
-
delete opts.workerPort;
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
opts.workerPort = null;
|
|
21
|
-
}
|
|
22
|
-
// Provide a dedicated Worker instance via blob; pdf.js prefers workerPort over workerSrc
|
|
23
|
-
try {
|
|
24
|
-
const blob = new Blob([rawPdfWorkerSrc], { type: "text/javascript" });
|
|
25
|
-
const worker = new Worker(URL.createObjectURL(blob), { type: "module" });
|
|
26
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
27
|
-
pdfjs.GlobalWorkerOptions.workerPort = worker;
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
// Ignore – pdf.js may fallback to fake worker in extreme cases
|
|
31
|
-
}
|
|
5
|
+
// Use new URL(..., import.meta.url) — understood by Rolldown/Rollup on all platforms,
|
|
6
|
+
// including Windows where ?raw query strings are invalid in file paths.
|
|
7
|
+
pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
|
32
8
|
export function PdfViewer({ url, file }) {
|
|
33
9
|
let [numPages, setNumPages] = useState(0);
|
|
34
10
|
let [error, setError] = useState(null);
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
.ob-breadcrumbs {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
overflow: hidden;
|
|
5
|
+
width: 100%;
|
|
6
|
+
height: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.ob-breadcrumbs__list {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
list-style: none;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
gap: var(--ds-space-100, 0.25rem);
|
|
16
|
+
flex-wrap: nowrap;
|
|
17
|
+
overflow: hidden;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.ob-breadcrumbs__item {
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
font-size: var(--ds-font-size-100, 0.875rem);
|
|
24
|
+
white-space: nowrap;
|
|
25
|
+
color: var(--ds-text-subtle, currentColor);
|
|
26
|
+
|
|
27
|
+
a {
|
|
28
|
+
color: var(--ds-link-default, var(--ds-text-subtle, currentColor));
|
|
29
|
+
text-decoration: none;
|
|
30
|
+
|
|
31
|
+
&:hover {
|
|
32
|
+
text-decoration: underline;
|
|
33
|
+
color: var(--ds-link-default-hovered, var(--ds-text, currentColor));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&:focus-visible {
|
|
37
|
+
outline: 2px solid var(--ds-border-focused, currentColor);
|
|
38
|
+
outline-offset: 2px;
|
|
39
|
+
border-radius: var(--ds-border-radius-100, 2px);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
&:not(:last-child)::after {
|
|
44
|
+
content: "/";
|
|
45
|
+
margin-inline-start: var(--ds-space-100, 0.25rem);
|
|
46
|
+
color: var(--ds-text-disabled, currentColor);
|
|
47
|
+
opacity: 0.5;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
span[aria-current="page"] {
|
|
51
|
+
color: var(--ds-text, currentColor);
|
|
52
|
+
font-weight: var(--ds-font-weight-semibold, 500);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ob-breadcrumbs__overflow {
|
|
57
|
+
position: relative;
|
|
58
|
+
|
|
59
|
+
button {
|
|
60
|
+
background: none;
|
|
61
|
+
border: 1px solid transparent;
|
|
62
|
+
border-radius: var(--ds-border-radius-100, 2px);
|
|
63
|
+
color: var(--ds-text-subtle, currentColor);
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
font-size: var(--ds-font-size-100, 0.875rem);
|
|
66
|
+
line-height: 1;
|
|
67
|
+
padding: var(--ds-space-050, 0.125rem) var(--ds-space-150, 0.375rem);
|
|
68
|
+
|
|
69
|
+
&:hover {
|
|
70
|
+
background: var(--ds-background-neutral-default, rgba(0, 0, 0, 0.06));
|
|
71
|
+
border-color: var(--ds-border-default, transparent);
|
|
72
|
+
color: var(--ds-text, currentColor);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
&:focus-visible {
|
|
76
|
+
outline: 2px solid var(--ds-border-focused, currentColor);
|
|
77
|
+
outline-offset: 2px;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
&::after {
|
|
82
|
+
content: "/";
|
|
83
|
+
margin-inline-start: var(--ds-space-100, 0.25rem);
|
|
84
|
+
color: var(--ds-text-disabled, currentColor);
|
|
85
|
+
opacity: 0.5;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.ob-breadcrumbs__overflow-menu {
|
|
90
|
+
background: var(--ds-surface-overlay, #fff);
|
|
91
|
+
border: 1px solid var(--ds-border-default, currentColor);
|
|
92
|
+
border-radius: var(--ds-border-radius-100, 4px);
|
|
93
|
+
box-shadow: var(--ds-shadow-overlay, 0 4px 8px rgba(0, 0, 0, 0.16));
|
|
94
|
+
left: 0;
|
|
95
|
+
list-style: none;
|
|
96
|
+
margin: var(--ds-space-050, 0.25rem) 0 0;
|
|
97
|
+
min-width: 10rem;
|
|
98
|
+
padding: var(--ds-space-100, 0.25rem) 0;
|
|
99
|
+
position: absolute;
|
|
100
|
+
top: 100%;
|
|
101
|
+
z-index: var(--ds-z-index-modal, 400);
|
|
102
|
+
|
|
103
|
+
li {
|
|
104
|
+
a,
|
|
105
|
+
span {
|
|
106
|
+
color: var(--ds-text, inherit);
|
|
107
|
+
display: block;
|
|
108
|
+
font-size: var(--ds-font-size-100, 0.875rem);
|
|
109
|
+
padding: var(--ds-space-100, 0.25rem) var(--ds-space-200, 0.5rem);
|
|
110
|
+
text-decoration: none;
|
|
111
|
+
white-space: nowrap;
|
|
112
|
+
|
|
113
|
+
&:hover {
|
|
114
|
+
background: var(--ds-background-neutral-default, rgba(0, 0, 0, 0.06));
|
|
115
|
+
color: var(--ds-text, inherit);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
a:focus-visible {
|
|
120
|
+
outline: 2px solid var(--ds-border-focused, currentColor);
|
|
121
|
+
outline-offset: -2px;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import "./Toolbar.css";
|
|
2
|
+
export type ToolbarPortalSlot = "breadcrumbs" | "search" | "actions";
|
|
3
|
+
export type ToolbarPortalProps = {
|
|
4
|
+
slot: ToolbarPortalSlot;
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
};
|
|
7
|
+
export declare function ToolbarPortal({ slot, children }: ToolbarPortalProps): import("react").ReactPortal | null;
|
|
8
|
+
export type BreadcrumbItem = {
|
|
9
|
+
label: string;
|
|
10
|
+
href?: string;
|
|
11
|
+
};
|
|
12
|
+
export type ToolbarBreadcrumbsProps = {
|
|
13
|
+
items: BreadcrumbItem[];
|
|
14
|
+
};
|
|
15
|
+
export declare function ToolbarBreadcrumbs({ items }: ToolbarBreadcrumbsProps): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import "./Toolbar.css";
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
export function ToolbarPortal({ slot, children }) {
|
|
6
|
+
// Target element IDs: ob-toolbar-breadcrumbs, ob-toolbar-search, ob-toolbar-actions
|
|
7
|
+
const target = document.getElementById(`ob-toolbar-${slot}`);
|
|
8
|
+
if (!target)
|
|
9
|
+
return null;
|
|
10
|
+
return createPortal(children, target);
|
|
11
|
+
}
|
|
12
|
+
function BreadcrumbsInner({ items }) {
|
|
13
|
+
let [firstVisible, setFirstVisible] = useState(0);
|
|
14
|
+
let [overflowOpen, setOverflowOpen] = useState(false);
|
|
15
|
+
let navRef = useRef(null);
|
|
16
|
+
let listRef = useRef(null);
|
|
17
|
+
let overflowItemRef = useRef(null);
|
|
18
|
+
// Detect overflow: increment firstVisible until the list fits within the nav.
|
|
19
|
+
// Runs after every render so it keeps pushing items into overflow until the
|
|
20
|
+
// list stops overflowing or only the last item (current page) remains.
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
let nav = navRef.current;
|
|
23
|
+
let list = listRef.current;
|
|
24
|
+
if (!nav || !list)
|
|
25
|
+
return;
|
|
26
|
+
if (list.scrollWidth > nav.clientWidth && firstVisible < items.length - 1) {
|
|
27
|
+
setFirstVisible((prev) => prev + 1);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
// ResizeObserver resets firstVisible to 0 on container resize so that the
|
|
31
|
+
// overflow calculation starts fresh at the new container width.
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
let nav = navRef.current;
|
|
34
|
+
if (!nav)
|
|
35
|
+
return;
|
|
36
|
+
let observer = new ResizeObserver(() => {
|
|
37
|
+
setFirstVisible(0);
|
|
38
|
+
});
|
|
39
|
+
observer.observe(nav);
|
|
40
|
+
return () => observer.disconnect();
|
|
41
|
+
}, []);
|
|
42
|
+
// Close the overflow dropdown when the user clicks outside of it.
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!overflowOpen)
|
|
45
|
+
return;
|
|
46
|
+
function handleClickOutside(event) {
|
|
47
|
+
if (overflowItemRef.current && !overflowItemRef.current.contains(event.target)) {
|
|
48
|
+
setOverflowOpen(false);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
52
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
53
|
+
}, [overflowOpen]);
|
|
54
|
+
let hiddenItems = items.slice(0, firstVisible);
|
|
55
|
+
let visibleItems = items.slice(firstVisible);
|
|
56
|
+
return (_jsx("nav", { className: "ob-breadcrumbs", "aria-label": "Breadcrumb", ref: navRef, children: _jsxs("ol", { className: "ob-breadcrumbs__list", ref: listRef, children: [firstVisible > 0 && (_jsxs("li", { className: "ob-breadcrumbs__item ob-breadcrumbs__overflow", ref: overflowItemRef, children: [_jsx("button", { type: "button", "aria-label": "Show hidden breadcrumbs", "aria-expanded": overflowOpen, "aria-haspopup": "true", onClick: () => setOverflowOpen((prev) => !prev), children: "\u2026" }), overflowOpen && (_jsx("ul", { className: "ob-breadcrumbs__overflow-menu", role: "menu", children: hiddenItems.map((item, index) => (_jsx("li", { role: "menuitem", children: item.href ? _jsx("a", { href: item.href, children: item.label }) : _jsx("span", { children: item.label }) }, index))) }))] })), visibleItems.map((item, index) => {
|
|
57
|
+
let actualIndex = firstVisible + index;
|
|
58
|
+
let isCurrent = actualIndex === items.length - 1;
|
|
59
|
+
return (_jsx("li", { className: "ob-breadcrumbs__item", children: isCurrent ? (_jsx("span", { "aria-current": "page", children: item.label })) : item.href ? (_jsx("a", { href: item.href, children: item.label })) : (_jsx("span", { children: item.label })) }, actualIndex));
|
|
60
|
+
})] }) }));
|
|
61
|
+
}
|
|
62
|
+
export function ToolbarBreadcrumbs({ items }) {
|
|
63
|
+
return (_jsx(ToolbarPortal, { slot: "breadcrumbs", children: _jsx(BreadcrumbsInner, { items: items }) }));
|
|
64
|
+
}
|
package/es/index.d.ts
CHANGED
|
@@ -2,14 +2,16 @@ export { Checkbox, type CheckboxProps } from "./components/Checkbox/index.js";
|
|
|
2
2
|
export { CardColumn, CardDetail, CardLabel, CardStep, CardStepList, ColorCard } from "./components/ColorCard/index.js";
|
|
3
3
|
export { DateNavigator } from "./components/DateNavigator/index.js";
|
|
4
4
|
export * from "./components/FilePond.js";
|
|
5
|
+
export * from "./components/ImageEditor/index.js";
|
|
5
6
|
export { Card, LinkedList, LinkedListItem } from "./components/LinkedCardList/index.js";
|
|
7
|
+
export { MergeProgressDialog, type MergeProgressDialogProps } from "./components/MergeProgressDialog/index.js";
|
|
6
8
|
export { BannerImage as PageBannerImage, type BannerImageProps, PageBanner, type PageBannerProps, Tab as PageBannerTab, TabList as PageBannerTabList, type TabListProps as PageBannerTabListProps, type TabProps as PageBannerTabProps, Title as PageBannerTitle, type TitleProps as PageBannerTitleProps, } from "./components/PageBanner/index.js";
|
|
9
|
+
export * from "./components/PdfViewer/index.js";
|
|
7
10
|
export { Portal } from "./components/Portal/index.js";
|
|
11
|
+
export { ToolbarBreadcrumbs, ToolbarPortal, type BreadcrumbItem, type ToolbarBreadcrumbsProps, type ToolbarPortalProps, type ToolbarPortalSlot, } from "./components/Toolbar/index.js";
|
|
8
12
|
export { ProgressBar } from "./components/ProgressBar/index.js";
|
|
9
13
|
export { Sidebar, type SidebarProps } from "./components/Sidebar/index.js";
|
|
10
14
|
export { Spinner, type SpinnerProps } from "./components/Spinner/index.js";
|
|
11
15
|
export { SplitContainer, type SplitContainerProps } from "./components/SplitContainer/index.js";
|
|
12
16
|
export { ValueToggleGroup, type ValueToggleGroupProps } from "./components/ValueToggleGroup/index.js";
|
|
13
17
|
export { useTranslation } from "./useTranslation/index.js";
|
|
14
|
-
export * from "./components/ImageEditor/index.js";
|
|
15
|
-
export * from "./components/PdfViewer/index.js";
|
package/es/index.js
CHANGED
|
@@ -2,14 +2,16 @@ export { Checkbox } from "./components/Checkbox/index.js";
|
|
|
2
2
|
export { CardColumn, CardDetail, CardLabel, CardStep, CardStepList, ColorCard } from "./components/ColorCard/index.js";
|
|
3
3
|
export { DateNavigator } from "./components/DateNavigator/index.js";
|
|
4
4
|
export * from "./components/FilePond.js";
|
|
5
|
+
export * from "./components/ImageEditor/index.js";
|
|
5
6
|
export { Card, LinkedList, LinkedListItem } from "./components/LinkedCardList/index.js";
|
|
7
|
+
export { MergeProgressDialog } from "./components/MergeProgressDialog/index.js";
|
|
6
8
|
export { BannerImage as PageBannerImage, PageBanner, Tab as PageBannerTab, TabList as PageBannerTabList, Title as PageBannerTitle, } from "./components/PageBanner/index.js";
|
|
9
|
+
export * from "./components/PdfViewer/index.js";
|
|
7
10
|
export { Portal } from "./components/Portal/index.js";
|
|
11
|
+
export { ToolbarBreadcrumbs, ToolbarPortal, } from "./components/Toolbar/index.js";
|
|
8
12
|
export { ProgressBar } from "./components/ProgressBar/index.js";
|
|
9
13
|
export { Sidebar } from "./components/Sidebar/index.js";
|
|
10
14
|
export { Spinner } from "./components/Spinner/index.js";
|
|
11
15
|
export { SplitContainer } from "./components/SplitContainer/index.js";
|
|
12
16
|
export { ValueToggleGroup } from "./components/ValueToggleGroup/index.js";
|
|
13
17
|
export { useTranslation } from "./useTranslation/index.js";
|
|
14
|
-
export * from "./components/ImageEditor/index.js";
|
|
15
|
-
export * from "./components/PdfViewer/index.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olenbetong/synergi-react",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"description": "Standalone React component for SynergiWeb and Partner Portal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"react-pdf": "^10.2.0",
|
|
44
44
|
"@olenbetong/appframe-core": "2.11.10",
|
|
45
45
|
"@olenbetong/appframe-react": "1.21.25",
|
|
46
|
-
"@olenbetong/appframe-mui": "6.1.
|
|
46
|
+
"@olenbetong/appframe-mui": "6.1.1"
|
|
47
47
|
},
|
|
48
48
|
"repository": {
|
|
49
49
|
"type": "git",
|
|
@@ -22,7 +22,7 @@ import FilePondPluginImageResize from "filepond-plugin-image-resize";
|
|
|
22
22
|
import FilePondPluginImageTransform from "filepond-plugin-image-transform";
|
|
23
23
|
import { type RefObject, useEffect, useMemo, useRef } from "react";
|
|
24
24
|
|
|
25
|
-
export {
|
|
25
|
+
export { FileStatus, registerPlugin };
|
|
26
26
|
|
|
27
27
|
registerPlugin(
|
|
28
28
|
FilePondPluginImageExifOrientation,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
export { ImageEditor } from "./ImageEditor.js";
|
|
2
|
-
export type { ImageEditorRef } from "./ImageEditor.js";
|
|
3
1
|
export { defaultPlugins, defaultViewPlugin } from "./defaultPlugins.js";
|
|
2
|
+
export type { ImageEditorRef } from "./ImageEditor.js";
|
|
3
|
+
export { ImageEditor } from "./ImageEditor.js";
|
|
4
4
|
export { cropPlugin } from "./plugins/cropPlugin/index.js";
|
|
5
5
|
export { rotateCcwPlugin, rotateCwPlugin } from "./plugins/rotatePlugin.js";
|
|
6
6
|
export type { ActionPlugin, EditAction, Plugin, ToolPlugin, ViewPlugin } from "./types.js";
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { Box, Button, DialogActions, LinearProgress, Link, Typography } from "@mui/material";
|
|
2
|
+
import { getLocalizedString } from "@olenbetong/appframe-core";
|
|
3
|
+
import { ResponsiveDialog } from "@olenbetong/appframe-mui";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
|
|
6
|
+
export type MergeProgressDialogProps = {
|
|
7
|
+
open: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
/** Called once when the dialog opens; should return the merger ID for the start route */
|
|
10
|
+
getMergerId: () => Promise<string | number>;
|
|
11
|
+
mergeType?: "pdf" | "zip";
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type MergePhase =
|
|
15
|
+
| { kind: "preparing" }
|
|
16
|
+
| { kind: "processing"; completed: number; total: number; currentFile: string; startedAt: number }
|
|
17
|
+
| { kind: "completed"; jobId: string; fileName: string }
|
|
18
|
+
| { kind: "failed"; error: string };
|
|
19
|
+
|
|
20
|
+
function formatRemaining(ms: number) {
|
|
21
|
+
if (ms < 60_000) return `~${Math.ceil(ms / 1000)}s`;
|
|
22
|
+
let mins = Math.floor(ms / 60_000);
|
|
23
|
+
let secs = Math.ceil((ms % 60_000) / 1000);
|
|
24
|
+
return secs > 0 ? `~${mins}m ${secs}s` : `~${mins}m`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function MergeProgressDialog({ open, onClose, getMergerId, mergeType = "pdf" }: MergeProgressDialogProps) {
|
|
28
|
+
let [phase, setPhase] = useState<MergePhase>({ kind: "preparing" });
|
|
29
|
+
let [now, setNow] = useState(Date.now);
|
|
30
|
+
let esRef = useRef<EventSource | null>(null);
|
|
31
|
+
let doneRef = useRef(false);
|
|
32
|
+
|
|
33
|
+
// Tick every second while processing to recompute the time estimate
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (phase.kind !== "processing") return;
|
|
36
|
+
let timer = setInterval(() => setNow(Date.now()), 1000);
|
|
37
|
+
return () => clearInterval(timer);
|
|
38
|
+
}, [phase.kind]);
|
|
39
|
+
|
|
40
|
+
// Start the merge job each time the dialog opens
|
|
41
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally only re-runs on open change
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!open) return;
|
|
44
|
+
|
|
45
|
+
setPhase({ kind: "preparing" });
|
|
46
|
+
doneRef.current = false;
|
|
47
|
+
|
|
48
|
+
let cancelled = false;
|
|
49
|
+
let es: EventSource | null = null;
|
|
50
|
+
|
|
51
|
+
async function start() {
|
|
52
|
+
try {
|
|
53
|
+
let mergerId = await getMergerId();
|
|
54
|
+
if (cancelled) return;
|
|
55
|
+
|
|
56
|
+
let response = await fetch(`/api/ob/merger/start/${mergerId}/${mergeType}`, { method: "POST" });
|
|
57
|
+
if (!response.ok) throw new Error(`${response.status} ${response.statusText}`);
|
|
58
|
+
let { jobId } = await response.json();
|
|
59
|
+
if (cancelled) return;
|
|
60
|
+
|
|
61
|
+
es = new EventSource(`/api/ob/merger/events/${jobId}`);
|
|
62
|
+
esRef.current = es;
|
|
63
|
+
|
|
64
|
+
es.addEventListener("progress", (e) => {
|
|
65
|
+
let d = JSON.parse((e as MessageEvent).data);
|
|
66
|
+
setPhase((prev) => ({
|
|
67
|
+
kind: "processing",
|
|
68
|
+
completed: d.completed,
|
|
69
|
+
total: d.total,
|
|
70
|
+
currentFile: d.currentFile ?? "",
|
|
71
|
+
startedAt: prev.kind === "processing" ? prev.startedAt : Date.now(),
|
|
72
|
+
}));
|
|
73
|
+
setNow(Date.now());
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
es.addEventListener("done", (e) => {
|
|
77
|
+
doneRef.current = true;
|
|
78
|
+
es?.close();
|
|
79
|
+
let d = JSON.parse((e as MessageEvent).data);
|
|
80
|
+
setPhase({ kind: "completed", jobId, fileName: d.fileName ?? "" });
|
|
81
|
+
window.open(`/api/ob/merger/download/${jobId}`);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Catches both server-sent "event: error" and connection errors.
|
|
85
|
+
// After a "done" event the server closes the stream, which also fires error —
|
|
86
|
+
// doneRef guards against treating that as a failure.
|
|
87
|
+
es.addEventListener("error", (e) => {
|
|
88
|
+
if (doneRef.current) return;
|
|
89
|
+
es?.close();
|
|
90
|
+
let message = getLocalizedString("Unknown error");
|
|
91
|
+
let data = (e as MessageEvent).data;
|
|
92
|
+
if (data) {
|
|
93
|
+
try {
|
|
94
|
+
message = JSON.parse(data).message ?? message;
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
setPhase({ kind: "failed", error: message });
|
|
98
|
+
});
|
|
99
|
+
} catch (err: unknown) {
|
|
100
|
+
if (!cancelled) {
|
|
101
|
+
setPhase({ kind: "failed", error: (err as Error)?.message ?? String(err) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
start();
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
cancelled = true;
|
|
110
|
+
es?.close();
|
|
111
|
+
esRef.current = null;
|
|
112
|
+
};
|
|
113
|
+
}, [open]);
|
|
114
|
+
|
|
115
|
+
function handleClose() {
|
|
116
|
+
esRef.current?.close();
|
|
117
|
+
esRef.current = null;
|
|
118
|
+
onClose();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let pct = 0;
|
|
122
|
+
let remaining: string | null = null;
|
|
123
|
+
|
|
124
|
+
if (phase.kind === "processing" && phase.total > 0) {
|
|
125
|
+
pct = Math.round((phase.completed / phase.total) * 100);
|
|
126
|
+
if (phase.completed > 0) {
|
|
127
|
+
let elapsed = now - phase.startedAt;
|
|
128
|
+
let msPerFile = elapsed / phase.completed;
|
|
129
|
+
let remainingMs = msPerFile * (phase.total - phase.completed);
|
|
130
|
+
remaining = formatRemaining(remainingMs);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let title =
|
|
135
|
+
phase.kind === "completed"
|
|
136
|
+
? getLocalizedString("Merge complete")
|
|
137
|
+
: phase.kind === "failed"
|
|
138
|
+
? getLocalizedString("Merge failed")
|
|
139
|
+
: getLocalizedString("Merging drawings");
|
|
140
|
+
|
|
141
|
+
let canClose = phase.kind === "completed" || phase.kind === "failed";
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<ResponsiveDialog
|
|
145
|
+
open={open}
|
|
146
|
+
onClose={canClose ? handleClose : undefined}
|
|
147
|
+
title={title}
|
|
148
|
+
maxWidth="sm"
|
|
149
|
+
fullWidth
|
|
150
|
+
slotProps={{
|
|
151
|
+
transition: {
|
|
152
|
+
mountOnEnter: true,
|
|
153
|
+
unmountOnExit: true,
|
|
154
|
+
},
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<Box sx={{ px: 3, pb: 1 }}>
|
|
158
|
+
{(phase.kind === "preparing" || phase.kind === "processing") && (
|
|
159
|
+
<LinearProgress
|
|
160
|
+
variant={phase.kind === "preparing" || phase.total === 0 ? "indeterminate" : "determinate"}
|
|
161
|
+
value={pct}
|
|
162
|
+
sx={{ mb: 1.5 }}
|
|
163
|
+
/>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{phase.kind === "preparing" && (
|
|
167
|
+
<Typography
|
|
168
|
+
variant="body2"
|
|
169
|
+
sx={{
|
|
170
|
+
color: "text.secondary",
|
|
171
|
+
}}
|
|
172
|
+
>
|
|
173
|
+
{getLocalizedString("Starting merge job…")}
|
|
174
|
+
</Typography>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
{phase.kind === "processing" && (
|
|
178
|
+
<>
|
|
179
|
+
<Typography variant="body2">
|
|
180
|
+
{phase.completed} / {phase.total} {getLocalizedString("files")}
|
|
181
|
+
{remaining && (
|
|
182
|
+
<Typography
|
|
183
|
+
component="span"
|
|
184
|
+
variant="body2"
|
|
185
|
+
sx={{
|
|
186
|
+
color: "text.secondary",
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{" "}
|
|
190
|
+
— {remaining}
|
|
191
|
+
</Typography>
|
|
192
|
+
)}
|
|
193
|
+
</Typography>
|
|
194
|
+
{phase.currentFile && (
|
|
195
|
+
<Typography
|
|
196
|
+
variant="body2"
|
|
197
|
+
noWrap
|
|
198
|
+
title={phase.currentFile}
|
|
199
|
+
sx={{
|
|
200
|
+
color: "text.secondary",
|
|
201
|
+
mt: 0.5,
|
|
202
|
+
}}
|
|
203
|
+
>
|
|
204
|
+
{phase.currentFile}
|
|
205
|
+
</Typography>
|
|
206
|
+
)}
|
|
207
|
+
</>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
{phase.kind === "completed" && (
|
|
211
|
+
<>
|
|
212
|
+
<Typography
|
|
213
|
+
variant="body2"
|
|
214
|
+
sx={{
|
|
215
|
+
color: "success.main",
|
|
216
|
+
}}
|
|
217
|
+
>
|
|
218
|
+
{getLocalizedString("Download started. You can close this dialog.")}
|
|
219
|
+
</Typography>
|
|
220
|
+
<Typography
|
|
221
|
+
variant="body2"
|
|
222
|
+
sx={{
|
|
223
|
+
color: "text.secondary",
|
|
224
|
+
mt: 1,
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
{getLocalizedString("If the file did not open automatically, ")}{" "}
|
|
228
|
+
<Link href={`/api/ob/merger/download/${phase.jobId}`} target="_blank" rel="noopener noreferrer">
|
|
229
|
+
{getLocalizedString("click here to open it")}
|
|
230
|
+
</Link>
|
|
231
|
+
.
|
|
232
|
+
</Typography>
|
|
233
|
+
</>
|
|
234
|
+
)}
|
|
235
|
+
|
|
236
|
+
{phase.kind === "failed" && (
|
|
237
|
+
<Typography variant="body2" color="error">
|
|
238
|
+
{phase.error}
|
|
239
|
+
</Typography>
|
|
240
|
+
)}
|
|
241
|
+
</Box>
|
|
242
|
+
<DialogActions>
|
|
243
|
+
<Button onClick={handleClose} disabled={!canClose}>
|
|
244
|
+
{getLocalizedString("Close")}
|
|
245
|
+
</Button>
|
|
246
|
+
</DialogActions>
|
|
247
|
+
</ResponsiveDialog>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
@@ -1,31 +1,10 @@
|
|
|
1
|
-
import rawPdfWorkerSrc from "pdfjs-dist/build/pdf.worker.min.mjs?raw";
|
|
2
1
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
2
|
import { Document, Page, pdfjs } from "react-pdf";
|
|
4
3
|
import "./PdfViewer.css";
|
|
5
4
|
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
if (opts.workerPort && typeof opts.workerPort === "object" && typeof opts.workerPort.terminate === "function") {
|
|
10
|
-
try {
|
|
11
|
-
opts.workerPort.terminate();
|
|
12
|
-
} catch {}
|
|
13
|
-
}
|
|
14
|
-
// Remove the property entirely to avoid type checks on undefined
|
|
15
|
-
try {
|
|
16
|
-
delete opts.workerPort;
|
|
17
|
-
} catch {
|
|
18
|
-
opts.workerPort = null;
|
|
19
|
-
}
|
|
20
|
-
// Provide a dedicated Worker instance via blob; pdf.js prefers workerPort over workerSrc
|
|
21
|
-
try {
|
|
22
|
-
const blob = new Blob([rawPdfWorkerSrc], { type: "text/javascript" });
|
|
23
|
-
const worker = new Worker(URL.createObjectURL(blob), { type: "module" });
|
|
24
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
25
|
-
(pdfjs.GlobalWorkerOptions as any).workerPort = worker;
|
|
26
|
-
} catch {
|
|
27
|
-
// Ignore – pdf.js may fallback to fake worker in extreme cases
|
|
28
|
-
}
|
|
5
|
+
// Use new URL(..., import.meta.url) — understood by Rolldown/Rollup on all platforms,
|
|
6
|
+
// including Windows where ?raw query strings are invalid in file paths.
|
|
7
|
+
pdfjs.GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString();
|
|
29
8
|
|
|
30
9
|
type PdfViewerProps = { url?: string; file?: string | Blob | ArrayBuffer };
|
|
31
10
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
.ob-breadcrumbs {
|
|
2
|
+
display: flex;
|
|
3
|
+
align-items: center;
|
|
4
|
+
overflow: hidden;
|
|
5
|
+
width: 100%;
|
|
6
|
+
height: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.ob-breadcrumbs__list {
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
list-style: none;
|
|
13
|
+
margin: 0;
|
|
14
|
+
padding: 0;
|
|
15
|
+
gap: var(--ds-space-100, 0.25rem);
|
|
16
|
+
flex-wrap: nowrap;
|
|
17
|
+
overflow: hidden;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.ob-breadcrumbs__item {
|
|
21
|
+
display: flex;
|
|
22
|
+
align-items: center;
|
|
23
|
+
font-size: var(--ds-font-size-100, 0.875rem);
|
|
24
|
+
white-space: nowrap;
|
|
25
|
+
color: var(--ds-text-subtle, currentColor);
|
|
26
|
+
|
|
27
|
+
a {
|
|
28
|
+
color: var(--ds-link-default, var(--ds-text-subtle, currentColor));
|
|
29
|
+
text-decoration: none;
|
|
30
|
+
|
|
31
|
+
&:hover {
|
|
32
|
+
text-decoration: underline;
|
|
33
|
+
color: var(--ds-link-default-hovered, var(--ds-text, currentColor));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
&:focus-visible {
|
|
37
|
+
outline: 2px solid var(--ds-border-focused, currentColor);
|
|
38
|
+
outline-offset: 2px;
|
|
39
|
+
border-radius: var(--ds-border-radius-100, 2px);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
&:not(:last-child)::after {
|
|
44
|
+
content: "/";
|
|
45
|
+
margin-inline-start: var(--ds-space-100, 0.25rem);
|
|
46
|
+
color: var(--ds-text-disabled, currentColor);
|
|
47
|
+
opacity: 0.5;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
span[aria-current="page"] {
|
|
51
|
+
color: var(--ds-text, currentColor);
|
|
52
|
+
font-weight: var(--ds-font-weight-semibold, 500);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.ob-breadcrumbs__overflow {
|
|
57
|
+
position: relative;
|
|
58
|
+
|
|
59
|
+
button {
|
|
60
|
+
background: none;
|
|
61
|
+
border: 1px solid transparent;
|
|
62
|
+
border-radius: var(--ds-border-radius-100, 2px);
|
|
63
|
+
color: var(--ds-text-subtle, currentColor);
|
|
64
|
+
cursor: pointer;
|
|
65
|
+
font-size: var(--ds-font-size-100, 0.875rem);
|
|
66
|
+
line-height: 1;
|
|
67
|
+
padding: var(--ds-space-050, 0.125rem) var(--ds-space-150, 0.375rem);
|
|
68
|
+
|
|
69
|
+
&:hover {
|
|
70
|
+
background: var(--ds-background-neutral-default, rgba(0, 0, 0, 0.06));
|
|
71
|
+
border-color: var(--ds-border-default, transparent);
|
|
72
|
+
color: var(--ds-text, currentColor);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
&:focus-visible {
|
|
76
|
+
outline: 2px solid var(--ds-border-focused, currentColor);
|
|
77
|
+
outline-offset: 2px;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
&::after {
|
|
82
|
+
content: "/";
|
|
83
|
+
margin-inline-start: var(--ds-space-100, 0.25rem);
|
|
84
|
+
color: var(--ds-text-disabled, currentColor);
|
|
85
|
+
opacity: 0.5;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.ob-breadcrumbs__overflow-menu {
|
|
90
|
+
background: var(--ds-surface-overlay, #fff);
|
|
91
|
+
border: 1px solid var(--ds-border-default, currentColor);
|
|
92
|
+
border-radius: var(--ds-border-radius-100, 4px);
|
|
93
|
+
box-shadow: var(--ds-shadow-overlay, 0 4px 8px rgba(0, 0, 0, 0.16));
|
|
94
|
+
left: 0;
|
|
95
|
+
list-style: none;
|
|
96
|
+
margin: var(--ds-space-050, 0.25rem) 0 0;
|
|
97
|
+
min-width: 10rem;
|
|
98
|
+
padding: var(--ds-space-100, 0.25rem) 0;
|
|
99
|
+
position: absolute;
|
|
100
|
+
top: 100%;
|
|
101
|
+
z-index: var(--ds-z-index-modal, 400);
|
|
102
|
+
|
|
103
|
+
li {
|
|
104
|
+
a,
|
|
105
|
+
span {
|
|
106
|
+
color: var(--ds-text, inherit);
|
|
107
|
+
display: block;
|
|
108
|
+
font-size: var(--ds-font-size-100, 0.875rem);
|
|
109
|
+
padding: var(--ds-space-100, 0.25rem) var(--ds-space-200, 0.5rem);
|
|
110
|
+
text-decoration: none;
|
|
111
|
+
white-space: nowrap;
|
|
112
|
+
|
|
113
|
+
&:hover {
|
|
114
|
+
background: var(--ds-background-neutral-default, rgba(0, 0, 0, 0.06));
|
|
115
|
+
color: var(--ds-text, inherit);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
a:focus-visible {
|
|
120
|
+
outline: 2px solid var(--ds-border-focused, currentColor);
|
|
121
|
+
outline-offset: -2px;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import "./Toolbar.css";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
|
|
6
|
+
export type ToolbarPortalSlot = "breadcrumbs" | "search" | "actions";
|
|
7
|
+
|
|
8
|
+
export type ToolbarPortalProps = {
|
|
9
|
+
slot: ToolbarPortalSlot;
|
|
10
|
+
children: React.ReactNode;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function ToolbarPortal({ slot, children }: ToolbarPortalProps) {
|
|
14
|
+
// Target element IDs: ob-toolbar-breadcrumbs, ob-toolbar-search, ob-toolbar-actions
|
|
15
|
+
const target = document.getElementById(`ob-toolbar-${slot}`);
|
|
16
|
+
if (!target) return null;
|
|
17
|
+
return createPortal(children, target);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type BreadcrumbItem = {
|
|
21
|
+
label: string;
|
|
22
|
+
href?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ToolbarBreadcrumbsProps = {
|
|
26
|
+
items: BreadcrumbItem[];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function BreadcrumbsInner({ items }: ToolbarBreadcrumbsProps) {
|
|
30
|
+
let [firstVisible, setFirstVisible] = useState(0);
|
|
31
|
+
let [overflowOpen, setOverflowOpen] = useState(false);
|
|
32
|
+
let navRef = useRef<HTMLElement>(null);
|
|
33
|
+
let listRef = useRef<HTMLOListElement>(null);
|
|
34
|
+
let overflowItemRef = useRef<HTMLLIElement>(null);
|
|
35
|
+
|
|
36
|
+
// Detect overflow: increment firstVisible until the list fits within the nav.
|
|
37
|
+
// Runs after every render so it keeps pushing items into overflow until the
|
|
38
|
+
// list stops overflowing or only the last item (current page) remains.
|
|
39
|
+
useLayoutEffect(() => {
|
|
40
|
+
let nav = navRef.current;
|
|
41
|
+
let list = listRef.current;
|
|
42
|
+
if (!nav || !list) return;
|
|
43
|
+
|
|
44
|
+
if (list.scrollWidth > nav.clientWidth && firstVisible < items.length - 1) {
|
|
45
|
+
setFirstVisible((prev) => prev + 1);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ResizeObserver resets firstVisible to 0 on container resize so that the
|
|
50
|
+
// overflow calculation starts fresh at the new container width.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let nav = navRef.current;
|
|
53
|
+
if (!nav) return;
|
|
54
|
+
|
|
55
|
+
let observer = new ResizeObserver(() => {
|
|
56
|
+
setFirstVisible(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
observer.observe(nav);
|
|
60
|
+
return () => observer.disconnect();
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
// Close the overflow dropdown when the user clicks outside of it.
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!overflowOpen) return;
|
|
66
|
+
|
|
67
|
+
function handleClickOutside(event: MouseEvent) {
|
|
68
|
+
if (overflowItemRef.current && !overflowItemRef.current.contains(event.target as Node)) {
|
|
69
|
+
setOverflowOpen(false);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
74
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
75
|
+
}, [overflowOpen]);
|
|
76
|
+
|
|
77
|
+
let hiddenItems = items.slice(0, firstVisible);
|
|
78
|
+
let visibleItems = items.slice(firstVisible);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<nav className="ob-breadcrumbs" aria-label="Breadcrumb" ref={navRef}>
|
|
82
|
+
<ol className="ob-breadcrumbs__list" ref={listRef}>
|
|
83
|
+
{firstVisible > 0 && (
|
|
84
|
+
<li className="ob-breadcrumbs__item ob-breadcrumbs__overflow" ref={overflowItemRef}>
|
|
85
|
+
<button
|
|
86
|
+
type="button"
|
|
87
|
+
aria-label="Show hidden breadcrumbs"
|
|
88
|
+
aria-expanded={overflowOpen}
|
|
89
|
+
aria-haspopup="true"
|
|
90
|
+
onClick={() => setOverflowOpen((prev) => !prev)}
|
|
91
|
+
>
|
|
92
|
+
…
|
|
93
|
+
</button>
|
|
94
|
+
{overflowOpen && (
|
|
95
|
+
<ul className="ob-breadcrumbs__overflow-menu" role="menu">
|
|
96
|
+
{hiddenItems.map((item, index) => (
|
|
97
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: stable list with no reordering
|
|
98
|
+
<li key={index} role="menuitem">
|
|
99
|
+
{item.href ? <a href={item.href}>{item.label}</a> : <span>{item.label}</span>}
|
|
100
|
+
</li>
|
|
101
|
+
))}
|
|
102
|
+
</ul>
|
|
103
|
+
)}
|
|
104
|
+
</li>
|
|
105
|
+
)}
|
|
106
|
+
{visibleItems.map((item, index) => {
|
|
107
|
+
let actualIndex = firstVisible + index;
|
|
108
|
+
let isCurrent = actualIndex === items.length - 1;
|
|
109
|
+
return (
|
|
110
|
+
<li key={actualIndex} className="ob-breadcrumbs__item">
|
|
111
|
+
{isCurrent ? (
|
|
112
|
+
<span aria-current="page">{item.label}</span>
|
|
113
|
+
) : item.href ? (
|
|
114
|
+
<a href={item.href}>{item.label}</a>
|
|
115
|
+
) : (
|
|
116
|
+
<span>{item.label}</span>
|
|
117
|
+
)}
|
|
118
|
+
</li>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
</ol>
|
|
122
|
+
</nav>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function ToolbarBreadcrumbs({ items }: ToolbarBreadcrumbsProps) {
|
|
127
|
+
return (
|
|
128
|
+
<ToolbarPortal slot="breadcrumbs">
|
|
129
|
+
<BreadcrumbsInner items={items} />
|
|
130
|
+
</ToolbarPortal>
|
|
131
|
+
);
|
|
132
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2,7 +2,9 @@ export { Checkbox, type CheckboxProps } from "./components/Checkbox/index.js";
|
|
|
2
2
|
export { CardColumn, CardDetail, CardLabel, CardStep, CardStepList, ColorCard } from "./components/ColorCard/index.js";
|
|
3
3
|
export { DateNavigator } from "./components/DateNavigator/index.js";
|
|
4
4
|
export * from "./components/FilePond.js";
|
|
5
|
+
export * from "./components/ImageEditor/index.js";
|
|
5
6
|
export { Card, LinkedList, LinkedListItem } from "./components/LinkedCardList/index.js";
|
|
7
|
+
export { MergeProgressDialog, type MergeProgressDialogProps } from "./components/MergeProgressDialog/index.js";
|
|
6
8
|
export {
|
|
7
9
|
BannerImage as PageBannerImage,
|
|
8
10
|
type BannerImageProps,
|
|
@@ -15,12 +17,19 @@ export {
|
|
|
15
17
|
Title as PageBannerTitle,
|
|
16
18
|
type TitleProps as PageBannerTitleProps,
|
|
17
19
|
} from "./components/PageBanner/index.js";
|
|
20
|
+
export * from "./components/PdfViewer/index.js";
|
|
18
21
|
export { Portal } from "./components/Portal/index.js";
|
|
22
|
+
export {
|
|
23
|
+
ToolbarBreadcrumbs,
|
|
24
|
+
ToolbarPortal,
|
|
25
|
+
type BreadcrumbItem,
|
|
26
|
+
type ToolbarBreadcrumbsProps,
|
|
27
|
+
type ToolbarPortalProps,
|
|
28
|
+
type ToolbarPortalSlot,
|
|
29
|
+
} from "./components/Toolbar/index.js";
|
|
19
30
|
export { ProgressBar } from "./components/ProgressBar/index.js";
|
|
20
31
|
export { Sidebar, type SidebarProps } from "./components/Sidebar/index.js";
|
|
21
32
|
export { Spinner, type SpinnerProps } from "./components/Spinner/index.js";
|
|
22
33
|
export { SplitContainer, type SplitContainerProps } from "./components/SplitContainer/index.js";
|
|
23
34
|
export { ValueToggleGroup, type ValueToggleGroupProps } from "./components/ValueToggleGroup/index.js";
|
|
24
35
|
export { useTranslation } from "./useTranslation/index.js";
|
|
25
|
-
export * from "./components/ImageEditor/index.js";
|
|
26
|
-
export * from "./components/PdfViewer/index.js";
|