konpeki 0.3.0 → 0.4.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/AGENTS.md +21 -28
- package/AUTHORING.md +68 -229
- package/CONTRIBUTING.md +10 -23
- package/README.md +96 -87
- package/SETUP.md +50 -114
- package/docs/development.md +43 -248
- package/docs/workflow.md +6 -108
- package/html/README.md +140 -0
- package/html/browser.ts +103 -0
- package/html/document.ts +38 -0
- package/html/floor.ts +34 -0
- package/html/index.html +5 -0
- package/html/inspect.ts +313 -0
- package/html/preview.css +62 -0
- package/html/preview.tsx +463 -0
- package/html/review-hints.ts +63 -0
- package/html/server.ts +99 -0
- package/html/source.ts +104 -0
- package/html/starter.ts +6 -0
- package/html/theme-authoring.md +176 -0
- package/html/theme.md +61 -0
- package/index.html +9 -9
- package/package.json +21 -35
- package/plugin.json +1 -1
- package/public/og.png +0 -0
- package/runtime/browser-B-29TH1a.mjs +595 -0
- package/runtime/floor-Cmk7G3pU.mjs +41 -0
- package/runtime/konpeki.mjs +90 -1410
- package/runtime/server-BAqC_5WD.mjs +2 -0
- package/runtime/server-DfRpfcY9.mjs +205 -0
- package/runtime/source-B_Ui9tMp.mjs +141 -0
- package/runtime/source-CZQq9GUO.mjs +2 -0
- package/skills/konpeki/SKILL.md +88 -172
- package/skills/konpeki/assets/blank.html +17 -0
- package/skills/konpeki/floor.md +76 -0
- package/skills/konpeki/references/cover.md +27 -0
- package/skills/konpeki/references/long-document.md +40 -0
- package/skills/konpeki/references/one-pager.md +27 -0
- package/skills/konpeki/references/patterns.md +118 -0
- package/skills/konpeki/references/resume.md +28 -0
- package/skills/konpeki/references/slides.md +28 -0
- package/skills/konpeki/scripts/ensure-runtime.mjs +10 -31
- package/skills/konpeki/scripts/prepare-document.mjs +25 -14
- package/src/components/PageBoard.tsx +156 -0
- package/src/lib/alignment.ts +21 -0
- package/src/lib/page-board.ts +25 -0
- package/src/lib/review-position.ts +19 -0
- package/src/styles/base.css +4 -6
- package/src/styles/feedback.css +97 -61
- package/src/styles/shell.css +95 -323
- package/theme-base.css +90 -0
- package/theme.css +56 -0
- package/vite.config.ts +2 -5
- package/composition/README.md +0 -156
- package/composition/compile.ts +0 -227
- package/composition/document.ts +0 -600
- package/composition/schema.json +0 -3001
- package/composition/schema.ts +0 -437
- package/composition/theme-tokens.ts +0 -16
- package/composition/types.ts +0 -269
- package/composition/validate.ts +0 -226
- package/composition/vector.ts +0 -143
- package/composition/visualizations.ts +0 -319
- package/design/README.md +0 -17
- package/design/palettes/README.md +0 -14
- package/design/palettes/base.ts +0 -14
- package/design/palettes/candidates.ts +0 -19
- package/design/palettes/index.ts +0 -78
- package/design/review/color-theme.md +0 -44
- package/design/review/layout.md +0 -16
- package/design/review/text.md +0 -18
- package/design/review/typography.md +0 -15
- package/design/review/visuals.md +0 -31
- package/design/semantic-patterns.md +0 -43
- package/design/themes/README.md +0 -40
- package/design/themes/index.ts +0 -24
- package/design/visual-languages/technical-product.md +0 -17
- package/design/visual-review.md +0 -88
- package/lib/assets.d.ts +0 -8
- package/lib/charts.ts +0 -18
- package/lib/contrast.ts +0 -16
- package/lib/layouts.ts +0 -50
- package/lib/slide.tsx +0 -42
- package/lib/taste.ts +0 -17
- package/lib/text.tsx +0 -89
- package/lib/typeface.ts +0 -44
- package/scripts/migrate-react-page.ts +0 -120
- package/skills/konpeki/assets/blank.json +0 -23
- package/slides/README.md +0 -153
- package/slides/architecture/PROMPT.md +0 -31
- package/slides/architecture/index.tsx +0 -102
- package/slides/article-brief/PROMPT.md +0 -35
- package/slides/article-brief/index.tsx +0 -71
- package/slides/bar-chart/PROMPT.md +0 -39
- package/slides/bar-chart/index.tsx +0 -97
- package/slides/comparison/PROMPT.md +0 -29
- package/slides/comparison/index.tsx +0 -95
- package/slides/decision-memo/PROMPT.md +0 -34
- package/slides/decision-memo/index.tsx +0 -85
- package/slides/delivery-plan/PROMPT.md +0 -45
- package/slides/delivery-plan/index.tsx +0 -105
- package/slides/experiment/PROMPT.md +0 -44
- package/slides/experiment/index.tsx +0 -127
- package/slides/incident-workflow/PROMPT.md +0 -57
- package/slides/incident-workflow/index.tsx +0 -78
- package/slides/introducing-konpeki/PROMPT.md +0 -40
- package/slides/introducing-konpeki/README.md +0 -76
- package/slides/introducing-konpeki/SOURCE.md +0 -26
- package/slides/introducing-konpeki/author.ts +0 -165
- package/slides/introducing-konpeki/composition.json +0 -3270
- package/slides/line-chart/PROMPT.md +0 -40
- package/slides/line-chart/index.tsx +0 -72
- package/slides/migration/PROMPT.md +0 -38
- package/slides/migration/index.tsx +0 -89
- package/slides/og-images/PROMPT.md +0 -21
- package/slides/og-images/index.tsx +0 -76
- package/slides/product-introduction/PROMPT.md +0 -24
- package/slides/product-introduction/index.tsx +0 -105
- package/slides/research-brief/PROMPT.md +0 -40
- package/slides/research-brief/index.tsx +0 -104
- package/slides/results-explanation/PROMPT.md +0 -32
- package/slides/results-explanation/index.tsx +0 -96
- package/slides/retrospective/PROMPT.md +0 -43
- package/slides/retrospective/index.tsx +0 -105
- package/slides/sankey/PROMPT.md +0 -11
- package/slides/sankey/index.tsx +0 -93
- package/slides/teaching/PROMPT.md +0 -45
- package/slides/teaching/index.tsx +0 -124
- package/slides/vertical-bar-charts/PROMPT.md +0 -13
- package/slides/vertical-bar-charts/index.tsx +0 -97
- package/src/app/App.tsx +0 -820
- package/src/components/BuildOrb.tsx +0 -40
- package/src/components/Canvas.tsx +0 -1185
- package/src/components/DiagramTypeIcon.tsx +0 -78
- package/src/components/InspectorPanel.tsx +0 -773
- package/src/components/LeftPanel.tsx +0 -120
- package/src/components/PageSizePicker.tsx +0 -30
- package/src/components/Presentation.tsx +0 -105
- package/src/components/RevisionNotes.tsx +0 -56
- package/src/components/RightPanel.tsx +0 -201
- package/src/components/VectorOverflowWarning.tsx +0 -46
- package/src/components/WorkspaceChrome.tsx +0 -288
- package/src/components/ui.tsx +0 -53
- package/src/lib/examples/react-page-migration.json +0 -1295
- package/src/lib/examples.ts +0 -42
- package/src/lib/export-png.ts +0 -104
- package/src/lib/file-session.ts +0 -87
- package/src/lib/history.ts +0 -53
- package/src/lib/model.ts +0 -188
- package/src/lib/page-size.ts +0 -24
- package/src/lib/presentation.ts +0 -17
- package/src/lib/review.ts +0 -26
- package/src/lib/storage.ts +0 -71
- package/src/lib/theme.ts +0 -25
- package/src/lib/use-file-session.ts +0 -227
- package/src/main.tsx +0 -29
- package/src/styles/canvas.css +0 -299
- package/src/styles/chrome.css +0 -384
- package/src/styles/component-previews.css +0 -386
- package/src/styles/left-panel.css +0 -166
- package/src/styles/presentation.css +0 -72
- package/src/styles/right-panel.css +0 -1215
package/html/preview.tsx
ADDED
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { PageBoard, type PageBoardControls } from "../src/components/PageBoard.tsx";
|
|
4
|
+
import { reviewPosition } from "../src/lib/review-position.ts";
|
|
5
|
+
import { referenceGuides, type Guide } from "../src/lib/alignment.ts";
|
|
6
|
+
import { inspectHTMLPage, type Diagnostic } from "./inspect.ts";
|
|
7
|
+
import { documentHTML } from "./document.ts";
|
|
8
|
+
import { starterSource } from "./starter.ts";
|
|
9
|
+
import { hintAlphabet, reviewHints, targetRect } from "./review-hints.ts";
|
|
10
|
+
import "@fontsource/ibm-plex-sans/400.css";
|
|
11
|
+
import "@fontsource/ibm-plex-sans/500.css";
|
|
12
|
+
import "../src/styles/base.css";
|
|
13
|
+
import "../src/styles/shell.css";
|
|
14
|
+
import "../src/styles/feedback.css";
|
|
15
|
+
import "./preview.css";
|
|
16
|
+
|
|
17
|
+
type Snapshot = { source: string; revision: string; name: string; path: string; key: string; pages: string[] };
|
|
18
|
+
type Target = { page: string; id: string };
|
|
19
|
+
type Note = Target & { key: string; text: string };
|
|
20
|
+
type Page = { id: string; name: string; canvas: { width: number; height: number } };
|
|
21
|
+
type Correction = Target & ({ kind: "move"; translate: string } | { kind: "delete" });
|
|
22
|
+
type Hints = { page: string; prefix: string; entries: ReturnType<typeof reviewHints>; scrollLeft: number; scrollTop: number };
|
|
23
|
+
const submitKey = /Mac|iPhone|iPad/.test(navigator.platform) ? "⌘ Enter" : "Ctrl + Enter";
|
|
24
|
+
const token = new URLSearchParams(location.search).get("session") ?? "";
|
|
25
|
+
async function request(edit?: unknown, revision?: string): Promise<Snapshot> {
|
|
26
|
+
if (!token) {
|
|
27
|
+
if (edit) throw new Error("Open a local CLI preview to save source corrections.");
|
|
28
|
+
const source = starterSource(), doc = new DOMParser().parseFromString(source, "text/html");
|
|
29
|
+
const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(source));
|
|
30
|
+
return { source, revision: Array.from(new Uint8Array(hash), n => n.toString(16).padStart(2, "0")).join(""), name: "blank.html", path: "skills/konpeki/assets/blank.html", key: "starter:default", pages: Array.from(doc.querySelectorAll("body > [data-page]"), page => page.id) };
|
|
31
|
+
}
|
|
32
|
+
const response = await fetch("/__konpeki/html", { method: edit ? "PATCH" : "GET", headers: { "x-konpeki-session": token, "Content-Type": "application/json" }, ...(edit ? { body: JSON.stringify({ edit, revision }) } : {}) });
|
|
33
|
+
const body = await response.json();
|
|
34
|
+
if (!response.ok) throw new Error(body.error);
|
|
35
|
+
return body;
|
|
36
|
+
}
|
|
37
|
+
function HTMLPage({ page, revision, source, notes, selected, commenting, locked, onSize, onSelect, onComment, onEdit, onBusy, onBlocked }: {
|
|
38
|
+
page: Page; revision: string; source?: string; notes: Note[]; selected?: Target; commenting: boolean; locked: boolean;
|
|
39
|
+
onSize: (width: number, height: number) => void; onSelect: (target?: Target) => void;
|
|
40
|
+
onComment: (target: Target, anchor: DOMRect) => void; onEdit: (edit: Correction) => void; onBusy: (busy: boolean) => void;
|
|
41
|
+
onBlocked: (message: string) => void;
|
|
42
|
+
}) {
|
|
43
|
+
const outer = useRef<HTMLDivElement>(null), frame = useRef<HTMLIFrameElement>(null);
|
|
44
|
+
const [scale, setScale] = useState(1), [ready, setReady] = useState(false), [tick, redraw] = useState(0);
|
|
45
|
+
const [hover, setHover] = useState<string>();
|
|
46
|
+
const [staticURL, setStaticURL] = useState<string>();
|
|
47
|
+
const [diagnostics, setDiagnostics] = useState<Diagnostic[]>([]), [guides, setGuides] = useState<Guide[]>([]);
|
|
48
|
+
const drag = useRef<{ target: HTMLElement; id: string; x: number; y: number; tx: number; ty: number; style: string; moved: boolean; frame: number; dx: number; dy: number; targets: { x: number[]; y: number[] } } | undefined>(undefined);
|
|
49
|
+
const url = source === undefined ? `/__konpeki/html/${encodeURIComponent(token)}/document/?page=${encodeURIComponent(page.id)}&revision=${revision}` : staticURL;
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (source === undefined) return;
|
|
52
|
+
const objectURL = URL.createObjectURL(new Blob([documentHTML(source, page.id)], { type: "text/html" }));
|
|
53
|
+
setStaticURL(objectURL);
|
|
54
|
+
return () => URL.revokeObjectURL(objectURL);
|
|
55
|
+
}, [source, page.id]);
|
|
56
|
+
useEffect(() => { setReady(false); }, [url]);
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const p = frame.current?.contentDocument?.getElementById(page.id);
|
|
59
|
+
if (ready && p) { setDiagnostics(inspectHTMLPage(p).diagnostics); redraw(n => n + 1); }
|
|
60
|
+
}, [ready, page.id, page.canvas.width, page.canvas.height]);
|
|
61
|
+
useLayoutEffect(() => {
|
|
62
|
+
const observer = new ResizeObserver(() => setScale(outer.current!.clientWidth / page.canvas.width));
|
|
63
|
+
observer.observe(outer.current!); return () => observer.disconnect();
|
|
64
|
+
}, [page.canvas.width]);
|
|
65
|
+
function element(id: string) { return frame.current?.contentDocument?.getElementById(id); }
|
|
66
|
+
function box(id: string) {
|
|
67
|
+
const el = element(id); if (!el) return undefined;
|
|
68
|
+
const rect = el.getBoundingClientRect();
|
|
69
|
+
return { left: rect.x * scale, top: rect.y * scale, width: rect.width * scale, height: rect.height * scale };
|
|
70
|
+
}
|
|
71
|
+
function anchor(id: string) {
|
|
72
|
+
const el = element(id)!, bounds = outer.current!.getBoundingClientRect(), r = el.getBoundingClientRect();
|
|
73
|
+
const zoom = bounds.width / page.canvas.width;
|
|
74
|
+
return new DOMRect(bounds.x + r.x * zoom, bounds.y + r.y * zoom, r.width * zoom, r.height * zoom);
|
|
75
|
+
}
|
|
76
|
+
function hit(event: React.MouseEvent) {
|
|
77
|
+
const rect = outer.current!.getBoundingClientRect(), zoom = rect.width / page.canvas.width;
|
|
78
|
+
return frame.current?.contentDocument?.elementFromPoint((event.clientX - rect.x) / zoom, (event.clientY - rect.y) / zoom)?.closest<HTMLElement>("[id]");
|
|
79
|
+
}
|
|
80
|
+
function stop(cancel = false) {
|
|
81
|
+
const d = drag.current; if (!d) return;
|
|
82
|
+
cancelAnimationFrame(d.frame); drag.current = undefined;
|
|
83
|
+
setGuides([]);
|
|
84
|
+
onBusy(false);
|
|
85
|
+
if (cancel) d.target.style.cssText = d.style;
|
|
86
|
+
else if (d.moved) {
|
|
87
|
+
const translate = `${d.tx + d.dx}px ${d.ty + d.dy}px`;
|
|
88
|
+
d.target.style.translate = translate;
|
|
89
|
+
onEdit({ kind: "move", page: page.id, id: d.id, translate });
|
|
90
|
+
}
|
|
91
|
+
const pageElement = element(page.id);
|
|
92
|
+
if (pageElement) setDiagnostics(inspectHTMLPage(pageElement).diagnostics);
|
|
93
|
+
redraw(n => n + 1);
|
|
94
|
+
}
|
|
95
|
+
useEffect(() => () => { if (drag.current) cancelAnimationFrame(drag.current.frame); }, []);
|
|
96
|
+
const outlined = commenting ? hover : selected?.page === page.id ? selected.id : undefined;
|
|
97
|
+
const bounds = ready && outlined ? box(outlined) : undefined;
|
|
98
|
+
void tick;
|
|
99
|
+
return <>
|
|
100
|
+
<header className="stage-meta"><span className="page-index">{page.name}</span><h2>{page.id}</h2>{diagnostics.length > 0 && <details className="html-diagnostics"><summary>{diagnostics.length} layout {diagnostics.length === 1 ? "issue" : "issues"}</summary><ul>{diagnostics.map(d => <li key={`${d.code}/${d.target}`}><strong>{d.target}</strong>: {d.message}</li>)}</ul></details>}</header>
|
|
101
|
+
<div className="html-page" ref={outer} tabIndex={0} data-html-page={page.id} aria-busy={!ready || locked} style={{ aspectRatio: `${page.canvas.width}/${page.canvas.height}` }}
|
|
102
|
+
onPointerDown={event => {
|
|
103
|
+
if (event.button !== 0) return;
|
|
104
|
+
// The iframe is a canvas surface, not a native text-selection target.
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
if (locked || !ready) return;
|
|
107
|
+
const target = hit(event); if (!target) return;
|
|
108
|
+
const value = { page: page.id, id: target.id };
|
|
109
|
+
if (commenting) { onComment(value, anchor(target.id)); return; }
|
|
110
|
+
if (target.id === page.id) { onSelect(undefined); outer.current!.blur(); return; }
|
|
111
|
+
onSelect(value); outer.current!.focus({ preventScroll: true });
|
|
112
|
+
if (source !== undefined) return;
|
|
113
|
+
const translated = frame.current!.contentWindow!.getComputedStyle(target).translate;
|
|
114
|
+
// Percentage/3D translations need a defined correction policy; don't silently replace them.
|
|
115
|
+
if (translated !== "none" && !/^-?[\d.]+px(?: -?[\d.]+px)?$/.test(translated)) { onBlocked("Move this element in source; its translation is not a simple pixel offset."); return; }
|
|
116
|
+
for (let ancestor = target.parentElement; ancestor; ancestor = ancestor.parentElement) {
|
|
117
|
+
const style = frame.current!.contentWindow!.getComputedStyle(ancestor);
|
|
118
|
+
if (ancestor.namespaceURI === "http://www.w3.org/2000/svg" || style.transform !== "none" || style.rotate !== "none" || style.scale !== "none" || Number(style.zoom) !== 1) {
|
|
119
|
+
onBlocked("Move this element in source; its parent changes the coordinate system."); return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const [tx, ty = 0] = translated === "none" ? [0, 0] : translated.split(" ").map(parseFloat);
|
|
123
|
+
const peers = Array.from(element(page.id)!.querySelectorAll("[id]")).filter(el => el !== target && !el.contains(target) && !target.contains(el) && el.getClientRects().length).map(el => el.getBoundingClientRect());
|
|
124
|
+
const targets = { x: [0, page.canvas.width / 2, page.canvas.width, ...peers.flatMap(r => [r.x, r.x + r.width / 2, r.right])], y: [0, page.canvas.height / 2, page.canvas.height, ...peers.flatMap(r => [r.y, r.y + r.height / 2, r.bottom])] };
|
|
125
|
+
drag.current = { target, id: target.id, x: event.clientX, y: event.clientY, tx, ty, style: target.style.cssText, moved: false, frame: 0, dx: 0, dy: 0, targets };
|
|
126
|
+
outer.current!.setPointerCapture(event.pointerId); onBusy(true);
|
|
127
|
+
}}
|
|
128
|
+
onPointerMove={event => {
|
|
129
|
+
const d = drag.current;
|
|
130
|
+
if (!d) { setHover(hit(event)?.id); return; }
|
|
131
|
+
const zoom = outer.current!.getBoundingClientRect().width / page.canvas.width;
|
|
132
|
+
d.dx = Math.round((event.clientX - d.x) / zoom); d.dy = Math.round((event.clientY - d.y) / zoom);
|
|
133
|
+
d.moved ||= Math.abs(d.dx) + Math.abs(d.dy) > 2;
|
|
134
|
+
if (d.moved && !d.frame) d.frame = requestAnimationFrame(() => { d.frame = 0; d.target.style.translate = `${d.tx + d.dx}px ${d.ty + d.dy}px`; setGuides(referenceGuides(d.target.getBoundingClientRect(), d.targets)); redraw(n => n + 1); });
|
|
135
|
+
}} onPointerUp={() => stop()} onPointerCancel={() => stop(true)} onLostPointerCapture={() => stop(true)} onPointerLeave={() => setHover(undefined)}
|
|
136
|
+
onDoubleClick={event => {
|
|
137
|
+
event.preventDefault();
|
|
138
|
+
if (locked || !ready) return;
|
|
139
|
+
const target = hit(event);
|
|
140
|
+
if (target && target.id !== page.id) onComment({ page: page.id, id: target.id }, anchor(target.id));
|
|
141
|
+
}}>
|
|
142
|
+
<iframe title={page.id} ref={frame} sandbox="allow-same-origin" src={url} style={{ width: page.canvas.width, height: page.canvas.height, transform: `scale(${scale})` }}
|
|
143
|
+
onLoad={async () => { const doc = frame.current?.contentDocument; if (!doc) return; await doc.fonts.ready; await Promise.all(Array.from(doc.images).map(image => image.decode().catch(() => {}))); const p = doc.getElementById(page.id); if (!p) return; const report = inspectHTMLPage(p); setDiagnostics(report.diagnostics); if (report.width > 0 && report.height > 0 && report.width <= 8192 && report.height <= 8192) onSize(report.width, report.height); setReady(true); redraw(n => n + 1); }} />
|
|
144
|
+
{bounds && <div className="html-outline" style={bounds} aria-hidden="true" />}
|
|
145
|
+
{guides.map(g => <div key={g.axis} className={`html-guide ${g.axis}`} style={g.axis === "x" ? { left: g.value * scale } : { top: g.value * scale }} aria-hidden="true" />)}
|
|
146
|
+
{ready && notes.filter(n => n.page === page.id).map(note => { const b = box(note.id); return b && <button key={note.key} className="html-pin" style={{ left: b.left + b.width - 10, top: b.top - 10 }} aria-label={`Comment on ${note.id}`} onPointerDown={e => e.stopPropagation()} onClick={() => onComment(note, anchor(note.id))}>{notes.indexOf(note) + 1}</button>; })}
|
|
147
|
+
</div>
|
|
148
|
+
</>;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function Preview() {
|
|
152
|
+
const [snapshot, setSnapshot] = useState<Snapshot>(), [sizes, setSizes] = useState<Record<string, { width: number; height: number }>>({});
|
|
153
|
+
const [selected, setSelected] = useState<Target>(), [view, setView] = useState<"closed" | "select" | "queue" | Target>("closed");
|
|
154
|
+
const [notes, setNotes] = useState<Note[]>([]), [drafts, setDrafts] = useState<Record<string, string>>({});
|
|
155
|
+
const [cleared, setCleared] = useState<Note[]>([]);
|
|
156
|
+
const [error, setError] = useState(""), [notice, setNotice] = useState(""), [saving, setSaving] = useState(false), [fallback, setFallback] = useState("");
|
|
157
|
+
const [reset, setReset] = useState(0);
|
|
158
|
+
const [anchor, setAnchor] = useState<DOMRect>();
|
|
159
|
+
const [vim, setVim] = useState(() => { try { return localStorage.getItem("html-review:vim") === "true"; } catch { return false; } });
|
|
160
|
+
const [help, setHelp] = useState(false), [hints, setHints] = useState<Hints>();
|
|
161
|
+
const [activePage, setActivePage] = useState(""), [prefix, setPrefix] = useState("");
|
|
162
|
+
const [queueIndex, setQueueIndex] = useState(0);
|
|
163
|
+
const board = useRef<PageBoardControls>(null), workspace = useRef<HTMLElement>(null), shortcuts = useRef<HTMLDialogElement>(null);
|
|
164
|
+
const composerOrigin = useRef<"closed" | "queue">("closed");
|
|
165
|
+
const current = useRef(snapshot), busy = useRef(false), loaded = useRef(false), panel = useRef<HTMLElement>(null);
|
|
166
|
+
const launcher = useRef<HTMLButtonElement>(null), restoreFocus = useRef(false);
|
|
167
|
+
const storedComments = useRef<string | null>(null);
|
|
168
|
+
current.current = snapshot;
|
|
169
|
+
useEffect(() => {
|
|
170
|
+
let live = true, fetching = false;
|
|
171
|
+
async function poll() {
|
|
172
|
+
if (fetching || busy.current) return;
|
|
173
|
+
fetching = true;
|
|
174
|
+
try {
|
|
175
|
+
const next = await request();
|
|
176
|
+
if (!live || busy.current) return;
|
|
177
|
+
if (!loaded.current) {
|
|
178
|
+
const saved = localStorage.getItem(`html-review:${next.key}`);
|
|
179
|
+
if (saved) { const value = JSON.parse(saved); if (!Array.isArray(value) || !value.every(n => typeof n.key === "string" && typeof n.page === "string" && typeof n.id === "string" && typeof n.text === "string")) throw new Error("Stored comments could not be read."); setNotes(value); }
|
|
180
|
+
storedComments.current = saved;
|
|
181
|
+
loaded.current = true;
|
|
182
|
+
}
|
|
183
|
+
if (next.revision !== current.current?.revision) setSnapshot(next);
|
|
184
|
+
} catch (e) { if (live) setError(String(e)); }
|
|
185
|
+
finally { fetching = false; }
|
|
186
|
+
}
|
|
187
|
+
void poll(); const timer = token ? setInterval(poll, 1200) : undefined;
|
|
188
|
+
return () => { live = false; clearInterval(timer); };
|
|
189
|
+
}, []);
|
|
190
|
+
useEffect(() => { if (!notice) return; const id = setTimeout(() => setNotice(""), 4500); return () => clearTimeout(id); }, [notice]);
|
|
191
|
+
useEffect(() => { if (!prefix) return; const id = setTimeout(() => setPrefix(""), 1200); return () => clearTimeout(id); }, [prefix]);
|
|
192
|
+
useEffect(() => { setHints(undefined); }, [snapshot?.revision, sizes]);
|
|
193
|
+
useEffect(() => {
|
|
194
|
+
function cancelHints() { setHints(undefined); setPrefix(""); }
|
|
195
|
+
const surface = workspace.current!;
|
|
196
|
+
function scrolled() {
|
|
197
|
+
// Navigation can deliver its scroll event after the next `f` key. Keep
|
|
198
|
+
// hints measured at that position; cancel only if their geometry moved.
|
|
199
|
+
setHints(h => h?.scrollLeft === surface.scrollLeft && h.scrollTop === surface.scrollTop ? h : undefined);
|
|
200
|
+
setPrefix("");
|
|
201
|
+
}
|
|
202
|
+
surface.addEventListener("scroll", scrolled);
|
|
203
|
+
surface.addEventListener("gesturestart", cancelHints);
|
|
204
|
+
window.addEventListener("resize", cancelHints);
|
|
205
|
+
window.addEventListener("blur", cancelHints);
|
|
206
|
+
return () => { surface.removeEventListener("scroll", scrolled); surface.removeEventListener("gesturestart", cancelHints); window.removeEventListener("resize", cancelHints); window.removeEventListener("blur", cancelHints); };
|
|
207
|
+
}, []);
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (help) shortcuts.current?.showModal();
|
|
210
|
+
else shortcuts.current?.close();
|
|
211
|
+
}, [help]);
|
|
212
|
+
async function save(edit: Correction | { kind: "undo" }) {
|
|
213
|
+
if (!token || !current.current || saving) return;
|
|
214
|
+
busy.current = true; setSaving(true); setError("");
|
|
215
|
+
try { setSnapshot(await request(edit, current.current.revision)); setNotice("Saved"); if (edit.kind === "delete") setSelected(undefined); }
|
|
216
|
+
catch (e) { setError(String(e)); setReset(n => n + 1); try { setSnapshot(await request()); } catch {} }
|
|
217
|
+
finally { busy.current = false; setSaving(false); }
|
|
218
|
+
}
|
|
219
|
+
function pageSurface(id: string) {
|
|
220
|
+
return workspace.current?.querySelector<HTMLElement>(`.html-page[data-html-page="${CSS.escape(id)}"]`);
|
|
221
|
+
}
|
|
222
|
+
function selectedPage() {
|
|
223
|
+
return selected ? pageSurface(selected.page) : null;
|
|
224
|
+
}
|
|
225
|
+
function closePanel() {
|
|
226
|
+
restoreFocus.current = true; setView("closed"); setHints(undefined); setPrefix("");
|
|
227
|
+
}
|
|
228
|
+
function dismissComposer() {
|
|
229
|
+
if (typeof view === "object" && composerOrigin.current === "queue") setView("queue");
|
|
230
|
+
else closePanel();
|
|
231
|
+
}
|
|
232
|
+
function openQueue() {
|
|
233
|
+
setHints(undefined); setView("queue"); setFallback(""); setQueueIndex(0);
|
|
234
|
+
}
|
|
235
|
+
function openComment(target: Target, rect: DOMRect) {
|
|
236
|
+
composerOrigin.current = vim && view === "queue" ? "queue" : "closed";
|
|
237
|
+
setHints(undefined); setActivePage(target.page);
|
|
238
|
+
setSelected(target.id === target.page ? undefined : target);
|
|
239
|
+
setView(target); setAnchor(rect); setFallback("");
|
|
240
|
+
}
|
|
241
|
+
function commentOn(target: Target, reveal = false) {
|
|
242
|
+
const surface = pageSurface(target.page);
|
|
243
|
+
if (!surface || !targetRect(surface, target.id)) { setError("This target is unavailable. Your comments were kept; wait for the page to load or select another target."); return; }
|
|
244
|
+
if (reveal) {
|
|
245
|
+
board.current?.goTo(target.page);
|
|
246
|
+
const rect = targetRect(surface, target.id)!;
|
|
247
|
+
board.current?.pan(rect.left + rect.width / 2 - workspace.current!.clientWidth / 2, rect.top + rect.height / 2 - workspace.current!.clientHeight / 2);
|
|
248
|
+
}
|
|
249
|
+
openComment(target, targetRect(surface, target.id)!);
|
|
250
|
+
}
|
|
251
|
+
function startHints() {
|
|
252
|
+
if (busy.current) return;
|
|
253
|
+
board.current?.stopZoom();
|
|
254
|
+
const surface = pageSurface(activePage);
|
|
255
|
+
if (!surface || surface.getAttribute("aria-busy") !== "false") return;
|
|
256
|
+
const entries = reviewHints(surface);
|
|
257
|
+
if (!entries.length) { setNotice("No visible targets. Pan to an element, or use Shift+C to comment on the page."); return; }
|
|
258
|
+
setView("closed"); setHints({ page: activePage, prefix: "", entries, scrollLeft: workspace.current!.scrollLeft, scrollTop: workspace.current!.scrollTop });
|
|
259
|
+
surface.focus({ preventScroll: true });
|
|
260
|
+
}
|
|
261
|
+
function startComment() {
|
|
262
|
+
const outline = selectedPage()?.querySelector(".html-outline");
|
|
263
|
+
if (selected && outline) openComment(selected, outline.getBoundingClientRect());
|
|
264
|
+
else if (vim) startHints();
|
|
265
|
+
else setView("select");
|
|
266
|
+
setFallback("");
|
|
267
|
+
}
|
|
268
|
+
useEffect(() => {
|
|
269
|
+
function key(event: KeyboardEvent) {
|
|
270
|
+
if (event.defaultPrevented || event.isComposing || event.keyCode === 229) return;
|
|
271
|
+
const element = event.target as HTMLElement;
|
|
272
|
+
const typing = element.isContentEditable || !!element.closest("textarea,input,select");
|
|
273
|
+
const plain = !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
274
|
+
const motion = plain && vim && view === "closed" && !hints && !help && !typing && ["h", "j", "k", "l", "+", "=", "-"].includes(event.key);
|
|
275
|
+
if (event.repeat && !motion) { if (!typing && event.key === "Enter") event.preventDefault(); return; }
|
|
276
|
+
if (help) {
|
|
277
|
+
if (event.key === "Escape") { event.preventDefault(); setHelp(false); }
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (event.key === "Escape") {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
if (hints) setHints(undefined);
|
|
283
|
+
else if (prefix) setPrefix("");
|
|
284
|
+
else if (view !== "closed") dismissComposer();
|
|
285
|
+
else {
|
|
286
|
+
setSelected(undefined);
|
|
287
|
+
if (document.activeElement?.matches(".html-page")) (document.activeElement as HTMLElement).blur();
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (saving || busy.current) return;
|
|
292
|
+
if (vim && view === "queue" && (event.ctrlKey || event.metaKey) && !event.altKey && event.key === "Enter") {
|
|
293
|
+
event.preventDefault(); if (notes.length) void copy(); return;
|
|
294
|
+
}
|
|
295
|
+
if (typing) return;
|
|
296
|
+
if (plain && event.key === "?") { event.preventDefault(); setHints(undefined); setPrefix(""); setHelp(true); return; }
|
|
297
|
+
if (hints) {
|
|
298
|
+
if (!plain) return;
|
|
299
|
+
event.preventDefault();
|
|
300
|
+
if (event.key === "Backspace") setHints({ ...hints, prefix: hints.prefix.slice(0, -1) });
|
|
301
|
+
else if (hintAlphabet.includes(event.key.toLowerCase()) && event.key.length === 1) {
|
|
302
|
+
const next = hints.prefix + event.key.toLowerCase();
|
|
303
|
+
const match = hints.entries.find(hint => hint.label === next);
|
|
304
|
+
if (match) commentOn({ page: hints.page, id: match.id });
|
|
305
|
+
else if (hints.entries.some(hint => hint.label.startsWith(next))) setHints({ ...hints, prefix: next });
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (vim && plain && (view === "closed" || view === "queue")) {
|
|
310
|
+
if (event.key === "q") { event.preventDefault(); if (view === "queue") closePanel(); else openQueue(); return; }
|
|
311
|
+
if (view === "queue") {
|
|
312
|
+
if (["j", "k"].includes(event.key)) {
|
|
313
|
+
event.preventDefault(); setQueueIndex(i => Math.max(0, Math.min(notes.length - 1, i + (event.key === "j" ? 1 : -1)))); return;
|
|
314
|
+
}
|
|
315
|
+
if (event.key === "Enter" && element.closest(".revision-note-target")) {
|
|
316
|
+
event.preventDefault(); if (notes[queueIndex]) commentOn(notes[queueIndex], true); return;
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
const first = prefix === "g" && event.key === "g";
|
|
320
|
+
setPrefix("");
|
|
321
|
+
if (event.key === "g" && !first) { event.preventDefault(); setPrefix("g"); return; }
|
|
322
|
+
if (["[", "]", "G"].includes(event.key) || first) {
|
|
323
|
+
event.preventDefault();
|
|
324
|
+
const ids = snapshot?.pages ?? [], index = ids.indexOf(activePage);
|
|
325
|
+
const next = first ? 0 : event.key === "G" ? ids.length - 1 : Math.max(0, Math.min(ids.length - 1, index + (event.key === "]" ? 1 : -1)));
|
|
326
|
+
if (ids[next]) { setSelected(undefined); board.current?.goTo(ids[next]); }
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (motion) {
|
|
330
|
+
event.preventDefault();
|
|
331
|
+
if (["+", "=", "-"].includes(event.key)) board.current?.zoom(event.key === "-" ? 1 / 1.2 : 1.2);
|
|
332
|
+
else board.current?.pan(event.key === "h" ? -80 : event.key === "l" ? 80 : 0, event.key === "k" ? -80 : event.key === "j" ? 80 : 0);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (event.key === "0") { event.preventDefault(); board.current?.goTo(activePage, true); return; }
|
|
336
|
+
if (event.key === "f") { event.preventDefault(); startHints(); return; }
|
|
337
|
+
if (event.key === "C") { event.preventDefault(); commentOn({ page: activePage, id: activePage }); return; }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (plain && (view === "closed" || view === "queue") && event.key.toLowerCase() === "c") {
|
|
341
|
+
event.preventDefault(); startComment(); return;
|
|
342
|
+
}
|
|
343
|
+
if (element.closest("button")) return;
|
|
344
|
+
if (view === "closed" && selected && event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
345
|
+
event.preventDefault(); startComment(); return;
|
|
346
|
+
}
|
|
347
|
+
if (!token) return;
|
|
348
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "z") { event.preventDefault(); void save({ kind: "undo" }); }
|
|
349
|
+
if (["Delete", "Backspace"].includes(event.key) && selected && view === "closed") { event.preventDefault(); void save({ ...selected, kind: "delete" }); }
|
|
350
|
+
}
|
|
351
|
+
window.addEventListener("keydown", key); return () => window.removeEventListener("keydown", key);
|
|
352
|
+
}, [selected, view, saving, vim, hints, help, activePage, prefix, queueIndex, notes, snapshot]);
|
|
353
|
+
useLayoutEffect(() => {
|
|
354
|
+
if (saving) return;
|
|
355
|
+
if (view === "closed" && restoreFocus.current) {
|
|
356
|
+
restoreFocus.current = false;
|
|
357
|
+
(selectedPage() ?? (vim ? pageSurface(activePage) : null) ?? launcher.current)?.focus({ preventScroll: true });
|
|
358
|
+
}
|
|
359
|
+
if (view === "queue") {
|
|
360
|
+
const actions = panel.current?.querySelector(".review-actions");
|
|
361
|
+
const rows = panel.current?.querySelectorAll<HTMLButtonElement>(".revision-note-target");
|
|
362
|
+
const row = vim ? rows?.[Math.min(queueIndex, (rows?.length ?? 1) - 1)] : undefined;
|
|
363
|
+
(panel.current?.querySelector("textarea") ?? row ?? actions?.querySelector<HTMLButtonElement>(".primary:not(:disabled)") ?? actions?.querySelector<HTMLButtonElement>("button:not(:disabled)"))?.focus({ preventScroll: true });
|
|
364
|
+
row?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (typeof view !== "object" || !panel.current || !anchor) return;
|
|
368
|
+
const position = reviewPosition(anchor, panel.current.getBoundingClientRect(), { x: 0, y: 0, width: innerWidth, height: innerHeight - 80 });
|
|
369
|
+
Object.assign(panel.current.style, { left: `${position.left}px`, top: `${position.top}px`, right: "auto", bottom: "auto" });
|
|
370
|
+
panel.current.querySelector("textarea")?.focus();
|
|
371
|
+
}, [view, anchor, notes.length, saving, fallback, queueIndex, vim]);
|
|
372
|
+
function persist(next: Note[]) {
|
|
373
|
+
const key = `html-review:${snapshot!.key}`;
|
|
374
|
+
if (localStorage.getItem(key) !== storedComments.current) throw new Error("Comments changed in another preview. Keep your draft and reload before changing the queue.");
|
|
375
|
+
const value = JSON.stringify(next);
|
|
376
|
+
localStorage.setItem(key, value); storedComments.current = value;
|
|
377
|
+
setNotes(next);
|
|
378
|
+
}
|
|
379
|
+
async function copy() {
|
|
380
|
+
if (saving || !notes.length) return;
|
|
381
|
+
const batch = notes;
|
|
382
|
+
const prompt = [`Revise the HTML document ${JSON.stringify(snapshot!.path)} using these comments.`, "Reread the latest HTML source first. Preserve unrelated edits, stable element IDs and saved CSS position corrections. If a target is missing or ambiguous, ask rather than guessing. Render the HTML in a browser and inspect every affected page before delivery.", ...batch.map((n, i) => `${i + 1}. Page: ${JSON.stringify(n.page)}\nElement: ${JSON.stringify(n.id)}\nComment:\n${n.text}`)].join("\n\n");
|
|
383
|
+
setSaving(true); setError("");
|
|
384
|
+
try { await navigator.clipboard.writeText(prompt); persist(notes.filter(n => !batch.includes(n))); setCleared(batch); closePanel(); setNotice("Copied and cleared"); }
|
|
385
|
+
catch (e) { setError(`Could not copy and clear. Comments were kept. ${e}`); setFallback(prompt); }
|
|
386
|
+
finally { setSaving(false); }
|
|
387
|
+
}
|
|
388
|
+
function addComment(event: React.FormEvent) {
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
if (!target || !text.trim() || saving || !snapshot) return;
|
|
391
|
+
const doc = new DOMParser().parseFromString(snapshot.source, "text/html");
|
|
392
|
+
if (doc.getElementById(target.id)?.closest("[data-page]")?.id !== target.page) {
|
|
393
|
+
setError("This target changed outside the preview. Select it again; your draft was kept.");
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
persist([...notes, { ...target, key: crypto.randomUUID(), text }]);
|
|
398
|
+
setDrafts(v => ({ ...v, [draftKey]: "" }));
|
|
399
|
+
if (vim) { closePanel(); setNotice("Comment added"); }
|
|
400
|
+
else setView("queue");
|
|
401
|
+
} catch (e) { setError(String(e)); }
|
|
402
|
+
}
|
|
403
|
+
const pages: Page[] = snapshot?.pages.map((id, i) => ({ id, name: String(i + 1).padStart(2, "0"), canvas: sizes[id] ?? { width: 794, height: 1123 } })) ?? [];
|
|
404
|
+
const target = typeof view === "object" ? view : undefined, draftKey = target ? `${target.page}/${target.id}` : "";
|
|
405
|
+
const text = drafts[draftKey] ?? "";
|
|
406
|
+
return <main ref={workspace} className="workspace" onWheelCapture={() => setHints(undefined)} onPointerDown={e => {
|
|
407
|
+
const element = e.target as HTMLElement;
|
|
408
|
+
if (element.closest(".revision-notes,.review-launcher,.html-pin,.review-hints,.review-status,.shortcut-help,.shortcut-launcher")) return;
|
|
409
|
+
setHints(undefined); setPrefix("");
|
|
410
|
+
if (!element.closest(".html-page")) {
|
|
411
|
+
setSelected(undefined);
|
|
412
|
+
if (document.activeElement?.matches(".html-page")) (document.activeElement as HTMLElement).blur();
|
|
413
|
+
}
|
|
414
|
+
if (view === "queue" || target) setView("closed");
|
|
415
|
+
}}>
|
|
416
|
+
<header className="board-heading html-heading"><h1>Konpeki</h1>{!token && <>
|
|
417
|
+
<a href="https://github.com/vcfgdev/konpeki/blob/main/html/README.md">Authoring guide</a>
|
|
418
|
+
</>}<button className="shortcut-launcher" aria-label="Keyboard shortcuts" aria-keyshortcuts="Shift+/" title="Keyboard shortcuts (?)" onClick={() => { setHints(undefined); setHelp(true); }}><kbd>?</kbd></button></header>
|
|
419
|
+
{!snapshot && <p>{error || "Opening HTML…"}</p>}
|
|
420
|
+
{snapshot && <PageBoard draft={{ pages }} controls={board} onActivePage={setActivePage}>{page => <HTMLPage key={`${page.id}:${reset}`} page={page} revision={snapshot.revision} source={token ? undefined : snapshot.source} notes={notes} selected={selected} commenting={view === "select"} locked={saving}
|
|
421
|
+
onSize={(width, height) => setSizes(current => current[page.id]?.width === width && current[page.id]?.height === height ? current : { ...current, [page.id]: { width, height } })}
|
|
422
|
+
onSelect={value => { setSelected(value); setActivePage(page.id); }} onComment={openComment} onEdit={edit => void save(edit)} onBusy={value => { if (!value && saving) return; busy.current = value; }} onBlocked={setError} />}</PageBoard>}
|
|
423
|
+
{(notice || error) && <div className={`feedback-notice toast visible${error ? " error" : ""}`} role="status"><span>{error || notice}</span>{!error && notice === "Copied and cleared" && cleared.length > 0 && <button disabled={saving} onClick={() => { try { persist([...cleared.filter(c => !notes.some(n => n.key === c.key)), ...notes]); setCleared([]); setNotice("Reviews restored"); } catch (e) { setError(String(e)); } }}>Undo</button>}{error && <button className="toast-dismiss" onClick={() => setError("")} aria-label="Dismiss error"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6" /></svg></button>}</div>}
|
|
424
|
+
<button ref={launcher} className="review-launcher" aria-label="Comment" title="Comment (C)" aria-keyshortcuts="c" aria-pressed={view === "select" || !!target || !!hints} aria-expanded={view === "queue" || !!target} disabled={!snapshot || saving} onClick={() => { if (view !== "closed" || hints) closePanel(); else if (notes.length) openQueue(); else startComment(); }}><svg viewBox="0 0 24 24" aria-hidden="true"><path transform="translate(1.5 .5)" d="M20 11a8 8 0 0 1-8 8H5l-4 3V11a9 9 0 0 1 19 0Z" /></svg>{notes.length > 0 && <span className="review-count">{notes.length}</span>}</button>
|
|
425
|
+
{(target || view === "queue") && <section ref={panel} className={`revision-notes${target ? " comment-composer" : ""}`} role="dialog" aria-label={target ? "Add comment" : "Pending reviews"} key={draftKey}>
|
|
426
|
+
{target && <header><h2 className="revision-note-scope">{target.id}</h2></header>}
|
|
427
|
+
{!target && vim && <header className="review-queue-heading"><h2>{notes.length} pending {notes.length === 1 ? "comment" : "comments"}</h2><span><kbd>j</kbd>/<kbd>k</kbd> select · <kbd>Enter</kbd> locate</span></header>}
|
|
428
|
+
<ol className="revision-note-list">{notes.filter(n => !target || n.id === target.id && n.page === target.page).map(n => <li key={n.key} className={vim && !target && notes.indexOf(n) === queueIndex ? "review-note-active" : undefined}>
|
|
429
|
+
{vim && !target ? <button className="revision-note-target" disabled={saving} onFocus={() => setQueueIndex(notes.indexOf(n))} onClick={() => commentOn(n, true)}><span className="revision-note-number">{notes.indexOf(n) + 1}</span><span className="revision-note-label" title={`${n.page} · ${n.id}`}>{n.page} · {n.id}</span></button> : <><span className="revision-note-number">{notes.indexOf(n) + 1}</span><span className="revision-note-label" title={n.id}>{n.id}</span></>}
|
|
430
|
+
<p>{n.text}</p><div className="revision-note-actions"><button className="revision-note-remove" disabled={saving} aria-label={`Remove comment ${notes.indexOf(n) + 1}`} title="Remove comment" onClick={() => { try { persist(notes.filter(note => note !== n)); setQueueIndex(i => Math.max(0, Math.min(i, notes.length - 2))); } catch (e) { setError(String(e)); } }}><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m6 6 12 12M6 18 18 6" /></svg></button></div></li>)}</ol>
|
|
431
|
+
{target ? <form onSubmit={addComment}>
|
|
432
|
+
<textarea aria-label="What should change?" placeholder="Leave a comment…" value={text} maxLength={4000} onChange={e => setDrafts(v => ({ ...v, [draftKey]: e.target.value }))} onKeyDown={e => { if ((e.ctrlKey || e.metaKey) && e.key === "Enter" && !e.nativeEvent.isComposing && e.keyCode !== 229 && !e.repeat) { e.preventDefault(); e.currentTarget.form?.requestSubmit(); } }} />
|
|
433
|
+
<div className="revision-note-submit"><button type="button" className="review-secondary" onClick={dismissComposer}>Cancel</button><button className="primary" title="Add comment (Ctrl/⌘ + Enter)" disabled={!text.trim() || saving}>Add{vim && <kbd>{submitKey}</kbd>}</button></div>
|
|
434
|
+
</form> : <>{!notes.length && <p className="review-empty">No pending comments</p>}{fallback && <textarea aria-label="Prompt to copy" readOnly value={fallback} onFocus={e => e.currentTarget.select()} />}<footer className="review-actions"><button className="review-secondary" disabled={saving} onClick={() => vim ? startHints() : setView("select")}>New comment</button><button className="primary" disabled={!notes.length || saving} onClick={() => void copy()}>Copy & clear{vim && <kbd>{submitKey}</kbd>}</button></footer></>}
|
|
435
|
+
</section>}
|
|
436
|
+
{hints && <div className="review-hints" aria-label="Comment targets">
|
|
437
|
+
<svg aria-hidden="true">{hints.entries.filter(hint => hint.label.startsWith(hints.prefix)).map(hint => <g key={hint.id}>
|
|
438
|
+
<rect x={hint.rect.x} y={hint.rect.y} width={hint.rect.width} height={hint.rect.height} />
|
|
439
|
+
<line x1={hint.rect.x} y1={hint.rect.y + 11} x2={hint.badge.right} y2={hint.badge.y + 11} />
|
|
440
|
+
</g>)}</svg>
|
|
441
|
+
{hints.entries.filter(hint => hint.label.startsWith(hints.prefix)).map(hint => <button key={hint.id} className="review-hint" data-target={hint.id} aria-label={`${hint.label}: comment on ${hint.id}`} style={{ left: hint.badge.x, top: hint.badge.y, width: hint.badge.width }} onClick={() => commentOn({ page: hints.page, id: hint.id })}><span>{hints.prefix}</span>{hint.label.slice(hints.prefix.length)}</button>)}
|
|
442
|
+
</div>}
|
|
443
|
+
{vim && snapshot && <div className="review-status" aria-label="Review keyboard mode">
|
|
444
|
+
<button onClick={() => { setHints(undefined); setHelp(true); }} title="Keyboard shortcuts (?)">VIM · {hints ? "HINTS" : target ? "COMMENT" : view === "queue" ? "PENDING" : "NORMAL"}</button>
|
|
445
|
+
{(hints || prefix) && <kbd>{hints ? `${hints.prefix}_` : `${prefix}_`}</kbd>}
|
|
446
|
+
<span>{hints ? "Esc cancel" : target ? `${submitKey} add` : view === "queue" ? "Esc back" : "f comment · ? help"}</span>
|
|
447
|
+
<span className="review-page-number">Page {Math.max(0, pages.findIndex(p => p.id === activePage)) + 1} / {pages.length}</span>
|
|
448
|
+
</div>}
|
|
449
|
+
<dialog ref={shortcuts} className="shortcut-help" aria-labelledby="shortcut-title" onCancel={() => setHelp(false)} onClose={() => setHelp(false)}>
|
|
450
|
+
<header><h2 id="shortcut-title">Keyboard shortcuts</h2><button onClick={() => setHelp(false)} aria-label="Close keyboard shortcuts">×</button></header>
|
|
451
|
+
<label className="vim-toggle"><input type="checkbox" checked={vim} onChange={e => { setVim(e.target.checked); setHints(undefined); try { localStorage.setItem("html-review:vim", String(e.target.checked)); } catch { setError("The Vim mode preference could not be saved. This change applies to the current session only."); } }} />Vim review mode</label>
|
|
452
|
+
<p>Letter hints and navigation. Comments use normal text editing.</p>
|
|
453
|
+
<h3>Canvas <span>Vim mode</span></h3>
|
|
454
|
+
<dl><dt><kbd>f</kbd></dt><dd>Hint visible elements, then type their letters</dd><dt><kbd>c</kbd> / <kbd>Shift+C</kbd></dt><dd>Comment on selection / current page</dd><dt><kbd>h j k l</kbd></dt><dd>Pan left, down, up, right</dd><dt><kbd>[</kbd> / <kbd>]</kbd></dt><dd>Previous / next page · keep zoom</dd><dt><kbd>gg</kbd> / <kbd>G</kbd></dt><dd>First / last page</dd><dt><kbd>+</kbd> / <kbd>−</kbd> / <kbd>0</kbd></dt><dd>Zoom in / out / fit current page</dd><dt><kbd>q</kbd></dt><dd>Open pending comments</dd></dl>
|
|
455
|
+
<h3>Pending comments <span>Vim mode</span></h3>
|
|
456
|
+
<dl><dt><kbd>j</kbd> / <kbd>k</kbd></dt><dd>Select next / previous comment</dd><dt><kbd>Enter</kbd></dt><dd>Reveal selected target and its notes</dd><dt><kbd>{submitKey}</kbd></dt><dd>Copy & clear · Undo remains available</dd></dl>
|
|
457
|
+
<h3>Always available</h3>
|
|
458
|
+
<dl><dt><kbd>c</kbd> / <kbd>Enter</kbd></dt><dd>Comment / comment on selected element</dd><dt><kbd>{submitKey}</kbd></dt><dd>Add comment · return to canvas in Vim mode</dd><dt><kbd>Esc</kbd></dt><dd>Back one level · keep unfinished drafts</dd><dt><kbd>?</kbd></dt><dd>Open this help outside text inputs</dd></dl>
|
|
459
|
+
</dialog>
|
|
460
|
+
</main>;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
createRoot(document.getElementById("root")!).render(<Preview />);
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const hintAlphabet = "asdfghjkl";
|
|
2
|
+
|
|
3
|
+
/** Equal-length codes are prefix-free, including at the 9/10 and 81/82 boundaries. */
|
|
4
|
+
export function hintLabels(count: number): string[] {
|
|
5
|
+
let length = 1;
|
|
6
|
+
while (hintAlphabet.length ** length < count) length++;
|
|
7
|
+
return Array.from({ length: count }, (_, index) => {
|
|
8
|
+
let label = "";
|
|
9
|
+
for (let digit = 0; digit < length; digit++) {
|
|
10
|
+
label = hintAlphabet[index % hintAlphabet.length] + label;
|
|
11
|
+
index = Math.floor(index / hintAlphabet.length);
|
|
12
|
+
}
|
|
13
|
+
return label;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Map an ID inside the sandboxed document to the zoomed host viewport. */
|
|
18
|
+
export function targetRect(surface: HTMLElement, id: string): DOMRect | undefined {
|
|
19
|
+
if (surface.getAttribute("aria-busy") !== "false") return;
|
|
20
|
+
const frame = surface.querySelector("iframe"), element = frame?.contentDocument?.getElementById(id);
|
|
21
|
+
if (!frame || !element || element.closest("[data-page]")?.id !== surface.dataset.htmlPage ||
|
|
22
|
+
!element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return;
|
|
23
|
+
const rect = element.getBoundingClientRect(), bounds = frame.getBoundingClientRect();
|
|
24
|
+
if (!rect.width || !rect.height) return;
|
|
25
|
+
const scale = bounds.width / frame.clientWidth;
|
|
26
|
+
return new DOMRect(bounds.left + rect.left * scale, bounds.top + rect.top * scale, rect.width * scale, rect.height * scale);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function reviewHints(surface: HTMLElement) {
|
|
30
|
+
const page = surface.querySelector("iframe")?.contentDocument?.getElementById(surface.dataset.htmlPage!);
|
|
31
|
+
const bounds = surface.getBoundingClientRect();
|
|
32
|
+
const targets = Array.from(page?.querySelectorAll("[id]") ?? []).flatMap(element => {
|
|
33
|
+
const rect = targetRect(surface, element.id);
|
|
34
|
+
if (!rect) return [];
|
|
35
|
+
const left = Math.max(8, bounds.left, rect.left), top = Math.max(8, bounds.top, rect.top);
|
|
36
|
+
const right = Math.min(innerWidth - 8, bounds.right, rect.right), bottom = Math.min(innerHeight - 64, bounds.bottom, rect.bottom);
|
|
37
|
+
if (right <= left || bottom <= top) return [];
|
|
38
|
+
return [{ id: element.id, rect: new DOMRect(left, top, right - left, bottom - top) }];
|
|
39
|
+
});
|
|
40
|
+
const labels = hintLabels(targets.length);
|
|
41
|
+
const placed: DOMRect[] = [];
|
|
42
|
+
return targets.map((target, index) => {
|
|
43
|
+
const label = labels[index], width = label.length * 8 + 12;
|
|
44
|
+
// Prefer the left gutter so a badge does not hide the first word of a note's target.
|
|
45
|
+
const x = Math.max(8, target.rect.left - width - 6);
|
|
46
|
+
const y = target.rect.left >= width + 14 ? target.rect.top : Math.max(8, target.rect.top - 26);
|
|
47
|
+
let badge = new DOMRect(x, y, width, 22);
|
|
48
|
+
const overlaps = (box: DOMRect) => placed.some(p => box.left < p.right + 4 && box.right + 4 > p.left && box.top < p.bottom + 4 && box.bottom + 4 > p.top);
|
|
49
|
+
// Keep nested targets reachable: move overlapping badges, with a leader line
|
|
50
|
+
// back to their element. Prefer the same column before scanning the viewport.
|
|
51
|
+
for (let top = y; overlaps(badge) && top + 22 < innerHeight - 64; top += 26) badge = new DOMRect(x, top, width, 22);
|
|
52
|
+
if (overlaps(badge)) {
|
|
53
|
+
search: for (let top = 8; top + 22 < innerHeight - 64; top += 26) {
|
|
54
|
+
for (let left = 8; left + width < innerWidth - 8; left += width + 4) {
|
|
55
|
+
const next = new DOMRect(left, top, width, 22);
|
|
56
|
+
if (!overlaps(next)) { badge = next; break search; }
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
placed.push(badge);
|
|
61
|
+
return { ...target, label, badge };
|
|
62
|
+
});
|
|
63
|
+
}
|