@textcortex/slidewise 1.0.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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/dist/__vite-browser-external-DYxpcVy9.js +5 -0
  4. package/dist/__vite-browser-external-DYxpcVy9.js.map +1 -0
  5. package/dist/file.svg +1 -0
  6. package/dist/globe.svg +1 -0
  7. package/dist/index.mjs +16697 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/dist/slidewise.css +1 -0
  10. package/dist/types/SlidewiseEditor.d.ts +47 -0
  11. package/dist/types/SlidewiseFileEditor.d.ts +54 -0
  12. package/dist/types/components/editor/BottomToolbar.d.ts +1 -0
  13. package/dist/types/components/editor/Canvas.d.ts +1 -0
  14. package/dist/types/components/editor/Editor.d.ts +8 -0
  15. package/dist/types/components/editor/ElementView.d.ts +6 -0
  16. package/dist/types/components/editor/FloatingToolbar.d.ts +6 -0
  17. package/dist/types/components/editor/GridView.d.ts +1 -0
  18. package/dist/types/components/editor/PlayMode.d.ts +1 -0
  19. package/dist/types/components/editor/SelectionFrame.d.ts +8 -0
  20. package/dist/types/components/editor/SlideRail.d.ts +1 -0
  21. package/dist/types/components/editor/SlideView.d.ts +5 -0
  22. package/dist/types/components/editor/TopBar.d.ts +7 -0
  23. package/dist/types/index.d.ts +7 -0
  24. package/dist/types/lib/StoreProvider.d.ts +8 -0
  25. package/dist/types/lib/fonts.d.ts +9 -0
  26. package/dist/types/lib/pptx/deckToPptx.d.ts +9 -0
  27. package/dist/types/lib/pptx/index.d.ts +3 -0
  28. package/dist/types/lib/pptx/pptxToDeck.d.ts +18 -0
  29. package/dist/types/lib/pptx/types.d.ts +15 -0
  30. package/dist/types/lib/pptx/units.d.ts +25 -0
  31. package/dist/types/lib/schema/migrate.d.ts +25 -0
  32. package/dist/types/lib/seed.d.ts +2 -0
  33. package/dist/types/lib/store.d.ts +55 -0
  34. package/dist/types/lib/types.d.ts +141 -0
  35. package/dist/window.svg +1 -0
  36. package/package.json +86 -0
  37. package/src/App.tsx +261 -0
  38. package/src/SlidewiseEditor.css +146 -0
  39. package/src/SlidewiseEditor.tsx +214 -0
  40. package/src/SlidewiseFileEditor.tsx +242 -0
  41. package/src/components/editor/BottomToolbar.tsx +216 -0
  42. package/src/components/editor/Canvas.tsx +467 -0
  43. package/src/components/editor/Editor.tsx +53 -0
  44. package/src/components/editor/ElementView.tsx +588 -0
  45. package/src/components/editor/FloatingToolbar.tsx +729 -0
  46. package/src/components/editor/GridView.tsx +232 -0
  47. package/src/components/editor/PlayMode.tsx +260 -0
  48. package/src/components/editor/SelectionFrame.tsx +241 -0
  49. package/src/components/editor/SlideRail.tsx +285 -0
  50. package/src/components/editor/SlideView.tsx +55 -0
  51. package/src/components/editor/TopBar.tsx +240 -0
  52. package/src/fonts.css +2 -0
  53. package/src/index.css +13 -0
  54. package/src/index.ts +36 -0
  55. package/src/lib/StoreProvider.tsx +43 -0
  56. package/src/lib/__tests__/css-scope.test.ts +133 -0
  57. package/src/lib/fonts.ts +104 -0
  58. package/src/lib/pptx/__tests__/roundtrip.test.ts +240 -0
  59. package/src/lib/pptx/deckToPptx.ts +300 -0
  60. package/src/lib/pptx/index.ts +3 -0
  61. package/src/lib/pptx/pptxToDeck.ts +1515 -0
  62. package/src/lib/pptx/types.ts +17 -0
  63. package/src/lib/pptx/units.ts +32 -0
  64. package/src/lib/schema/__tests__/migrate.test.ts +70 -0
  65. package/src/lib/schema/migrate.ts +102 -0
  66. package/src/lib/seed.ts +777 -0
  67. package/src/lib/store.ts +384 -0
  68. package/src/lib/types.ts +185 -0
  69. package/src/main.tsx +10 -0
  70. package/src/vite-env.d.ts +3 -0
