konpeki 0.3.1 → 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 (167) hide show
  1. package/AGENTS.md +21 -28
  2. package/AUTHORING.md +68 -229
  3. package/CONTRIBUTING.md +10 -23
  4. package/README.md +98 -106
  5. package/SETUP.md +49 -129
  6. package/docs/development.md +43 -256
  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 -37
  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 +1 -10
  49. package/src/styles/feedback.css +2 -1
  50. package/src/styles/shell.css +94 -354
  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 -56
  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/drawing-references.md +0 -32
  103. package/slides/experiment/PROMPT.md +0 -44
  104. package/slides/experiment/index.tsx +0 -127
  105. package/slides/github-cover/PROMPT.md +0 -21
  106. package/slides/github-cover/README.md +0 -21
  107. package/slides/github-cover/author.ts +0 -67
  108. package/slides/github-cover/composition.json +0 -595
  109. package/slides/incident-workflow/PROMPT.md +0 -57
  110. package/slides/incident-workflow/index.tsx +0 -78
  111. package/slides/introducing-konpeki/PROMPT.md +0 -30
  112. package/slides/introducing-konpeki/README.md +0 -44
  113. package/slides/introducing-konpeki/SOURCE.md +0 -19
  114. package/slides/introducing-konpeki/author.ts +0 -165
  115. package/slides/introducing-konpeki/composition.json +0 -3270
  116. package/slides/line-chart/PROMPT.md +0 -40
  117. package/slides/line-chart/index.tsx +0 -72
  118. package/slides/migration/PROMPT.md +0 -38
  119. package/slides/migration/index.tsx +0 -89
  120. package/slides/og-images/PROMPT.md +0 -21
  121. package/slides/og-images/index.tsx +0 -76
  122. package/slides/product-introduction/PROMPT.md +0 -24
  123. package/slides/product-introduction/index.tsx +0 -105
  124. package/slides/research-brief/PROMPT.md +0 -40
  125. package/slides/research-brief/index.tsx +0 -104
  126. package/slides/results-explanation/PROMPT.md +0 -32
  127. package/slides/results-explanation/index.tsx +0 -96
  128. package/slides/retrospective/PROMPT.md +0 -43
  129. package/slides/retrospective/index.tsx +0 -105
  130. package/slides/sankey/PROMPT.md +0 -11
  131. package/slides/sankey/index.tsx +0 -93
  132. package/slides/teaching/PROMPT.md +0 -45
  133. package/slides/teaching/index.tsx +0 -124
  134. package/slides/vertical-bar-charts/PROMPT.md +0 -13
  135. package/slides/vertical-bar-charts/index.tsx +0 -97
  136. package/src/app/App.tsx +0 -845
  137. package/src/components/BuildOrb.tsx +0 -40
  138. package/src/components/Canvas.tsx +0 -1191
  139. package/src/components/DiagramTypeIcon.tsx +0 -78
  140. package/src/components/InspectorPanel.tsx +0 -757
  141. package/src/components/LeftPanel.tsx +0 -120
  142. package/src/components/PageSizePicker.tsx +0 -30
  143. package/src/components/Presentation.tsx +0 -105
  144. package/src/components/RevisionNotes.tsx +0 -55
  145. package/src/components/RightPanel.tsx +0 -233
  146. package/src/components/VectorOverflowWarning.tsx +0 -73
  147. package/src/components/WorkspaceChrome.tsx +0 -316
  148. package/src/components/ui.tsx +0 -147
  149. package/src/lib/examples/react-page-migration.json +0 -1295
  150. package/src/lib/examples.ts +0 -42
  151. package/src/lib/export-png.ts +0 -104
  152. package/src/lib/file-session.ts +0 -87
  153. package/src/lib/history.ts +0 -53
  154. package/src/lib/model.ts +0 -188
  155. package/src/lib/page-size.ts +0 -24
  156. package/src/lib/presentation.ts +0 -17
  157. package/src/lib/review.ts +0 -26
  158. package/src/lib/storage.ts +0 -71
  159. package/src/lib/theme.ts +0 -25
  160. package/src/lib/use-file-session.ts +0 -249
  161. package/src/main.tsx +0 -29
  162. package/src/styles/canvas.css +0 -299
  163. package/src/styles/chrome.css +0 -475
  164. package/src/styles/component-previews.css +0 -386
  165. package/src/styles/left-panel.css +0 -187
  166. package/src/styles/presentation.css +0 -72
  167. package/src/styles/right-panel.css +0 -1298
