konpeki 0.3.0 → 0.3.1
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/README.md +56 -39
- package/SETUP.md +35 -19
- package/docs/development.md +11 -3
- package/package.json +3 -1
- package/plugin.json +1 -1
- package/skills/konpeki/scripts/ensure-runtime.mjs +1 -1
- package/slides/README.md +35 -132
- package/slides/drawing-references.md +32 -0
- package/slides/github-cover/PROMPT.md +21 -0
- package/slides/github-cover/README.md +21 -0
- package/slides/github-cover/author.ts +67 -0
- package/slides/github-cover/composition.json +595 -0
- package/slides/introducing-konpeki/PROMPT.md +20 -30
- package/slides/introducing-konpeki/README.md +28 -60
- package/slides/introducing-konpeki/SOURCE.md +13 -20
- package/src/app/App.tsx +63 -38
- package/src/components/Canvas.tsx +8 -2
- package/src/components/InspectorPanel.tsx +11 -27
- package/src/components/RevisionNotes.tsx +6 -7
- package/src/components/RightPanel.tsx +42 -10
- package/src/components/VectorOverflowWarning.tsx +35 -8
- package/src/components/WorkspaceChrome.tsx +57 -29
- package/src/components/ui.tsx +95 -1
- package/src/lib/use-file-session.ts +60 -38
- package/src/styles/base.css +7 -0
- package/src/styles/chrome.css +103 -12
- package/src/styles/feedback.css +96 -61
- package/src/styles/left-panel.css +27 -6
- package/src/styles/right-panel.css +86 -3
- package/src/styles/shell.css +43 -11
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
} from "../../composition/types.ts";
|
|
6
6
|
import { componentInstanceLabel, type Draft } from "../lib/model.ts";
|
|
7
7
|
import { InspectorPanel } from "./InspectorPanel.tsx";
|
|
8
|
+
import { VectorOverflowWarning } from "./VectorOverflowWarning.tsx";
|
|
8
9
|
|
|
9
10
|
export type RightPanelView = "settings" | "layers";
|
|
10
11
|
|
|
@@ -20,6 +21,7 @@ function LayersPanel({
|
|
|
20
21
|
onReorderPaintOrder: (ids: string[]) => void;
|
|
21
22
|
}) {
|
|
22
23
|
const [dragging, setDragging] = useState<string>();
|
|
24
|
+
const [dropIndex, setDropIndex] = useState<number>();
|
|
23
25
|
const layers = [...slide.paintOrder].reverse();
|
|
24
26
|
function moveLayer(id: string, toIndex: number) {
|
|
25
27
|
const next = layers.filter((item) => item !== id);
|
|
@@ -33,7 +35,36 @@ function LayersPanel({
|
|
|
33
35
|
<span>Front to back</span>
|
|
34
36
|
</div>
|
|
35
37
|
{layers.length ? (
|
|
36
|
-
<div className="layer-list"
|
|
38
|
+
<div className="layer-list"
|
|
39
|
+
onDragOver={(event) => {
|
|
40
|
+
if (!dragging) return;
|
|
41
|
+
event.preventDefault();
|
|
42
|
+
const rows = [...event.currentTarget.querySelectorAll<HTMLElement>(".layer-row")];
|
|
43
|
+
const next = rows.findIndex((row) => {
|
|
44
|
+
const rect = row.getBoundingClientRect();
|
|
45
|
+
return event.clientY < rect.top + rect.height / 2;
|
|
46
|
+
});
|
|
47
|
+
const boundary = next < 0 ? layers.length : next;
|
|
48
|
+
const sourceIndex = layers.indexOf(dragging);
|
|
49
|
+
const unchanged = boundary === sourceIndex || boundary === sourceIndex + 1;
|
|
50
|
+
event.dataTransfer.dropEffect = unchanged ? "none" : "move";
|
|
51
|
+
setDropIndex(unchanged ? undefined : boundary);
|
|
52
|
+
}}
|
|
53
|
+
onDragLeave={(event) => {
|
|
54
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
55
|
+
setDropIndex(undefined);
|
|
56
|
+
}
|
|
57
|
+
}}
|
|
58
|
+
onDrop={(event) => {
|
|
59
|
+
if (!dragging) return;
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
if (dropIndex !== undefined) {
|
|
62
|
+
moveLayer(dragging, dropIndex > layers.indexOf(dragging) ? dropIndex - 1 : dropIndex);
|
|
63
|
+
}
|
|
64
|
+
setDragging(undefined);
|
|
65
|
+
setDropIndex(undefined);
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
37
68
|
{layers.map((id, index) => {
|
|
38
69
|
const component = slide.components.find((item) => item.id === id)!;
|
|
39
70
|
const groups = slide.groups
|
|
@@ -44,23 +75,18 @@ function LayersPanel({
|
|
|
44
75
|
<div
|
|
45
76
|
key={id}
|
|
46
77
|
data-layer={id}
|
|
78
|
+
data-drop-edge={dropIndex === index ? "before" : dropIndex === layers.length && index === layers.length - 1 ? "after" : undefined}
|
|
47
79
|
className={`layer-row ${selectedComponentId === id ? "selected" : ""} ${dragging === id ? "dragging" : ""}`}
|
|
48
80
|
draggable
|
|
49
81
|
onDragStart={(event) => {
|
|
50
82
|
setDragging(id);
|
|
83
|
+
setDropIndex(undefined);
|
|
51
84
|
event.dataTransfer.effectAllowed = "move";
|
|
52
85
|
event.dataTransfer.setData("text/plain", id);
|
|
53
86
|
}}
|
|
54
|
-
onDragEnd={() =>
|
|
55
|
-
onDragOver={(event) => {
|
|
56
|
-
event.preventDefault();
|
|
57
|
-
event.dataTransfer.dropEffect = "move";
|
|
58
|
-
}}
|
|
59
|
-
onDrop={(event) => {
|
|
60
|
-
event.preventDefault();
|
|
61
|
-
const source = dragging || event.dataTransfer.getData("text/plain");
|
|
62
|
-
if (source && source !== id) moveLayer(source, index);
|
|
87
|
+
onDragEnd={() => {
|
|
63
88
|
setDragging(undefined);
|
|
89
|
+
setDropIndex(undefined);
|
|
64
90
|
}}
|
|
65
91
|
>
|
|
66
92
|
<span className="layer-drag" aria-hidden="true">⠿</span>
|
|
@@ -112,6 +138,7 @@ export function RightPanel({
|
|
|
112
138
|
onSelectSlide,
|
|
113
139
|
onSelectComponent,
|
|
114
140
|
onSelectVectorElement,
|
|
141
|
+
onSelectOverflow,
|
|
115
142
|
onReorderPaintOrder,
|
|
116
143
|
onComponent,
|
|
117
144
|
onSlide,
|
|
@@ -132,6 +159,7 @@ export function RightPanel({
|
|
|
132
159
|
onSelectSlide: () => void;
|
|
133
160
|
onSelectComponent: (id: string) => void;
|
|
134
161
|
onSelectVectorElement: (id?: string) => void;
|
|
162
|
+
onSelectOverflow: (componentId: string, elementId?: string) => void;
|
|
135
163
|
onReorderPaintOrder: (ids: string[]) => void;
|
|
136
164
|
onComponent: (component: CompositionComponent, mergeKey?: string) => void;
|
|
137
165
|
onSlide: (slide: CompositionSlide, mergeKey?: string) => void;
|
|
@@ -154,6 +182,9 @@ export function RightPanel({
|
|
|
154
182
|
</button>
|
|
155
183
|
))}
|
|
156
184
|
</div>
|
|
185
|
+
<div className="overflow-control" inert={collapsed}>
|
|
186
|
+
<VectorOverflowWarning slide={slide} theme={draft.theme} onSelect={onSelectOverflow} />
|
|
187
|
+
</div>
|
|
157
188
|
<button
|
|
158
189
|
type="button"
|
|
159
190
|
className="panel-toggle"
|
|
@@ -174,6 +205,7 @@ export function RightPanel({
|
|
|
174
205
|
<div className="right-panel-body" inert={collapsed}>
|
|
175
206
|
{view === "settings" ? (
|
|
176
207
|
<InspectorPanel
|
|
208
|
+
key={component?.id ?? slide.id}
|
|
177
209
|
component={component}
|
|
178
210
|
componentLabel={componentLabel}
|
|
179
211
|
slide={slide}
|
|
@@ -1,22 +1,31 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
2
2
|
import type { CompositionSlide, CompositionDocument } from "../../composition/types.ts";
|
|
3
|
+
import { componentInstanceLabel } from "../lib/model.ts";
|
|
3
4
|
|
|
4
5
|
/** Measure rendered text, including nested SVG transforms, after fonts settle. */
|
|
5
|
-
export function VectorOverflowWarning({ slide, theme }: {
|
|
6
|
+
export function VectorOverflowWarning({ slide, theme, onSelect }: {
|
|
6
7
|
slide: CompositionSlide;
|
|
7
8
|
theme: CompositionDocument["theme"];
|
|
9
|
+
onSelect: (componentId: string, elementId?: string) => void;
|
|
8
10
|
}) {
|
|
9
|
-
const [
|
|
11
|
+
const [issues, setIssues] = useState<{ componentId: string; elementId?: string; text: string }[]>([]);
|
|
12
|
+
const id = useId();
|
|
13
|
+
const popover = useRef<HTMLDivElement>(null);
|
|
14
|
+
useEffect(() => { popover.current?.hidePopover(); }, [slide.id]);
|
|
10
15
|
useEffect(() => {
|
|
11
16
|
let cancelled = false;
|
|
12
17
|
let frame = 0;
|
|
13
18
|
function measure() {
|
|
14
19
|
frame = requestAnimationFrame(() => {
|
|
15
20
|
if (cancelled) return;
|
|
16
|
-
|
|
21
|
+
const overflow: typeof issues = [];
|
|
22
|
+
function add(text: Element) {
|
|
23
|
+
const componentId = text.closest<HTMLElement>("[data-component]")?.dataset.component;
|
|
24
|
+
if (componentId) overflow.push({ componentId, elementId: text.getAttribute("data-vector-element") ?? undefined, text: text.textContent?.trim().slice(0, 80) ?? "Text" });
|
|
25
|
+
}
|
|
17
26
|
document.querySelectorAll<HTMLElement>(".stage .text-block-content").forEach((text) => {
|
|
18
27
|
const surface = text.parentElement;
|
|
19
|
-
if (surface && (text.scrollHeight > surface.clientHeight + 1 || text.scrollWidth > surface.clientWidth + 1))
|
|
28
|
+
if (surface && (text.scrollHeight > surface.clientHeight + 1 || text.scrollWidth > surface.clientWidth + 1)) add(text);
|
|
20
29
|
});
|
|
21
30
|
document.querySelectorAll<SVGSVGElement>(".stage .custom-vector-art").forEach((svg) => {
|
|
22
31
|
const root = svg.getScreenCTM();
|
|
@@ -32,15 +41,33 @@ export function VectorOverflowWarning({ slide, theme }: {
|
|
|
32
41
|
if (corners.some(([x, y]) => {
|
|
33
42
|
const p = new DOMPoint(x, y).matrixTransform(transform);
|
|
34
43
|
return p.x < view.x - 1 || p.y < view.y - 1 || p.x > view.x + view.width + 1 || p.y > view.y + view.height + 1;
|
|
35
|
-
}))
|
|
44
|
+
})) add(text);
|
|
36
45
|
});
|
|
37
46
|
});
|
|
38
|
-
|
|
47
|
+
setIssues(overflow);
|
|
48
|
+
if (!overflow.length) popover.current?.hidePopover();
|
|
39
49
|
});
|
|
40
50
|
}
|
|
41
51
|
void document.fonts.ready.then(measure);
|
|
42
52
|
document.fonts.addEventListener("loadingdone", measure);
|
|
43
53
|
return () => { cancelled = true; cancelAnimationFrame(frame); document.fonts.removeEventListener("loadingdone", measure); };
|
|
44
54
|
}, [slide, theme]);
|
|
45
|
-
return
|
|
55
|
+
return <>
|
|
56
|
+
<span className="sr-only" role="status">{issues.length ? `${issues.length} text overflow ${issues.length === 1 ? "warning" : "warnings"} on this page` : ""}</span>
|
|
57
|
+
<button type="button" className="overflow-trigger" popoverTarget={id} disabled={!issues.length}
|
|
58
|
+
aria-label={`${issues.length} text overflow warnings`} title="Text overflow warnings">
|
|
59
|
+
<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3 18 17H2ZM10 8v4m0 2v.5" /></svg>
|
|
60
|
+
<span>{issues.length}</span>
|
|
61
|
+
</button>
|
|
62
|
+
<div id={id} ref={popover} popover="auto" className="overflow-popover">
|
|
63
|
+
<strong>Text outside its frame</strong>
|
|
64
|
+
<p>Select an item to revise its text or size. Nothing was resized automatically.</p>
|
|
65
|
+
<ul>{issues.map(issue => <li key={`${issue.componentId}:${issue.elementId ?? "native"}`}>
|
|
66
|
+
<button type="button" onClick={() => { popover.current?.hidePopover(); onSelect(issue.componentId, issue.elementId); }}>
|
|
67
|
+
<strong>{componentInstanceLabel(slide.components, issue.componentId)}{issue.elementId ? ` · ${issue.elementId}` : ""}</strong>
|
|
68
|
+
<span>{issue.text}</span>
|
|
69
|
+
</button>
|
|
70
|
+
</li>)}</ul>
|
|
71
|
+
</div>
|
|
72
|
+
</>;
|
|
46
73
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
|
2
2
|
import type { CompositionComponent } from "../../composition/types.ts";
|
|
3
3
|
import { componentLabels } from "../lib/model.ts";
|
|
4
4
|
import mark from "../assets/konpeki-mark.png";
|
|
@@ -103,13 +103,30 @@ export function WorkspaceChrome({
|
|
|
103
103
|
onComponentTool: (kind: CompositionComponent["kind"]) => void;
|
|
104
104
|
}) {
|
|
105
105
|
const browserMenu = useRef<HTMLDetailsElement>(null);
|
|
106
|
+
const [browserMenuOpen, setBrowserMenuOpen] = useState(false);
|
|
106
107
|
const importInput = useRef<HTMLInputElement>(null);
|
|
107
|
-
function closeBrowserMenu() {
|
|
108
|
-
|
|
108
|
+
function closeBrowserMenu(restoreFocus = true) {
|
|
109
|
+
const menu = browserMenu.current;
|
|
110
|
+
if (!menu?.open) return;
|
|
111
|
+
setBrowserMenuOpen(false);
|
|
112
|
+
if (restoreFocus) menu.querySelector("summary")?.focus();
|
|
109
113
|
}
|
|
110
114
|
useEffect(() => {
|
|
111
|
-
if (leftCollapsed) closeBrowserMenu();
|
|
115
|
+
if (leftCollapsed) closeBrowserMenu(false);
|
|
112
116
|
}, [leftCollapsed]);
|
|
117
|
+
useEffect(() => {
|
|
118
|
+
function dismissOutside(event: Event) {
|
|
119
|
+
if (event.target instanceof Node && !browserMenu.current?.contains(event.target)) {
|
|
120
|
+
closeBrowserMenu(false);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
document.addEventListener("pointerdown", dismissOutside);
|
|
124
|
+
document.addEventListener("focusin", dismissOutside);
|
|
125
|
+
return () => {
|
|
126
|
+
document.removeEventListener("pointerdown", dismissOutside);
|
|
127
|
+
document.removeEventListener("focusin", dismissOutside);
|
|
128
|
+
};
|
|
129
|
+
}, []);
|
|
113
130
|
return (
|
|
114
131
|
<>
|
|
115
132
|
<div className={`left-sidebar${leftCollapsed ? " collapsed" : ""}`}>
|
|
@@ -134,18 +151,16 @@ export function WorkspaceChrome({
|
|
|
134
151
|
Enter a title.
|
|
135
152
|
</span>
|
|
136
153
|
)}
|
|
137
|
-
{fileStatus &&
|
|
154
|
+
{fileStatus && (
|
|
138
155
|
<span
|
|
139
|
-
className={`file-status ${fileStatus}
|
|
156
|
+
className={`file-status ${fileStatus}`}
|
|
140
157
|
role="status"
|
|
141
158
|
aria-live="polite"
|
|
142
|
-
title={fileStatus === "
|
|
143
|
-
? "The file changed elsewhere while this browser has edits."
|
|
144
|
-
: undefined}
|
|
159
|
+
title={fileStatus === "saved" ? "Saved to file" : fileStatus === "saving" ? "Saving to file" : fileStatus === "loading" ? "Opening file" : "Changes are not saved — see the recovery notice"}
|
|
145
160
|
>
|
|
146
|
-
{fileStatus === "loading" ? "Opening"
|
|
161
|
+
<span className="sr-only">{fileStatus === "saved" ? "Saved" : fileStatus === "loading" ? "Opening"
|
|
147
162
|
: fileStatus === "saving" ? "Saving"
|
|
148
|
-
: fileStatus === "conflict" ? "Conflict" : "Save error"}
|
|
163
|
+
: fileStatus === "conflict" ? "Conflict" : "Save error"}</span>
|
|
149
164
|
</span>
|
|
150
165
|
)}
|
|
151
166
|
<button type="button" className="panel-toggle"
|
|
@@ -184,17 +199,31 @@ export function WorkspaceChrome({
|
|
|
184
199
|
</svg>
|
|
185
200
|
</button>
|
|
186
201
|
{browserTools && (
|
|
187
|
-
<details className="browser-menu" ref={browserMenu}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
202
|
+
<details className="browser-menu" ref={browserMenu} open={browserMenuOpen} onKeyDown={(event) => {
|
|
203
|
+
if (event.key === "Escape" && browserMenu.current?.open) {
|
|
204
|
+
event.preventDefault();
|
|
205
|
+
event.stopPropagation();
|
|
206
|
+
closeBrowserMenu();
|
|
207
|
+
}
|
|
208
|
+
}}>
|
|
209
|
+
<summary onClick={(event) => {
|
|
210
|
+
event.preventDefault();
|
|
211
|
+
setBrowserMenuOpen(open => !open);
|
|
212
|
+
}}>
|
|
213
|
+
Demo Mode
|
|
214
|
+
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="m4 10 4-4 4 4" /></svg>
|
|
215
|
+
</summary>
|
|
216
|
+
<div className="browser-menu-popover" role="region" aria-label="Demo Mode" inert={!browserMenuOpen}>
|
|
217
|
+
<div className="browser-menu-intro">
|
|
218
|
+
<strong>Demo Mode</strong>
|
|
219
|
+
<p>
|
|
220
|
+
Explore the canvas here. No agent connection or file sync.{" "}
|
|
221
|
+
<span role="status">{browserTools.saveMessage}</span>
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
195
224
|
<div className="browser-menu-actions">
|
|
196
225
|
<button type="button" onClick={() => importInput.current?.click()}>
|
|
197
|
-
Import
|
|
226
|
+
Import
|
|
198
227
|
</button>
|
|
199
228
|
<input
|
|
200
229
|
ref={importInput}
|
|
@@ -214,33 +243,32 @@ export function WorkspaceChrome({
|
|
|
214
243
|
closeBrowserMenu();
|
|
215
244
|
browserTools.onDownloadJSON();
|
|
216
245
|
}}>
|
|
217
|
-
|
|
246
|
+
Export
|
|
218
247
|
</button>
|
|
219
|
-
<button type="button" onClick={() => {
|
|
248
|
+
<button type="button" className="secondary" onClick={() => {
|
|
220
249
|
closeBrowserMenu();
|
|
221
250
|
browserTools.onStartBlank();
|
|
222
251
|
}}>
|
|
223
252
|
Start blank
|
|
224
253
|
</button>
|
|
225
|
-
<button type="button" onClick={() => {
|
|
254
|
+
<button type="button" className="secondary" onClick={() => {
|
|
226
255
|
closeBrowserMenu();
|
|
227
256
|
browserTools.onReset();
|
|
228
257
|
}}>
|
|
229
258
|
{browserTools.example ? "Reset example" : "Reset local draft"}
|
|
230
259
|
</button>
|
|
231
260
|
</div>
|
|
232
|
-
<
|
|
233
|
-
|
|
234
|
-
|
|
261
|
+
<div className="browser-agent-handoff">
|
|
262
|
+
<strong>Continue with an agent</strong>
|
|
263
|
+
<p>Export the JSON file, then ask your coding agent to open it with Konpeki.</p>
|
|
235
264
|
<a
|
|
236
265
|
href="https://github.com/vcfgdev/konpeki/blob/main/SETUP.md"
|
|
237
266
|
target="_blank"
|
|
238
267
|
rel="noreferrer"
|
|
239
268
|
>
|
|
240
|
-
|
|
269
|
+
Setup guide
|
|
241
270
|
</a>
|
|
242
|
-
|
|
243
|
-
</p>
|
|
271
|
+
</div>
|
|
244
272
|
</div>
|
|
245
273
|
</details>
|
|
246
274
|
)}
|
package/src/components/ui.tsx
CHANGED
|
@@ -1,4 +1,98 @@
|
|
|
1
|
-
import { useId, type ReactNode } from "react";
|
|
1
|
+
import { useEffect, useId, useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import { createPortal } from "react-dom";
|
|
3
|
+
|
|
4
|
+
/** Retain the last content while CSS transitions out; hidden notices are inert. */
|
|
5
|
+
export function FeedbackNotice({ kind, children, tone = "neutral", onDismiss, onPauseChange }: {
|
|
6
|
+
kind: "toast" | "recovery";
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
tone?: "neutral" | "error";
|
|
9
|
+
onDismiss?: () => void;
|
|
10
|
+
onPauseChange?: (paused: boolean) => void;
|
|
11
|
+
}) {
|
|
12
|
+
const visible = Boolean(children);
|
|
13
|
+
const [last, setLast] = useState({ children, tone });
|
|
14
|
+
if (visible && (children !== last.children || tone !== last.tone)) setLast({ children, tone });
|
|
15
|
+
return <div className={`feedback-notice ${kind}${visible ? " visible" : ""}${last.tone === "error" ? " error" : ""}`}
|
|
16
|
+
inert={!visible} aria-hidden={!visible} role={kind === "recovery" && visible ? "alert" : undefined}
|
|
17
|
+
onMouseEnter={() => onPauseChange?.(true)} onMouseLeave={() => onPauseChange?.(false)}
|
|
18
|
+
onFocus={() => onPauseChange?.(true)} onBlur={event => { if (!event.currentTarget.contains(event.relatedTarget)) onPauseChange?.(false); }}>
|
|
19
|
+
{kind === "toast" ? <span role={visible ? last.tone === "error" ? "alert" : "status" : undefined}
|
|
20
|
+
aria-live={visible ? last.tone === "error" ? "assertive" : "polite" : "off"}>{last.children}</span> : last.children}
|
|
21
|
+
{onDismiss && last.children && <button type="button" className="toast-dismiss" aria-label="Dismiss notification" onClick={onDismiss}>×</button>}
|
|
22
|
+
</div>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Keep incomplete input out of the document, without silently correcting it. */
|
|
26
|
+
export function NumberField({ label, name, value, min, max, step = 1, unit, onCommit, onEditEnd }: {
|
|
27
|
+
label: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
value: number;
|
|
30
|
+
min: number;
|
|
31
|
+
max: number;
|
|
32
|
+
step?: number;
|
|
33
|
+
unit?: string;
|
|
34
|
+
onCommit: (value: number) => void;
|
|
35
|
+
onEditEnd: () => void;
|
|
36
|
+
}) {
|
|
37
|
+
const [text, setText] = useState(String(value));
|
|
38
|
+
const [error, setError] = useState("");
|
|
39
|
+
const [showError, setShowError] = useState(false);
|
|
40
|
+
const [position, setPosition] = useState({ top: 0, left: 0 });
|
|
41
|
+
const input = useRef<HTMLInputElement>(null);
|
|
42
|
+
const id = useId();
|
|
43
|
+
useEffect(() => { setText(String(value)); setError(""); }, [value]);
|
|
44
|
+
useLayoutEffect(() => {
|
|
45
|
+
if (!error || !showError) return;
|
|
46
|
+
function place() {
|
|
47
|
+
const rect = input.current!.getBoundingClientRect();
|
|
48
|
+
const panel = input.current!.closest(".right-panel")!.getBoundingClientRect();
|
|
49
|
+
const beside = panel.left >= 276;
|
|
50
|
+
setPosition({ top: Math.max(8, Math.min(beside ? rect.top : rect.bottom + 6, window.innerHeight - 80)), left: beside ? panel.left - 268 : Math.max(8, Math.min(rect.left, window.innerWidth - 268)) });
|
|
51
|
+
if (rect.bottom < panel.top + 58 || rect.top > panel.bottom) setShowError(false);
|
|
52
|
+
}
|
|
53
|
+
function focus(event: FocusEvent) {
|
|
54
|
+
if (event.target !== input.current) setShowError(false);
|
|
55
|
+
}
|
|
56
|
+
place();
|
|
57
|
+
window.addEventListener("resize", place);
|
|
58
|
+
window.addEventListener("scroll", place, true);
|
|
59
|
+
document.addEventListener("focusin", focus);
|
|
60
|
+
return () => {
|
|
61
|
+
window.removeEventListener("resize", place);
|
|
62
|
+
window.removeEventListener("scroll", place, true);
|
|
63
|
+
document.removeEventListener("focusin", focus);
|
|
64
|
+
};
|
|
65
|
+
}, [error, showError]);
|
|
66
|
+
function commit() {
|
|
67
|
+
const number = Number(text);
|
|
68
|
+
if (!text.trim() || !Number.isFinite(number) || number < min || number > max) {
|
|
69
|
+
setError(`${label} must be ${min}–${max}${unit ? ` ${unit}` : ""}. Escape restores the previous value.`);
|
|
70
|
+
setShowError(true);
|
|
71
|
+
} else {
|
|
72
|
+
setError("");
|
|
73
|
+
if (number !== value) onCommit(number);
|
|
74
|
+
}
|
|
75
|
+
onEditEnd();
|
|
76
|
+
}
|
|
77
|
+
return <Field label={label}>
|
|
78
|
+
<span className={unit ? "parameter-input" : "number-input"}>
|
|
79
|
+
<input ref={input} name={name} type="number" autoComplete="off" min={min} max={max} step={step}
|
|
80
|
+
value={text} aria-invalid={error ? true : undefined} aria-describedby={error ? id : undefined}
|
|
81
|
+
onChange={event => setText(event.target.value)} onFocus={() => setShowError(true)} onBlur={commit}
|
|
82
|
+
onKeyDown={event => {
|
|
83
|
+
if ((event.metaKey || event.ctrlKey) && ["z", "y"].includes(event.key.toLowerCase()) && text !== String(value)) event.stopPropagation();
|
|
84
|
+
if (event.key === "Enter") { event.preventDefault(); commit(); }
|
|
85
|
+
if (event.key === "Escape") {
|
|
86
|
+
event.preventDefault(); event.stopPropagation();
|
|
87
|
+
setText(String(value)); setError(""); onEditEnd();
|
|
88
|
+
}
|
|
89
|
+
}} />
|
|
90
|
+
{unit && <span className="parameter-unit" aria-hidden="true">{unit}</span>}
|
|
91
|
+
</span>
|
|
92
|
+
{error && createPortal(<span id={id} role="alert" className={showError ? "parameter-error" : "sr-only"} style={showError ? position : undefined}>{error}</span>, document.body)}
|
|
93
|
+
</Field>;
|
|
94
|
+
}
|
|
95
|
+
|
|
2
96
|
export function Field({
|
|
3
97
|
label,
|
|
4
98
|
children,
|
|
@@ -35,6 +35,8 @@ export function useFileSession(
|
|
|
35
35
|
const reviewRef = useRef(review);
|
|
36
36
|
const buildPending = useRef(false);
|
|
37
37
|
const submittingBuild = useRef(false);
|
|
38
|
+
const loading = useRef(false);
|
|
39
|
+
const loadGeneration = useRef(0);
|
|
38
40
|
const callbacksRef = useRef(callbacks);
|
|
39
41
|
callbacksRef.current = callbacks;
|
|
40
42
|
const draftRef = useRef(draft);
|
|
@@ -49,29 +51,15 @@ export function useFileSession(
|
|
|
49
51
|
|
|
50
52
|
useEffect(() => {
|
|
51
53
|
if (!enabled || !token) return;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
revision: session.revision,
|
|
58
|
-
savedJSON: canonicalJSON(session.document),
|
|
59
|
-
};
|
|
60
|
-
setStatus("saved");
|
|
61
|
-
receiveReview(session.review);
|
|
62
|
-
callbacksRef.current.onOpen(session.document, session.name);
|
|
63
|
-
}).catch((error) => {
|
|
64
|
-
if (cancelled) return;
|
|
65
|
-
setStatus("error");
|
|
66
|
-
callbacksRef.current.onError(
|
|
67
|
-
error instanceof Error ? error.message : "Could not open the file-backed composition.",
|
|
68
|
-
);
|
|
69
|
-
});
|
|
70
|
-
return () => { cancelled = true; };
|
|
54
|
+
void reload();
|
|
55
|
+
return () => {
|
|
56
|
+
loadGeneration.current++;
|
|
57
|
+
loading.current = false;
|
|
58
|
+
};
|
|
71
59
|
}, [enabled, token]);
|
|
72
60
|
|
|
73
61
|
useEffect(() => {
|
|
74
|
-
if (!enabled || !state.current.ready || ["conflict", "error"].includes(status))
|
|
62
|
+
if (!enabled || !state.current.ready || ["loading", "conflict", "error"].includes(status))
|
|
75
63
|
return;
|
|
76
64
|
if (!validateComposition(draft).ok) return;
|
|
77
65
|
const timer = window.setTimeout(() => { void save(draft); }, 250);
|
|
@@ -82,19 +70,20 @@ export function useFileSession(
|
|
|
82
70
|
if (!enabled || !token) return;
|
|
83
71
|
let cancelled = false;
|
|
84
72
|
const timer = window.setInterval(() => {
|
|
85
|
-
if (!state.current.ready || state.current.drain || submittingBuild.current) return;
|
|
73
|
+
if (!state.current.ready || loading.current || state.current.drain || submittingBuild.current) return;
|
|
86
74
|
const revision = state.current.revision;
|
|
75
|
+
const generation = loadGeneration.current;
|
|
87
76
|
void loadFileSession(token).then((remote) => {
|
|
88
|
-
if (cancelled || state.current.drain || submittingBuild.current || revision !== state.current.revision) return;
|
|
77
|
+
if (cancelled || generation !== loadGeneration.current || loading.current || state.current.drain || submittingBuild.current || revision !== state.current.revision) return;
|
|
89
78
|
receiveReview(remote.review);
|
|
90
|
-
|
|
79
|
+
if (canonicalJSON(draftRef.current) === state.current.savedJSON) {
|
|
80
|
+
setStatus("saved");
|
|
81
|
+
callbacksRef.current.onError("");
|
|
82
|
+
}
|
|
91
83
|
if (remote.revision === revision) return;
|
|
92
84
|
if (canonicalJSON(draftRef.current) !== state.current.savedJSON) {
|
|
93
85
|
setStatus("conflict");
|
|
94
|
-
callbacksRef.current.
|
|
95
|
-
"The file changed elsewhere. Neither version was overwritten.",
|
|
96
|
-
"error",
|
|
97
|
-
);
|
|
86
|
+
callbacksRef.current.onError("The file changed elsewhere. Your browser edits are not saved; neither version was overwritten.");
|
|
98
87
|
return;
|
|
99
88
|
}
|
|
100
89
|
state.current = {
|
|
@@ -104,12 +93,10 @@ export function useFileSession(
|
|
|
104
93
|
};
|
|
105
94
|
setStatus("saved");
|
|
106
95
|
callbacksRef.current.onExternalChange(remote.document);
|
|
107
|
-
}).catch(() => {
|
|
108
|
-
if (cancelled || submittingBuild.current || revision !== state.current.revision) return;
|
|
96
|
+
}).catch((error) => {
|
|
97
|
+
if (cancelled || generation !== loadGeneration.current || loading.current || state.current.drain || submittingBuild.current || revision !== state.current.revision) return;
|
|
109
98
|
setStatus("error");
|
|
110
|
-
|
|
111
|
-
callbacksRef.current.onNotice("Could not load the agent result. Check the local file service.", "error");
|
|
112
|
-
}
|
|
99
|
+
callbacksRef.current.onError(`Could not load the latest file. ${error instanceof Error ? error.message : "Check the local file service."}`);
|
|
113
100
|
});
|
|
114
101
|
}, 1000);
|
|
115
102
|
return () => {
|
|
@@ -132,6 +119,7 @@ export function useFileSession(
|
|
|
132
119
|
}
|
|
133
120
|
|
|
134
121
|
async function noteAction(action: () => Promise<ReviewState>, saveFirst = false) {
|
|
122
|
+
if (loading.current || !state.current.ready) throw new Error("Wait for the file to open before submitting.");
|
|
135
123
|
if (submittingBuild.current) throw new Error("Wait for the current save before submitting.");
|
|
136
124
|
submittingBuild.current = true;
|
|
137
125
|
try {
|
|
@@ -141,7 +129,8 @@ export function useFileSession(
|
|
|
141
129
|
}
|
|
142
130
|
|
|
143
131
|
function save(next: Draft): Promise<string | undefined> {
|
|
144
|
-
if (!token || !state.current.ready || activeRequest(reviewRef.current.request)) return Promise.resolve(undefined);
|
|
132
|
+
if (!token || loading.current || !state.current.ready || activeRequest(reviewRef.current.request)) return Promise.resolve(undefined);
|
|
133
|
+
if (!validateComposition(next).ok) return Promise.resolve(undefined);
|
|
145
134
|
const serialized = canonicalJSON(next);
|
|
146
135
|
if (serialized === state.current.savedJSON && !state.current.drain)
|
|
147
136
|
return Promise.resolve(state.current.revision);
|
|
@@ -170,15 +159,15 @@ export function useFileSession(
|
|
|
170
159
|
state.current.revision = result.revision;
|
|
171
160
|
state.current.savedJSON = pending.serialized;
|
|
172
161
|
setStatus("saved");
|
|
162
|
+
callbacksRef.current.onError("");
|
|
173
163
|
} catch (error) {
|
|
174
164
|
state.current.pending = undefined;
|
|
175
165
|
const conflict = error instanceof FileSessionError && error.status === 409;
|
|
176
166
|
setStatus(conflict ? "conflict" : "error");
|
|
177
|
-
callbacksRef.current.
|
|
167
|
+
callbacksRef.current.onError(
|
|
178
168
|
conflict
|
|
179
|
-
? "The
|
|
180
|
-
: error instanceof Error ? error.message : "Could not save the composition file."
|
|
181
|
-
"error",
|
|
169
|
+
? "The file changed elsewhere. Your browser edits are not saved; neither version was overwritten."
|
|
170
|
+
: `Changes are not saved. ${error instanceof Error ? error.message : "Could not save the composition file."}`,
|
|
182
171
|
);
|
|
183
172
|
return;
|
|
184
173
|
}
|
|
@@ -191,7 +180,7 @@ export function useFileSession(
|
|
|
191
180
|
componentId?: string,
|
|
192
181
|
elementId?: string,
|
|
193
182
|
) {
|
|
194
|
-
if (!token || buildPending.current) return false;
|
|
183
|
+
if (!token || loading.current || !state.current.ready || buildPending.current) return false;
|
|
195
184
|
if (submittingBuild.current) throw new Error("Wait for your note to finish saving before building.");
|
|
196
185
|
buildPending.current = true;
|
|
197
186
|
submittingBuild.current = true;
|
|
@@ -218,8 +207,41 @@ export function useFileSession(
|
|
|
218
207
|
}
|
|
219
208
|
}
|
|
220
209
|
|
|
210
|
+
async function reload() {
|
|
211
|
+
if (!token || loading.current || state.current.drain || submittingBuild.current) return;
|
|
212
|
+
loading.current = true;
|
|
213
|
+
const generation = ++loadGeneration.current;
|
|
214
|
+
const requestedJSON = canonicalJSON(draftRef.current);
|
|
215
|
+
setStatus("loading");
|
|
216
|
+
try {
|
|
217
|
+
const session = await loadFileSession(token);
|
|
218
|
+
if (generation !== loadGeneration.current) return;
|
|
219
|
+
if (canonicalJSON(draftRef.current) !== requestedJSON) {
|
|
220
|
+
setStatus("conflict");
|
|
221
|
+
callbacksRef.current.onError("Your browser edits changed while opening the file. Neither version was overwritten. Download JSON before loading the file again.");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
state.current = { ready: true, revision: session.revision, savedJSON: canonicalJSON(session.document) };
|
|
225
|
+
draftRef.current = session.document;
|
|
226
|
+
receiveReview(session.review);
|
|
227
|
+
callbacksRef.current.onOpen(session.document, session.name);
|
|
228
|
+
callbacksRef.current.onError("");
|
|
229
|
+
setStatus("saved");
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (generation !== loadGeneration.current) return;
|
|
232
|
+
setStatus("error");
|
|
233
|
+
callbacksRef.current.onError(error instanceof Error ? error.message : "Could not open the file.");
|
|
234
|
+
} finally {
|
|
235
|
+
if (generation === loadGeneration.current) loading.current = false;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
221
239
|
return {
|
|
222
240
|
status, build, building, review,
|
|
241
|
+
opening: status === "loading",
|
|
242
|
+
ready: state.current.ready,
|
|
243
|
+
reload,
|
|
244
|
+
retry: () => state.current.ready && !activeRequest(reviewRef.current.request) ? save(draftRef.current) : reload(),
|
|
223
245
|
addNote: (target: ReviewTarget, text: string) => noteAction(() => addNote(token!, target, text), true),
|
|
224
246
|
removeNote: (id: string) => noteAction(() => removeNote(token!, id)),
|
|
225
247
|
cancel: () => noteAction(() => cancelRequest(token!, reviewRef.current.request!.id)),
|
package/src/styles/base.css
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
--ui-hover: #f2f2f2;
|
|
18
18
|
--ui-selected: #edf6fa;
|
|
19
19
|
--ui-shadow: 0 8px 28px #00000014, 0 1px 3px #00000012;
|
|
20
|
+
--ui-floating-shadow: 0 0 0 1px #0000000f, 0 2px 4px #00000006, 0 8px 24px #0000000a;
|
|
20
21
|
}
|
|
21
22
|
* {
|
|
22
23
|
box-sizing: border-box;
|
|
@@ -52,6 +53,12 @@ button:disabled {
|
|
|
52
53
|
cursor: default;
|
|
53
54
|
color: #767676;
|
|
54
55
|
}
|
|
56
|
+
.right-panel input[type="number"],
|
|
57
|
+
.revision-note-count,
|
|
58
|
+
.overflow-trigger,
|
|
59
|
+
.thumbnail-page-number {
|
|
60
|
+
font-variant-numeric: tabular-nums;
|
|
61
|
+
}
|
|
55
62
|
:focus-visible {
|
|
56
63
|
outline: 2px solid var(--accent);
|
|
57
64
|
outline-offset: 3px;
|