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.
Files changed (121) hide show
  1. package/.agents/skills/authoring-visuals/SKILL.md +82 -0
  2. package/AGENTS.md +33 -0
  3. package/AUTHORING.md +233 -0
  4. package/LICENSE +201 -0
  5. package/README.md +70 -0
  6. package/SETUP.md +86 -0
  7. package/composition/README.md +166 -0
  8. package/composition/compile.ts +227 -0
  9. package/composition/document.ts +709 -0
  10. package/composition/schema.json +2992 -0
  11. package/composition/schema.ts +435 -0
  12. package/composition/theme-tokens.ts +16 -0
  13. package/composition/types.ts +286 -0
  14. package/composition/validate.ts +684 -0
  15. package/composition/vector.ts +143 -0
  16. package/composition/visualizations.ts +335 -0
  17. package/design/README.md +17 -0
  18. package/design/palettes/README.md +14 -0
  19. package/design/palettes/base.ts +14 -0
  20. package/design/palettes/candidates.ts +19 -0
  21. package/design/palettes/index.ts +78 -0
  22. package/design/review/color-theme.md +44 -0
  23. package/design/review/layout.md +16 -0
  24. package/design/review/text.md +18 -0
  25. package/design/review/typography.md +15 -0
  26. package/design/review/visuals.md +31 -0
  27. package/design/semantic-patterns.md +43 -0
  28. package/design/themes/README.md +22 -0
  29. package/design/themes/index.ts +13 -0
  30. package/design/visual-languages/technical-product.md +17 -0
  31. package/design/visual-review.md +88 -0
  32. package/docs/development.md +140 -0
  33. package/docs/workflow.md +61 -0
  34. package/index.html +17 -0
  35. package/lib/assets.d.ts +8 -0
  36. package/lib/charts.ts +18 -0
  37. package/lib/contrast.ts +16 -0
  38. package/lib/layouts.ts +50 -0
  39. package/lib/slide.tsx +42 -0
  40. package/lib/taste.ts +17 -0
  41. package/lib/text.tsx +89 -0
  42. package/lib/typeface.ts +44 -0
  43. package/package.json +85 -0
  44. package/runtime/konpeki.mjs +1685 -0
  45. package/scripts/migrate-react-page.ts +120 -0
  46. package/slides/README.md +153 -0
  47. package/slides/architecture/PROMPT.md +31 -0
  48. package/slides/architecture/index.tsx +102 -0
  49. package/slides/article-brief/PROMPT.md +35 -0
  50. package/slides/article-brief/index.tsx +71 -0
  51. package/slides/bar-chart/PROMPT.md +39 -0
  52. package/slides/bar-chart/index.tsx +97 -0
  53. package/slides/comparison/PROMPT.md +29 -0
  54. package/slides/comparison/index.tsx +95 -0
  55. package/slides/decision-memo/PROMPT.md +34 -0
  56. package/slides/decision-memo/index.tsx +85 -0
  57. package/slides/delivery-plan/PROMPT.md +45 -0
  58. package/slides/delivery-plan/index.tsx +105 -0
  59. package/slides/experiment/PROMPT.md +44 -0
  60. package/slides/experiment/index.tsx +127 -0
  61. package/slides/incident-workflow/PROMPT.md +57 -0
  62. package/slides/incident-workflow/index.tsx +78 -0
  63. package/slides/introducing-konpeki/PROMPT.md +19 -0
  64. package/slides/introducing-konpeki/README.md +54 -0
  65. package/slides/introducing-konpeki/SOURCE.md +20 -0
  66. package/slides/introducing-konpeki/author.ts +163 -0
  67. package/slides/introducing-konpeki/composition.json +3265 -0
  68. package/slides/line-chart/PROMPT.md +40 -0
  69. package/slides/line-chart/index.tsx +72 -0
  70. package/slides/migration/PROMPT.md +38 -0
  71. package/slides/migration/index.tsx +89 -0
  72. package/slides/og-images/PROMPT.md +21 -0
  73. package/slides/og-images/index.tsx +76 -0
  74. package/slides/product-introduction/PROMPT.md +24 -0
  75. package/slides/product-introduction/index.tsx +105 -0
  76. package/slides/research-brief/PROMPT.md +40 -0
  77. package/slides/research-brief/index.tsx +104 -0
  78. package/slides/results-explanation/PROMPT.md +32 -0
  79. package/slides/results-explanation/index.tsx +96 -0
  80. package/slides/retrospective/PROMPT.md +43 -0
  81. package/slides/retrospective/index.tsx +105 -0
  82. package/slides/sankey/PROMPT.md +11 -0
  83. package/slides/sankey/index.tsx +93 -0
  84. package/slides/teaching/PROMPT.md +45 -0
  85. package/slides/teaching/index.tsx +124 -0
  86. package/slides/vertical-bar-charts/PROMPT.md +13 -0
  87. package/slides/vertical-bar-charts/index.tsx +97 -0
  88. package/src/app/App.tsx +694 -0
  89. package/src/assets/konpeki-mark.png +0 -0
  90. package/src/components/Canvas.tsx +1168 -0
  91. package/src/components/DiagramTypeIcon.tsx +78 -0
  92. package/src/components/InspectorPanel.tsx +687 -0
  93. package/src/components/LeftPanel.tsx +107 -0
  94. package/src/components/PageSizePicker.tsx +30 -0
  95. package/src/components/Presentation.tsx +105 -0
  96. package/src/components/RightPanel.tsx +201 -0
  97. package/src/components/VectorOverflowWarning.tsx +46 -0
  98. package/src/components/WorkspaceChrome.tsx +199 -0
  99. package/src/components/ui.tsx +53 -0
  100. package/src/lib/examples/react-page-migration.json +1295 -0
  101. package/src/lib/examples.ts +42 -0
  102. package/src/lib/export-png.ts +101 -0
  103. package/src/lib/file-session.ts +74 -0
  104. package/src/lib/history.ts +53 -0
  105. package/src/lib/model.ts +188 -0
  106. package/src/lib/page-size.ts +24 -0
  107. package/src/lib/presentation.ts +17 -0
  108. package/src/lib/storage.ts +43 -0
  109. package/src/lib/theme.ts +25 -0
  110. package/src/lib/use-file-session.ts +162 -0
  111. package/src/main.tsx +26 -0
  112. package/src/styles/base.css +105 -0
  113. package/src/styles/canvas.css +268 -0
  114. package/src/styles/chrome.css +214 -0
  115. package/src/styles/component-previews.css +386 -0
  116. package/src/styles/feedback.css +71 -0
  117. package/src/styles/left-panel.css +125 -0
  118. package/src/styles/presentation.css +72 -0
  119. package/src/styles/right-panel.css +1172 -0
  120. package/src/styles/shell.css +247 -0
  121. package/vite.config.ts +6 -0