@@ -1,1191 +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
- event.currentTarget.focus({ preventScroll: true });
796
- onSelect();
797
- }
798
- }}
799
- >
800
- <div className="slide-wrap">
801
- {interactive && <div className="stage-meta">
802
- {editingSlideName ? (
803
- <input
804
- aria-label="Page name"
805
- autoFocus
806
- value={nextSlideName}
807
- onPointerDown={(event) => event.stopPropagation()}
808
- onChange={(event) => setNextSlideName(event.target.value)}
809
- onBlur={() => {
810
- const name = nextSlideName.trim();
811
- if (name && name !== slide.name) onSlideName(name);
812
- else setNextSlideName(slide.name);
813
- setEditingSlideName(false);
814
- }}
815
- onKeyDown={(event) => {
816
- if (event.key === "Enter") event.currentTarget.blur();
817
- if (event.key === "Escape") {
818
- setNextSlideName(slide.name);
819
- setEditingSlideName(false);
820
- }
821
- }}
822
- />
823
- ) : (
824
- <h2
825
- title="Double-click to rename page"
826
- onDoubleClick={() => {
827
- setNextSlideName(slide.name);
828
- setEditingSlideName(true);
829
- }}
830
- >
831
- {slide.name}
832
- </h2>
833
- )}
834
- {selected && slide.components.some((component) => component.id === selected && component.customVisual?.format === "vector") && (
835
- <button
836
- type="button"
837
- className="edit-elements-action"
838
- aria-pressed={vectorSelection?.componentId === selected}
839
- onPointerDown={(event) => event.stopPropagation()}
840
- onClick={() => {
841
- setEditingIntent(undefined);
842
- onVectorSelect?.(vectorSelection?.componentId === selected ? undefined : { componentId: selected });
843
- }}
844
- >
845
- {vectorSelection?.componentId === selected ? "Done editing" : "Edit elements"}
846
- </button>
847
- )}
848
- </div>}
849
- <div
850
- ref={canvas}
851
- className={`canvas ${themeMode} ${interactive ? "" : "presentation-canvas"}`}
852
- data-theme={themeId}
853
- style={canvasStyle}
854
- aria-label="Page canvas"
855
- onPointerDown={(e) => {
856
- if (interactive && e.target === e.currentTarget) {
857
- e.currentTarget.closest<HTMLElement>(".stage")?.focus({ preventScroll: true });
858
- onSelect();
859
- }
860
- }}
861
- onPointerMove={interactive ? move : undefined}
862
- onPointerUp={interactive ? () => end() : undefined}
863
- onPointerCancel={interactive ? () => end() : undefined}
864
- onLostPointerCapture={interactive ? () => end() : undefined}
865
- onDragOver={(e) => {
866
- if (!interactive) return;
867
- e.preventDefault();
868
- e.dataTransfer.dropEffect = "copy";
869
- }}
870
- onDrop={(e) => {
871
- if (!interactive) return;
872
- e.preventDefault();
873
- const kind = e.dataTransfer.getData(
874
- "application/konpeki-component",
875
- );
876
- if (!componentKinds.includes(kind as CompositionComponent["kind"]))
877
- return;
878
- const r = e.currentTarget.getBoundingClientRect();
879
- onAdd(kind as CompositionComponent["kind"], {
880
- x: ((e.clientX - r.left) / r.width) * slide.canvas.width,
881
- y: ((e.clientY - r.top) / r.height) * slide.canvas.height,
882
- });
883
- }}
884
- >
885
- {slide.readingOrder
886
- .flatMap((entry) => entry.kind === "component"
887
- ? [entry.id]
888
- : slide.groups.find((group) => group.id === entry.id)!.childIds)
889
- .map((id) => slide.components.find((c) => c.id === id)!)
890
- .map((component) => {
891
- const r = component.preferredRect;
892
- const label = componentInstanceLabel(
893
- slide.components,
894
- component.id,
895
- );
896
- const a = {
897
- ...defaultAppearance(component.kind),
898
- ...(component.appearance as Record<string, string> | undefined),
899
- };
900
- const style = {
901
- ...componentColors(component, palette),
902
- zIndex: slide.paintOrder.indexOf(component.id),
903
- left: `${r.x * 100 / slide.canvas.width}%`,
904
- top: `${r.y * 100 / slide.canvas.height}%`,
905
- width: `${r.width * 100 / slide.canvas.width}%`,
906
- height: `${r.height * 100 / slide.canvas.height}%`,
907
- textAlign:
908
- a?.alignment === "end"
909
- ? "right"
910
- : a?.alignment === "center"
911
- ? "center"
912
- : "left",
913
- } as CSSProperties;
914
- return (
915
- <div
916
- key={component.id}
917
- data-component={component.id}
918
- 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"}`}
919
- style={style}
920
- >
921
- <div
922
- className="component-surface"
923
- role={interactive ? "button" : undefined}
924
- tabIndex={interactive ? 0 : undefined}
925
- aria-label={interactive ? `Select ${label}` : undefined}
926
- aria-pressed={interactive ? selected === component.id : undefined}
927
- onPointerDown={interactive ? (e) => {
928
- if (
929
- e.target instanceof Element &&
930
- e.target.closest(".inline-intent-editor")
931
- )
932
- return;
933
- start(e, component);
934
- } : undefined}
935
- onClick={interactive ? () => onSelect(component.id) : undefined}
936
- onDoubleClick={interactive ? (e) => {
937
- e.stopPropagation();
938
- onSelect(component.id);
939
- onVectorSelect?.(undefined);
940
- setEditingIntent(component.id);
941
- } : undefined}
942
- onKeyDown={interactive ? (e) => {
943
- if (e.target !== e.currentTarget) return;
944
- if (e.key === "Enter") {
945
- e.preventDefault();
946
- onSelect(component.id);
947
- if (component.customVisual?.format === "vector")
948
- onVectorSelect?.({ componentId: component.id });
949
- else if (!component.customVisual) setEditingIntent(component.id);
950
- return;
951
- }
952
- if (e.key === " ") {
953
- e.preventDefault();
954
- onSelect(component.id);
955
- return;
956
- }
957
- if (
958
- ![
959
- "ArrowLeft",
960
- "ArrowRight",
961
- "ArrowUp",
962
- "ArrowDown",
963
- ].includes(e.key)
964
- )
965
- return;
966
- e.preventDefault();
967
- const next = snapRect(
968
- {
969
- ...r,
970
- x:
971
- r.x +
972
- (e.key === "ArrowRight"
973
- ? 10
974
- : e.key === "ArrowLeft"
975
- ? -10
976
- : 0),
977
- y:
978
- r.y +
979
- (e.key === "ArrowDown"
980
- ? 10
981
- : e.key === "ArrowUp"
982
- ? -10
983
- : 0),
984
- },
985
- [],
986
- 0,
987
- slide.canvas,
988
- slide.innerPadding,
989
- ).rect;
990
- onComponent(
991
- { ...component, preferredRect: next },
992
- `geometry:${component.id}`,
993
- );
994
- } : undefined}
995
- onKeyUp={interactive ? (event) => {
996
- if (
997
- event.target === event.currentTarget &&
998
- [
999
- "ArrowLeft",
1000
- "ArrowRight",
1001
- "ArrowUp",
1002
- "ArrowDown",
1003
- ].includes(event.key)
1004
- )
1005
- onEditEnd();
1006
- } : undefined}
1007
- >
1008
- {component.customVisual?.format === "vector" ? (
1009
- <EditableVectorVisual
1010
- component={component}
1011
- interactive={interactive}
1012
- active={vectorSelection?.componentId === component.id}
1013
- selectedElementId={
1014
- vectorSelection?.componentId === component.id
1015
- ? vectorSelection.elementId
1016
- : undefined
1017
- }
1018
- onSelectElement={(elementId, edit) =>
1019
- onVectorSelect?.({ componentId: component.id, elementId }, edit)
1020
- }
1021
- onElement={(element, mergeKey) => {
1022
- if (component.customVisual?.format !== "vector") return;
1023
- onComponent({
1024
- ...component,
1025
- customVisual: {
1026
- ...component.customVisual,
1027
- elements: component.customVisual.elements.map((item) =>
1028
- item.id === element.id ? element : item,
1029
- ),
1030
- },
1031
- }, mergeKey);
1032
- }}
1033
- onEditEnd={onEditEnd}
1034
- />
1035
- ) : component.customVisual ? (
1036
- <img
1037
- className={`custom-visual-art fit-${component.customVisual.fit ?? "contain"}`}
1038
- src={customVisualSource(component)}
1039
- alt={component.customVisual.description}
1040
- draggable={false}
1041
- />
1042
- ) : component.kind === "text-block" ? (
1043
- <div className="text-block-content" style={textContentStyle(component, slide.canvas.width)}>{component.content ?? ""}</div>
1044
- ) : (
1045
- <>
1046
- <span className="component-heading">
1047
- {label}
1048
- </span>
1049
- {editingIntent !== component.id && (
1050
- <span className="component-intent">
1051
- {component.intent}
1052
- </span>
1053
- )}
1054
- {(component.kind === "diagram" || component.kind === "chart") && component.topology && (
1055
- <span className="topology-indicator">
1056
- Recorded topology: {component.topology.nodes.length} nodes ·{" "}
1057
- {component.topology.edges.length} connections
1058
- </span>
1059
- )}
1060
- <Illustration component={component} />
1061
- </>
1062
- )}
1063
- </div>
1064
- {interactive && editingIntent === component.id && (
1065
- <textarea
1066
- className="component-intent inline-intent-editor"
1067
- aria-label={`Edit ${label} ${component.kind === "text-block" ? "text" : "intent"}`}
1068
- style={component.kind === "text-block" ? textContentStyle(component, slide.canvas.width) : undefined}
1069
- autoFocus
1070
- value={component.kind === "text-block" ? component.content ?? "" : component.intent ?? ""}
1071
- onFocus={(event) => event.currentTarget.select()}
1072
- onPointerDown={(event) => event.stopPropagation()}
1073
- onChange={(event) =>
1074
- onComponent(
1075
- component.kind === "text-block" ? { ...component, content: event.target.value } : { ...component, intent: event.target.value },
1076
- `${component.kind === "text-block" ? "content" : "intent"}:${component.id}`,
1077
- )
1078
- }
1079
- onBlur={() => {
1080
- setEditingIntent(undefined);
1081
- onEditEnd();
1082
- }}
1083
- onKeyDown={(event) => {
1084
- if (event.key !== "Escape") return;
1085
- event.preventDefault();
1086
- const surface = event.currentTarget
1087
- .previousElementSibling as HTMLElement | null;
1088
- event.currentTarget.blur();
1089
- requestAnimationFrame(() => surface?.focus());
1090
- }}
1091
- />
1092
- )}
1093
- {interactive && selected === component.id &&
1094
- (["nw", "ne", "sw", "se"] as const).map((corner) => (
1095
- <button
1096
- key={corner}
1097
- type="button"
1098
- className={`resize-handle resize-${corner}`}
1099
- aria-label={`Resize ${label} from ${corner}`}
1100
- title="Resize with pointer or arrow keys"
1101
- onPointerDown={(e) => start(e, component, corner)}
1102
- onKeyDown={(e) => {
1103
- if (
1104
- ![
1105
- "ArrowLeft",
1106
- "ArrowRight",
1107
- "ArrowUp",
1108
- "ArrowDown",
1109
- ].includes(e.key)
1110
- )
1111
- return;
1112
- e.preventDefault();
1113
- e.stopPropagation();
1114
- const dx =
1115
- e.key === "ArrowRight"
1116
- ? 10
1117
- : e.key === "ArrowLeft"
1118
- ? -10
1119
- : 0;
1120
- const dy =
1121
- e.key === "ArrowDown"
1122
- ? 10
1123
- : e.key === "ArrowUp"
1124
- ? -10
1125
- : 0;
1126
- const next = snapResizeRect(
1127
- resizeFromCorner(r, dx, dy, corner),
1128
- [],
1129
- 0,
1130
- {
1131
- left: corner.endsWith("w"),
1132
- top: corner.startsWith("n"),
1133
- right: corner.endsWith("e"),
1134
- bottom: corner.startsWith("s"),
1135
- },
1136
- slide.canvas,
1137
- slide.innerPadding,
1138
- ).rect;
1139
- onComponent(
1140
- { ...component, preferredRect: next },
1141
- `geometry:${component.id}`,
1142
- );
1143
- onNotice(
1144
- `Size ${Math.round(next.width)} by ${Math.round(next.height)}`,
1145
- );
1146
- }}
1147
- onKeyUp={onEditEnd}
1148
- />
1149
- ))}
1150
- </div>
1151
- );
1152
- })}
1153
- {interactive && revisionNotes.map((note, index) => {
1154
- if (note.slideId !== slide.id || note.resolved) return null;
1155
- const component = slide.components.find(c => c.id === note.componentId);
1156
- if (note.componentId && !component) return null;
1157
- const rect = component?.preferredRect;
1158
- const previous = revisionNotes.slice(0, index).filter(n => !n.resolved && n.slideId === note.slideId && n.componentId === note.componentId).length;
1159
- return <button key={note.id} type="button" className="revision-pin"
1160
- aria-label={`Revision note ${index + 1}: ${note.text}`} title={note.text}
1161
- 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 }}
1162
- onPointerDown={event => event.stopPropagation()}
1163
- onClick={event => { event.stopPropagation(); onSelectNote?.(note); }}>{index + 1}</button>;
1164
- })}
1165
- {slide.pageNumber?.style !== "none" && (
1166
- <span
1167
- className={`slide-page-number color-${slide.pageNumber?.color ?? "muted"}`}
1168
- aria-label="Page number"
1169
- >
1170
- {slide.pageNumber?.style === "01/02"
1171
- ? `${page}/${pageTotal}`
1172
- : page}
1173
- </span>
1174
- )}
1175
- {interactive && guides.map((guide, index) => (
1176
- <div
1177
- key={index}
1178
- aria-hidden="true"
1179
- className={`guide guide-${guide.axis}`}
1180
- style={
1181
- guide.axis === "x"
1182
- ? { left: `${guide.value * 100 / slide.canvas.width}%` }
1183
- : { top: `${guide.value * 100 / slide.canvas.height}%` }
1184
- }
1185
- />
1186
- ))}
1187
- </div>
1188
- </div>
1189
- </main>
1190
- );
1191
- }