konpeki 0.1.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/skills/authoring-visuals/SKILL.md +82 -0
- package/AGENTS.md +33 -0
- package/AUTHORING.md +233 -0
- package/LICENSE +201 -0
- package/README.md +70 -0
- package/SETUP.md +86 -0
- package/composition/README.md +166 -0
- package/composition/compile.ts +227 -0
- package/composition/document.ts +709 -0
- package/composition/schema.json +2992 -0
- package/composition/schema.ts +435 -0
- package/composition/theme-tokens.ts +16 -0
- package/composition/types.ts +286 -0
- package/composition/validate.ts +684 -0
- package/composition/vector.ts +143 -0
- package/composition/visualizations.ts +335 -0
- package/design/README.md +17 -0
- package/design/palettes/README.md +14 -0
- package/design/palettes/base.ts +14 -0
- package/design/palettes/candidates.ts +19 -0
- package/design/palettes/index.ts +78 -0
- package/design/review/color-theme.md +44 -0
- package/design/review/layout.md +16 -0
- package/design/review/text.md +18 -0
- package/design/review/typography.md +15 -0
- package/design/review/visuals.md +31 -0
- package/design/semantic-patterns.md +43 -0
- package/design/themes/README.md +22 -0
- package/design/themes/index.ts +13 -0
- package/design/visual-languages/technical-product.md +17 -0
- package/design/visual-review.md +88 -0
- package/docs/development.md +140 -0
- package/docs/workflow.md +61 -0
- package/index.html +17 -0
- package/lib/assets.d.ts +8 -0
- package/lib/charts.ts +18 -0
- package/lib/contrast.ts +16 -0
- package/lib/layouts.ts +50 -0
- package/lib/slide.tsx +42 -0
- package/lib/taste.ts +17 -0
- package/lib/text.tsx +89 -0
- package/lib/typeface.ts +44 -0
- package/package.json +85 -0
- package/runtime/konpeki.mjs +1685 -0
- package/scripts/migrate-react-page.ts +120 -0
- package/slides/README.md +153 -0
- package/slides/architecture/PROMPT.md +31 -0
- package/slides/architecture/index.tsx +102 -0
- package/slides/article-brief/PROMPT.md +35 -0
- package/slides/article-brief/index.tsx +71 -0
- package/slides/bar-chart/PROMPT.md +39 -0
- package/slides/bar-chart/index.tsx +97 -0
- package/slides/comparison/PROMPT.md +29 -0
- package/slides/comparison/index.tsx +95 -0
- package/slides/decision-memo/PROMPT.md +34 -0
- package/slides/decision-memo/index.tsx +85 -0
- package/slides/delivery-plan/PROMPT.md +45 -0
- package/slides/delivery-plan/index.tsx +105 -0
- package/slides/experiment/PROMPT.md +44 -0
- package/slides/experiment/index.tsx +127 -0
- package/slides/incident-workflow/PROMPT.md +57 -0
- package/slides/incident-workflow/index.tsx +78 -0
- package/slides/introducing-konpeki/PROMPT.md +19 -0
- package/slides/introducing-konpeki/README.md +54 -0
- package/slides/introducing-konpeki/SOURCE.md +20 -0
- package/slides/introducing-konpeki/author.ts +163 -0
- package/slides/introducing-konpeki/composition.json +3265 -0
- package/slides/line-chart/PROMPT.md +40 -0
- package/slides/line-chart/index.tsx +72 -0
- package/slides/migration/PROMPT.md +38 -0
- package/slides/migration/index.tsx +89 -0
- package/slides/og-images/PROMPT.md +21 -0
- package/slides/og-images/index.tsx +76 -0
- package/slides/product-introduction/PROMPT.md +24 -0
- package/slides/product-introduction/index.tsx +105 -0
- package/slides/research-brief/PROMPT.md +40 -0
- package/slides/research-brief/index.tsx +104 -0
- package/slides/results-explanation/PROMPT.md +32 -0
- package/slides/results-explanation/index.tsx +96 -0
- package/slides/retrospective/PROMPT.md +43 -0
- package/slides/retrospective/index.tsx +105 -0
- package/slides/sankey/PROMPT.md +11 -0
- package/slides/sankey/index.tsx +93 -0
- package/slides/teaching/PROMPT.md +45 -0
- package/slides/teaching/index.tsx +124 -0
- package/slides/vertical-bar-charts/PROMPT.md +13 -0
- package/slides/vertical-bar-charts/index.tsx +97 -0
- package/src/app/App.tsx +694 -0
- package/src/assets/konpeki-mark.png +0 -0
- package/src/components/Canvas.tsx +1168 -0
- package/src/components/DiagramTypeIcon.tsx +78 -0
- package/src/components/InspectorPanel.tsx +687 -0
- package/src/components/LeftPanel.tsx +107 -0
- package/src/components/PageSizePicker.tsx +30 -0
- package/src/components/Presentation.tsx +105 -0
- package/src/components/RightPanel.tsx +201 -0
- package/src/components/VectorOverflowWarning.tsx +46 -0
- package/src/components/WorkspaceChrome.tsx +199 -0
- package/src/components/ui.tsx +53 -0
- package/src/lib/examples/react-page-migration.json +1295 -0
- package/src/lib/examples.ts +42 -0
- package/src/lib/export-png.ts +101 -0
- package/src/lib/file-session.ts +74 -0
- package/src/lib/history.ts +53 -0
- package/src/lib/model.ts +188 -0
- package/src/lib/page-size.ts +24 -0
- package/src/lib/presentation.ts +17 -0
- package/src/lib/storage.ts +43 -0
- package/src/lib/theme.ts +25 -0
- package/src/lib/use-file-session.ts +162 -0
- package/src/main.tsx +26 -0
- package/src/styles/base.css +105 -0
- package/src/styles/canvas.css +268 -0
- package/src/styles/chrome.css +214 -0
- package/src/styles/component-previews.css +386 -0
- package/src/styles/feedback.css +71 -0
- package/src/styles/left-panel.css +125 -0
- package/src/styles/presentation.css +72 -0
- package/src/styles/right-panel.css +1172 -0
- package/src/styles/shell.css +247 -0
- package/vite.config.ts +6 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
CompositionSlide,
|
|
4
|
+
ThemeMode,
|
|
5
|
+
} from "../../composition/types.ts";
|
|
6
|
+
import type { Draft } from "../lib/model.ts";
|
|
7
|
+
|
|
8
|
+
function ThumbnailCanvas({
|
|
9
|
+
slide,
|
|
10
|
+
themeMode,
|
|
11
|
+
pageIndex,
|
|
12
|
+
pageTotal,
|
|
13
|
+
}: {
|
|
14
|
+
slide: CompositionSlide;
|
|
15
|
+
themeMode: ThemeMode;
|
|
16
|
+
pageIndex: number;
|
|
17
|
+
pageTotal: number;
|
|
18
|
+
}) {
|
|
19
|
+
return (
|
|
20
|
+
<span className={`slide-thumbnail-canvas ${themeMode}`} style={{ aspectRatio: slide.canvas.width / slide.canvas.height }}>
|
|
21
|
+
{slide.components.map((component) => {
|
|
22
|
+
const rect = component.preferredRect;
|
|
23
|
+
const style = {
|
|
24
|
+
left: `${rect.x * 100 / slide.canvas.width}%`,
|
|
25
|
+
top: `${rect.y * 100 / slide.canvas.height}%`,
|
|
26
|
+
width: `${rect.width * 100 / slide.canvas.width}%`,
|
|
27
|
+
height: `${rect.height * 100 / slide.canvas.height}%`,
|
|
28
|
+
} as CSSProperties;
|
|
29
|
+
return (
|
|
30
|
+
<span
|
|
31
|
+
key={component.id}
|
|
32
|
+
className={`thumbnail-component kind-${component.kind} ${component.kind === "text-block" ? `role-${component.appearance.role}` : ""}`}
|
|
33
|
+
style={style}
|
|
34
|
+
/>
|
|
35
|
+
);
|
|
36
|
+
})}
|
|
37
|
+
{slide.pageNumber?.style !== "none" && (
|
|
38
|
+
<span className="thumbnail-page-number">
|
|
39
|
+
{String(pageIndex).padStart(2, "0")}
|
|
40
|
+
{slide.pageNumber?.style === "01/02"
|
|
41
|
+
? `/${String(pageTotal).padStart(2, "0")}`
|
|
42
|
+
: ""}
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
</span>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function LeftPanel({
|
|
50
|
+
draft,
|
|
51
|
+
activeSlideId,
|
|
52
|
+
collapsed,
|
|
53
|
+
onSelectSlide,
|
|
54
|
+
onAddSlide,
|
|
55
|
+
onRemoveSlide,
|
|
56
|
+
}: {
|
|
57
|
+
draft: Draft;
|
|
58
|
+
activeSlideId: string;
|
|
59
|
+
collapsed: boolean;
|
|
60
|
+
onSelectSlide: (id: string) => void;
|
|
61
|
+
onAddSlide: () => void;
|
|
62
|
+
onRemoveSlide: (id: string) => void;
|
|
63
|
+
}) {
|
|
64
|
+
return (
|
|
65
|
+
<aside className={`left-panel${collapsed ? " collapsed" : ""}`} aria-label="Workspace panel" inert={collapsed}>
|
|
66
|
+
<div className="panel-toolbar">
|
|
67
|
+
<span className="panel-heading">Pages</span>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="slide-list">
|
|
70
|
+
{draft.slides.map((item, index) => (
|
|
71
|
+
<div key={item.id} className="slide-thumbnail-item">
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
className={`slide-thumbnail ${item.id === activeSlideId ? "selected" : ""}`}
|
|
75
|
+
aria-current={item.id === activeSlideId ? "page" : undefined}
|
|
76
|
+
onClick={() => onSelectSlide(item.id)}
|
|
77
|
+
>
|
|
78
|
+
<ThumbnailCanvas
|
|
79
|
+
slide={item}
|
|
80
|
+
themeMode={draft.theme?.mode ?? "paper"}
|
|
81
|
+
pageIndex={index + 1}
|
|
82
|
+
pageTotal={draft.slides.length}
|
|
83
|
+
/>
|
|
84
|
+
<span>{item.name}</span>
|
|
85
|
+
</button>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
className="remove-slide"
|
|
89
|
+
aria-label={`Remove ${item.name}`}
|
|
90
|
+
onClick={() => onRemoveSlide(item.id)}
|
|
91
|
+
>
|
|
92
|
+
<span className="panel-close-icon" aria-hidden="true" />
|
|
93
|
+
</button>
|
|
94
|
+
</div>
|
|
95
|
+
))}
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
className="add-slide"
|
|
99
|
+
aria-label="Add page"
|
|
100
|
+
onClick={onAddSlide}
|
|
101
|
+
>
|
|
102
|
+
<span aria-hidden="true">+</span>
|
|
103
|
+
</button>
|
|
104
|
+
</div>
|
|
105
|
+
</aside>
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CompositionSlide } from "../../composition/types.ts";
|
|
2
|
+
import { pagePresets, pageSizeIssue, resizePage } from "../lib/page-size.ts";
|
|
3
|
+
|
|
4
|
+
export function PageSizePicker({ slide, onChange }: { slide: CompositionSlide; onChange: (slide: CompositionSlide) => void }) {
|
|
5
|
+
const preset = pagePresets.find((item) => item.width === slide.canvas.width && item.height === slide.canvas.height);
|
|
6
|
+
function apply(w: number, h: number, destination: CompositionSlide["intendedViewingSize"] = "custom") {
|
|
7
|
+
const size = { width: w, height: h };
|
|
8
|
+
if (!pageSizeIssue(slide, size)) {
|
|
9
|
+
onChange(resizePage(slide, size, destination));
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return <fieldset>
|
|
13
|
+
<legend>Size</legend>
|
|
14
|
+
<label className="field">
|
|
15
|
+
<span className="sr-only">Size</span>
|
|
16
|
+
<select name="page-format" value={preset?.name ?? "Imported"} onChange={(event) => {
|
|
17
|
+
const next = pagePresets.find((item) => item.name === event.target.value);
|
|
18
|
+
if (next) apply(next.width, next.height, next.destination);
|
|
19
|
+
}}>
|
|
20
|
+
{!preset && <option value="Imported" disabled>{slide.canvas.width} × {slide.canvas.height} · Imported size</option>}
|
|
21
|
+
{pagePresets.map((item) => {
|
|
22
|
+
const unavailable = Boolean(pageSizeIssue(slide, item));
|
|
23
|
+
return <option key={item.name} value={item.name} disabled={unavailable}>
|
|
24
|
+
{item.name} · {item.width} × {item.height}{unavailable ? " · unavailable" : ""}
|
|
25
|
+
</option>;
|
|
26
|
+
})}
|
|
27
|
+
</select>
|
|
28
|
+
</label>
|
|
29
|
+
</fieldset>;
|
|
30
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { Draft } from "../lib/model.ts";
|
|
3
|
+
import { presentationAction } from "../lib/presentation.ts";
|
|
4
|
+
import { Canvas } from "./Canvas.tsx";
|
|
5
|
+
|
|
6
|
+
const noop = () => {};
|
|
7
|
+
|
|
8
|
+
export function Presentation({
|
|
9
|
+
draft,
|
|
10
|
+
initialSlideId,
|
|
11
|
+
onExit,
|
|
12
|
+
}: {
|
|
13
|
+
draft: Draft;
|
|
14
|
+
initialSlideId: string;
|
|
15
|
+
onExit: () => void;
|
|
16
|
+
}) {
|
|
17
|
+
const [index, setIndex] = useState(() =>
|
|
18
|
+
Math.max(0, draft.slides.findIndex((slide) => slide.id === initialSlideId)),
|
|
19
|
+
);
|
|
20
|
+
const overlay = useRef<HTMLDivElement>(null);
|
|
21
|
+
const slide = draft.slides[index];
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
overlay.current?.focus();
|
|
25
|
+
}, []);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
function keydown(event: KeyboardEvent) {
|
|
29
|
+
const interactiveTarget =
|
|
30
|
+
event.target instanceof Element &&
|
|
31
|
+
Boolean(event.target.closest("button,a,input,textarea,select,[contenteditable=true]"));
|
|
32
|
+
const action = presentationAction(
|
|
33
|
+
event.key,
|
|
34
|
+
index,
|
|
35
|
+
draft.slides.length,
|
|
36
|
+
interactiveTarget,
|
|
37
|
+
);
|
|
38
|
+
if (action === undefined) return;
|
|
39
|
+
event.preventDefault();
|
|
40
|
+
if (action === "exit") onExit();
|
|
41
|
+
else setIndex(action);
|
|
42
|
+
}
|
|
43
|
+
window.addEventListener("keydown", keydown);
|
|
44
|
+
return () => window.removeEventListener("keydown", keydown);
|
|
45
|
+
}, [draft.slides.length, index, onExit]);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
ref={overlay}
|
|
50
|
+
className="presentation"
|
|
51
|
+
role="dialog"
|
|
52
|
+
aria-modal="true"
|
|
53
|
+
aria-label="Presentation mode"
|
|
54
|
+
tabIndex={-1}
|
|
55
|
+
>
|
|
56
|
+
<Canvas
|
|
57
|
+
key={slide.id}
|
|
58
|
+
mode="present"
|
|
59
|
+
draft={draft}
|
|
60
|
+
activeSlideId={slide.id}
|
|
61
|
+
onSelect={noop}
|
|
62
|
+
onSlideName={noop}
|
|
63
|
+
onComponent={noop}
|
|
64
|
+
onEditEnd={noop}
|
|
65
|
+
onAdd={noop}
|
|
66
|
+
onNotice={noop}
|
|
67
|
+
/>
|
|
68
|
+
<nav className="presentation-controls" aria-label="Presentation controls">
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
disabled={index === 0}
|
|
72
|
+
aria-label="Previous page"
|
|
73
|
+
onClick={() => setIndex((value) => Math.max(0, value - 1))}
|
|
74
|
+
>
|
|
75
|
+
←
|
|
76
|
+
</button>
|
|
77
|
+
<span aria-live="polite">
|
|
78
|
+
{index + 1} / {draft.slides.length}
|
|
79
|
+
</span>
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
disabled={index === draft.slides.length - 1}
|
|
83
|
+
aria-label="Next page"
|
|
84
|
+
onClick={() =>
|
|
85
|
+
setIndex((value) => Math.min(draft.slides.length - 1, value + 1))
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
→
|
|
89
|
+
</button>
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={() => {
|
|
93
|
+
if (document.fullscreenElement) void document.exitFullscreen();
|
|
94
|
+
else void overlay.current?.requestFullscreen();
|
|
95
|
+
}}
|
|
96
|
+
>
|
|
97
|
+
Full screen
|
|
98
|
+
</button>
|
|
99
|
+
<button type="button" onClick={onExit}>
|
|
100
|
+
Exit
|
|
101
|
+
</button>
|
|
102
|
+
</nav>
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type {
|
|
3
|
+
CompositionComponent,
|
|
4
|
+
CompositionSlide,
|
|
5
|
+
} from "../../composition/types.ts";
|
|
6
|
+
import { componentInstanceLabel, type Draft } from "../lib/model.ts";
|
|
7
|
+
import { InspectorPanel } from "./InspectorPanel.tsx";
|
|
8
|
+
|
|
9
|
+
export type RightPanelView = "settings" | "layers";
|
|
10
|
+
|
|
11
|
+
function LayersPanel({
|
|
12
|
+
slide,
|
|
13
|
+
selectedComponentId,
|
|
14
|
+
onSelectComponent,
|
|
15
|
+
onReorderPaintOrder,
|
|
16
|
+
}: {
|
|
17
|
+
slide: CompositionSlide;
|
|
18
|
+
selectedComponentId?: string;
|
|
19
|
+
onSelectComponent: (id: string) => void;
|
|
20
|
+
onReorderPaintOrder: (ids: string[]) => void;
|
|
21
|
+
}) {
|
|
22
|
+
const [dragging, setDragging] = useState<string>();
|
|
23
|
+
const layers = [...slide.paintOrder].reverse();
|
|
24
|
+
function moveLayer(id: string, toIndex: number) {
|
|
25
|
+
const next = layers.filter((item) => item !== id);
|
|
26
|
+
next.splice(Math.max(0, Math.min(toIndex, next.length)), 0, id);
|
|
27
|
+
onReorderPaintOrder(next.reverse());
|
|
28
|
+
}
|
|
29
|
+
return (
|
|
30
|
+
<section className="layers-panel" aria-labelledby="layers-heading">
|
|
31
|
+
<div className="layers-heading-row">
|
|
32
|
+
<h2 id="layers-heading">Components & layers</h2>
|
|
33
|
+
<span>Front to back</span>
|
|
34
|
+
</div>
|
|
35
|
+
{layers.length ? (
|
|
36
|
+
<div className="layer-list">
|
|
37
|
+
{layers.map((id, index) => {
|
|
38
|
+
const component = slide.components.find((item) => item.id === id)!;
|
|
39
|
+
const groups = slide.groups
|
|
40
|
+
.filter((group) => group.childIds.includes(id))
|
|
41
|
+
.map((group) => group.label?.trim() || group.id);
|
|
42
|
+
const label = componentInstanceLabel(slide.components, id);
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
key={id}
|
|
46
|
+
data-layer={id}
|
|
47
|
+
className={`layer-row ${selectedComponentId === id ? "selected" : ""} ${dragging === id ? "dragging" : ""}`}
|
|
48
|
+
draggable
|
|
49
|
+
onDragStart={(event) => {
|
|
50
|
+
setDragging(id);
|
|
51
|
+
event.dataTransfer.effectAllowed = "move";
|
|
52
|
+
event.dataTransfer.setData("text/plain", id);
|
|
53
|
+
}}
|
|
54
|
+
onDragEnd={() => setDragging(undefined)}
|
|
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);
|
|
63
|
+
setDragging(undefined);
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
<span className="layer-drag" aria-hidden="true">⠿</span>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
className="layer-select"
|
|
70
|
+
onClick={() => onSelectComponent(id)}
|
|
71
|
+
>
|
|
72
|
+
<strong>{label}</strong>
|
|
73
|
+
<span>{groups.length ? groups.join(", ") : "Page root"}</span>
|
|
74
|
+
</button>
|
|
75
|
+
<span className="layer-actions">
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
aria-label={`Move ${label} forward`}
|
|
79
|
+
disabled={index === 0}
|
|
80
|
+
onClick={() => moveLayer(id, index - 1)}
|
|
81
|
+
>↑</button>
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
aria-label={`Move ${label} backward`}
|
|
85
|
+
disabled={index === layers.length - 1}
|
|
86
|
+
onClick={() => moveLayer(id, index + 1)}
|
|
87
|
+
>↓</button>
|
|
88
|
+
</span>
|
|
89
|
+
</div>
|
|
90
|
+
);
|
|
91
|
+
})}
|
|
92
|
+
</div>
|
|
93
|
+
) : (
|
|
94
|
+
<p className="layers-empty">No components yet.</p>
|
|
95
|
+
)}
|
|
96
|
+
</section>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function RightPanel({
|
|
101
|
+
view,
|
|
102
|
+
collapsed,
|
|
103
|
+
component,
|
|
104
|
+
componentLabel,
|
|
105
|
+
selectedComponentId,
|
|
106
|
+
selectedVectorElementId,
|
|
107
|
+
vectorEditRequest,
|
|
108
|
+
slide,
|
|
109
|
+
draft,
|
|
110
|
+
onView,
|
|
111
|
+
onCollapsedChange,
|
|
112
|
+
onSelectSlide,
|
|
113
|
+
onSelectComponent,
|
|
114
|
+
onSelectVectorElement,
|
|
115
|
+
onReorderPaintOrder,
|
|
116
|
+
onComponent,
|
|
117
|
+
onSlide,
|
|
118
|
+
onDraft,
|
|
119
|
+
onEditEnd,
|
|
120
|
+
}: {
|
|
121
|
+
view: RightPanelView;
|
|
122
|
+
collapsed: boolean;
|
|
123
|
+
component?: CompositionComponent;
|
|
124
|
+
componentLabel?: string;
|
|
125
|
+
selectedComponentId?: string;
|
|
126
|
+
selectedVectorElementId?: string;
|
|
127
|
+
vectorEditRequest?: number;
|
|
128
|
+
slide: CompositionSlide;
|
|
129
|
+
draft: Draft;
|
|
130
|
+
onView: (view: RightPanelView) => void;
|
|
131
|
+
onCollapsedChange: (collapsed: boolean) => void;
|
|
132
|
+
onSelectSlide: () => void;
|
|
133
|
+
onSelectComponent: (id: string) => void;
|
|
134
|
+
onSelectVectorElement: (id?: string) => void;
|
|
135
|
+
onReorderPaintOrder: (ids: string[]) => void;
|
|
136
|
+
onComponent: (component: CompositionComponent, mergeKey?: string) => void;
|
|
137
|
+
onSlide: (slide: CompositionSlide, mergeKey?: string) => void;
|
|
138
|
+
onDraft: (patch: Partial<Draft>, mergeKey?: string) => void;
|
|
139
|
+
onEditEnd: () => void;
|
|
140
|
+
}) {
|
|
141
|
+
return (
|
|
142
|
+
<aside className={`right-panel${collapsed ? " collapsed" : ""}`} aria-label="Inspector panel">
|
|
143
|
+
<div className="panel-toolbar">
|
|
144
|
+
<div className="panel-tabs" aria-label="Right panel view" inert={collapsed}>
|
|
145
|
+
{(["settings", "layers"] as const).map((tab) => (
|
|
146
|
+
<button
|
|
147
|
+
key={tab}
|
|
148
|
+
type="button"
|
|
149
|
+
aria-pressed={view === tab}
|
|
150
|
+
className={view === tab ? "active" : ""}
|
|
151
|
+
onClick={() => onView(tab)}
|
|
152
|
+
>
|
|
153
|
+
{tab === "settings" ? "Settings" : "Layers"}
|
|
154
|
+
</button>
|
|
155
|
+
))}
|
|
156
|
+
</div>
|
|
157
|
+
<button
|
|
158
|
+
type="button"
|
|
159
|
+
className="panel-toggle"
|
|
160
|
+
aria-label={collapsed ? "Expand right panel" : "Collapse right panel"}
|
|
161
|
+
aria-expanded={!collapsed}
|
|
162
|
+
onClick={() => onCollapsedChange(!collapsed)}
|
|
163
|
+
>
|
|
164
|
+
<svg
|
|
165
|
+
className="panel-collapse-icon"
|
|
166
|
+
viewBox="0 0 24 24"
|
|
167
|
+
aria-hidden="true"
|
|
168
|
+
>
|
|
169
|
+
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
170
|
+
<path d={collapsed ? "M15 4v16M11 9l-3 3 3 3" : "M15 4v16M8 9l3 3-3 3"} />
|
|
171
|
+
</svg>
|
|
172
|
+
</button>
|
|
173
|
+
</div>
|
|
174
|
+
<div className="right-panel-body" inert={collapsed}>
|
|
175
|
+
{view === "settings" ? (
|
|
176
|
+
<InspectorPanel
|
|
177
|
+
component={component}
|
|
178
|
+
componentLabel={componentLabel}
|
|
179
|
+
slide={slide}
|
|
180
|
+
draft={draft}
|
|
181
|
+
selectedVectorElementId={selectedVectorElementId}
|
|
182
|
+
vectorEditRequest={vectorEditRequest}
|
|
183
|
+
onSelectSlide={onSelectSlide}
|
|
184
|
+
onSelectVectorElement={onSelectVectorElement}
|
|
185
|
+
onComponent={onComponent}
|
|
186
|
+
onSlide={onSlide}
|
|
187
|
+
onDraft={onDraft}
|
|
188
|
+
onEditEnd={onEditEnd}
|
|
189
|
+
/>
|
|
190
|
+
) : (
|
|
191
|
+
<LayersPanel
|
|
192
|
+
slide={slide}
|
|
193
|
+
selectedComponentId={selectedComponentId}
|
|
194
|
+
onSelectComponent={onSelectComponent}
|
|
195
|
+
onReorderPaintOrder={onReorderPaintOrder}
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
</aside>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import type { CompositionSlide, CompositionDocument } from "../../composition/types.ts";
|
|
3
|
+
|
|
4
|
+
/** Measure rendered text, including nested SVG transforms, after fonts settle. */
|
|
5
|
+
export function VectorOverflowWarning({ slide, theme }: {
|
|
6
|
+
slide: CompositionSlide;
|
|
7
|
+
theme: CompositionDocument["theme"];
|
|
8
|
+
}) {
|
|
9
|
+
const [count, setCount] = useState(0);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
let cancelled = false;
|
|
12
|
+
let frame = 0;
|
|
13
|
+
function measure() {
|
|
14
|
+
frame = requestAnimationFrame(() => {
|
|
15
|
+
if (cancelled) return;
|
|
16
|
+
let overflow = 0;
|
|
17
|
+
document.querySelectorAll<HTMLElement>(".stage .text-block-content").forEach((text) => {
|
|
18
|
+
const surface = text.parentElement;
|
|
19
|
+
if (surface && (text.scrollHeight > surface.clientHeight + 1 || text.scrollWidth > surface.clientWidth + 1)) overflow++;
|
|
20
|
+
});
|
|
21
|
+
document.querySelectorAll<SVGSVGElement>(".stage .custom-vector-art").forEach((svg) => {
|
|
22
|
+
const root = svg.getScreenCTM();
|
|
23
|
+
if (!root) return;
|
|
24
|
+
const view = svg.viewBox.baseVal;
|
|
25
|
+
svg.querySelectorAll<SVGTextElement>("text").forEach((text) => {
|
|
26
|
+
const matrix = text.getScreenCTM();
|
|
27
|
+
if (!matrix) return;
|
|
28
|
+
const box = text.getBBox();
|
|
29
|
+
const transform = root.inverse().multiply(matrix);
|
|
30
|
+
const corners = [[box.x, box.y], [box.x + box.width, box.y],
|
|
31
|
+
[box.x, box.y + box.height], [box.x + box.width, box.y + box.height]];
|
|
32
|
+
if (corners.some(([x, y]) => {
|
|
33
|
+
const p = new DOMPoint(x, y).matrixTransform(transform);
|
|
34
|
+
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
|
+
})) overflow++;
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
setCount(overflow);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
void document.fonts.ready.then(measure);
|
|
42
|
+
document.fonts.addEventListener("loadingdone", measure);
|
|
43
|
+
return () => { cancelled = true; cancelAnimationFrame(frame); document.fonts.removeEventListener("loadingdone", measure); };
|
|
44
|
+
}, [slide, theme]);
|
|
45
|
+
return count ? <p role="status" className="field-error">{count} text {count === 1 ? "element exceeds" : "elements exceed"} its component viewport on this slide. Revise the text or geometry; nothing was resized.</p> : null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { RefObject } from "react";
|
|
2
|
+
import type { CompositionComponent } from "../../composition/types.ts";
|
|
3
|
+
import { componentLabels } from "../lib/model.ts";
|
|
4
|
+
import mark from "../assets/konpeki-mark.png";
|
|
5
|
+
|
|
6
|
+
const dockItems: {
|
|
7
|
+
kind: CompositionComponent["kind"];
|
|
8
|
+
}[] = [
|
|
9
|
+
{ kind: "text-block" },
|
|
10
|
+
{ kind: "diagram" },
|
|
11
|
+
{ kind: "chart" },
|
|
12
|
+
{ kind: "image" },
|
|
13
|
+
{ kind: "table" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function DockIcon({
|
|
17
|
+
kind,
|
|
18
|
+
}: {
|
|
19
|
+
kind: CompositionComponent["kind"] | "select";
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<svg className="dock-glyph" viewBox="0 0 24 24" aria-hidden="true">
|
|
23
|
+
{kind === "select" ? (
|
|
24
|
+
<path d="M5 3.8 18 12l-6.2 1.2L8.6 19Z" />
|
|
25
|
+
) : kind === "text-block" ? (
|
|
26
|
+
<path d="M5 6h14M5 10h14M5 14h10M5 18h12" />
|
|
27
|
+
) : kind === "chart" ? (
|
|
28
|
+
<>
|
|
29
|
+
<path d="M5 19V5M5 19h14" />
|
|
30
|
+
<path d="M8 16v-4M12 16V8M16 16v-7" />
|
|
31
|
+
</>
|
|
32
|
+
) : kind === "diagram" ? (
|
|
33
|
+
<>
|
|
34
|
+
<rect x="3" y="5" width="6" height="5" rx="1" />
|
|
35
|
+
<rect x="15" y="14" width="6" height="5" rx="1" />
|
|
36
|
+
<path d="M9 8h6v8M12 13l3 3 3-3" />
|
|
37
|
+
</>
|
|
38
|
+
) : kind === "image" ? (
|
|
39
|
+
<>
|
|
40
|
+
<rect x="4" y="5" width="16" height="14" rx="1" />
|
|
41
|
+
<circle cx="15.5" cy="9" r="1.5" />
|
|
42
|
+
<path d="m6 17 4.5-5 3 3 2-2 2.5 4" />
|
|
43
|
+
</>
|
|
44
|
+
) : kind === "table" ? (
|
|
45
|
+
<>
|
|
46
|
+
<rect x="3" y="5" width="18" height="14" rx="1" />
|
|
47
|
+
<path d="M3 10h18M9 5v14M15 5v14" />
|
|
48
|
+
</>
|
|
49
|
+
) : (
|
|
50
|
+
<path d="M5 7h14M5 12h10M5 17h14" />
|
|
51
|
+
)}
|
|
52
|
+
</svg>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function WorkspaceChrome({
|
|
57
|
+
title,
|
|
58
|
+
titleRef,
|
|
59
|
+
titleError,
|
|
60
|
+
selectedKind,
|
|
61
|
+
onTitle,
|
|
62
|
+
onEditEnd,
|
|
63
|
+
onExportPNG,
|
|
64
|
+
exporting,
|
|
65
|
+
onPresent,
|
|
66
|
+
fileStatus,
|
|
67
|
+
building,
|
|
68
|
+
onBuild,
|
|
69
|
+
onSelectTool,
|
|
70
|
+
onComponentTool,
|
|
71
|
+
leftCollapsed,
|
|
72
|
+
onToggleLeftPanel,
|
|
73
|
+
}: {
|
|
74
|
+
leftCollapsed: boolean;
|
|
75
|
+
onToggleLeftPanel: () => void;
|
|
76
|
+
title: string;
|
|
77
|
+
titleRef: RefObject<HTMLInputElement | null>;
|
|
78
|
+
titleError: boolean;
|
|
79
|
+
selectedKind?: CompositionComponent["kind"];
|
|
80
|
+
onTitle: (title: string, mergeKey?: string) => void;
|
|
81
|
+
onEditEnd: () => void;
|
|
82
|
+
onExportPNG: () => void;
|
|
83
|
+
exporting: boolean;
|
|
84
|
+
onPresent: () => void;
|
|
85
|
+
fileStatus?: "loading" | "saved" | "saving" | "conflict" | "error";
|
|
86
|
+
building: boolean;
|
|
87
|
+
onBuild?: () => void;
|
|
88
|
+
onSelectTool: () => void;
|
|
89
|
+
onComponentTool: (kind: CompositionComponent["kind"]) => void;
|
|
90
|
+
}) {
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<header className="document-island" aria-label="Document controls">
|
|
94
|
+
<img src={mark} alt="Konpeki" />
|
|
95
|
+
<label className="document-title">
|
|
96
|
+
<span className="sr-only">Composition title</span>
|
|
97
|
+
<input
|
|
98
|
+
ref={titleRef}
|
|
99
|
+
aria-invalid={titleError || undefined}
|
|
100
|
+
aria-describedby={titleError ? "title-error" : undefined}
|
|
101
|
+
name="composition-title"
|
|
102
|
+
autoComplete="off"
|
|
103
|
+
value={title}
|
|
104
|
+
onChange={(event) => onTitle(event.target.value, "title")}
|
|
105
|
+
onBlur={onEditEnd}
|
|
106
|
+
/>
|
|
107
|
+
</label>
|
|
108
|
+
{titleError && (
|
|
109
|
+
<span className="document-title-error" id="title-error">
|
|
110
|
+
Enter a title.
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
{fileStatus && fileStatus !== "saved" && (
|
|
114
|
+
<span
|
|
115
|
+
className={`file-status ${fileStatus}`}
|
|
116
|
+
role="status"
|
|
117
|
+
aria-live="polite"
|
|
118
|
+
title={fileStatus === "conflict"
|
|
119
|
+
? "The file changed elsewhere while this browser has edits."
|
|
120
|
+
: undefined}
|
|
121
|
+
>
|
|
122
|
+
{fileStatus === "loading" ? "Opening"
|
|
123
|
+
: fileStatus === "saving" ? "Saving"
|
|
124
|
+
: fileStatus === "conflict" ? "Conflict" : "Save error"}
|
|
125
|
+
</span>
|
|
126
|
+
)}
|
|
127
|
+
<button type="button" className="panel-toggle"
|
|
128
|
+
aria-label={leftCollapsed ? "Expand left panel" : "Collapse left panel"}
|
|
129
|
+
onClick={onToggleLeftPanel}>
|
|
130
|
+
<svg className="panel-collapse-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
131
|
+
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
132
|
+
<path d={leftCollapsed ? "M9 4v16M13 9l3 3-3 3" : "M9 4v16M16 9l-3 3 3 3"} />
|
|
133
|
+
</svg>
|
|
134
|
+
</button>
|
|
135
|
+
</header>
|
|
136
|
+
|
|
137
|
+
<div className="action-island" aria-label="Document actions" inert={leftCollapsed}>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
className="icon-only"
|
|
141
|
+
aria-label="Export PNG"
|
|
142
|
+
title="Export active page as PNG"
|
|
143
|
+
disabled={exporting}
|
|
144
|
+
onClick={onExportPNG}
|
|
145
|
+
>
|
|
146
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
147
|
+
<rect x="4" y="4" width="16" height="12" rx="2" />
|
|
148
|
+
<path d="m7 14 3.5-4 3 3 2-2 2.5 3M12 16v5m-3-3 3 3 3-3" />
|
|
149
|
+
</svg>
|
|
150
|
+
</button>
|
|
151
|
+
<button type="button" onClick={onPresent}>
|
|
152
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
153
|
+
<rect x="3" y="4" width="18" height="12" rx="2" />
|
|
154
|
+
<path d="M12 16v5m-4 0h8M10 7l5 3-5 3Z" />
|
|
155
|
+
</svg>
|
|
156
|
+
Present
|
|
157
|
+
</button>
|
|
158
|
+
{onBuild && (
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
className="primary"
|
|
162
|
+
disabled={fileStatus !== "saved" || building}
|
|
163
|
+
onClick={onBuild}
|
|
164
|
+
>
|
|
165
|
+
<svg viewBox="0 0 24 24" aria-hidden="true">
|
|
166
|
+
<path d="m5 19 10-10 3 3L8 22Zm10-10 2-2 3 3-2 2M5 3v6M2 6h6m10-5v4m-2-2h4" />
|
|
167
|
+
</svg>
|
|
168
|
+
{building ? "Building…" : "Build it"}
|
|
169
|
+
</button>
|
|
170
|
+
)}
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<nav className="component-dock" aria-label="Canvas tools">
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
className={!selectedKind ? "active" : ""}
|
|
177
|
+
aria-pressed={!selectedKind}
|
|
178
|
+
onClick={onSelectTool}
|
|
179
|
+
>
|
|
180
|
+
<DockIcon kind="select" />
|
|
181
|
+
<span>Select</span>
|
|
182
|
+
</button>
|
|
183
|
+
{dockItems.map(({ kind }) => (
|
|
184
|
+
<button
|
|
185
|
+
key={kind}
|
|
186
|
+
type="button"
|
|
187
|
+
className={selectedKind === kind ? "active" : ""}
|
|
188
|
+
aria-pressed={selectedKind === kind}
|
|
189
|
+
title={`Add or select ${componentLabels[kind]}`}
|
|
190
|
+
onClick={() => onComponentTool(kind)}
|
|
191
|
+
>
|
|
192
|
+
<DockIcon kind={kind} />
|
|
193
|
+
<span>{componentLabels[kind]}</span>
|
|
194
|
+
</button>
|
|
195
|
+
))}
|
|
196
|
+
</nav>
|
|
197
|
+
</>
|
|
198
|
+
);
|
|
199
|
+
}
|