@@ -0,0 +1,216 @@
1
+ import {
2
+ MousePointer2,
3
+ Type,
4
+ Shapes,
5
+ Spline,
6
+ Image as ImageIcon,
7
+ Table2,
8
+ Sigma,
9
+ Sparkles,
10
+ MonitorPlay,
11
+ Maximize2,
12
+ ChevronDown,
13
+ } from "lucide-react";
14
+ import { useEditor } from "@/lib/StoreProvider";
15
+ import type { Tool } from "@/lib/store";
16
+
17
+ export function BottomToolbar() {
18
+ const tool = useEditor((s) => s.tool);
19
+ const setTool = useEditor((s) => s.setTool);
20
+ const zoom = useEditor((s) => s.zoom);
21
+ const setZoom = useEditor((s) => s.setZoom);
22
+ const fitMode = useEditor((s) => s.fitMode);
23
+ const setFitMode = useEditor((s) => s.setFitMode);
24
+
25
+ const items: { id: Tool; icon: React.ReactNode; label: string }[] = [
26
+ { id: "select", icon: <MousePointer2 size={18} />, label: "Select (V)" },
27
+ { id: "text", icon: <Type size={18} />, label: "Text (T)" },
28
+ { id: "shape", icon: <Shapes size={18} />, label: "Shape (S)" },
29
+ { id: "line", icon: <Spline size={18} />, label: "Line (L)" },
30
+ { id: "image", icon: <ImageIcon size={18} />, label: "Image (I)" },
31
+ { id: "table", icon: <Table2 size={18} />, label: "Table" },
32
+ { id: "formula", icon: <Sigma size={18} />, label: "Formula" },
33
+ { id: "icon", icon: <Sparkles size={18} />, label: "Icon" },
34
+ { id: "embed", icon: <MonitorPlay size={18} />, label: "Embed" },
35
+ ];
36
+
37
+ return (
38
+ <div
39
+ style={{
40
+ position: "absolute",
41
+ bottom: 18,
42
+ left: "50%",
43
+ transform: "translateX(-50%)",
44
+ display: "flex",
45
+ alignItems: "center",
46
+ gap: 4,
47
+ padding: 8,
48
+ background: "var(--toolbar-bg)",
49
+ backdropFilter: "blur(20px)",
50
+ WebkitBackdropFilter: "blur(20px)",
51
+ border: "1px solid var(--border)",
52
+ borderRadius: 18,
53
+ boxShadow: "var(--toolbar-shadow)",
54
+ zIndex: 20,
55
+ color: "var(--ink)",
56
+ fontFamily: "Inter, system-ui, sans-serif",
57
+ }}
58
+ >
59
+ {items.map((it) => {
60
+ const active = tool === it.id;
61
+ return (
62
+ <button
63
+ key={it.id}
64
+ title={it.label}
65
+ aria-label={it.label}
66
+ aria-pressed={active}
67
+ onClick={() => setTool(it.id)}
68
+ style={{
69
+ width: 40,
70
+ height: 40,
71
+ borderRadius: 12,
72
+ border: "none",
73
+ cursor: "pointer",
74
+ display: "flex",
75
+ alignItems: "center",
76
+ justifyContent: "center",
77
+ background: active ? "var(--tool-active-bg)" : "transparent",
78
+ color: active ? "var(--tool-active-fg)" : "var(--ink)",
79
+ transition: "background 120ms ease, color 120ms ease",
80
+ }}
81
+ onMouseEnter={(e) => {
82
+ if (!active) e.currentTarget.style.background = "var(--hover)";
83
+ }}
84
+ onMouseLeave={(e) => {
85
+ if (!active) e.currentTarget.style.background = "transparent";
86
+ }}
87
+ >
88
+ {it.icon}
89
+ </button>
90
+ );
91
+ })}
92
+
93
+ <div style={{ width: 1, height: 24, background: "var(--border-strong)", margin: "0 6px" }} />
94
+
95
+ <button
96
+ title="Fit to window"
97
+ aria-label="Fit slide to window"
98
+ onClick={() => setFitMode("fit")}
99
+ style={{
100
+ width: 40,
101
+ height: 40,
102
+ borderRadius: 12,
103
+ border: "none",
104
+ cursor: "pointer",
105
+ display: "flex",
106
+ alignItems: "center",
107
+ justifyContent: "center",
108
+ background: fitMode === "fit" ? "var(--active)" : "transparent",
109
+ color: "var(--ink)",
110
+ }}
111
+ >
112
+ <Maximize2 size={16} />
113
+ </button>
114
+
115
+ <ZoomMenu
116
+ zoom={zoom}
117
+ fitMode={fitMode}
118
+ onChange={(z) => setZoom(z)}
119
+ onFit={() => setFitMode("fit")}
120
+ />
121
+ </div>
122
+ );
123
+ }
124
+
125
+ function ZoomMenu({
126
+ zoom,
127
+ fitMode,
128
+ onChange,
129
+ onFit,
130
+ }: {
131
+ zoom: number;
132
+ fitMode: "fit" | "fill" | "manual";
133
+ onChange: (z: number) => void;
134
+ onFit: () => void;
135
+ }) {
136
+ const presets = [0.25, 0.5, 0.6, 0.75, 1, 1.5, 2];
137
+ const display = fitMode === "fit" ? "Fit" : `${Math.round(zoom * 100)}%`;
138
+ return (
139
+ <div style={{ position: "relative" }}>
140
+ <details style={{ position: "relative" }}>
141
+ <summary
142
+ aria-label={`Zoom (${display})`}
143
+ onMouseEnter={(e) =>
144
+ (e.currentTarget.style.background = "var(--hover)")
145
+ }
146
+ onMouseLeave={(e) =>
147
+ (e.currentTarget.style.background = "transparent")
148
+ }
149
+ style={{
150
+ listStyle: "none",
151
+ cursor: "pointer",
152
+ height: 40,
153
+ padding: "0 12px",
154
+ display: "flex",
155
+ alignItems: "center",
156
+ gap: 4,
157
+ color: "var(--ink)",
158
+ fontSize: 13,
159
+ fontWeight: 500,
160
+ borderRadius: 12,
161
+ }}
162
+ >
163
+ {display}
164
+ <ChevronDown size={14} style={{ opacity: 0.6 }} />
165
+ </summary>
166
+ <div
167
+ style={{
168
+ position: "absolute",
169
+ bottom: 48,
170
+ right: 0,
171
+ background: "var(--menu-bg)",
172
+ border: "1px solid var(--border-strong)",
173
+ borderRadius: 10,
174
+ padding: 6,
175
+ minWidth: 130,
176
+ boxShadow: "var(--menu-shadow)",
177
+ color: "var(--ink)",
178
+ zIndex: 50,
179
+ }}
180
+ >
181
+ <ZoomItem label="Fit" onClick={onFit} />
182
+ {presets.map((p) => (
183
+ <ZoomItem
184
+ key={p}
185
+ label={`${Math.round(p * 100)}%`}
186
+ onClick={() => onChange(p)}
187
+ />
188
+ ))}
189
+ </div>
190
+ </details>
191
+ </div>
192
+ );
193
+ }
194
+
195
+ function ZoomItem({ label, onClick }: { label: string; onClick: () => void }) {
196
+ return (
197
+ <button
198
+ onClick={onClick}
199
+ style={{
200
+ width: "100%",
201
+ textAlign: "left",
202
+ padding: "6px 10px",
203
+ background: "transparent",
204
+ border: "none",
205
+ cursor: "pointer",
206
+ borderRadius: 6,
207
+ fontSize: 13,
208
+ color: "var(--ink)",
209
+ }}
210
+ onMouseEnter={(e) => (e.currentTarget.style.background = "var(--hover)")}
211
+ onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
212
+ >
213
+ {label}
214
+ </button>
215
+ );
216
+ }
@@ -0,0 +1,467 @@
1
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
2
+ import { useEditor, useEditorStore } from "@/lib/StoreProvider";
3
+ import type { Tool } from "@/lib/store";
4
+ import { SLIDE_W, SLIDE_H, type SlideElement, type ElementDraft } from "@/lib/types";
5
+ import { ElementView } from "./ElementView";
6
+ import { SelectionFrame } from "./SelectionFrame";
7
+ import { FloatingToolbar } from "./FloatingToolbar";
8
+
9
+ export function Canvas() {
10
+ const store = useEditorStore();
11
+ const slide = useEditor((s) => s.currentSlide());
12
+ const tool = useEditor((s) => s.tool);
13
+ const setTool = useEditor((s) => s.setTool);
14
+ const zoom = useEditor((s) => s.zoom);
15
+ const fitMode = useEditor((s) => s.fitMode);
16
+ const setZoom = useEditor((s) => s.setZoom);
17
+ const selectedIds = useEditor((s) => s.selectedIds);
18
+ const selectElement = useEditor((s) => s.selectElement);
19
+ const clearSelection = useEditor((s) => s.clearSelection);
20
+ const addElement = useEditor((s) => s.addElement);
21
+ const updateElement = useEditor((s) => s.updateElement);
22
+ const deleteElement = useEditor((s) => s.deleteElement);
23
+ const pushHistory = useEditor((s) => s.pushHistory);
24
+
25
+ const [editingId, setEditingId] = useState<string | null>(null);
26
+ const wrapRef = useRef<HTMLDivElement>(null);
27
+ const [autoScale, setAutoScale] = useState(0.6);
28
+
29
+ useLayoutEffect(() => {
30
+ if (fitMode !== "fit" || !wrapRef.current) return;
31
+ const recompute = () => {
32
+ const r = wrapRef.current!.getBoundingClientRect();
33
+ // Generous fill: small breathing room horizontally, plus enough vertical
34
+ // headroom for the floating toolbar (~56) and the bottom toolbar (~76).
35
+ const padX = 32;
36
+ const padY = 56 + 76 + 16;
37
+ const fit = Math.min(
38
+ (r.width - padX) / SLIDE_W,
39
+ (r.height - padY) / SLIDE_H
40
+ );
41
+ setAutoScale(Math.max(0.05, fit));
42
+ };
43
+ recompute();
44
+ const ro = new ResizeObserver(recompute);
45
+ ro.observe(wrapRef.current);
46
+ return () => ro.disconnect();
47
+ }, [fitMode]);
48
+
49
+ const scale = fitMode === "fit" ? autoScale : zoom;
50
+
51
+ // keyboard
52
+ useEffect(() => {
53
+ function onKey(e: KeyboardEvent) {
54
+ if (editingId) return;
55
+ const target = e.target as HTMLElement;
56
+ if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA"))
57
+ return;
58
+ if ((e.key === "Backspace" || e.key === "Delete") && selectedIds.length) {
59
+ e.preventDefault();
60
+ selectedIds.forEach(deleteElement);
61
+ }
62
+ if (e.key === "Escape") clearSelection();
63
+ if (e.key === "Enter" && selectedIds.length === 1) {
64
+ const el = slide.elements.find((x) => x.id === selectedIds[0]);
65
+ if (el && el.type === "text") {
66
+ e.preventDefault();
67
+ setEditingId(el.id);
68
+ }
69
+ }
70
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
71
+ e.preventDefault();
72
+ store.getState().undo();
73
+ }
74
+ if (
75
+ ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z" && e.shiftKey) ||
76
+ ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "y")
77
+ ) {
78
+ e.preventDefault();
79
+ store.getState().redo();
80
+ }
81
+ if (selectedIds.length) {
82
+ const step = e.shiftKey ? 16 : 2;
83
+ if (e.key === "ArrowLeft" || e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "ArrowDown") {
84
+ e.preventDefault();
85
+ selectedIds.forEach((id) => {
86
+ const el = slide.elements.find((x) => x.id === id);
87
+ if (!el) return;
88
+ const dx = e.key === "ArrowLeft" ? -step : e.key === "ArrowRight" ? step : 0;
89
+ const dy = e.key === "ArrowUp" ? -step : e.key === "ArrowDown" ? step : 0;
90
+ updateElement(id, { x: el.x + dx, y: el.y + dy });
91
+ });
92
+ }
93
+ }
94
+ }
95
+ window.addEventListener("keydown", onKey);
96
+ return () => window.removeEventListener("keydown", onKey);
97
+ }, [
98
+ editingId,
99
+ selectedIds,
100
+ deleteElement,
101
+ clearSelection,
102
+ slide.elements,
103
+ updateElement,
104
+ store,
105
+ ]);
106
+
107
+ // wheel zoom (cmd/ctrl)
108
+ function handleWheel(e: React.WheelEvent) {
109
+ if (e.metaKey || e.ctrlKey) {
110
+ e.preventDefault();
111
+ const next = scale * (e.deltaY < 0 ? 1.08 : 1 / 1.08);
112
+ setZoom(next);
113
+ }
114
+ }
115
+
116
+ function clientToSlide(clientX: number, clientY: number) {
117
+ const r = surfaceRef.current!.getBoundingClientRect();
118
+ return {
119
+ x: (clientX - r.left) / scale,
120
+ y: (clientY - r.top) / scale,
121
+ };
122
+ }
123
+
124
+ const surfaceRef = useRef<HTMLDivElement>(null);
125
+
126
+ // create-on-drag for shape/text/line/etc.
127
+ const [draftRect, setDraftRect] = useState<
128
+ | null
129
+ | { x: number; y: number; w: number; h: number; type: typeof tool }
130
+ >(null);
131
+
132
+ function startCreate(e: React.MouseEvent) {
133
+ if (tool === "select") return;
134
+ const start = clientToSlide(e.clientX, e.clientY);
135
+ setDraftRect({ x: start.x, y: start.y, w: 1, h: 1, type: tool });
136
+ const move = (ev: MouseEvent) => {
137
+ const cur = clientToSlide(ev.clientX, ev.clientY);
138
+ setDraftRect({
139
+ x: Math.min(start.x, cur.x),
140
+ y: Math.min(start.y, cur.y),
141
+ w: Math.abs(cur.x - start.x),
142
+ h: Math.abs(cur.y - start.y),
143
+ type: tool,
144
+ });
145
+ };
146
+ const up = (ev: MouseEvent) => {
147
+ window.removeEventListener("mousemove", move);
148
+ window.removeEventListener("mouseup", up);
149
+ const cur = clientToSlide(ev.clientX, ev.clientY);
150
+ let w = Math.abs(cur.x - start.x);
151
+ let h = Math.abs(cur.y - start.y);
152
+ const x = Math.min(start.x, cur.x);
153
+ const y = Math.min(start.y, cur.y);
154
+ if (w < 8 && h < 8) {
155
+ w = defaultSize(tool).w;
156
+ h = defaultSize(tool).h;
157
+ }
158
+ const created = createDefault(tool, x, y, w, h);
159
+ const wasText = tool === "text" || tool === "formula";
160
+ if (created) {
161
+ const newId = addElement(created);
162
+ if (wasText) setEditingId(newId);
163
+ }
164
+ setDraftRect(null);
165
+ setTool("select");
166
+ };
167
+ window.addEventListener("mousemove", move);
168
+ window.addEventListener("mouseup", up);
169
+ }
170
+
171
+ function onElementDown(e: React.MouseEvent, el: SlideElement) {
172
+ if (tool !== "select") return;
173
+ if (editingId === el.id) return;
174
+ e.stopPropagation();
175
+ if (!selectedIds.includes(el.id)) {
176
+ selectElement(el.id, e.shiftKey);
177
+ }
178
+
179
+ const start = { x: e.clientX, y: e.clientY };
180
+ const snapshot = store.getState();
181
+ const orig = snapshot.currentSlide().elements.filter((x) =>
182
+ snapshot.selectedIds.concat(el.id).includes(x.id)
183
+ );
184
+ const ids = Array.from(new Set([el.id, ...snapshot.selectedIds]));
185
+
186
+ let dragStarted = false;
187
+ const DRAG_THRESHOLD = 4;
188
+
189
+ const move = (ev: MouseEvent) => {
190
+ const dx = ev.clientX - start.x;
191
+ const dy = ev.clientY - start.y;
192
+ if (!dragStarted && Math.hypot(dx, dy) > DRAG_THRESHOLD) {
193
+ dragStarted = true;
194
+ pushHistory();
195
+ }
196
+ if (!dragStarted) return;
197
+ ids.forEach((id) => {
198
+ const o = orig.find((x) => x.id === id);
199
+ if (!o) return;
200
+ updateElement(id, {
201
+ x: Math.round(o.x + dx / scale),
202
+ y: Math.round(o.y + dy / scale),
203
+ });
204
+ });
205
+ };
206
+ const up = () => {
207
+ window.removeEventListener("mousemove", move);
208
+ window.removeEventListener("mouseup", up);
209
+ if (!dragStarted && el.type === "text" && !e.shiftKey) {
210
+ setEditingId(el.id);
211
+ }
212
+ };
213
+ window.addEventListener("mousemove", move);
214
+ window.addEventListener("mouseup", up);
215
+ }
216
+
217
+ return (
218
+ <div
219
+ ref={wrapRef}
220
+ onWheel={handleWheel}
221
+ style={{
222
+ flex: 1,
223
+ position: "relative",
224
+ overflow: "hidden",
225
+ background:
226
+ "radial-gradient(circle at 50% 30%, var(--canvas-bg-from) 0%, var(--canvas-bg-to) 100%)",
227
+ cursor: tool !== "select" ? "crosshair" : "default",
228
+ }}
229
+ onMouseDown={(e) => {
230
+ if (e.target === e.currentTarget) clearSelection();
231
+ }}
232
+ >
233
+ <div
234
+ ref={surfaceRef}
235
+ onMouseDown={(e) => {
236
+ if (e.target === e.currentTarget) {
237
+ clearSelection();
238
+ startCreate(e);
239
+ }
240
+ }}
241
+ style={{
242
+ position: "absolute",
243
+ left: "50%",
244
+ top: "50%",
245
+ width: SLIDE_W * scale,
246
+ height: SLIDE_H * scale,
247
+ transform: "translate(-50%, -50%)",
248
+ background: slide.background,
249
+ borderRadius: 8,
250
+ boxShadow: "var(--slide-shadow)",
251
+ }}
252
+ >
253
+ <div
254
+ style={{
255
+ position: "absolute",
256
+ inset: 0,
257
+ transform: `scale(${scale})`,
258
+ transformOrigin: "top left",
259
+ width: SLIDE_W,
260
+ height: SLIDE_H,
261
+ }}
262
+ >
263
+ {[...slide.elements]
264
+ .sort((a, b) => a.z - b.z)
265
+ .map((el) => {
266
+ return (
267
+ <div
268
+ key={el.id}
269
+ onMouseDown={(e) => onElementDown(e, el)}
270
+ onDoubleClick={() => {
271
+ if (el.type === "text") setEditingId(el.id);
272
+ }}
273
+ style={{
274
+ position: "absolute",
275
+ left: el.x,
276
+ top: el.y,
277
+ width: el.w,
278
+ height: el.h,
279
+ transform: `rotate(${el.rotation}deg)`,
280
+ cursor: tool === "select" ? "move" : "crosshair",
281
+ }}
282
+ >
283
+ <ElementView
284
+ el={el}
285
+ editing={editingId === el.id && el.type === "text"}
286
+ onTextCommit={(text, runs) => {
287
+ if (el.type === "text") {
288
+ // The contentEditable surface preserves run styles
289
+ // when the user only edits text within them. If the
290
+ // editor returned undefined runs (homogeneous style),
291
+ // fall back to the flat representation.
292
+ updateElement(el.id, { text, runs });
293
+ }
294
+ setEditingId(null);
295
+ }}
296
+ />
297
+ </div>
298
+ );
299
+ })}
300
+
301
+ {draftRect && (
302
+ <div
303
+ style={{
304
+ position: "absolute",
305
+ left: draftRect.x,
306
+ top: draftRect.y,
307
+ width: draftRect.w,
308
+ height: draftRect.h,
309
+ border: "2px dashed var(--accent)",
310
+ background: "var(--accent-soft)",
311
+ pointerEvents: "none",
312
+ }}
313
+ />
314
+ )}
315
+ </div>
316
+
317
+ {selectedIds.map((id) => {
318
+ const el = slide.elements.find((e) => e.id === id);
319
+ if (!el) return null;
320
+ return (
321
+ <SelectionFrame
322
+ key={id}
323
+ el={el}
324
+ scale={scale}
325
+ editing={editingId === id}
326
+ onChange={(patch) => updateElement(id, patch)}
327
+ onCommitStart={() => pushHistory()}
328
+ />
329
+ );
330
+ })}
331
+ </div>
332
+
333
+ {selectedIds.length === 1 && (
334
+ <FloatingToolbar
335
+ element={slide.elements.find((e) => e.id === selectedIds[0])!}
336
+ scale={scale}
337
+ surfaceRef={surfaceRef}
338
+ />
339
+ )}
340
+ </div>
341
+ );
342
+ }
343
+
344
+ function defaultSize(t: Tool) {
345
+ switch (t) {
346
+ case "text":
347
+ return { w: 600, h: 100 };
348
+ case "line":
349
+ return { w: 400, h: 4 };
350
+ case "image":
351
+ return { w: 600, h: 400 };
352
+ case "table":
353
+ return { w: 800, h: 300 };
354
+ case "icon":
355
+ return { w: 120, h: 120 };
356
+ case "embed":
357
+ return { w: 600, h: 360 };
358
+ default:
359
+ return { w: 300, h: 200 };
360
+ }
361
+ }
362
+
363
+ function createDefault(
364
+ tool: Tool,
365
+ x: number,
366
+ y: number,
367
+ w: number,
368
+ h: number
369
+ ): ElementDraft | null {
370
+ const base = {
371
+ x: Math.round(x),
372
+ y: Math.round(y),
373
+ w: Math.round(w),
374
+ h: Math.round(h),
375
+ rotation: 0,
376
+ };
377
+ switch (tool) {
378
+ case "shape":
379
+ return {
380
+ ...base,
381
+ type: "shape",
382
+ shape: "rounded",
383
+ fill: "#4F5BD5",
384
+ radius: 16,
385
+ };
386
+ case "text":
387
+ return {
388
+ ...base,
389
+ type: "text",
390
+ text: "Type something",
391
+ fontFamily: "Inter",
392
+ fontSize: 48,
393
+ fontWeight: 600,
394
+ italic: false,
395
+ underline: false,
396
+ strike: false,
397
+ color: "#0E1330",
398
+ align: "left",
399
+ vAlign: "top",
400
+ lineHeight: 1.2,
401
+ letterSpacing: 0,
402
+ };
403
+ case "line":
404
+ return {
405
+ ...base,
406
+ h: Math.max(2, base.h),
407
+ type: "line",
408
+ stroke: "#0E1330",
409
+ strokeWidth: 4,
410
+ };
411
+ case "image":
412
+ return {
413
+ ...base,
414
+ type: "image",
415
+ src: "https://images.unsplash.com/photo-1517292987719-0369a794ec0f?auto=format&fit=crop&w=1200&q=70",
416
+ fit: "cover",
417
+ radius: 12,
418
+ };
419
+ case "table":
420
+ return {
421
+ ...base,
422
+ type: "table",
423
+ rows: [
424
+ ["Header A", "Header B", "Header C"],
425
+ ["Item 1", "Item 2", "Item 3"],
426
+ ["Item 4", "Item 5", "Item 6"],
427
+ ],
428
+ headerFill: "#D7DBE2",
429
+ rowFill: "#EBEDF1",
430
+ textColor: "#0E1330",
431
+ fontSize: 22,
432
+ };
433
+ case "icon":
434
+ return {
435
+ ...base,
436
+ type: "icon",
437
+ icon: "★",
438
+ color: "#4F5BD5",
439
+ };
440
+ case "embed":
441
+ return {
442
+ ...base,
443
+ type: "embed",
444
+ url: "https://example.com",
445
+ label: "Embed",
446
+ };
447
+ case "formula":
448
+ return {
449
+ ...base,
450
+ type: "text",
451
+ text: "E = mc²",
452
+ fontFamily: "JetBrains Mono",
453
+ fontSize: 40,
454
+ fontWeight: 500,
455
+ italic: false,
456
+ underline: false,
457
+ strike: false,
458
+ color: "#0E1330",
459
+ align: "left",
460
+ vAlign: "top",
461
+ lineHeight: 1.2,
462
+ letterSpacing: 0,
463
+ };
464
+ default:
465
+ return null;
466
+ }
467
+ }