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