@@ -0,0 +1,1168 @@
1
+ import {
2
+ createElement,
3
+ useRef,
4
+ useState,
5
+ type CSSProperties,
6
+ type PointerEvent,
7
+ } from "react";
8
+ import {
9
+ componentKinds,
10
+ type CompositionComponent,
11
+ type Rect,
12
+ type VectorElement,
13
+ } from "../../composition/types.ts";
14
+ import { tableStyleForAppearance } from "../../composition/schema.ts";
15
+ import { diagramDefinition } from "../../composition/visualizations.ts";
16
+ import {
17
+ componentInstanceLabel,
18
+ defaultAppearance,
19
+ getSlide,
20
+ snapRect,
21
+ snapResizeRect,
22
+ type Draft,
23
+ type Guide,
24
+ } from "../lib/model.ts";
25
+ import { composerPalette, themeLabel } from "../lib/theme.ts";
26
+ import { getTheme } from "../../design/themes/index.ts";
27
+ import { resolveVectorAttribute } from "../../composition/theme-tokens.ts";
28
+ import { DiagramTypeIcon } from "./DiagramTypeIcon.tsx";
29
+
30
+ type ResizeCorner = "nw" | "ne" | "sw" | "se";
31
+
32
+ function resizeFromCorner(rect: Rect, dx: number, dy: number, corner: ResizeCorner) {
33
+ const left = corner.endsWith("w");
34
+ const top = corner.startsWith("n");
35
+ return {
36
+ x: left ? rect.x + dx : rect.x,
37
+ y: top ? rect.y + dy : rect.y,
38
+ width: rect.width + (left ? -dx : dx),
39
+ height: rect.height + (top ? -dy : dy),
40
+ };
41
+ }
42
+
43
+ function fallbackTopologyRects(
44
+ width: number,
45
+ height: number,
46
+ count: number,
47
+ layout: string,
48
+ ) {
49
+ const nodeWidth = Math.min(220, width / Math.max(2, count + 0.5));
50
+ const nodeHeight = Math.min(100, height * 0.28);
51
+ const centered = (cx: number, cy: number) => ({
52
+ x: Math.max(0, Math.min(width - nodeWidth, cx - nodeWidth / 2)),
53
+ y: Math.max(0, Math.min(height - nodeHeight, cy - nodeHeight / 2)),
54
+ width: nodeWidth,
55
+ height: nodeHeight,
56
+ });
57
+ if (layout === "cyclical" || layout === "hub-and-spoke") {
58
+ const hub = layout === "hub-and-spoke";
59
+ return Array.from({ length: count }, (_, index) => {
60
+ if (hub && index === 0) return centered(width / 2, height / 2);
61
+ const radialCount = hub ? count - 1 : count;
62
+ const radialIndex = hub ? index - 1 : index;
63
+ const angle = -Math.PI / 2 + radialIndex * Math.PI * 2 / Math.max(1, radialCount);
64
+ return centered(
65
+ width / 2 + Math.cos(angle) * Math.max(0, width / 2 - nodeWidth * 0.75),
66
+ height / 2 + Math.sin(angle) * Math.max(0, height / 2 - nodeHeight * 0.75),
67
+ );
68
+ });
69
+ }
70
+ if (layout === "branching")
71
+ return Array.from({ length: count }, (_, index) =>
72
+ index === 0
73
+ ? centered(nodeWidth * 0.7, height / 2)
74
+ : centered(
75
+ width - nodeWidth * 0.7,
76
+ (index - 0.5) * height / Math.max(1, count - 1),
77
+ ),
78
+ );
79
+ if (layout === "layered")
80
+ return Array.from({ length: count }, (_, index) =>
81
+ centered(
82
+ (index + 1) * width / (count + 1),
83
+ (index + 1) * height / (count + 1),
84
+ ),
85
+ );
86
+ if (layout === "request-flow")
87
+ return Array.from({ length: count }, (_, index) =>
88
+ centered(
89
+ index % 2 ? width * 0.72 : width * 0.28,
90
+ (Math.floor(index / 2) + 1) * height / (Math.ceil(count / 2) + 1),
91
+ ),
92
+ );
93
+ return Array.from({ length: count }, (_, index) =>
94
+ centered((index + 1) * width / (count + 1), height / 2),
95
+ );
96
+ }
97
+
98
+ function Illustration({ component }: { component: CompositionComponent }) {
99
+ const appearance = {
100
+ ...defaultAppearance(component.kind),
101
+ ...(component.appearance as Record<string, string> | undefined),
102
+ };
103
+ const template = component.kind === "diagram"
104
+ ? diagramDefinition(component.appearance.type).layout
105
+ : appearance?.template;
106
+ if (component.kind === "chart" && !component.topology) {
107
+ const count = appearance?.density === "standard" ? 4 : 3;
108
+ const legend = appearance?.legend ?? "none";
109
+ return (
110
+ <div className={`chart-preview legend-${legend}`}>
111
+ <svg className="illustration" viewBox="0 0 600 240" aria-hidden="true">
112
+ {template === "line" ? (
113
+ <>
114
+ <path d="M30 195H570M30 20V195" className="chart-axis" />
115
+ <path
116
+ d={
117
+ count === 4
118
+ ? "M30 165L140 110L260 137L390 58L560 35"
119
+ : "M30 165L260 137L560 35"
120
+ }
121
+ className="chart-line"
122
+ />
123
+ </>
124
+ ) : template === "pie" ? (
125
+ <>
126
+ <circle cx="300" cy="120" r="92" className="chart-context" />
127
+ <path
128
+ d="M300 120V28A92 92 0 0 1 386 153Z"
129
+ className="chart-accent"
130
+ />
131
+ <path
132
+ d="M300 120 386 153A92 92 0 0 1 252 199Z"
133
+ className="chart-tertiary"
134
+ />
135
+ </>
136
+ ) : template === "radar" ? (
137
+ <>
138
+ <path d="M300 25 410 88 368 205 232 205 190 88Z" className="chart-axis" />
139
+ <path d="M300 58 378 101 348 174 255 188 219 99Z" className="chart-context" />
140
+ <path d="M300 42 390 112 342 188 246 168 210 94Z" className="chart-line" />
141
+ <path d="M300 25V215M180 88 420 174M420 88 180 174" className="chart-axis" />
142
+ </>
143
+ ) : template === "treemap" ? (
144
+ <>
145
+ <rect x="30" y="25" width="330" height="190" className="chart-accent" />
146
+ <rect x="370" y="25" width="200" height="110" className="chart-context" />
147
+ <rect x="370" y="145" width="125" height="70" className="chart-tertiary" />
148
+ <rect x="505" y="145" width="65" height="70" className="chart-context" />
149
+ </>
150
+ ) : template === "scatter" ? (
151
+ <>
152
+ <path d="M30 215H570M30 20V215" className="chart-axis" />
153
+ {[[95, 175], [142, 158], [205, 147], [248, 122], [315, 133], [370, 92], [428, 74], [515, 48]].map(([cx, cy], index) => (
154
+ <circle key={index} cx={cx} cy={cy} r={index === 7 ? 11 : 7} className={index === 7 ? "chart-accent" : "chart-context"} />
155
+ ))}
156
+ </>
157
+ ) : template === "sankey" ? (
158
+ <>
159
+ <path d="M70 72C210 72 230 105 350 105S450 58 530 58" className="chart-line" style={{ strokeWidth: 28, opacity: 0.72 }} />
160
+ <path d="M70 150C210 150 235 116 350 116S450 170 530 170" className="chart-axis" style={{ strokeWidth: 19, opacity: 0.7 }} />
161
+ <path d="M70 92C205 92 240 162 350 162S450 112 530 112" className="chart-line" style={{ strokeWidth: 10, opacity: 0.42 }} />
162
+ <rect x="55" y="50" width="20" height="125" className="chart-context" />
163
+ <rect x="340" y="85" width="20" height="98" className="chart-tertiary" />
164
+ <rect x="525" y="38" width="20" height="154" className="chart-accent" />
165
+ </>
166
+ ) : template === "annotated-detail" ? (
167
+ <>
168
+ <rect
169
+ x="60"
170
+ y="20"
171
+ width="480"
172
+ height="195"
173
+ className="chart-context"
174
+ />
175
+ <path
176
+ d="M90 195L220 70L325 155L410 100L510 195"
177
+ fill="none"
178
+ className="chart-line"
179
+ />
180
+ <circle cx="430" cy="60" r="15" className="chart-accent" />
181
+ <path d="M340 95L565 30" className="chart-axis" />
182
+ </>
183
+ ) : appearance?.orientation === "horizontal" ? (
184
+ <>
185
+ {[155, 330, 245, 285].slice(0, count).map((width, i) => (
186
+ <g key={i}>
187
+ <rect
188
+ x="25"
189
+ y={14 + i * (200 / count)}
190
+ width={width}
191
+ height="18"
192
+ className="chart-accent"
193
+ />
194
+ <rect
195
+ x="25"
196
+ y={36 + i * (200 / count)}
197
+ width={width * 0.72}
198
+ height="13"
199
+ className="chart-context"
200
+ />
201
+ </g>
202
+ ))}
203
+ </>
204
+ ) : (
205
+ <>
206
+ <path d="M25 215H575" className="chart-axis" />
207
+ {[92, 175, 130, 155].slice(0, count).map((height, i) => {
208
+ const step = 510 / count;
209
+ return (
210
+ <g key={i}>
211
+ <rect
212
+ x={48 + i * step}
213
+ y={215 - height}
214
+ width="42"
215
+ height={height}
216
+ className="chart-accent"
217
+ />
218
+ <rect
219
+ x={94 + i * step}
220
+ y={215 - height * 0.7}
221
+ width="34"
222
+ height={height * 0.7}
223
+ className="chart-context"
224
+ />
225
+ </g>
226
+ );
227
+ })}
228
+ </>
229
+ )}
230
+ </svg>
231
+ {legend !== "none" && (
232
+ <span className="chart-legend" aria-hidden="true">
233
+ <span><i className="legend-accent" />A</span>
234
+ <span><i className="legend-context" />B</span>
235
+ </span>
236
+ )}
237
+ </div>
238
+ );
239
+ }
240
+ if (component.kind === "diagram" || component.kind === "chart") {
241
+ const topology = component.topology;
242
+ const positionedNodes = topology
243
+ ? (() => {
244
+ const fallbackRects = fallbackTopologyRects(
245
+ component.preferredRect.width,
246
+ component.preferredRect.height,
247
+ topology.nodes.length,
248
+ template ?? "linear",
249
+ );
250
+ return topology.nodes.map((node, index) => {
251
+ const rect = node.preferredRect;
252
+ if (!rect) return { ...node, rect: fallbackRects[index] };
253
+ return {
254
+ ...node,
255
+ rect: {
256
+ x: rect.x - component.preferredRect.x,
257
+ y: rect.y - component.preferredRect.y,
258
+ width: rect.width,
259
+ height: rect.height,
260
+ },
261
+ };
262
+ });
263
+ })()
264
+ : undefined;
265
+ if (positionedNodes) {
266
+ const explicitNodes = positionedNodes.filter((node) => node.visible !== false);
267
+ const byId = new Map(positionedNodes.map((node) => [node.id, node]));
268
+ const centers = (explicitNodes.length ? explicitNodes : positionedNodes).map((node) => ({
269
+ x: node.rect.x + node.rect.width / 2,
270
+ y: node.rect.y + node.rect.height / 2,
271
+ }));
272
+ const center = {
273
+ x: centers.reduce((sum, point) => sum + point.x, 0) / centers.length,
274
+ y: centers.reduce((sum, point) => sum + point.y, 0) / centers.length,
275
+ };
276
+ const curved = template === "cyclical";
277
+ const curveOffset = Math.min(
278
+ component.preferredRect.width,
279
+ component.preferredRect.height,
280
+ ) * 0.16;
281
+ const boundaryPoint = (
282
+ node: (typeof explicitNodes)[number],
283
+ toward: { x: number; y: number },
284
+ ) => {
285
+ const origin = {
286
+ x: node.rect.x + node.rect.width / 2,
287
+ y: node.rect.y + node.rect.height / 2,
288
+ };
289
+ const dx = toward.x - origin.x;
290
+ const dy = toward.y - origin.y;
291
+ if (!dx && !dy) return origin;
292
+ const scale = Math.min(
293
+ dx ? node.rect.width / 2 / Math.abs(dx) : Infinity,
294
+ dy ? node.rect.height / 2 / Math.abs(dy) : Infinity,
295
+ );
296
+ return { x: origin.x + dx * scale, y: origin.y + dy * scale };
297
+ };
298
+ const edgePath = (from: (typeof explicitNodes)[number], to: (typeof explicitNodes)[number]) => {
299
+ const fromCenter = {
300
+ x: from.rect.x + from.rect.width / 2,
301
+ y: from.rect.y + from.rect.height / 2,
302
+ };
303
+ const toCenter = {
304
+ x: to.rect.x + to.rect.width / 2,
305
+ y: to.rect.y + to.rect.height / 2,
306
+ };
307
+ const start = boundaryPoint(from, toCenter);
308
+ const end = boundaryPoint(to, fromCenter);
309
+ if (!curved) return `M${start.x} ${start.y}L${end.x} ${end.y}`;
310
+ const midpoint = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
311
+ const outward = { x: midpoint.x - center.x, y: midpoint.y - center.y };
312
+ const length = Math.hypot(outward.x, outward.y) || 1;
313
+ return `M${start.x} ${start.y}Q${midpoint.x + outward.x / length * curveOffset} ${midpoint.y + outward.y / length * curveOffset} ${end.x} ${end.y}`;
314
+ };
315
+ const edgeMidpoint = (from: (typeof explicitNodes)[number], to: (typeof explicitNodes)[number]) => ({
316
+ x: (from.rect.x + from.rect.width / 2 + to.rect.x + to.rect.width / 2) / 2,
317
+ y: (from.rect.y + from.rect.height / 2 + to.rect.y + to.rect.height / 2) / 2,
318
+ });
319
+ const markerId = `topology-arrow-${component.id.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
320
+ return (
321
+ <svg
322
+ className="illustration topology-preview"
323
+ viewBox={`0 0 ${component.preferredRect.width} ${component.preferredRect.height}`}
324
+ aria-hidden="true"
325
+ >
326
+ <defs>
327
+ <marker
328
+ id={markerId}
329
+ viewBox="0 0 10 10"
330
+ refX="9"
331
+ refY="5"
332
+ markerWidth="7"
333
+ markerHeight="7"
334
+ orient="auto-start-reverse"
335
+ >
336
+ <path d="M0 0 10 5 0 10Z" className="topology-arrowhead" />
337
+ </marker>
338
+ </defs>
339
+ <g className="chart-axis">
340
+ {component.topology!.edges.map((edge, index) => {
341
+ const from = byId.get(edge.from)!;
342
+ const to = byId.get(edge.to)!;
343
+ if (from.visible === false || to.visible === false) return null;
344
+ return (
345
+ <path
346
+ key={`${edge.from}-${edge.to}-${index}`}
347
+ d={edgePath(from, to)}
348
+ markerEnd={`url(#${markerId})`}
349
+ />
350
+ );
351
+ })}
352
+ </g>
353
+ {component.topology!.edges.map((edge, index) => {
354
+ if (!edge.label) return null;
355
+ const from = byId.get(edge.from)!;
356
+ const to = byId.get(edge.to)!;
357
+ if (from.visible === false || to.visible === false) return null;
358
+ const midpoint = edgeMidpoint(from, to);
359
+ return (
360
+ <text
361
+ key={`label-${edge.from}-${edge.to}-${index}`}
362
+ x={midpoint.x}
363
+ y={midpoint.y - 8}
364
+ textAnchor="middle"
365
+ className="topology-edge-label"
366
+ >
367
+ {edge.label}
368
+ </text>
369
+ );
370
+ })}
371
+ {explicitNodes.map((node) => (
372
+ <g key={node.id}>
373
+ {node.primitive?.kind === "shape" && node.primitive.shape === "circle" ? (
374
+ <ellipse
375
+ cx={node.rect.x + node.rect.width / 2}
376
+ cy={node.rect.y + node.rect.height / 2}
377
+ rx={node.rect.width / 2}
378
+ ry={node.rect.height / 2}
379
+ className={`topology-node fill-${node.primitive.fill ?? "none"} color-${node.primitive.color ?? "accent"}`}
380
+ />
381
+ ) : node.primitive?.kind === "shape" && node.primitive.shape === "line" ? (
382
+ <path
383
+ d={`M${node.rect.x} ${node.rect.y + node.rect.height / 2}H${node.rect.x + node.rect.width}`}
384
+ className={`topology-node topology-line color-${node.primitive.color ?? "accent"}`}
385
+ />
386
+ ) : (
387
+ <rect
388
+ {...node.rect}
389
+ rx={node.primitive?.kind === "shape" && node.primitive.shape === "rectangle"
390
+ ? 0
391
+ : curved
392
+ ? node.rect.width / 2
393
+ : Math.min(node.rect.width, node.rect.height) * 0.08}
394
+ className={`topology-node ${node.primitive?.kind === "shape" ? `fill-${node.primitive.fill ?? "none"} color-${node.primitive.color ?? "accent"}` : ""}`}
395
+ />
396
+ )}
397
+ {node.primitive?.kind === "icon" && (
398
+ <path
399
+ d={`M${node.rect.x + node.rect.width * 0.35} ${node.rect.y + node.rect.height * 0.62}L${node.rect.x + node.rect.width * 0.5} ${node.rect.y + node.rect.height * 0.35}L${node.rect.x + node.rect.width * 0.65} ${node.rect.y + node.rect.height * 0.62}Z`}
400
+ className={`topology-icon style-${node.primitive.style ?? "outline"} color-${node.primitive.color ?? "accent"}`}
401
+ />
402
+ )}
403
+ <text
404
+ x={node.rect.x + node.rect.width / 2}
405
+ y={node.rect.y + node.rect.height / 2}
406
+ dy="0.35em"
407
+ textAnchor="middle"
408
+ className="node-text topology-node-label"
409
+ >
410
+ {node.primitive?.kind === "icon"
411
+ ? node.primitive.name?.trim() || "Icon"
412
+ : node.id}
413
+ </text>
414
+ </g>
415
+ ))}
416
+ </svg>
417
+ );
418
+ }
419
+ if (component.kind === "chart") return null;
420
+ return (
421
+ <DiagramTypeIcon
422
+ type={component.appearance.type}
423
+ className="diagram-canvas-icon"
424
+ />
425
+ );
426
+ }
427
+ if (component.kind === "image")
428
+ return (
429
+ <svg className="illustration image-preview" viewBox="0 0 600 240" aria-hidden="true">
430
+ <rect x="24" y="18" width="552" height="204" rx="4" className="chart-context" />
431
+ <circle cx="455" cy="66" r="22" className="chart-accent" />
432
+ <path d="M45 205 190 72l112 103 80-68 173 98Z" className="image-mountain" />
433
+ </svg>
434
+ );
435
+ if (component.kind === "table") {
436
+ const columns = 3;
437
+ const rows = 4;
438
+ const tableStyle = tableStyleForAppearance(appearance);
439
+ return (
440
+ <span
441
+ className={`table-preview table-style-${tableStyle} table-density-${appearance?.density ?? "sparse"}`}
442
+ aria-hidden="true"
443
+ >
444
+ {Array.from({ length: rows * columns }, (_, index) => {
445
+ const row = Math.floor(index / columns);
446
+ const isHeader = row === 0;
447
+ return <i key={index} className={isHeader ? "table-header" : ""} />;
448
+ })}
449
+ </span>
450
+ );
451
+ }
452
+ return null;
453
+ }
454
+
455
+ function textContentStyle(component: Extract<CompositionComponent, { kind: "text-block" }>, pageWidth: number): CSSProperties {
456
+ const style = component.textStyle;
457
+ return {
458
+ fontSize: `${(style?.size ?? 36) * 100 / pageWidth}cqw`,
459
+ fontWeight: style?.weight ?? 400,
460
+ lineHeight: style?.lineHeight ?? 1.4,
461
+ fontFamily: `var(--vector-${style?.font ?? "body"}-font)`,
462
+ color: `var(--slide-${style?.color ?? "ink"})`,
463
+ };
464
+ }
465
+
466
+ function componentColors(
467
+ component: CompositionComponent,
468
+ palette: ReturnType<typeof composerPalette>,
469
+ ) {
470
+ const appearance = {
471
+ ...defaultAppearance(component.kind),
472
+ ...(component.appearance as Record<string, string> | undefined),
473
+ };
474
+ const scheme = appearance?.colorScheme ?? "accent-with-muted-context";
475
+ let accent = palette.accent;
476
+ let context = palette.line;
477
+ if (scheme === "monochrome") [accent, context] = [palette.fg, palette.muted];
478
+ if (scheme === "categorical")
479
+ [accent, context] = [palette.categorical[0], palette.categorical[1]];
480
+ if (scheme === "sequential")
481
+ [accent, context] = [palette.sequential[4], palette.sequential[1]];
482
+ if (scheme === "status")
483
+ [accent, context] = [palette.status.complete, palette.status.blocked];
484
+ if (appearance?.emphasis === "none") accent = context;
485
+ if (appearance?.emphasis === "latest-series") accent = palette.categorical[2];
486
+ if (appearance?.emphasis === "exception") accent = palette.emphasis;
487
+ return {
488
+ "--component-accent": accent,
489
+ "--component-context": context,
490
+ } as CSSProperties;
491
+ }
492
+
493
+ function customVisualSource(component: CompositionComponent) {
494
+ if (!component.customVisual || component.customVisual.format !== "svg") return undefined;
495
+ return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(component.customVisual.source)}`;
496
+ }
497
+
498
+ const reactSvgAttributes: Record<string, string> = {
499
+ "fill-opacity": "fillOpacity",
500
+ "font-family": "fontFamily",
501
+ "font-size": "fontSize",
502
+ "font-style": "fontStyle",
503
+ "font-weight": "fontWeight",
504
+ "stroke-dasharray": "strokeDasharray",
505
+ "stroke-linecap": "strokeLinecap",
506
+ "stroke-linejoin": "strokeLinejoin",
507
+ "stroke-opacity": "strokeOpacity",
508
+ "stroke-width": "strokeWidth",
509
+ "text-anchor": "textAnchor",
510
+ "dominant-baseline": "dominantBaseline",
511
+ "vector-effect": "vectorEffect",
512
+ };
513
+
514
+ function vectorProps(attributes: VectorElement["attributes"]) {
515
+ return Object.fromEntries(
516
+ Object.entries(attributes).map(([key, value]) => [reactSvgAttributes[key] ?? key, resolveVectorAttribute(key, value)]),
517
+ );
518
+ }
519
+
520
+ function EditableVectorVisual({
521
+ component,
522
+ interactive,
523
+ active,
524
+ selectedElementId,
525
+ onSelectElement,
526
+ onElement,
527
+ onEditEnd,
528
+ }: {
529
+ component: CompositionComponent;
530
+ interactive: boolean;
531
+ active: boolean;
532
+ selectedElementId?: string;
533
+ onSelectElement: (id?: string, edit?: boolean) => void;
534
+ onElement: (element: VectorElement, mergeKey?: string) => void;
535
+ onEditEnd: () => void;
536
+ }) {
537
+ const visual = component.customVisual;
538
+ if (!visual || visual.format !== "vector") return null;
539
+ const vectorVisual = visual;
540
+ const svg = useRef<SVGSVGElement>(null);
541
+ const gesture = useRef<{
542
+ id: string;
543
+ endpoint: "start" | "end";
544
+ pointerId: number;
545
+ } | undefined>(undefined);
546
+ const children = new Map<string | undefined, VectorElement[]>();
547
+ for (const element of visual.elements) {
548
+ const peers = children.get(element.parentId) ?? [];
549
+ peers.push(element);
550
+ children.set(element.parentId, peers);
551
+ }
552
+ function renderElement(element: VectorElement): React.ReactNode {
553
+ const nested = (children.get(element.id) ?? []).map(renderElement);
554
+ return createElement(
555
+ element.kind,
556
+ {
557
+ ...vectorProps(element.attributes),
558
+ key: element.id,
559
+ "data-vector-element": element.id,
560
+ className: selectedElementId === element.id ? "vector-element selected" : "vector-element",
561
+ onDoubleClick: interactive && active
562
+ ? (event: React.MouseEvent<SVGElement>) => {
563
+ event.stopPropagation();
564
+ onSelectElement(element.id, true);
565
+ }
566
+ : undefined,
567
+ onPointerDown: interactive && active
568
+ ? (event: React.PointerEvent<SVGElement>) => {
569
+ event.stopPropagation();
570
+ onSelectElement(element.id);
571
+ }
572
+ : undefined,
573
+ },
574
+ element.text,
575
+ ...nested,
576
+ );
577
+ }
578
+ const selected = vectorVisual.elements.find((element) => element.id === selectedElementId);
579
+ const line = selected?.kind === "line" && !selected.parentId ? selected : undefined;
580
+ function point(event: React.PointerEvent<SVGCircleElement>) {
581
+ const point = svg.current!.createSVGPoint();
582
+ point.x = event.clientX;
583
+ point.y = event.clientY;
584
+ return point.matrixTransform(event.currentTarget.getScreenCTM()!.inverse());
585
+ }
586
+ function handleMove(event: React.PointerEvent<SVGCircleElement>) {
587
+ const current = gesture.current;
588
+ if (!current || current.pointerId !== event.pointerId || !line) return;
589
+ const next = point(event);
590
+ const attributes = {
591
+ ...line.attributes,
592
+ [current.endpoint === "start" ? "x1" : "x2"]: Math.round(next.x),
593
+ [current.endpoint === "start" ? "y1" : "y2"]: Math.round(next.y),
594
+ };
595
+ onElement({ ...line, attributes }, `vector:${component.id}:${line.id}`);
596
+ }
597
+ const preserveAspectRatio = vectorVisual.fit === "stretch"
598
+ ? "none"
599
+ : vectorVisual.fit === "cover"
600
+ ? "xMidYMid slice"
601
+ : "xMidYMid meet";
602
+ return (
603
+ <svg
604
+ ref={svg}
605
+ className={`custom-vector-art ${active ? "editing" : ""}`}
606
+ viewBox={`${vectorVisual.viewBox.x} ${vectorVisual.viewBox.y} ${vectorVisual.viewBox.width} ${vectorVisual.viewBox.height}`}
607
+ preserveAspectRatio={preserveAspectRatio}
608
+ role="img"
609
+ aria-label={vectorVisual.description}
610
+ >
611
+ {(children.get(undefined) ?? []).map(renderElement)}
612
+ {interactive && active && line && (
613
+ <g className="vector-handles" transform={String(line.attributes.transform ?? "")} aria-label={`Edit line ${line.id}`}>
614
+ {(["start", "end"] as const).map((endpoint) => {
615
+ const x = Number(line.attributes[endpoint === "start" ? "x1" : "x2"] ?? 0);
616
+ const y = Number(line.attributes[endpoint === "start" ? "y1" : "y2"] ?? 0);
617
+ return (
618
+ <circle
619
+ key={endpoint}
620
+ cx={x}
621
+ cy={y}
622
+ r={Math.max(8, vectorVisual.viewBox.width / 150)}
623
+ tabIndex={0}
624
+ aria-label={`${endpoint} endpoint`}
625
+ onPointerDown={(event) => {
626
+ event.stopPropagation();
627
+ event.currentTarget.setPointerCapture(event.pointerId);
628
+ gesture.current = { id: line.id, endpoint, pointerId: event.pointerId };
629
+ }}
630
+ onPointerMove={handleMove}
631
+ onPointerUp={(event) => {
632
+ if (gesture.current?.pointerId !== event.pointerId) return;
633
+ gesture.current = undefined;
634
+ onEditEnd();
635
+ }}
636
+ />
637
+ );
638
+ })}
639
+ </g>
640
+ )}
641
+ </svg>
642
+ );
643
+ }
644
+
645
+ export function Canvas({
646
+ mode = "edit",
647
+ draft,
648
+ activeSlideId,
649
+ selected,
650
+ vectorSelection,
651
+ onSelect,
652
+ onVectorSelect,
653
+ onSlideName,
654
+ onComponent,
655
+ onDuplicate,
656
+ onEditEnd,
657
+ onAdd,
658
+ onNotice,
659
+ }: {
660
+ mode?: "edit" | "present";
661
+ draft: Draft;
662
+ activeSlideId: string;
663
+ selected?: string;
664
+ vectorSelection?: { componentId: string; elementId?: string };
665
+ onSelect: (id?: string) => void;
666
+ onVectorSelect?: (selection?: { componentId: string; elementId?: string }, edit?: boolean) => void;
667
+ onSlideName: (name: string) => void;
668
+ onComponent: (component: CompositionComponent, mergeKey?: string) => void;
669
+ onDuplicate?: (id: string, rect: Rect) => CompositionComponent | undefined;
670
+ onEditEnd: () => void;
671
+ onAdd: (
672
+ kind: CompositionComponent["kind"],
673
+ at: { x: number; y: number },
674
+ ) => void;
675
+ onNotice: (message: string) => void;
676
+ }) {
677
+ const interactive = mode === "edit";
678
+ const slide = getSlide(draft, activeSlideId);
679
+ const page = String(draft.slides.indexOf(slide) + 1).padStart(2, "0");
680
+ const pageTotal = String(draft.slides.length).padStart(2, "0");
681
+ const canvas = useRef<HTMLDivElement>(null);
682
+ const gesture = useRef<{
683
+ id: string;
684
+ x: number;
685
+ y: number;
686
+ rect: Rect;
687
+ corner?: ResizeCorner;
688
+ scale: number;
689
+ duplicate?: boolean;
690
+ } | null>(null);
691
+ const [guides, setGuides] = useState<Guide[]>([]);
692
+ const [editingIntent, setEditingIntent] = useState<string>();
693
+ const [editingSlideName, setEditingSlideName] = useState(false);
694
+ const [nextSlideName, setNextSlideName] = useState(slide.name);
695
+ function start(
696
+ event: PointerEvent,
697
+ component: CompositionComponent,
698
+ corner?: ResizeCorner,
699
+ ) {
700
+ if (event.button !== 0) return;
701
+ event.stopPropagation();
702
+ event.currentTarget.setPointerCapture(event.pointerId);
703
+ (event.currentTarget as HTMLElement).focus();
704
+ onSelect(component.id);
705
+ gesture.current = {
706
+ id: component.id,
707
+ duplicate: event.altKey && !corner,
708
+ x: event.clientX,
709
+ y: event.clientY,
710
+ rect: component.preferredRect,
711
+ corner,
712
+ scale: canvas.current!.getBoundingClientRect().width / slide.canvas.width,
713
+ };
714
+ }
715
+ function move(event: PointerEvent) {
716
+ const active = gesture.current;
717
+ if (!active) return;
718
+ const component = slide.components.find((c) => c.id === active.id)!;
719
+ const dx = (event.clientX - active.x) / active.scale;
720
+ const dy = (event.clientY - active.y) / active.scale;
721
+ if (active.duplicate && Math.hypot(event.clientX - active.x, event.clientY - active.y) < 3) return;
722
+ const rect = active.corner
723
+ ? resizeFromCorner(active.rect, dx, dy, active.corner)
724
+ : { ...active.rect, x: active.rect.x + dx, y: active.rect.y + dy };
725
+ const others = slide.components
726
+ .filter((c) => c.id !== active.id)
727
+ .map((c) => c.preferredRect);
728
+ const result = active.corner
729
+ ? snapResizeRect(rect, others, 14, {
730
+ left: active.corner.endsWith("w"),
731
+ top: active.corner.startsWith("n"),
732
+ right: active.corner.endsWith("e"),
733
+ bottom: active.corner.startsWith("s"),
734
+ }, slide.canvas, slide.innerPadding)
735
+ : snapRect(rect, others, 14, slide.canvas, slide.innerPadding);
736
+ setGuides(result.guides);
737
+ if (active.duplicate) {
738
+ active.duplicate = false;
739
+ const copy = onDuplicate?.(component.id, result.rect);
740
+ if (!copy) { gesture.current = null; return; }
741
+ active.id = copy.id;
742
+ return;
743
+ }
744
+ onComponent(
745
+ { ...component, preferredRect: result.rect },
746
+ `geometry:${component.id}`,
747
+ );
748
+ }
749
+ function end() {
750
+ if (!gesture.current) return;
751
+ gesture.current = null;
752
+ setGuides([]);
753
+ onEditEnd();
754
+ }
755
+ const themeId = draft.theme?.id ?? "plex";
756
+ const themeMode = draft.theme?.mode ?? "paper";
757
+ const palette = composerPalette(themeId, themeMode);
758
+ const typography = getTheme(themeLabel(themeId), themeMode);
759
+ const canvasStyle = {
760
+ "--component-layers": slide.paintOrder.length,
761
+ "--slide-bg": palette.bg,
762
+ "--slide-accent": palette.accent,
763
+ "--slide-ink": palette.fg,
764
+ "--slide-muted": palette.muted,
765
+ "--slide-context": palette.line,
766
+ "--slide-wash": palette.wash,
767
+ "--vector-ink": palette.fg,
768
+ "--vector-muted": palette.muted,
769
+ "--vector-background": palette.bg,
770
+ "--vector-surface": palette.surface,
771
+ "--vector-divider": palette.line,
772
+ "--vector-accent": palette.accent,
773
+ "--vector-on-accent": palette.bg,
774
+ "--vector-wash": palette.wash,
775
+ "--vector-heading-font": typography.headline,
776
+ "--vector-body-font": typography.body,
777
+ } as CSSProperties;
778
+ return (
779
+ <main
780
+ id="canvas-stage"
781
+ className={`stage ${interactive ? "" : "presentation-stage"}`}
782
+ style={{ "--page-ratio": slide.canvas.width / slide.canvas.height } as CSSProperties}
783
+ tabIndex={-1}
784
+ onPointerDown={(event) => {
785
+ if (!interactive) return;
786
+ if (
787
+ event.target instanceof Element &&
788
+ !event.target.closest(".canvas")
789
+ )
790
+ onSelect();
791
+ }}
792
+ >
793
+ <div className="slide-wrap">
794
+ {interactive && <div className="stage-meta">
795
+ {editingSlideName ? (
796
+ <input
797
+ aria-label="Page name"
798
+ autoFocus
799
+ value={nextSlideName}
800
+ onChange={(event) => setNextSlideName(event.target.value)}
801
+ onBlur={() => {
802
+ const name = nextSlideName.trim();
803
+ if (name && name !== slide.name) onSlideName(name);
804
+ else setNextSlideName(slide.name);
805
+ setEditingSlideName(false);
806
+ }}
807
+ onKeyDown={(event) => {
808
+ if (event.key === "Enter") event.currentTarget.blur();
809
+ if (event.key === "Escape") {
810
+ setNextSlideName(slide.name);
811
+ setEditingSlideName(false);
812
+ }
813
+ }}
814
+ />
815
+ ) : (
816
+ <h2
817
+ title="Double-click to rename page"
818
+ onDoubleClick={() => {
819
+ setNextSlideName(slide.name);
820
+ setEditingSlideName(true);
821
+ }}
822
+ >
823
+ {slide.name}
824
+ </h2>
825
+ )}
826
+ {selected && slide.components.some((component) => component.id === selected && component.customVisual?.format === "vector") && (
827
+ <button
828
+ type="button"
829
+ className="edit-elements-action"
830
+ aria-pressed={vectorSelection?.componentId === selected}
831
+ onPointerDown={(event) => event.stopPropagation()}
832
+ onClick={() => {
833
+ setEditingIntent(undefined);
834
+ onVectorSelect?.(vectorSelection?.componentId === selected ? undefined : { componentId: selected });
835
+ }}
836
+ >
837
+ {vectorSelection?.componentId === selected ? "Done editing" : "Edit elements"}
838
+ </button>
839
+ )}
840
+ </div>}
841
+ <div
842
+ ref={canvas}
843
+ className={`canvas ${themeMode} ${interactive ? "" : "presentation-canvas"}`}
844
+ data-theme={themeId}
845
+ style={canvasStyle}
846
+ aria-label="Page canvas"
847
+ onPointerDown={(e) => {
848
+ if (interactive && e.target === e.currentTarget) onSelect();
849
+ }}
850
+ onPointerMove={interactive ? move : undefined}
851
+ onPointerUp={interactive ? () => end() : undefined}
852
+ onPointerCancel={interactive ? () => end() : undefined}
853
+ onLostPointerCapture={interactive ? () => end() : undefined}
854
+ onDragOver={(e) => {
855
+ if (!interactive) return;
856
+ e.preventDefault();
857
+ e.dataTransfer.dropEffect = "copy";
858
+ }}
859
+ onDrop={(e) => {
860
+ if (!interactive) return;
861
+ e.preventDefault();
862
+ const kind = e.dataTransfer.getData(
863
+ "application/konpeki-component",
864
+ );
865
+ if (!componentKinds.includes(kind as CompositionComponent["kind"]))
866
+ return;
867
+ const r = e.currentTarget.getBoundingClientRect();
868
+ onAdd(kind as CompositionComponent["kind"], {
869
+ x: ((e.clientX - r.left) / r.width) * slide.canvas.width,
870
+ y: ((e.clientY - r.top) / r.height) * slide.canvas.height,
871
+ });
872
+ }}
873
+ >
874
+ {slide.readingOrder
875
+ .flatMap((entry) => entry.kind === "component"
876
+ ? [entry.id]
877
+ : slide.groups.find((group) => group.id === entry.id)!.childIds)
878
+ .map((id) => slide.components.find((c) => c.id === id)!)
879
+ .map((component) => {
880
+ const r = component.preferredRect;
881
+ const label = componentInstanceLabel(
882
+ slide.components,
883
+ component.id,
884
+ );
885
+ const a = {
886
+ ...defaultAppearance(component.kind),
887
+ ...(component.appearance as Record<string, string> | undefined),
888
+ };
889
+ const style = {
890
+ ...componentColors(component, palette),
891
+ zIndex: slide.paintOrder.indexOf(component.id),
892
+ left: `${r.x * 100 / slide.canvas.width}%`,
893
+ top: `${r.y * 100 / slide.canvas.height}%`,
894
+ width: `${r.width * 100 / slide.canvas.width}%`,
895
+ height: `${r.height * 100 / slide.canvas.height}%`,
896
+ textAlign:
897
+ a?.alignment === "end"
898
+ ? "right"
899
+ : a?.alignment === "center"
900
+ ? "center"
901
+ : "left",
902
+ } as CSSProperties;
903
+ return (
904
+ <div
905
+ key={component.id}
906
+ data-component={component.id}
907
+ className={`slide-component kind-${component.kind} role-${a?.role ?? "body"} ${component.customVisual ? "custom-visual" : ""} ${interactive && selected === component.id ? "selected" : ""} border-${a?.border ?? "none"} rule-${a?.rule ?? "none"} title-${a?.titleStyle ?? "plain"} purpose-${a?.purpose ?? "narrative"} treatment-${a?.treatment ?? "plain"} color-${a?.color ?? "ink"} scheme-${a?.colorScheme ?? "accent-with-muted-context"} emphasis-${a?.emphasis ?? "primary"} density-${a?.density ?? "sparse"} fit-${a?.fit ?? "cover"}`}
908
+ style={style}
909
+ >
910
+ <div
911
+ className="component-surface"
912
+ role={interactive ? "button" : undefined}
913
+ tabIndex={interactive ? 0 : undefined}
914
+ aria-label={interactive ? `Select ${label}` : undefined}
915
+ aria-pressed={interactive ? selected === component.id : undefined}
916
+ onPointerDown={interactive ? (e) => {
917
+ if (
918
+ e.target instanceof Element &&
919
+ e.target.closest(".inline-intent-editor")
920
+ )
921
+ return;
922
+ start(e, component);
923
+ } : undefined}
924
+ onClick={interactive ? () => onSelect(component.id) : undefined}
925
+ onDoubleClick={interactive ? (e) => {
926
+ e.stopPropagation();
927
+ onSelect(component.id);
928
+ onVectorSelect?.(undefined);
929
+ setEditingIntent(component.id);
930
+ } : undefined}
931
+ onKeyDown={interactive ? (e) => {
932
+ if (e.target !== e.currentTarget) return;
933
+ if (e.key === "Enter") {
934
+ e.preventDefault();
935
+ onSelect(component.id);
936
+ if (component.customVisual?.format === "vector")
937
+ onVectorSelect?.({ componentId: component.id });
938
+ else if (!component.customVisual) setEditingIntent(component.id);
939
+ return;
940
+ }
941
+ if (e.key === " ") {
942
+ e.preventDefault();
943
+ onSelect(component.id);
944
+ return;
945
+ }
946
+ if (
947
+ ![
948
+ "ArrowLeft",
949
+ "ArrowRight",
950
+ "ArrowUp",
951
+ "ArrowDown",
952
+ ].includes(e.key)
953
+ )
954
+ return;
955
+ e.preventDefault();
956
+ const next = snapRect(
957
+ {
958
+ ...r,
959
+ x:
960
+ r.x +
961
+ (e.key === "ArrowRight"
962
+ ? 10
963
+ : e.key === "ArrowLeft"
964
+ ? -10
965
+ : 0),
966
+ y:
967
+ r.y +
968
+ (e.key === "ArrowDown"
969
+ ? 10
970
+ : e.key === "ArrowUp"
971
+ ? -10
972
+ : 0),
973
+ },
974
+ [],
975
+ 0,
976
+ slide.canvas,
977
+ slide.innerPadding,
978
+ ).rect;
979
+ onComponent(
980
+ { ...component, preferredRect: next },
981
+ `geometry:${component.id}`,
982
+ );
983
+ } : undefined}
984
+ onKeyUp={interactive ? (event) => {
985
+ if (
986
+ event.target === event.currentTarget &&
987
+ [
988
+ "ArrowLeft",
989
+ "ArrowRight",
990
+ "ArrowUp",
991
+ "ArrowDown",
992
+ ].includes(event.key)
993
+ )
994
+ onEditEnd();
995
+ } : undefined}
996
+ >
997
+ {component.customVisual?.format === "vector" ? (
998
+ <EditableVectorVisual
999
+ component={component}
1000
+ interactive={interactive}
1001
+ active={vectorSelection?.componentId === component.id}
1002
+ selectedElementId={
1003
+ vectorSelection?.componentId === component.id
1004
+ ? vectorSelection.elementId
1005
+ : undefined
1006
+ }
1007
+ onSelectElement={(elementId, edit) =>
1008
+ onVectorSelect?.({ componentId: component.id, elementId }, edit)
1009
+ }
1010
+ onElement={(element, mergeKey) => {
1011
+ if (component.customVisual?.format !== "vector") return;
1012
+ onComponent({
1013
+ ...component,
1014
+ customVisual: {
1015
+ ...component.customVisual,
1016
+ elements: component.customVisual.elements.map((item) =>
1017
+ item.id === element.id ? element : item,
1018
+ ),
1019
+ },
1020
+ }, mergeKey);
1021
+ }}
1022
+ onEditEnd={onEditEnd}
1023
+ />
1024
+ ) : component.customVisual ? (
1025
+ <img
1026
+ className={`custom-visual-art fit-${component.customVisual.fit ?? "contain"}`}
1027
+ src={customVisualSource(component)}
1028
+ alt={component.customVisual.description}
1029
+ draggable={false}
1030
+ />
1031
+ ) : component.kind === "text-block" ? (
1032
+ <div className="text-block-content" style={textContentStyle(component, slide.canvas.width)}>{component.content ?? ""}</div>
1033
+ ) : (
1034
+ <>
1035
+ <span className="component-heading">
1036
+ {label}
1037
+ </span>
1038
+ {editingIntent !== component.id && (
1039
+ <span className="component-intent">
1040
+ {component.intent}
1041
+ </span>
1042
+ )}
1043
+ {(component.kind === "diagram" || component.kind === "chart") && component.topology && (
1044
+ <span className="topology-indicator">
1045
+ Recorded topology: {component.topology.nodes.length} nodes ·{" "}
1046
+ {component.topology.edges.length} connections
1047
+ </span>
1048
+ )}
1049
+ <Illustration component={component} />
1050
+ </>
1051
+ )}
1052
+ </div>
1053
+ {interactive && editingIntent === component.id && (
1054
+ <textarea
1055
+ className="component-intent inline-intent-editor"
1056
+ aria-label={`Edit ${label} ${component.kind === "text-block" ? "text" : "intent"}`}
1057
+ style={component.kind === "text-block" ? textContentStyle(component, slide.canvas.width) : undefined}
1058
+ autoFocus
1059
+ value={component.kind === "text-block" ? component.content ?? "" : component.intent ?? ""}
1060
+ onFocus={(event) => event.currentTarget.select()}
1061
+ onPointerDown={(event) => event.stopPropagation()}
1062
+ onChange={(event) =>
1063
+ onComponent(
1064
+ component.kind === "text-block" ? { ...component, content: event.target.value } : { ...component, intent: event.target.value },
1065
+ `${component.kind === "text-block" ? "content" : "intent"}:${component.id}`,
1066
+ )
1067
+ }
1068
+ onBlur={() => {
1069
+ setEditingIntent(undefined);
1070
+ onEditEnd();
1071
+ }}
1072
+ onKeyDown={(event) => {
1073
+ if (event.key !== "Escape") return;
1074
+ event.preventDefault();
1075
+ const surface = event.currentTarget
1076
+ .previousElementSibling as HTMLElement | null;
1077
+ event.currentTarget.blur();
1078
+ requestAnimationFrame(() => surface?.focus());
1079
+ }}
1080
+ />
1081
+ )}
1082
+ {interactive && selected === component.id &&
1083
+ (["nw", "ne", "sw", "se"] as const).map((corner) => (
1084
+ <button
1085
+ key={corner}
1086
+ type="button"
1087
+ className={`resize-handle resize-${corner}`}
1088
+ aria-label={`Resize ${label} from ${corner}`}
1089
+ title="Resize with pointer or arrow keys"
1090
+ onPointerDown={(e) => start(e, component, corner)}
1091
+ onKeyDown={(e) => {
1092
+ if (
1093
+ ![
1094
+ "ArrowLeft",
1095
+ "ArrowRight",
1096
+ "ArrowUp",
1097
+ "ArrowDown",
1098
+ ].includes(e.key)
1099
+ )
1100
+ return;
1101
+ e.preventDefault();
1102
+ e.stopPropagation();
1103
+ const dx =
1104
+ e.key === "ArrowRight"
1105
+ ? 10
1106
+ : e.key === "ArrowLeft"
1107
+ ? -10
1108
+ : 0;
1109
+ const dy =
1110
+ e.key === "ArrowDown"
1111
+ ? 10
1112
+ : e.key === "ArrowUp"
1113
+ ? -10
1114
+ : 0;
1115
+ const next = snapResizeRect(
1116
+ resizeFromCorner(r, dx, dy, corner),
1117
+ [],
1118
+ 0,
1119
+ {
1120
+ left: corner.endsWith("w"),
1121
+ top: corner.startsWith("n"),
1122
+ right: corner.endsWith("e"),
1123
+ bottom: corner.startsWith("s"),
1124
+ },
1125
+ slide.canvas,
1126
+ slide.innerPadding,
1127
+ ).rect;
1128
+ onComponent(
1129
+ { ...component, preferredRect: next },
1130
+ `geometry:${component.id}`,
1131
+ );
1132
+ onNotice(
1133
+ `Size ${Math.round(next.width)} by ${Math.round(next.height)}`,
1134
+ );
1135
+ }}
1136
+ onKeyUp={onEditEnd}
1137
+ />
1138
+ ))}
1139
+ </div>
1140
+ );
1141
+ })}
1142
+ {slide.pageNumber?.style !== "none" && (
1143
+ <span
1144
+ className={`slide-page-number color-${slide.pageNumber?.color ?? "muted"}`}
1145
+ aria-label="Page number"
1146
+ >
1147
+ {slide.pageNumber?.style === "01/02"
1148
+ ? `${page}/${pageTotal}`
1149
+ : page}
1150
+ </span>
1151
+ )}
1152
+ {interactive && guides.map((guide, index) => (
1153
+ <div
1154
+ key={index}
1155
+ aria-hidden="true"
1156
+ className={`guide guide-${guide.axis}`}
1157
+ style={
1158
+ guide.axis === "x"
1159
+ ? { left: `${guide.value * 100 / slide.canvas.width}%` }
1160
+ : { top: `${guide.value * 100 / slide.canvas.height}%` }
1161
+ }
1162
+ />
1163
+ ))}
1164
+ </div>
1165
+ </div>
1166
+ </main>
1167
+ );
1168
+ }