@kokoa/clotho-editor 0.1.0 → 0.1.3

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.
@@ -1,4 +1,4 @@
1
- import { updateCanvas, getDef, subscribe, updateMeta, undo, redo, isDirty, canUndo, canRedo, isDraft, uniqueChapterId, getCurrentTime, addChapter, setSelection, uniqueElementId, registerExternalAsset, addElement, registerDataUriAsset, listAnimations, saveAnimation, setDef, markClean, deleteAnimation, duplicateAnimation, createAnimation, getSelection, getSelectedElementIds, deleteElement, deleteChapter, deleteEffect, updateChapter, isGroup, ungroupElement, groupElements, moveElementToEnd, moveElementToFront, reorderElement, loadAnimation, setDraft, getCurrentSnapshot, setElementValueAtTime, findContainingGroup, toggleSelectionFor, isElementSelected, updateElementBase, uniqueEffectId, addEffect, removeAppearance, removeTrack, removeTrackKeyframe, setTrackKeyframe, setCurrentTime, addAppearance, updateAppearance, jumpBack, jumpForward, getHistory, promoteDraftToSaved, moveGroupBy, groupBbox, placeholderImageUrl, updateDuration, updateSettings, updateEffect, childIdsOf } from './chunk-UE32Q6U5.js';
1
+ import { updateCanvas, getDef, subscribe, updateMeta, downloadAnimationJson, importAnimation, setDef, markClean, undo, redo, updateSettings, isDirty, canUndo, canRedo, isDraft, uniqueChapterId, getCurrentTime, addChapter, setSelection, uniqueElementId, registerExternalAsset, addElement, registerDataUriAsset, listAnimations, saveAnimation, deleteAnimation, duplicateAnimation, createAnimation, getSelection, getSelectedElementIds, deleteElement, deleteChapter, deleteEffect, updateChapter, isGroup, ungroupElement, groupElements, moveElementToEnd, moveElementToFront, reorderElement, loadAnimation, setDraft, getCurrentSnapshot, setElementValueAtTime, findContainingGroup, isElementSelected, updateElementBase, uniqueEffectId, addEffect, removeAppearance, removeTrack, removeTrackKeyframe, setTrackKeyframe, setCurrentTime, addAppearance, jumpBack, jumpForward, getHistory, promoteDraftToSaved, toggleSelectionFor, moveGroupBy, placeholderImageUrl, groupBbox, updateDuration, updateAppearance, updateEffect, childIdsOf } from './chunk-Z2K5HFHI.js';
2
2
  import { activeAppearance, animationDocumentSchema, resolveAsset } from '@kokoa/clotho';
3
3
 
4
4
  // src/legacy/grid.ts
@@ -86,7 +86,11 @@ function anchorPointsOf(baseEl, state) {
86
86
  { x: x + w / 2, y, anchor: "top" },
87
87
  { x: x + w, y: y + h / 2, anchor: "right" },
88
88
  { x: x + w / 2, y: y + h, anchor: "bottom" },
89
- { x, y: y + h / 2, anchor: "left" }
89
+ { x, y: y + h / 2, anchor: "left" },
90
+ { x, y, anchor: "top-left" },
91
+ { x: x + w, y, anchor: "top-right" },
92
+ { x, y: y + h, anchor: "bottom-left" },
93
+ { x: x + w, y: y + h, anchor: "bottom-right" }
90
94
  ];
91
95
  }
92
96
  if (baseEl.type === "circle") {
@@ -119,6 +123,14 @@ function anchorPointOf(el, state, anchor) {
119
123
  return { x: cx, y: y + h };
120
124
  case "left":
121
125
  return { x, y: cy };
126
+ case "top-left":
127
+ return { x, y };
128
+ case "top-right":
129
+ return { x: x + w, y };
130
+ case "bottom-left":
131
+ return { x, y: y + h };
132
+ case "bottom-right":
133
+ return { x: x + w, y: y + h };
122
134
  case "center":
123
135
  case "auto":
124
136
  default:
@@ -419,422 +431,817 @@ function parsePolygonPoints(points) {
419
431
  return result;
420
432
  }
421
433
 
422
- // src/legacy/canvas-handles.ts
423
- function renderResizeHandles(canvasEl2, elId, snap2, byId) {
424
- const baseEl = byId.get(elId);
425
- const state = snap2.get(elId);
426
- if (!baseEl || !state) return null;
427
- let box;
428
- let handles = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
429
- if (baseEl.type === "rect" || baseEl.type === "image") {
430
- box = {
431
- x: state.x,
432
- y: state.y,
433
- w: state.width,
434
- h: state.height
435
- };
436
- } else if (baseEl.type === "circle") {
437
- const r = state.r;
438
- box = {
439
- x: state.cx - r,
440
- y: state.cy - r,
441
- w: r * 2,
442
- h: r * 2
443
- };
444
- handles = ["n", "e", "s", "w"];
445
- } else if (baseEl.type === "text") {
446
- const bbox = textBBoxOnCanvas(canvasEl2, elId);
447
- if (!bbox) return null;
448
- box = bbox;
449
- handles = ["se"];
450
- } else {
451
- return null;
452
- }
453
- const positions = {
454
- nw: { x: box.x, y: box.y, cursor: "nwse-resize" },
455
- n: { x: box.x + box.w / 2, y: box.y, cursor: "ns-resize" },
456
- ne: { x: box.x + box.w, y: box.y, cursor: "nesw-resize" },
457
- e: { x: box.x + box.w, y: box.y + box.h / 2, cursor: "ew-resize" },
458
- se: { x: box.x + box.w, y: box.y + box.h, cursor: "nwse-resize" },
459
- s: { x: box.x + box.w / 2, y: box.y + box.h, cursor: "ns-resize" },
460
- sw: { x: box.x, y: box.y + box.h, cursor: "nesw-resize" },
461
- w: { x: box.x, y: box.y + box.h / 2, cursor: "ew-resize" }
462
- };
463
- const g = document.createElementNS(SVG_NS, "g");
464
- const rotation = state.rotation || 0;
465
- if (rotation && (baseEl.type === "rect" || baseEl.type === "image" || baseEl.type === "circle")) {
466
- const cx = box.x + box.w / 2;
467
- const cy = box.y + box.h / 2;
468
- g.setAttribute("transform", `rotate(${rotation} ${cx} ${cy})`);
469
- }
470
- let html = "";
471
- for (const h of handles) {
472
- const p = positions[h];
473
- html += `<rect x="${p.x - 5}" y="${p.y - 5}" width="10" height="10"
474
- fill="white" stroke="var(--color-accent)" stroke-width="2" rx="2"
475
- data-resize-handle="${h}" data-elem-id="${escapeXml(elId)}" style="cursor: ${p.cursor}" />`;
476
- }
477
- g.innerHTML = html;
478
- return g;
434
+ // src/legacy/tool-state.ts
435
+ var activeTool = "select";
436
+ var listeners2 = /* @__PURE__ */ new Set();
437
+ function getActiveTool() {
438
+ return activeTool;
479
439
  }
480
- function renderRotationHandle(canvasEl2, elId, snap2, byId) {
481
- const baseEl = byId.get(elId);
482
- const state = snap2.get(elId);
483
- if (!baseEl || !state) return null;
484
- const center = centerOfElement(baseEl, state);
485
- if (!center) return null;
486
- let topY = null;
487
- if (baseEl.type === "rect" || baseEl.type === "image") {
488
- topY = state.y - 24;
489
- } else if (baseEl.type === "circle") {
490
- topY = state.cy - state.r - 24;
491
- } else if (baseEl.type === "text") {
492
- const bbox = textBBoxOnCanvas(canvasEl2, elId);
493
- if (bbox) topY = bbox.y - 24;
494
- } else if (baseEl.type === "polygon") {
495
- const bbox = polygonBoundingBox(String(state.points ?? ""));
496
- if (bbox) topY = bbox.y - 24;
497
- } else if (baseEl.type === "path") {
498
- const bbox = pathBBoxOnCanvas(canvasEl2, elId);
499
- if (bbox) topY = bbox.y - 24;
500
- }
501
- if (topY === null) return null;
502
- const g = document.createElementNS(SVG_NS, "g");
503
- const rotation = state.rotation || 0;
504
- if (rotation)
505
- g.setAttribute("transform", `rotate(${rotation} ${center.x} ${center.y})`);
506
- g.innerHTML = `
507
- <line x1="${center.x}" y1="${center.y}" x2="${center.x}" y2="${topY + 8}" stroke="var(--color-accent)" stroke-width="1.5" stroke-dasharray="3 2" pointer-events="none" />
508
- <circle cx="${center.x}" cy="${topY}" r="8" fill="var(--color-accent)" stroke="white" stroke-width="2" data-rotate-handle="${escapeXml(elId)}" style="cursor: grab" />
509
- <text x="${center.x + 14}" y="${topY + 4}" font-size="10" fill="var(--color-accent)" font-family="ui-monospace, monospace" pointer-events="none">${Math.round(rotation)}\xB0</text>
510
- `;
511
- return g;
440
+ function setActiveTool(tool) {
441
+ activeTool = tool;
442
+ for (const listener of listeners2) listener(tool);
512
443
  }
513
- function renderAnchorDots(elId, snap2, byId) {
514
- const baseEl = byId.get(elId);
515
- const state = snap2.get(elId);
516
- if (!baseEl || !state) return null;
517
- if (baseEl.type !== "rect" && baseEl.type !== "circle" && baseEl.type !== "image")
518
- return null;
519
- const points = anchorPointsOf(baseEl, state);
520
- const g = document.createElementNS(SVG_NS, "g");
521
- for (const p of points) {
522
- g.innerHTML += `<circle cx="${p.x}" cy="${p.y}" r="6" fill="white" stroke="#6366f1" stroke-width="2"
523
- data-anchor-handle data-elem-id="${escapeXml(elId)}" data-anchor="${p.anchor}" style="cursor: crosshair" />`;
524
- }
525
- return g;
444
+ function subscribeActiveTool(listener) {
445
+ listeners2.add(listener);
446
+ listener(activeTool);
447
+ return () => listeners2.delete(listener);
526
448
  }
527
- function renderLineEndpointHandles(elId, snap2, byId) {
528
- const baseEl = byId.get(elId);
529
- const state = snap2.get(elId);
530
- if (!baseEl || !state) return null;
531
- if (baseEl.type !== "line" && baseEl.type !== "arrow") return null;
532
- const coords = baseEl.type === "line" ? resolveLineCoords(state, snap2, byId) : resolveArrowCoords(state, snap2, byId);
533
- if (!coords) return null;
534
- const midX = (coords.x1 + coords.x2) / 2;
535
- const midY = (coords.y1 + coords.y2) / 2;
536
- const g = document.createElementNS(SVG_NS, "g");
537
- g.innerHTML = `
538
- <rect x="${coords.x1 - 6}" y="${coords.y1 - 6}" width="12" height="12" rx="2"
539
- fill="white" stroke="var(--color-accent)" stroke-width="2"
540
- data-endpoint-handle="start" data-elem-id="${escapeXml(elId)}" style="cursor: grab" />
541
- <rect x="${coords.x2 - 6}" y="${coords.y2 - 6}" width="12" height="12" rx="2"
542
- fill="white" stroke="var(--color-accent)" stroke-width="2"
543
- data-endpoint-handle="end" data-elem-id="${escapeXml(elId)}" style="cursor: grab" />
544
- <circle cx="${midX}" cy="${midY}" r="6"
545
- fill="var(--color-accent)" stroke="white" stroke-width="2"
546
- data-line-mid-handle data-elem-id="${escapeXml(elId)}" style="cursor: move" />
547
- `;
548
- return g;
449
+
450
+ // src/legacy/ui-interactions.ts
451
+ function hasSelectionModifier(modifiers) {
452
+ return modifiers.shiftKey || modifiers.ctrlKey || modifiers.metaKey;
549
453
  }
550
- function renderPolygonVertexHandles(elId, snap2, byId) {
551
- const baseEl = byId.get(elId);
552
- const state = snap2.get(elId);
553
- if (!baseEl || !state) return null;
554
- if (baseEl.type !== "polygon") return null;
555
- const points = String(state.points ?? "").trim().split(/\s+/);
556
- const parsed = [];
557
- for (let i = 0; i < points.length; i += 1) {
558
- const [x, y] = points[i].split(",").map(Number);
559
- if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
560
- parsed.push({ x, y, i });
561
- }
562
- const g = document.createElementNS(SVG_NS, "g");
563
- let html = "";
564
- if (parsed.length >= 2) {
565
- for (let i = 0; i < parsed.length; i += 1) {
566
- const a = parsed[i];
567
- const b = parsed[(i + 1) % parsed.length];
568
- const mx = (a.x + b.x) / 2;
569
- const my = (a.y + b.y) / 2;
570
- html += `<g data-vertex-add data-elem-id="${escapeXml(elId)}" data-after-index="${a.i}" style="cursor: copy">
571
- <circle cx="${mx}" cy="${my}" r="6" fill="rgba(99, 102, 241, 0.15)" stroke="var(--color-accent)" stroke-width="1.5" stroke-dasharray="2 2" />
572
- <text x="${mx}" y="${my + 3}" text-anchor="middle" font-size="10" fill="var(--color-accent)" font-weight="700" pointer-events="none">+</text>
573
- </g>`;
454
+ function shouldDeferElementPointerDown(modifiers, options) {
455
+ return hasSelectionModifier(modifiers) && options.isElement && !options.isHandle;
456
+ }
457
+ function nextPointerSelection(current, elementId, modifiers) {
458
+ return hasSelectionModifier(modifiers) ? toggleSelectionFor(current, elementId) : { kind: "element", elementId };
459
+ }
460
+ var TOOL_SHORTCUTS = {
461
+ v: "select",
462
+ r: "rect",
463
+ o: "circle",
464
+ l: "line",
465
+ a: "arrow",
466
+ t: "text",
467
+ i: "image",
468
+ b: "path",
469
+ y: "polygon"
470
+ };
471
+ function toolForShortcut(key, options) {
472
+ if (options.isEditing || options.modifierKey || options.altKey) return null;
473
+ return TOOL_SHORTCUTS[key.toLowerCase()] ?? null;
474
+ }
475
+
476
+ // src/legacy/element-list.ts
477
+ var listEl = null;
478
+ var toolsRootEl = null;
479
+ var searchEl = null;
480
+ var dragSourceId = null;
481
+ function initElementList(root, toolsRoot) {
482
+ listEl = root;
483
+ toolsRootEl = toolsRoot;
484
+ searchEl = document.getElementById(
485
+ "studio-element-search"
486
+ );
487
+ subscribe(render);
488
+ root.addEventListener("click", onClick);
489
+ root.addEventListener("dragstart", onDragStart);
490
+ root.addEventListener("dragover", onDragOver);
491
+ root.addEventListener("dragleave", onDragLeave);
492
+ root.addEventListener("drop", onDrop);
493
+ root.addEventListener("dragend", onDragEnd);
494
+ toolsRootEl?.addEventListener("click", onToolsClick);
495
+ subscribeActiveTool((tool) => {
496
+ toolsRootEl?.setAttribute("data-active-tool", tool);
497
+ document.body.dataset.studioTool = tool;
498
+ toolsRootEl?.querySelectorAll("[data-studio-tool], [data-add-element]").forEach((button) => {
499
+ const value = button.dataset.studioTool ?? button.dataset.addElement;
500
+ const active = value === tool;
501
+ button.classList.toggle("is-active", active);
502
+ button.setAttribute("aria-pressed", String(active));
503
+ });
504
+ });
505
+ searchEl?.addEventListener("input", render);
506
+ searchEl?.addEventListener("keydown", (e) => {
507
+ if (e.key === "Escape" && searchEl) {
508
+ searchEl.value = "";
509
+ render();
574
510
  }
511
+ });
512
+ render();
513
+ }
514
+ function escapeHtml(s) {
515
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
516
+ }
517
+ function friendlyElementLabel(el) {
518
+ const e = el;
519
+ const visible = e.name?.trim() || e.label?.trim() || e.content?.trim();
520
+ return visible ? visible : el.id;
521
+ }
522
+ function matchesSearch(el, query) {
523
+ if (!query) return true;
524
+ const e = el;
525
+ const haystack = [el.id, el.type, e.name, e.label, e.content].filter(Boolean).join(" ").toLowerCase();
526
+ return haystack.includes(query);
527
+ }
528
+ function render() {
529
+ if (!listEl) return;
530
+ const def = getDef();
531
+ if (!def || def.elements.length === 0) {
532
+ listEl.innerHTML = '<li style="color: var(--color-fg-muted); padding: 0.4rem;">\uC694\uC18C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</li>';
533
+ return;
575
534
  }
576
- for (const p of parsed) {
577
- const removable = parsed.length > 3;
578
- html += `<circle cx="${p.x}" cy="${p.y}" r="6" fill="white" stroke="var(--color-accent)" stroke-width="2"
579
- data-vertex-handle data-elem-id="${escapeXml(elId)}" data-vertex-index="${p.i}"
580
- style="cursor: grab" >
581
- <title>${removable ? "\uB4DC\uB798\uADF8=\uC774\uB3D9, \uC6B0\uD074\uB9AD=\uC0AD\uC81C" : "\uB4DC\uB798\uADF8=\uC774\uB3D9"}</title>
582
- </circle>`;
535
+ const sel = getSelection();
536
+ const query = searchEl?.value.trim().toLowerCase() ?? "";
537
+ const allReversed = [...def.elements].reverse();
538
+ const items = query ? allReversed.filter((el) => matchesSearch(el, query)) : allReversed;
539
+ const multiHint = sel.kind === "elements" ? `<div class="studio-element-list-hint">\uB2E4\uC911 \uC120\uD0DD: ${sel.elementIds.length}\uAC1C (Shift+\uD074\uB9AD\uC73C\uB85C \uCD94\uAC00/\uD574\uC81C)</div>` : "";
540
+ const filterHint = query ? `<div class="studio-element-list-hint">\uAC80\uC0C9 "${escapeHtml(query)}" \xB7 ${items.length}/${def.elements.length} \uC77C\uCE58 \xB7 Esc = \uC9C0\uC6B0\uAE30</div>` : `<div class="studio-element-list-hint">\uC704 = \uC55E\uCABD (z-index \u2191) \xB7 Shift+\uD074\uB9AD = \uB2E4\uC911 \uC120\uD0DD</div>`;
541
+ const emptyMsg = query && items.length === 0 ? `<li style="color: var(--color-fg-muted); padding: 0.4rem; font-size:0.78rem;">"${escapeHtml(query)}" \uC77C\uCE58 \uC5C6\uC74C</li>` : "";
542
+ const byParent = /* @__PURE__ */ new Map();
543
+ for (const element of items) {
544
+ if (!element.parentId) continue;
545
+ const children = byParent.get(element.parentId) ?? [];
546
+ children.push(element);
547
+ byParent.set(element.parentId, children);
583
548
  }
584
- g.innerHTML = html;
585
- return g;
549
+ const renderItem = (el, depth = 0) => {
550
+ const isSel = isElementSelected(sel, el.id);
551
+ const label = friendlyElementLabel(el);
552
+ const subId = label !== el.id ? `<span class="studio-element-list-item-sub">${escapeHtml(el.id)}</span>` : "";
553
+ const group = el.type === "group";
554
+ const children = query ? [] : byParent.get(el.id) ?? [];
555
+ return `<li class="studio-element-list-item ${isSel ? "is-selected" : ""} ${group ? "is-group" : ""}" data-elem-id="${escapeHtml(el.id)}" draggable="true" style="--studio-tree-depth:${depth}">
556
+ <span class="studio-element-grip" aria-hidden="true">${group ? "\u25BE" : "\u22EE\u22EE"}</span>
557
+ <span class="studio-element-list-item-label">${group ? "\uADF8\uB8F9 \xB7 " : ""}${escapeHtml(label)} <span class="studio-element-list-item-type">${escapeHtml(el.type)}</span>${subId}</span>
558
+ <button type="button" class="studio-element-list-delete" data-delete title="\uC0AD\uC81C">\u2715</button>
559
+ </li>${children.map((child) => renderItem(child, depth + 1)).join("")}`;
560
+ };
561
+ const visibleItems = query ? items : items.filter((element) => !element.parentId);
562
+ listEl.innerHTML = filterHint + multiHint + emptyMsg + visibleItems.map((element) => renderItem(element)).join("");
586
563
  }
587
- function renderSnapTargets(draggingElementId, snap2, byId, activeSnapTarget) {
588
- const g = document.createElementNS(SVG_NS, "g");
589
- let html = "";
590
- for (const baseEl of byId.values()) {
591
- if (baseEl.id === draggingElementId) continue;
592
- if (baseEl.type === "line" || baseEl.type === "arrow") continue;
593
- const state = snap2.get(baseEl.id);
594
- if (!state || !state.visible) continue;
595
- const points = getAnchorPoints(baseEl, state);
596
- for (const p of points) {
597
- const isActive = activeSnapTarget?.elementId === baseEl.id && activeSnapTarget?.anchor === p.anchor;
598
- if (isActive) {
599
- html += `<circle cx="${p.x}" cy="${p.y}" r="6" fill="var(--color-accent)" stroke="white" stroke-width="2" opacity="0.85" pointer-events="none" />`;
600
- html += `<circle cx="${p.x}" cy="${p.y}" r="12" fill="none" stroke="var(--color-accent)" stroke-width="1.5" opacity="0.4" pointer-events="none" />`;
601
- } else {
602
- html += `<circle cx="${p.x}" cy="${p.y}" r="3" fill="white" stroke="var(--color-accent)" stroke-width="1.5" opacity="0.5" pointer-events="none" />`;
603
- }
604
- }
564
+ function onClick(e) {
565
+ const target = e.target;
566
+ const li = target.closest(".studio-element-list-item");
567
+ if (!li) return;
568
+ const id = li.dataset.elemId ?? "";
569
+ if (target.closest("[data-delete]")) {
570
+ deleteElement(id);
571
+ return;
605
572
  }
606
- g.innerHTML = html;
607
- return g;
573
+ setActiveTool("select");
574
+ const me = e;
575
+ setSelection(nextPointerSelection(getSelection(), id, me));
608
576
  }
609
- function renderSelectionOutline(canvasEl2, elId, snap2, byId) {
610
- const baseEl = byId.get(elId);
611
- const state = snap2.get(elId);
612
- if (!baseEl || !state) return null;
613
- let box = null;
614
- if (baseEl.type === "rect" || baseEl.type === "image") {
615
- box = {
616
- x: state.x - 4,
617
- y: state.y - 4,
618
- w: state.width + 8,
619
- h: state.height + 8
620
- };
621
- } else if (baseEl.type === "circle") {
622
- const r = state.r;
623
- box = {
624
- x: state.cx - r - 4,
625
- y: state.cy - r - 4,
626
- w: r * 2 + 8,
627
- h: r * 2 + 8
628
- };
629
- } else if (baseEl.type === "text") {
630
- const bbox = textBBoxOnCanvas(canvasEl2, elId);
631
- if (bbox)
632
- box = { x: bbox.x - 4, y: bbox.y - 4, w: bbox.w + 8, h: bbox.h + 8 };
633
- } else if (baseEl.type === "line" || baseEl.type === "arrow") {
634
- const coords = baseEl.type === "line" ? resolveLineCoords(state, snap2, byId) : resolveArrowCoords(state, snap2, byId);
635
- if (!coords) return null;
636
- box = {
637
- x: Math.min(coords.x1, coords.x2) - 4,
638
- y: Math.min(coords.y1, coords.y2) - 4,
639
- w: Math.abs(coords.x2 - coords.x1) + 8,
640
- h: Math.abs(coords.y2 - coords.y1) + 8
641
- };
642
- } else if (baseEl.type === "polygon") {
643
- const bbox = polygonBoundingBox(String(state.points ?? ""));
644
- if (bbox)
645
- box = { x: bbox.x - 4, y: bbox.y - 4, w: bbox.w + 8, h: bbox.h + 8 };
646
- } else if (baseEl.type === "path") {
647
- const bbox = pathBBoxOnCanvas(canvasEl2, elId);
648
- if (bbox)
649
- box = { x: bbox.x - 4, y: bbox.y - 4, w: bbox.w + 8, h: bbox.h + 8 };
650
- }
651
- if (!box) return null;
652
- const rect = document.createElementNS(SVG_NS, "rect");
653
- rect.setAttribute("x", String(box.x));
654
- rect.setAttribute("y", String(box.y));
655
- rect.setAttribute("width", String(box.w));
656
- rect.setAttribute("height", String(box.h));
657
- rect.classList.add("element-selected-outline");
658
- return rect;
659
- }
660
-
661
- // src/legacy/canvas-preview.ts
662
- var previewRoot = null;
663
- var previewHandle = null;
664
- async function mountPreview(host, doc) {
665
- const { mountStage } = await import('@kokoa/clotho/dom');
666
- previewHandle = mountStage(host, doc, { player: { autoplay: true } });
667
- }
668
- function showPreview(canvasEl2, def) {
669
- if (!canvasEl2) return;
670
- const parent = canvasEl2.parentElement;
671
- if (!parent) return;
672
- hidePreview(canvasEl2);
673
- canvasEl2.style.visibility = "hidden";
674
- previewRoot = document.createElement("div");
675
- const bg = def.canvas.background && def.canvas.background !== "transparent" ? def.canvas.background : "transparent";
676
- previewRoot.style.cssText = `position:absolute;left:0;top:0;width:${def.canvas.width}px;height:${def.canvas.height}px;background:${bg};z-index:2;overflow:hidden;`;
677
- parent.style.position = "relative";
678
- parent.appendChild(previewRoot);
679
- void mountPreview(previewRoot, def);
680
- }
681
- function hidePreview(canvasEl2) {
682
- if (canvasEl2) canvasEl2.style.visibility = "";
683
- previewHandle?.destroy();
684
- previewHandle = null;
685
- if (previewRoot) {
686
- previewRoot.remove();
687
- previewRoot = null;
577
+ function onDragStart(e) {
578
+ const li = e.target.closest(
579
+ ".studio-element-list-item"
580
+ );
581
+ if (!li) return;
582
+ dragSourceId = li.dataset.elemId ?? null;
583
+ if (e.dataTransfer) {
584
+ e.dataTransfer.effectAllowed = "move";
585
+ e.dataTransfer.setData("text/plain", dragSourceId ?? "");
688
586
  }
587
+ li.classList.add("is-dragging");
689
588
  }
690
-
691
- // src/legacy/canvas.ts
692
- var SNAP_RADIUS = 12;
693
- var HEAD_MARKER_DEFS = `
694
- <marker id="studio-h-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
695
- <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
696
- </marker>
697
- <marker id="studio-h-arrow-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
698
- <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
699
- </marker>
700
- <marker id="studio-h-triangle" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
701
- <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
702
- </marker>
703
- <marker id="studio-h-triangle-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
704
- <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
705
- </marker>
706
- <marker id="studio-h-triangle-open" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto">
707
- <path d="M 0 0 L 10 5 L 0 10" fill="none" stroke="currentColor" stroke-width="1.5" />
708
- </marker>
709
- <marker id="studio-h-triangle-open-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
710
- <path d="M 0 0 L 10 5 L 0 10" fill="none" stroke="currentColor" stroke-width="1.5" />
711
- </marker>
712
- <marker id="studio-h-circle" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6">
713
- <circle cx="5" cy="5" r="4" fill="currentColor" />
714
- </marker>
715
- <marker id="studio-h-circle-open" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="7" markerHeight="7">
716
- <circle cx="5" cy="5" r="4" fill="white" stroke="currentColor" stroke-width="1.5" />
717
- </marker>
718
- <marker id="studio-h-diamond" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
719
- <path d="M 0 5 L 5 0 L 10 5 L 5 10 z" fill="currentColor" />
720
- </marker>
721
- <marker id="studio-h-diamond-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
722
- <path d="M 0 5 L 5 0 L 10 5 L 5 10 z" fill="currentColor" />
723
- </marker>
724
- <marker id="studio-h-diamond-open" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
725
- <path d="M 0 5 L 5 0 L 10 5 L 5 10 z" fill="white" stroke="currentColor" stroke-width="1.5" />
726
- </marker>
727
- <marker id="studio-h-diamond-open-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
728
- <path d="M 0 5 L 5 0 L 10 5 L 5 10 z" fill="white" stroke="currentColor" stroke-width="1.5" />
729
- </marker>
730
- <marker id="studio-h-bar" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="5" markerHeight="10" orient="auto">
731
- <rect x="4" y="0" width="2" height="10" fill="currentColor" />
732
- </marker>
733
- `;
734
- function markerUrlFor(head, end) {
735
- if (!head || head === "none") return "";
736
- const isOpenCircle = head === "circle-open";
737
- const isCircle = head === "circle" || isOpenCircle;
738
- if (isCircle) return `url(#studio-h-${head})`;
739
- const idBase = `studio-h-${head}`;
740
- return end === "start" ? `url(#${idBase}-start)` : `url(#${idBase})`;
741
- }
742
- var dragState = null;
743
- var rotateState = null;
744
- var connectState = null;
745
- var endpointDragState = null;
746
- var vertexDragState = null;
747
- var resizeState = null;
748
- var marqueeState = null;
749
- var marqueeJustFinished = false;
750
- var canvasEl = null;
751
- var hoveredElementId = null;
752
- var canvasZoom = 1;
753
- function initCanvas(root) {
754
- canvasEl = root;
755
- root.addEventListener("click", onCanvasClick);
756
- root.addEventListener("mousedown", onMouseDown);
757
- root.addEventListener("mouseover", onMouseOver);
758
- root.addEventListener("mouseleave", onMouseLeave);
759
- root.addEventListener("contextmenu", onContextMenu);
760
- root.addEventListener("wheel", onCanvasWheel, { passive: false });
761
- document.addEventListener("mousemove", onMouseMove);
762
- document.addEventListener("mouseup", onMouseUp);
763
- subscribe(render);
764
- render();
765
- }
766
- function onCanvasWheel(e) {
767
- if (!e.ctrlKey && !e.metaKey) return;
589
+ function onDragOver(e) {
590
+ if (!dragSourceId) return;
768
591
  e.preventDefault();
769
- const wrap = canvasEl?.closest(".studio-canvas-wrap");
770
- if (!wrap) {
771
- canvasZoom = Math.max(
772
- 0.1,
773
- Math.min(5, canvasZoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1))
774
- );
775
- render();
776
- return;
777
- }
778
- const wrapRect = wrap.getBoundingClientRect();
779
- const mxInWrap = e.clientX - wrapRect.left + wrap.scrollLeft;
780
- const myInWrap = e.clientY - wrapRect.top + wrap.scrollTop;
781
- const old = canvasZoom;
782
- canvasZoom = Math.max(
783
- 0.1,
784
- Math.min(5, canvasZoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1))
592
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
593
+ const li = e.target.closest(
594
+ ".studio-element-list-item"
785
595
  );
786
- const ratio = canvasZoom / old;
787
- render();
788
- wrap.scrollLeft = mxInWrap * ratio - (e.clientX - wrapRect.left);
789
- wrap.scrollTop = myInWrap * ratio - (e.clientY - wrapRect.top);
596
+ listEl?.querySelectorAll(".is-drop-above, .is-drop-below").forEach((n) => n.classList.remove("is-drop-above", "is-drop-below"));
597
+ if (!li) return;
598
+ const rect = li.getBoundingClientRect();
599
+ const upper = e.clientY < rect.top + rect.height / 2;
600
+ li.classList.add(upper ? "is-drop-above" : "is-drop-below");
790
601
  }
791
- function onContextMenu(e) {
792
- const target = e.target;
793
- const vertexEl = target?.closest("[data-vertex-handle]");
794
- if (!vertexEl) return;
795
- const id = vertexEl.dataset.elemId ?? "";
796
- const idx = Number(vertexEl.dataset.vertexIndex ?? "-1");
797
- const def = getDef();
798
- if (!def || idx < 0) return;
799
- const el = def.elements.find((x) => x.id === id);
800
- if (!el || el.type !== "polygon") return;
801
- const snap2 = getCurrentSnapshot();
802
- const state = snap2.get(id);
803
- if (!state) return;
804
- const points = String(state.points ?? "").trim().split(/\s+/);
805
- if (points.length <= 3) return;
806
- e.preventDefault();
807
- points.splice(idx, 1);
808
- setElementValueAtTime(id, { points: points.join(" ") });
602
+ function onDragLeave(e) {
603
+ const li = e.target.closest(
604
+ ".studio-element-list-item"
605
+ );
606
+ li?.classList.remove("is-drop-above", "is-drop-below");
809
607
  }
810
- function onMouseOver(e) {
811
- const id = findElementId(e.target);
812
- if (id !== hoveredElementId) {
813
- hoveredElementId = id;
814
- render();
815
- }
608
+ function onDrop(e) {
609
+ if (!dragSourceId) return;
610
+ e.preventDefault();
611
+ const li = e.target.closest(
612
+ ".studio-element-list-item"
613
+ );
614
+ if (!li) return;
615
+ const targetId = li.dataset.elemId ?? "";
616
+ if (!targetId || targetId === dragSourceId) return;
617
+ const rect = li.getBoundingClientRect();
618
+ const upper = e.clientY < rect.top + rect.height / 2;
619
+ const positionInVisualList = upper ? "before" : "after";
620
+ const positionInDefArray = positionInVisualList === "before" ? "after" : "before";
621
+ reorderElement(dragSourceId, targetId, positionInDefArray);
622
+ dragSourceId = null;
816
623
  }
817
- function onMouseLeave() {
818
- if (hoveredElementId !== null) {
819
- hoveredElementId = null;
820
- render();
821
- }
624
+ function onDragEnd() {
625
+ dragSourceId = null;
626
+ listEl?.querySelectorAll(".is-drop-above, .is-drop-below, .is-dragging").forEach(
627
+ (n) => n.classList.remove("is-drop-above", "is-drop-below", "is-dragging")
628
+ );
822
629
  }
823
- function svgPoint(clientX, clientY) {
824
- if (!canvasEl) return null;
825
- const def = getDef();
826
- if (!def) return null;
827
- const rect = canvasEl.getBoundingClientRect();
828
- const sx = def.canvas.width / rect.width;
829
- const sy = def.canvas.height / rect.height;
830
- return snapPoint({
831
- x: (clientX - rect.left) * sx,
832
- y: (clientY - rect.top) * sy
833
- });
630
+ function onToolsClick(e) {
631
+ const target = e.target;
632
+ const btn = target.closest(
633
+ "[data-studio-tool], [data-add-element]"
634
+ );
635
+ if (!btn) return;
636
+ const type = btn.dataset.studioTool ?? btn.dataset.addElement;
637
+ if (!type) return;
638
+ setActiveTool(type);
834
639
  }
835
- function findElementId(target) {
836
- let node = target;
837
- while (node && node !== canvasEl) {
640
+ function makeDefaultElement(type, id, cx, cy, polygonSides = 6) {
641
+ switch (type) {
642
+ case "rect":
643
+ return {
644
+ type: "rect",
645
+ id,
646
+ rotation: 0,
647
+ appearances: [],
648
+ tracks: [],
649
+ x: cx - 60,
650
+ y: cy - 30,
651
+ width: 120,
652
+ height: 60,
653
+ fill: "#a5b4fc",
654
+ stroke: "#6366f1",
655
+ strokeWidth: 1.5,
656
+ cornerRadius: 8,
657
+ label: id,
658
+ labelColor: "#0b0b0f",
659
+ labelSize: 14
660
+ };
661
+ case "circle":
662
+ return {
663
+ type: "circle",
664
+ id,
665
+ rotation: 0,
666
+ appearances: [],
667
+ tracks: [],
668
+ cx,
669
+ cy,
670
+ r: 36,
671
+ fill: "#a5b4fc",
672
+ stroke: "#6366f1",
673
+ strokeWidth: 1.5,
674
+ label: id,
675
+ labelColor: "#0b0b0f",
676
+ labelSize: 14
677
+ };
678
+ case "line":
679
+ return {
680
+ type: "line",
681
+ id,
682
+ rotation: 0,
683
+ appearances: [],
684
+ tracks: [],
685
+ x1: cx - 80,
686
+ y1: cy,
687
+ x2: cx + 80,
688
+ y2: cy,
689
+ stroke: "#6366f1",
690
+ strokeWidth: 2,
691
+ headStart: "none",
692
+ headEnd: "none"
693
+ };
694
+ case "arrow":
695
+ return {
696
+ type: "arrow",
697
+ id,
698
+ rotation: 0,
699
+ appearances: [],
700
+ tracks: [],
701
+ x1: cx - 100,
702
+ y1: cy,
703
+ x2: cx + 100,
704
+ y2: cy,
705
+ stroke: "#6366f1",
706
+ strokeWidth: 2,
707
+ curvature: 0,
708
+ labelColor: "#0b0b0f",
709
+ labelOffsetX: 0,
710
+ labelOffsetY: 4,
711
+ headStart: "none",
712
+ headEnd: "arrow"
713
+ };
714
+ case "text":
715
+ return {
716
+ type: "text",
717
+ id,
718
+ rotation: 0,
719
+ appearances: [],
720
+ tracks: [],
721
+ x: cx,
722
+ y: cy,
723
+ content: id,
724
+ fontSize: 18,
725
+ fontWeight: 400,
726
+ color: "#18181b",
727
+ textAnchor: "middle"
728
+ };
729
+ case "image":
730
+ return {
731
+ type: "image",
732
+ id,
733
+ rotation: 0,
734
+ appearances: [],
735
+ tracks: [],
736
+ x: cx - 50,
737
+ y: cy - 50,
738
+ width: 100,
739
+ height: 100,
740
+ assetId: registerExternalAsset(placeholderImageUrl()),
741
+ preserveAspectRatio: "xMidYMid meet",
742
+ opacity: 1
743
+ };
744
+ case "path":
745
+ return {
746
+ type: "path",
747
+ id,
748
+ rotation: 0,
749
+ appearances: [],
750
+ tracks: [],
751
+ x: cx - 40,
752
+ y: cy - 40,
753
+ d: "M 0 0 L 80 0 L 40 80 Z",
754
+ fill: "#a5b4fc",
755
+ stroke: "#6366f1",
756
+ strokeWidth: 2,
757
+ opacity: 1
758
+ };
759
+ case "polygon": {
760
+ const r = 40;
761
+ const sides = Math.max(3, Math.min(24, Math.round(polygonSides) || 6));
762
+ const pts = Array.from({ length: sides }, (_, i) => {
763
+ const a = i * Math.PI * 2 / sides - Math.PI / 2;
764
+ return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
765
+ }).join(" ");
766
+ return {
767
+ type: "polygon",
768
+ id,
769
+ rotation: 0,
770
+ appearances: [],
771
+ tracks: [],
772
+ points: pts,
773
+ fill: "#a5b4fc",
774
+ stroke: "#6366f1",
775
+ strokeWidth: 1.5,
776
+ opacity: 1
777
+ };
778
+ }
779
+ default:
780
+ return null;
781
+ }
782
+ }
783
+
784
+ // src/legacy/drawing-interactions.ts
785
+ function dragBounds(start, end) {
786
+ const width = Math.abs(end.x - start.x);
787
+ const height = Math.abs(end.y - start.y);
788
+ return {
789
+ x: Math.min(start.x, end.x),
790
+ y: Math.min(start.y, end.y),
791
+ width,
792
+ height,
793
+ centerX: (start.x + end.x) / 2,
794
+ centerY: (start.y + end.y) / 2,
795
+ distance: Math.hypot(end.x - start.x, end.y - start.y)
796
+ };
797
+ }
798
+ function isShapeDrag(start, end) {
799
+ return dragBounds(start, end).distance >= 4;
800
+ }
801
+ function regularPolygonPoints(center, radius, requestedSides) {
802
+ const sides = Math.max(3, Math.min(24, Math.round(requestedSides) || 6));
803
+ return Array.from({ length: sides }, (_, index) => {
804
+ const angle = index * Math.PI * 2 / sides - Math.PI / 2;
805
+ return `${(center.x + radius * Math.cos(angle)).toFixed(1)},${(center.y + radius * Math.sin(angle)).toFixed(1)}`;
806
+ }).join(" ");
807
+ }
808
+
809
+ // src/legacy/canvas-handles.ts
810
+ function renderResizeHandles(canvasEl2, elId, snap2, byId) {
811
+ const baseEl = byId.get(elId);
812
+ const state = snap2.get(elId);
813
+ if (!baseEl || !state) return null;
814
+ let box;
815
+ let handles = ["nw", "n", "ne", "e", "se", "s", "sw", "w"];
816
+ if (baseEl.type === "rect" || baseEl.type === "image") {
817
+ box = {
818
+ x: state.x,
819
+ y: state.y,
820
+ w: state.width,
821
+ h: state.height
822
+ };
823
+ } else if (baseEl.type === "circle") {
824
+ const r = state.r;
825
+ box = {
826
+ x: state.cx - r,
827
+ y: state.cy - r,
828
+ w: r * 2,
829
+ h: r * 2
830
+ };
831
+ handles = ["nw", "ne", "se", "sw"];
832
+ } else if (baseEl.type === "text") {
833
+ const bbox = textBBoxOnCanvas(canvasEl2, elId);
834
+ if (!bbox) return null;
835
+ box = bbox;
836
+ handles = ["se"];
837
+ } else {
838
+ return null;
839
+ }
840
+ const positions = {
841
+ nw: { x: box.x, y: box.y, cursor: "nwse-resize" },
842
+ n: { x: box.x + box.w / 2, y: box.y, cursor: "ns-resize" },
843
+ ne: { x: box.x + box.w, y: box.y, cursor: "nesw-resize" },
844
+ e: { x: box.x + box.w, y: box.y + box.h / 2, cursor: "ew-resize" },
845
+ se: { x: box.x + box.w, y: box.y + box.h, cursor: "nwse-resize" },
846
+ s: { x: box.x + box.w / 2, y: box.y + box.h, cursor: "ns-resize" },
847
+ sw: { x: box.x, y: box.y + box.h, cursor: "nesw-resize" },
848
+ w: { x: box.x, y: box.y + box.h / 2, cursor: "ew-resize" }
849
+ };
850
+ const g = document.createElementNS(SVG_NS, "g");
851
+ const rotation = state.rotation || 0;
852
+ if (rotation && (baseEl.type === "rect" || baseEl.type === "image" || baseEl.type === "circle")) {
853
+ const cx = box.x + box.w / 2;
854
+ const cy = box.y + box.h / 2;
855
+ g.setAttribute("transform", `rotate(${rotation} ${cx} ${cy})`);
856
+ }
857
+ let html = "";
858
+ for (const h of handles) {
859
+ const p = positions[h];
860
+ html += `<rect x="${p.x - 5}" y="${p.y - 5}" width="10" height="10"
861
+ fill="white" stroke="var(--color-accent)" stroke-width="2" rx="2"
862
+ data-resize-handle="${h}" data-elem-id="${escapeXml(elId)}" style="cursor: ${p.cursor}" />`;
863
+ }
864
+ g.innerHTML = html;
865
+ return g;
866
+ }
867
+ function renderRotationHandle(canvasEl2, elId, snap2, byId) {
868
+ const baseEl = byId.get(elId);
869
+ const state = snap2.get(elId);
870
+ if (!baseEl || !state) return null;
871
+ const center = centerOfElement(baseEl, state);
872
+ if (!center) return null;
873
+ let topY = null;
874
+ if (baseEl.type === "rect" || baseEl.type === "image") {
875
+ topY = state.y - 24;
876
+ } else if (baseEl.type === "circle") {
877
+ topY = state.cy - state.r - 24;
878
+ } else if (baseEl.type === "text") {
879
+ const bbox = textBBoxOnCanvas(canvasEl2, elId);
880
+ if (bbox) topY = bbox.y - 24;
881
+ } else if (baseEl.type === "polygon") {
882
+ const bbox = polygonBoundingBox(String(state.points ?? ""));
883
+ if (bbox) topY = bbox.y - 24;
884
+ } else if (baseEl.type === "path") {
885
+ const bbox = pathBBoxOnCanvas(canvasEl2, elId);
886
+ if (bbox) topY = bbox.y - 24;
887
+ }
888
+ if (topY === null) return null;
889
+ const g = document.createElementNS(SVG_NS, "g");
890
+ const rotation = state.rotation || 0;
891
+ if (rotation)
892
+ g.setAttribute("transform", `rotate(${rotation} ${center.x} ${center.y})`);
893
+ g.innerHTML = `
894
+ <line x1="${center.x}" y1="${center.y}" x2="${center.x}" y2="${topY + 8}" stroke="var(--color-accent)" stroke-width="1.5" stroke-dasharray="3 2" pointer-events="none" />
895
+ <circle cx="${center.x}" cy="${topY}" r="8" fill="var(--color-accent)" stroke="white" stroke-width="2" data-rotate-handle="${escapeXml(elId)}" style="cursor: grab" />
896
+ <text x="${center.x + 14}" y="${topY + 4}" font-size="10" fill="var(--color-accent)" font-family="ui-monospace, monospace" pointer-events="none">${Math.round(rotation)}\xB0</text>
897
+ `;
898
+ return g;
899
+ }
900
+ function renderAnchorDots(elId, snap2, byId) {
901
+ const baseEl = byId.get(elId);
902
+ const state = snap2.get(elId);
903
+ if (!baseEl || !state) return null;
904
+ if (baseEl.type !== "rect" && baseEl.type !== "circle" && baseEl.type !== "image")
905
+ return null;
906
+ const points = anchorPointsOf(baseEl, state);
907
+ const g = document.createElementNS(SVG_NS, "g");
908
+ for (const p of points) {
909
+ g.innerHTML += `<circle cx="${p.x}" cy="${p.y}" r="6" fill="white" stroke="#6366f1" stroke-width="2"
910
+ data-anchor-handle data-elem-id="${escapeXml(elId)}" data-anchor="${p.anchor}" style="cursor: crosshair" />`;
911
+ }
912
+ return g;
913
+ }
914
+ function renderLineEndpointHandles(elId, snap2, byId) {
915
+ const baseEl = byId.get(elId);
916
+ const state = snap2.get(elId);
917
+ if (!baseEl || !state) return null;
918
+ if (baseEl.type !== "line" && baseEl.type !== "arrow") return null;
919
+ const coords = baseEl.type === "line" ? resolveLineCoords(state, snap2, byId) : resolveArrowCoords(state, snap2, byId);
920
+ if (!coords) return null;
921
+ const midX = (coords.x1 + coords.x2) / 2;
922
+ const midY = (coords.y1 + coords.y2) / 2;
923
+ const g = document.createElementNS(SVG_NS, "g");
924
+ g.innerHTML = `
925
+ <rect x="${coords.x1 - 6}" y="${coords.y1 - 6}" width="12" height="12" rx="2"
926
+ fill="white" stroke="var(--color-accent)" stroke-width="2"
927
+ data-endpoint-handle="start" data-elem-id="${escapeXml(elId)}" style="cursor: grab" />
928
+ <rect x="${coords.x2 - 6}" y="${coords.y2 - 6}" width="12" height="12" rx="2"
929
+ fill="white" stroke="var(--color-accent)" stroke-width="2"
930
+ data-endpoint-handle="end" data-elem-id="${escapeXml(elId)}" style="cursor: grab" />
931
+ <circle cx="${midX}" cy="${midY}" r="6"
932
+ fill="var(--color-accent)" stroke="white" stroke-width="2"
933
+ data-line-mid-handle data-elem-id="${escapeXml(elId)}" style="cursor: move" />
934
+ `;
935
+ return g;
936
+ }
937
+ function renderPolygonVertexHandles(elId, snap2, byId) {
938
+ const baseEl = byId.get(elId);
939
+ const state = snap2.get(elId);
940
+ if (!baseEl || !state) return null;
941
+ if (baseEl.type !== "polygon") return null;
942
+ const points = String(state.points ?? "").trim().split(/\s+/);
943
+ const parsed = [];
944
+ for (let i = 0; i < points.length; i += 1) {
945
+ const [x, y] = points[i].split(",").map(Number);
946
+ if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
947
+ parsed.push({ x, y, i });
948
+ }
949
+ const g = document.createElementNS(SVG_NS, "g");
950
+ let html = "";
951
+ if (parsed.length >= 2) {
952
+ for (let i = 0; i < parsed.length; i += 1) {
953
+ const a = parsed[i];
954
+ const b = parsed[(i + 1) % parsed.length];
955
+ const mx = (a.x + b.x) / 2;
956
+ const my = (a.y + b.y) / 2;
957
+ html += `<g data-vertex-add data-elem-id="${escapeXml(elId)}" data-after-index="${a.i}" style="cursor: copy">
958
+ <circle cx="${mx}" cy="${my}" r="6" fill="rgba(99, 102, 241, 0.15)" stroke="var(--color-accent)" stroke-width="1.5" stroke-dasharray="2 2" />
959
+ <text x="${mx}" y="${my + 3}" text-anchor="middle" font-size="10" fill="var(--color-accent)" font-weight="700" pointer-events="none">+</text>
960
+ </g>`;
961
+ }
962
+ }
963
+ for (const p of parsed) {
964
+ const removable = parsed.length > 3;
965
+ html += `<circle cx="${p.x}" cy="${p.y}" r="6" fill="white" stroke="var(--color-accent)" stroke-width="2"
966
+ data-vertex-handle data-elem-id="${escapeXml(elId)}" data-vertex-index="${p.i}"
967
+ style="cursor: grab" >
968
+ <title>${removable ? "\uB4DC\uB798\uADF8=\uC774\uB3D9, \uC6B0\uD074\uB9AD=\uC0AD\uC81C" : "\uB4DC\uB798\uADF8=\uC774\uB3D9"}</title>
969
+ </circle>`;
970
+ }
971
+ g.innerHTML = html;
972
+ return g;
973
+ }
974
+ function renderSnapTargets(draggingElementId, snap2, byId, activeSnapTarget) {
975
+ const g = document.createElementNS(SVG_NS, "g");
976
+ let html = "";
977
+ for (const baseEl of byId.values()) {
978
+ if (baseEl.id === draggingElementId) continue;
979
+ if (baseEl.type === "line" || baseEl.type === "arrow") continue;
980
+ const state = snap2.get(baseEl.id);
981
+ if (!state || !state.visible) continue;
982
+ const points = getAnchorPoints(baseEl, state);
983
+ for (const p of points) {
984
+ const isActive = activeSnapTarget?.elementId === baseEl.id && activeSnapTarget?.anchor === p.anchor;
985
+ if (isActive) {
986
+ html += `<circle cx="${p.x}" cy="${p.y}" r="6" fill="var(--color-accent)" stroke="white" stroke-width="2" opacity="0.85" pointer-events="none" />`;
987
+ html += `<circle cx="${p.x}" cy="${p.y}" r="12" fill="none" stroke="var(--color-accent)" stroke-width="1.5" opacity="0.4" pointer-events="none" />`;
988
+ } else {
989
+ html += `<circle cx="${p.x}" cy="${p.y}" r="3" fill="white" stroke="var(--color-accent)" stroke-width="1.5" opacity="0.5" pointer-events="none" />`;
990
+ }
991
+ }
992
+ }
993
+ g.innerHTML = html;
994
+ return g;
995
+ }
996
+ function renderSelectionOutline(canvasEl2, elId, snap2, byId) {
997
+ const baseEl = byId.get(elId);
998
+ const state = snap2.get(elId);
999
+ if (!baseEl || !state) return null;
1000
+ let box = null;
1001
+ if (baseEl.type === "rect" || baseEl.type === "image") {
1002
+ box = {
1003
+ x: state.x - 4,
1004
+ y: state.y - 4,
1005
+ w: state.width + 8,
1006
+ h: state.height + 8
1007
+ };
1008
+ } else if (baseEl.type === "circle") {
1009
+ const r = state.r;
1010
+ box = {
1011
+ x: state.cx - r - 4,
1012
+ y: state.cy - r - 4,
1013
+ w: r * 2 + 8,
1014
+ h: r * 2 + 8
1015
+ };
1016
+ } else if (baseEl.type === "text") {
1017
+ const bbox = textBBoxOnCanvas(canvasEl2, elId);
1018
+ if (bbox)
1019
+ box = { x: bbox.x - 4, y: bbox.y - 4, w: bbox.w + 8, h: bbox.h + 8 };
1020
+ } else if (baseEl.type === "line" || baseEl.type === "arrow") {
1021
+ const coords = baseEl.type === "line" ? resolveLineCoords(state, snap2, byId) : resolveArrowCoords(state, snap2, byId);
1022
+ if (!coords) return null;
1023
+ box = {
1024
+ x: Math.min(coords.x1, coords.x2) - 4,
1025
+ y: Math.min(coords.y1, coords.y2) - 4,
1026
+ w: Math.abs(coords.x2 - coords.x1) + 8,
1027
+ h: Math.abs(coords.y2 - coords.y1) + 8
1028
+ };
1029
+ } else if (baseEl.type === "polygon") {
1030
+ const bbox = polygonBoundingBox(String(state.points ?? ""));
1031
+ if (bbox)
1032
+ box = { x: bbox.x - 4, y: bbox.y - 4, w: bbox.w + 8, h: bbox.h + 8 };
1033
+ } else if (baseEl.type === "path") {
1034
+ const bbox = pathBBoxOnCanvas(canvasEl2, elId);
1035
+ if (bbox)
1036
+ box = { x: bbox.x - 4, y: bbox.y - 4, w: bbox.w + 8, h: bbox.h + 8 };
1037
+ }
1038
+ if (!box) return null;
1039
+ const rect = document.createElementNS(SVG_NS, "rect");
1040
+ rect.setAttribute("x", String(box.x));
1041
+ rect.setAttribute("y", String(box.y));
1042
+ rect.setAttribute("width", String(box.w));
1043
+ rect.setAttribute("height", String(box.h));
1044
+ rect.classList.add("element-selected-outline");
1045
+ return rect;
1046
+ }
1047
+
1048
+ // src/legacy/canvas-preview.ts
1049
+ var previewRoot = null;
1050
+ var previewHandle = null;
1051
+ var unsubscribePreview = null;
1052
+ async function mountPreview(host, doc, options) {
1053
+ const { mountStage } = await import('@kokoa/clotho/dom');
1054
+ previewHandle = mountStage(host, doc, {
1055
+ player: {
1056
+ autoplay: true,
1057
+ loop: options.loop,
1058
+ speed: options.speed,
1059
+ onEnd: options.onEnd
1060
+ }
1061
+ });
1062
+ unsubscribePreview = previewHandle.player.subscribe((state) => {
1063
+ options.onTime(state.time);
1064
+ });
1065
+ }
1066
+ function showPreview(canvasEl2, def, options) {
1067
+ if (!canvasEl2) return;
1068
+ const parent = canvasEl2.parentElement;
1069
+ if (!parent) return;
1070
+ hidePreview(canvasEl2);
1071
+ canvasEl2.style.visibility = "hidden";
1072
+ previewRoot = document.createElement("div");
1073
+ const bg = def.canvas.background && def.canvas.background !== "transparent" ? def.canvas.background : "transparent";
1074
+ previewRoot.style.cssText = `position:absolute;left:0;top:0;width:${def.canvas.width}px;height:${def.canvas.height}px;background:${bg};z-index:2;overflow:hidden;`;
1075
+ parent.style.position = "relative";
1076
+ parent.appendChild(previewRoot);
1077
+ void mountPreview(previewRoot, def, options);
1078
+ }
1079
+ function hidePreview(canvasEl2) {
1080
+ if (canvasEl2) canvasEl2.style.visibility = "";
1081
+ unsubscribePreview?.();
1082
+ unsubscribePreview = null;
1083
+ previewHandle?.destroy();
1084
+ previewHandle = null;
1085
+ if (previewRoot) {
1086
+ previewRoot.remove();
1087
+ previewRoot = null;
1088
+ }
1089
+ }
1090
+
1091
+ // src/legacy/canvas.ts
1092
+ var SNAP_RADIUS = 12;
1093
+ var HEAD_MARKER_DEFS = `
1094
+ <marker id="studio-h-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
1095
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
1096
+ </marker>
1097
+ <marker id="studio-h-arrow-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
1098
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
1099
+ </marker>
1100
+ <marker id="studio-h-triangle" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto">
1101
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
1102
+ </marker>
1103
+ <marker id="studio-h-triangle-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
1104
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
1105
+ </marker>
1106
+ <marker id="studio-h-triangle-open" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="9" markerHeight="9" orient="auto">
1107
+ <path d="M 0 0 L 10 5 L 0 10" fill="none" stroke="currentColor" stroke-width="1.5" />
1108
+ </marker>
1109
+ <marker id="studio-h-triangle-open-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="9" markerHeight="9" orient="auto-start-reverse">
1110
+ <path d="M 0 0 L 10 5 L 0 10" fill="none" stroke="currentColor" stroke-width="1.5" />
1111
+ </marker>
1112
+ <marker id="studio-h-circle" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="6" markerHeight="6">
1113
+ <circle cx="5" cy="5" r="4" fill="currentColor" />
1114
+ </marker>
1115
+ <marker id="studio-h-circle-open" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="7" markerHeight="7">
1116
+ <circle cx="5" cy="5" r="4" fill="white" stroke="currentColor" stroke-width="1.5" />
1117
+ </marker>
1118
+ <marker id="studio-h-diamond" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
1119
+ <path d="M 0 5 L 5 0 L 10 5 L 5 10 z" fill="currentColor" />
1120
+ </marker>
1121
+ <marker id="studio-h-diamond-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
1122
+ <path d="M 0 5 L 5 0 L 10 5 L 5 10 z" fill="currentColor" />
1123
+ </marker>
1124
+ <marker id="studio-h-diamond-open" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto">
1125
+ <path d="M 0 5 L 5 0 L 10 5 L 5 10 z" fill="white" stroke="currentColor" stroke-width="1.5" />
1126
+ </marker>
1127
+ <marker id="studio-h-diamond-open-start" viewBox="0 0 10 10" refX="1" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
1128
+ <path d="M 0 5 L 5 0 L 10 5 L 5 10 z" fill="white" stroke="currentColor" stroke-width="1.5" />
1129
+ </marker>
1130
+ <marker id="studio-h-bar" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="5" markerHeight="10" orient="auto">
1131
+ <rect x="4" y="0" width="2" height="10" fill="currentColor" />
1132
+ </marker>
1133
+ `;
1134
+ function markerUrlFor(head, end) {
1135
+ if (!head || head === "none") return "";
1136
+ const isOpenCircle = head === "circle-open";
1137
+ const isCircle = head === "circle" || isOpenCircle;
1138
+ if (isCircle) return `url(#studio-h-${head})`;
1139
+ const idBase = `studio-h-${head}`;
1140
+ return end === "start" ? `url(#${idBase}-start)` : `url(#${idBase})`;
1141
+ }
1142
+ var dragState = null;
1143
+ var rotateState = null;
1144
+ var connectState = null;
1145
+ var endpointDragState = null;
1146
+ var vertexDragState = null;
1147
+ var resizeState = null;
1148
+ var marqueeState = null;
1149
+ var marqueeJustFinished = false;
1150
+ var toolJustFinished = false;
1151
+ var canvasEl = null;
1152
+ var hoveredElementId = null;
1153
+ var canvasZoom = 1;
1154
+ var toolDrawState = null;
1155
+ var elementDrawState = null;
1156
+ var canvasRenderFrame = null;
1157
+ var pathDraftState = null;
1158
+ function initCanvas(root) {
1159
+ canvasEl = root;
1160
+ root.addEventListener("click", onCanvasClick);
1161
+ root.addEventListener("dblclick", onPathDoubleClick);
1162
+ root.addEventListener("mousedown", onMouseDown);
1163
+ root.addEventListener("mouseover", onMouseOver);
1164
+ root.addEventListener("mouseleave", onMouseLeave);
1165
+ root.addEventListener("contextmenu", onContextMenu);
1166
+ root.addEventListener("wheel", onCanvasWheel, { passive: false });
1167
+ document.addEventListener("mousemove", onMouseMove);
1168
+ document.addEventListener("mouseup", onMouseUp);
1169
+ document.addEventListener("keydown", onPathKeyDown);
1170
+ subscribe(render2);
1171
+ render2();
1172
+ }
1173
+ function onCanvasWheel(e) {
1174
+ if (!e.ctrlKey && !e.metaKey) return;
1175
+ e.preventDefault();
1176
+ const wrap = canvasEl?.closest(".studio-canvas-wrap");
1177
+ if (!wrap) {
1178
+ canvasZoom = Math.max(
1179
+ 0.1,
1180
+ Math.min(5, canvasZoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1))
1181
+ );
1182
+ render2();
1183
+ return;
1184
+ }
1185
+ const wrapRect = wrap.getBoundingClientRect();
1186
+ const mxInWrap = e.clientX - wrapRect.left + wrap.scrollLeft;
1187
+ const myInWrap = e.clientY - wrapRect.top + wrap.scrollTop;
1188
+ const old = canvasZoom;
1189
+ canvasZoom = Math.max(
1190
+ 0.1,
1191
+ Math.min(5, canvasZoom * (e.deltaY < 0 ? 1.1 : 1 / 1.1))
1192
+ );
1193
+ const ratio = canvasZoom / old;
1194
+ render2();
1195
+ wrap.scrollLeft = mxInWrap * ratio - (e.clientX - wrapRect.left);
1196
+ wrap.scrollTop = myInWrap * ratio - (e.clientY - wrapRect.top);
1197
+ }
1198
+ function onContextMenu(e) {
1199
+ const target = e.target;
1200
+ const vertexEl = target?.closest("[data-vertex-handle]");
1201
+ if (!vertexEl) return;
1202
+ const id = vertexEl.dataset.elemId ?? "";
1203
+ const idx = Number(vertexEl.dataset.vertexIndex ?? "-1");
1204
+ const def = getDef();
1205
+ if (!def || idx < 0) return;
1206
+ const el = def.elements.find((x) => x.id === id);
1207
+ if (!el || el.type !== "polygon") return;
1208
+ const snap2 = getCurrentSnapshot();
1209
+ const state = snap2.get(id);
1210
+ if (!state) return;
1211
+ const points = String(state.points ?? "").trim().split(/\s+/);
1212
+ if (points.length <= 3) return;
1213
+ e.preventDefault();
1214
+ points.splice(idx, 1);
1215
+ setElementValueAtTime(id, { points: points.join(" ") });
1216
+ }
1217
+ function onMouseOver(e) {
1218
+ const id = findElementId(e.target);
1219
+ if (id !== hoveredElementId) {
1220
+ hoveredElementId = id;
1221
+ render2();
1222
+ }
1223
+ }
1224
+ function onMouseLeave() {
1225
+ if (hoveredElementId !== null) {
1226
+ hoveredElementId = null;
1227
+ render2();
1228
+ }
1229
+ }
1230
+ function svgPoint(clientX, clientY) {
1231
+ if (!canvasEl) return null;
1232
+ const def = getDef();
1233
+ if (!def) return null;
1234
+ const rect = canvasEl.getBoundingClientRect();
1235
+ const sx = def.canvas.width / rect.width;
1236
+ const sy = def.canvas.height / rect.height;
1237
+ return snapPoint({
1238
+ x: (clientX - rect.left) * sx,
1239
+ y: (clientY - rect.top) * sy
1240
+ });
1241
+ }
1242
+ function findElementId(target) {
1243
+ let node = target;
1244
+ while (node && node !== canvasEl) {
838
1245
  const id = node.getAttribute?.("data-elem-id");
839
1246
  if (id) return id;
840
1247
  node = node.parentElement;
@@ -847,6 +1254,10 @@ function onCanvasClick(e) {
847
1254
  marqueeJustFinished = false;
848
1255
  return;
849
1256
  }
1257
+ if (toolJustFinished) {
1258
+ toolJustFinished = false;
1259
+ return;
1260
+ }
850
1261
  const target = e.target;
851
1262
  const addVertexHandle = target?.closest("[data-vertex-add]");
852
1263
  if (addVertexHandle) {
@@ -878,11 +1289,8 @@ function onCanvasClick(e) {
878
1289
  const id = findElementId(e.target);
879
1290
  if (id) {
880
1291
  const resolvedId = e.altKey ? id : findContainingGroup(id)?.id ?? id;
881
- if (e.shiftKey || e.ctrlKey || e.metaKey) {
882
- setSelection(toggleSelectionFor(getSelection(), resolvedId));
883
- } else {
884
- setSelection({ kind: "element", elementId: resolvedId });
885
- }
1292
+ setActiveTool("select");
1293
+ setSelection(nextPointerSelection(getSelection(), resolvedId, e));
886
1294
  } else if (e.target === canvasEl) setSelection({ kind: "none" });
887
1295
  }
888
1296
  function onMouseDown(e) {
@@ -890,13 +1298,66 @@ function onMouseDown(e) {
890
1298
  const target = e.target;
891
1299
  const def = getDef();
892
1300
  if (!def) return;
1301
+ const activeTool2 = getActiveTool();
1302
+ if (activeTool2 !== "select" && e.target === canvasEl) {
1303
+ const point = svgPoint(e.clientX, e.clientY);
1304
+ if (!point) return;
1305
+ e.preventDefault();
1306
+ if (activeTool2 === "path") {
1307
+ if (!pathDraftState) {
1308
+ pathDraftState = { points: [point], current: point };
1309
+ } else {
1310
+ pathDraftState.points.push(point);
1311
+ pathDraftState.current = point;
1312
+ }
1313
+ render2();
1314
+ return;
1315
+ }
1316
+ if (activeTool2 === "line" || activeTool2 === "arrow") {
1317
+ toolDrawState = {
1318
+ tool: activeTool2,
1319
+ startX: point.x,
1320
+ startY: point.y,
1321
+ currentX: point.x,
1322
+ currentY: point.y
1323
+ };
1324
+ render2();
1325
+ return;
1326
+ }
1327
+ const sidesInput = document.getElementById(
1328
+ "studio-polygon-sides"
1329
+ );
1330
+ if (activeTool2 === "rect" || activeTool2 === "circle" || activeTool2 === "polygon" || activeTool2 === "text" || activeTool2 === "image") {
1331
+ elementDrawState = {
1332
+ tool: activeTool2,
1333
+ start: point,
1334
+ current: point,
1335
+ polygonSides: Number(sidesInput?.value ?? 6)
1336
+ };
1337
+ requestCanvasRender();
1338
+ return;
1339
+ }
1340
+ }
1341
+ if (activeTool2 !== "select" && !((activeTool2 === "line" || activeTool2 === "arrow") && target?.closest("[data-anchor-handle]"))) {
1342
+ return;
1343
+ }
1344
+ if (shouldDeferElementPointerDown(e, {
1345
+ isElement: Boolean(findElementId(e.target)),
1346
+ isHandle: Boolean(
1347
+ target?.closest(
1348
+ "[data-rotate-handle], [data-anchor-handle], [data-resize-handle], [data-endpoint-handle], [data-line-mid-handle], [data-vertex-handle], [data-vertex-add]"
1349
+ )
1350
+ )
1351
+ })) {
1352
+ return;
1353
+ }
893
1354
  if (target?.closest("[data-vertex-add]")) {
894
1355
  e.preventDefault();
895
1356
  e.stopPropagation();
896
1357
  return;
897
1358
  }
898
1359
  const resizeHandle = target?.closest("[data-resize-handle]");
899
- if (resizeHandle) {
1360
+ if (resizeHandle && activeTool2 === "select") {
900
1361
  e.preventDefault();
901
1362
  const id2 = resizeHandle.dataset.elemId ?? "";
902
1363
  const handle = resizeHandle.dataset.resizeHandle;
@@ -938,7 +1399,7 @@ function onMouseDown(e) {
938
1399
  }
939
1400
  resizeState = rs;
940
1401
  setSelection({ kind: "element", elementId: id2 });
941
- render();
1402
+ render2();
942
1403
  return;
943
1404
  }
944
1405
  const vertexHandle = target?.closest("[data-vertex-handle]");
@@ -955,7 +1416,7 @@ function onMouseDown(e) {
955
1416
  originalPoints: String(elState2.points ?? "")
956
1417
  };
957
1418
  setSelection({ kind: "element", elementId: id2 });
958
- render();
1419
+ render2();
959
1420
  return;
960
1421
  }
961
1422
  const endpointHandle = target?.closest("[data-endpoint-handle]");
@@ -973,7 +1434,7 @@ function onMouseDown(e) {
973
1434
  snapTarget: null
974
1435
  };
975
1436
  setSelection({ kind: "element", elementId: id2 });
976
- render();
1437
+ render2();
977
1438
  return;
978
1439
  }
979
1440
  const midHandle = target?.closest("[data-line-mid-handle]");
@@ -1022,7 +1483,7 @@ function onMouseDown(e) {
1022
1483
  return;
1023
1484
  }
1024
1485
  const anchorHandle = target?.closest("[data-anchor-handle]");
1025
- if (anchorHandle) {
1486
+ if (anchorHandle && (activeTool2 === "line" || activeTool2 === "arrow")) {
1026
1487
  e.preventDefault();
1027
1488
  const fromId = anchorHandle.dataset.elemId ?? "";
1028
1489
  const fromAnchor = anchorHandle.dataset.anchor ?? "auto";
@@ -1036,7 +1497,7 @@ function onMouseDown(e) {
1036
1497
  currentX: pt.x,
1037
1498
  currentY: pt.y
1038
1499
  };
1039
- render();
1500
+ render2();
1040
1501
  return;
1041
1502
  }
1042
1503
  const rawId = findElementId(e.target);
@@ -1099,6 +1560,29 @@ function collectDragExtras(anchorId, def, snap2) {
1099
1560
  return extras;
1100
1561
  }
1101
1562
  function onMouseMove(e) {
1563
+ if (pathDraftState && getActiveTool() === "path") {
1564
+ const point = svgPoint(e.clientX, e.clientY);
1565
+ if (point) {
1566
+ pathDraftState.current = point;
1567
+ render2();
1568
+ }
1569
+ return;
1570
+ }
1571
+ if (elementDrawState) {
1572
+ const point = svgPoint(e.clientX, e.clientY);
1573
+ if (!point) return;
1574
+ elementDrawState.current = point;
1575
+ requestCanvasRender();
1576
+ return;
1577
+ }
1578
+ if (toolDrawState) {
1579
+ const point = svgPoint(e.clientX, e.clientY);
1580
+ if (!point) return;
1581
+ toolDrawState.currentX = point.x;
1582
+ toolDrawState.currentY = point.y;
1583
+ render2();
1584
+ return;
1585
+ }
1102
1586
  if (resizeState) {
1103
1587
  handleResizeMove(e);
1104
1588
  return;
@@ -1168,7 +1652,7 @@ function onMouseMove(e) {
1168
1652
  }
1169
1653
  }
1170
1654
  }
1171
- render();
1655
+ render2();
1172
1656
  return;
1173
1657
  }
1174
1658
  if (rotateState) {
@@ -1186,7 +1670,7 @@ function onMouseMove(e) {
1186
1670
  if (!pt) return;
1187
1671
  connectState.currentX = pt.x;
1188
1672
  connectState.currentY = pt.y;
1189
- render();
1673
+ render2();
1190
1674
  return;
1191
1675
  }
1192
1676
  if (marqueeState) {
@@ -1194,7 +1678,7 @@ function onMouseMove(e) {
1194
1678
  if (!pt) return;
1195
1679
  marqueeState.currentX = pt.x;
1196
1680
  marqueeState.currentY = pt.y;
1197
- render();
1681
+ render2();
1198
1682
  return;
1199
1683
  }
1200
1684
  if (!dragState || !canvasEl) return;
@@ -1226,18 +1710,104 @@ function onMouseMove(e) {
1226
1710
  );
1227
1711
  }
1228
1712
  }
1713
+ function onPathDoubleClick(e) {
1714
+ if (!pathDraftState || getActiveTool() !== "path") return;
1715
+ e.preventDefault();
1716
+ const points = pathDraftState.points;
1717
+ if (points.length >= 2 && Math.hypot(
1718
+ points.at(-1).x - points.at(-2).x,
1719
+ points.at(-1).y - points.at(-2).y
1720
+ ) < 2) {
1721
+ points.pop();
1722
+ }
1723
+ finishPathDraft();
1724
+ }
1725
+ function onPathKeyDown(e) {
1726
+ if (!pathDraftState || getActiveTool() !== "path") return;
1727
+ const target = e.target;
1728
+ const isEditing = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLElement && target.isContentEditable;
1729
+ if (isEditing) return;
1730
+ if (e.key === "Enter") {
1731
+ e.preventDefault();
1732
+ finishPathDraft();
1733
+ } else if (e.key === "Escape") {
1734
+ e.preventDefault();
1735
+ pathDraftState = null;
1736
+ render2();
1737
+ }
1738
+ }
1739
+ function finishPathDraft() {
1740
+ const draft = pathDraftState;
1741
+ pathDraftState = null;
1742
+ if (!draft || draft.points.length < 2) {
1743
+ render2();
1744
+ return;
1745
+ }
1746
+ const origin = draft.points[0];
1747
+ const element = makeDefaultElement(
1748
+ "path",
1749
+ uniqueElementId("path"),
1750
+ origin.x,
1751
+ origin.y
1752
+ );
1753
+ if (element?.type === "path") {
1754
+ const d = draft.points.map((point, index) => {
1755
+ const command = index === 0 ? "M" : "L";
1756
+ return `${command} ${(point.x - origin.x).toFixed(1)} ${(point.y - origin.y).toFixed(1)}`;
1757
+ }).join(" ");
1758
+ addElement({ ...element, x: origin.x, y: origin.y, d });
1759
+ }
1760
+ render2();
1761
+ }
1229
1762
  function onMouseUp(e) {
1763
+ if (elementDrawState) {
1764
+ const draw = elementDrawState;
1765
+ const point = svgPoint(e.clientX, e.clientY);
1766
+ if (point) draw.current = point;
1767
+ elementDrawState = null;
1768
+ if (canvasRenderFrame !== null) {
1769
+ cancelAnimationFrame(canvasRenderFrame);
1770
+ canvasRenderFrame = null;
1771
+ }
1772
+ toolJustFinished = true;
1773
+ finishElementDraw(draw);
1774
+ render2();
1775
+ return;
1776
+ }
1777
+ if (toolDrawState) {
1778
+ const draw = toolDrawState;
1779
+ toolDrawState = null;
1780
+ if (Math.hypot(draw.currentX - draw.startX, draw.currentY - draw.startY) >= 4) {
1781
+ const element = makeDefaultElement(
1782
+ draw.tool,
1783
+ uniqueElementId(draw.tool),
1784
+ 0,
1785
+ 0
1786
+ );
1787
+ if (element && (element.type === "line" || element.type === "arrow")) {
1788
+ addElement({
1789
+ ...element,
1790
+ x1: draw.startX,
1791
+ y1: draw.startY,
1792
+ x2: draw.currentX,
1793
+ y2: draw.currentY
1794
+ });
1795
+ }
1796
+ }
1797
+ render2();
1798
+ return;
1799
+ }
1230
1800
  if (resizeState) {
1231
1801
  resizeState = null;
1232
- render();
1802
+ render2();
1233
1803
  }
1234
1804
  if (vertexDragState) {
1235
1805
  vertexDragState = null;
1236
- render();
1806
+ render2();
1237
1807
  }
1238
1808
  if (endpointDragState) {
1239
1809
  endpointDragState = null;
1240
- render();
1810
+ render2();
1241
1811
  }
1242
1812
  if (connectState) {
1243
1813
  const target = e.target;
@@ -1247,10 +1817,9 @@ function onMouseUp(e) {
1247
1817
  if (elemId && elemId !== connectState.fromId) {
1248
1818
  const def = getDef();
1249
1819
  if (def) {
1250
- const newId = uniqueElementId("arrow");
1251
- addElement({
1252
- type: "arrow",
1253
- id: newId,
1820
+ const connectionType = getActiveTool() === "line" ? "line" : "arrow";
1821
+ const common = {
1822
+ id: uniqueElementId(connectionType),
1254
1823
  rotation: 0,
1255
1824
  appearances: [],
1256
1825
  tracks: [],
@@ -1260,17 +1829,25 @@ function onMouseUp(e) {
1260
1829
  toAnchor,
1261
1830
  stroke: "#6366f1",
1262
1831
  strokeWidth: 2,
1263
- curvature: 0,
1264
- labelColor: "#0b0b0f",
1265
- labelOffsetX: 0,
1266
- labelOffsetY: 4,
1267
- headStart: "none",
1268
- headEnd: "arrow"
1269
- });
1832
+ headStart: "none"
1833
+ };
1834
+ if (connectionType === "line") {
1835
+ addElement({ ...common, type: "line", headEnd: "none" });
1836
+ } else {
1837
+ addElement({
1838
+ ...common,
1839
+ type: "arrow",
1840
+ curvature: 0,
1841
+ labelColor: "#0b0b0f",
1842
+ labelOffsetX: 0,
1843
+ labelOffsetY: 4,
1844
+ headEnd: "arrow"
1845
+ });
1846
+ }
1270
1847
  }
1271
1848
  }
1272
1849
  connectState = null;
1273
- render();
1850
+ render2();
1274
1851
  }
1275
1852
  if (marqueeState) {
1276
1853
  const mx = Math.min(marqueeState.startX, marqueeState.currentX);
@@ -1302,11 +1879,62 @@ function onMouseUp(e) {
1302
1879
  }
1303
1880
  }
1304
1881
  }
1305
- render();
1882
+ render2();
1306
1883
  }
1307
1884
  dragState = null;
1308
1885
  rotateState = null;
1309
1886
  }
1887
+ function requestCanvasRender() {
1888
+ if (canvasRenderFrame !== null) return;
1889
+ canvasRenderFrame = requestAnimationFrame(() => {
1890
+ canvasRenderFrame = null;
1891
+ render2();
1892
+ });
1893
+ }
1894
+ function finishElementDraw(draw) {
1895
+ if (draw.tool === "image") {
1896
+ document.getElementById("studio-image-file")?.click();
1897
+ return;
1898
+ }
1899
+ const dragged = isShapeDrag(draw.start, draw.current);
1900
+ const bounds = dragBounds(draw.start, draw.current);
1901
+ const center = dragged ? { x: bounds.centerX, y: bounds.centerY } : draw.start;
1902
+ const element = makeDefaultElement(
1903
+ draw.tool,
1904
+ uniqueElementId(draw.tool),
1905
+ center.x,
1906
+ center.y,
1907
+ draw.polygonSides
1908
+ );
1909
+ if (!element) return;
1910
+ if (dragged && element.type === "rect") {
1911
+ addElement({
1912
+ ...element,
1913
+ x: bounds.x,
1914
+ y: bounds.y,
1915
+ width: Math.max(1, bounds.width),
1916
+ height: Math.max(1, bounds.height)
1917
+ });
1918
+ } else if (dragged && element.type === "circle") {
1919
+ addElement({
1920
+ ...element,
1921
+ cx: bounds.centerX,
1922
+ cy: bounds.centerY,
1923
+ r: Math.max(2, bounds.distance / 2)
1924
+ });
1925
+ } else if (dragged && element.type === "polygon") {
1926
+ addElement({
1927
+ ...element,
1928
+ points: regularPolygonPoints(
1929
+ { x: bounds.centerX, y: bounds.centerY },
1930
+ Math.max(2, bounds.distance / 2),
1931
+ draw.polygonSides
1932
+ )
1933
+ });
1934
+ } else {
1935
+ addElement(element);
1936
+ }
1937
+ }
1310
1938
  function applyMove(baseEl, state, x, y, polygonOriginal) {
1311
1939
  if (baseEl.type === "rect" || baseEl.type === "image" || baseEl.type === "text") {
1312
1940
  setElementValueAtTime(baseEl.id, { x, y });
@@ -1509,7 +2137,7 @@ function handleResizeMove(e) {
1509
2137
  return;
1510
2138
  }
1511
2139
  }
1512
- function render() {
2140
+ function render2() {
1513
2141
  if (!canvasEl) return;
1514
2142
  const def = getDef();
1515
2143
  if (!def) {
@@ -1552,32 +2180,20 @@ function render() {
1552
2180
  snap2,
1553
2181
  elementsById
1554
2182
  );
1555
- if (outline) canvasEl.appendChild(outline);
1556
- const handle = renderRotationHandle(
1557
- canvasEl,
1558
- selection.elementId,
1559
- snap2,
1560
- elementsById
1561
- );
1562
- if (handle) canvasEl.appendChild(handle);
1563
- const endpoints = renderLineEndpointHandles(
1564
- selection.elementId,
1565
- snap2,
1566
- elementsById
1567
- );
1568
- if (endpoints) canvasEl.appendChild(endpoints);
1569
- const vertices = renderPolygonVertexHandles(
1570
- selection.elementId,
1571
- snap2,
1572
- elementsById
1573
- );
1574
- if (vertices) canvasEl.appendChild(vertices);
1575
- const resize = renderResizeHandles(
2183
+ if (outline) canvasEl.appendChild(outline);
2184
+ const selectMode = getActiveTool() === "select";
2185
+ const handle = selectMode ? renderRotationHandle(
1576
2186
  canvasEl,
1577
2187
  selection.elementId,
1578
2188
  snap2,
1579
2189
  elementsById
1580
- );
2190
+ ) : null;
2191
+ if (handle) canvasEl.appendChild(handle);
2192
+ const endpoints = selectMode ? renderLineEndpointHandles(selection.elementId, snap2, elementsById) : null;
2193
+ if (endpoints) canvasEl.appendChild(endpoints);
2194
+ const vertices = selectMode ? renderPolygonVertexHandles(selection.elementId, snap2, elementsById) : null;
2195
+ if (vertices) canvasEl.appendChild(vertices);
2196
+ const resize = selectMode ? renderResizeHandles(canvasEl, selection.elementId, snap2, elementsById) : null;
1581
2197
  if (resize) canvasEl.appendChild(resize);
1582
2198
  }
1583
2199
  } else if (selection.kind === "elements") {
@@ -1600,513 +2216,291 @@ function render() {
1600
2216
  );
1601
2217
  if (guides) canvasEl.appendChild(guides);
1602
2218
  }
1603
- if (hoveredElementId && (!connectState || hoveredElementId !== connectState.fromId)) {
2219
+ const activeTool2 = getActiveTool();
2220
+ if (hoveredElementId && (activeTool2 === "line" || activeTool2 === "arrow") && (!connectState || hoveredElementId !== connectState.fromId)) {
1604
2221
  const anchors = renderAnchorDots(hoveredElementId, snap2, elementsById);
1605
2222
  if (anchors) canvasEl.appendChild(anchors);
1606
2223
  }
1607
- if (connectState && connectState.fromId !== hoveredElementId) {
2224
+ if (connectState && (activeTool2 === "line" || activeTool2 === "arrow") && connectState.fromId !== hoveredElementId) {
1608
2225
  const anchors = renderAnchorDots(connectState.fromId, snap2, elementsById);
1609
2226
  if (anchors) canvasEl.appendChild(anchors);
1610
2227
  }
1611
2228
  if (connectState) {
1612
- const tempLine = document.createElementNS(SVG_NS, "line");
1613
- tempLine.setAttribute("x1", String(connectState.startX));
1614
- tempLine.setAttribute("y1", String(connectState.startY));
1615
- tempLine.setAttribute("x2", String(connectState.currentX));
1616
- tempLine.setAttribute("y2", String(connectState.currentY));
1617
- tempLine.setAttribute("stroke", "#6366f1");
1618
- tempLine.setAttribute("stroke-width", "2");
1619
- tempLine.setAttribute("stroke-dasharray", "5 3");
1620
- tempLine.setAttribute("marker-end", "url(#studio-arrow)");
1621
- tempLine.style.pointerEvents = "none";
1622
- canvasEl.appendChild(tempLine);
1623
- }
1624
- if (marqueeState) {
1625
- const mx = Math.min(marqueeState.startX, marqueeState.currentX);
1626
- const my = Math.min(marqueeState.startY, marqueeState.currentY);
1627
- const mw = Math.abs(marqueeState.currentX - marqueeState.startX);
1628
- const mh = Math.abs(marqueeState.currentY - marqueeState.startY);
1629
- if (mw > 0 || mh > 0) {
1630
- const marqueeRect = document.createElementNS(SVG_NS, "rect");
1631
- marqueeRect.setAttribute("x", String(mx));
1632
- marqueeRect.setAttribute("y", String(my));
1633
- marqueeRect.setAttribute("width", String(mw));
1634
- marqueeRect.setAttribute("height", String(mh));
1635
- marqueeRect.setAttribute("fill", "rgba(99, 102, 241, 0.08)");
1636
- marqueeRect.setAttribute("stroke", "#6366f1");
1637
- marqueeRect.setAttribute("stroke-width", "1");
1638
- marqueeRect.setAttribute("stroke-dasharray", "4 3");
1639
- marqueeRect.style.pointerEvents = "none";
1640
- canvasEl.appendChild(marqueeRect);
1641
- }
1642
- }
1643
- }
1644
- function findSnapTarget(x, y, excludeElementId) {
1645
- const def = getDef();
1646
- if (!def) return null;
1647
- const snap2 = getCurrentSnapshot();
1648
- const allAnchors = [];
1649
- for (const baseEl of def.elements) {
1650
- if (baseEl.id === excludeElementId) continue;
1651
- if (baseEl.type === "line" || baseEl.type === "arrow") continue;
1652
- const state = snap2.get(baseEl.id);
1653
- if (!state || !state.visible) continue;
1654
- const points = getAnchorPoints(baseEl, state);
1655
- allAnchors.push(...points);
1656
- }
1657
- const nearest = findNearestAnchor({ x, y }, allAnchors, SNAP_RADIUS);
1658
- if (!nearest) return null;
1659
- return {
1660
- elementId: nearest.elementId,
1661
- anchor: nearest.anchor,
1662
- x: nearest.x,
1663
- y: nearest.y
1664
- };
1665
- }
1666
- function renderGroupOutline(groupId) {
1667
- const box = groupBbox(groupId);
1668
- if (!box) return null;
1669
- const g = document.createElementNS(SVG_NS, "g");
1670
- g.setAttribute("data-elem-id", groupId);
1671
- const pad = 6;
1672
- const x = box.x - pad;
1673
- const y = box.y - pad;
1674
- const w = box.w + pad * 2;
1675
- const h = box.h + pad * 2;
1676
- const thickness = 8;
1677
- g.innerHTML = `
1678
- <rect x="${x}" y="${y}" width="${w}" height="${h}"
1679
- fill="none" stroke="var(--color-accent)" stroke-width="2" stroke-dasharray="6 4" rx="4" pointer-events="none" />
1680
- <rect x="${x - thickness}" y="${y - thickness}" width="${w + thickness * 2}" height="${thickness * 2}"
1681
- fill="rgba(0,0,0,0.0001)" style="cursor: move" pointer-events="all" />
1682
- <rect x="${x - thickness}" y="${y + h - thickness}" width="${w + thickness * 2}" height="${thickness * 2}"
1683
- fill="rgba(0,0,0,0.0001)" style="cursor: move" pointer-events="all" />
1684
- <rect x="${x - thickness}" y="${y}" width="${thickness * 2}" height="${h}"
1685
- fill="rgba(0,0,0,0.0001)" style="cursor: move" pointer-events="all" />
1686
- <rect x="${x + w - thickness}" y="${y}" width="${thickness * 2}" height="${h}"
1687
- fill="rgba(0,0,0,0.0001)" style="cursor: move" pointer-events="all" />
1688
- `;
1689
- return g;
1690
- }
1691
- function makeG(elementId, rotation, cx, cy) {
1692
- const g = document.createElementNS(SVG_NS, "g");
1693
- g.setAttribute("data-elem-id", elementId);
1694
- g.classList.add("element-handle");
1695
- if (rotation) g.setAttribute("transform", `rotate(${rotation} ${cx} ${cy})`);
1696
- return g;
1697
- }
1698
- function renderElement(baseEl, state, snap2, byId) {
1699
- const rotation = state.rotation || 0;
1700
- if (baseEl.type === "rect") {
1701
- const r = state;
1702
- const cx = r.x + r.width / 2;
1703
- const cy = r.y + r.height / 2;
1704
- const g = makeG(baseEl.id, rotation, cx, cy);
1705
- g.innerHTML = `
1706
- <rect x="${r.x}" y="${r.y}" width="${r.width}" height="${r.height}" rx="${r.cornerRadius}"
1707
- fill="${r.fill}" stroke="${r.stroke}" stroke-width="${r.strokeWidth}" />
1708
- ${r.label ? `<text x="${cx}" y="${cy + 5}" text-anchor="middle"
1709
- font-size="${r.labelSize}" font-weight="600" fill="${r.labelColor}">${escapeXml(r.label)}</text>` : ""}
1710
- ${r.subtitle ? `<text x="${cx}" y="${cy + r.labelSize + 8}"
1711
- text-anchor="middle" font-size="10" fill="${r.labelColor}" opacity="0.7">${escapeXml(r.subtitle)}</text>` : ""}
1712
- `;
1713
- return g;
1714
- }
1715
- if (baseEl.type === "circle") {
1716
- const c = state;
1717
- const g = makeG(baseEl.id, rotation, c.cx, c.cy);
1718
- g.innerHTML = `
1719
- <circle cx="${c.cx}" cy="${c.cy}" r="${c.r}" fill="${c.fill}" stroke="${c.stroke}"
1720
- stroke-width="${c.strokeWidth}" />
1721
- ${c.label ? `<text x="${c.cx}" y="${c.cy + 5}" text-anchor="middle"
1722
- font-size="${c.labelSize}" font-weight="600" fill="${c.labelColor}">${escapeXml(c.label)}</text>` : ""}
1723
- `;
1724
- return g;
1725
- }
1726
- if (baseEl.type === "line") {
1727
- const l = state;
1728
- const coords = resolveLineCoords(l, snap2, byId);
1729
- if (!coords) return null;
1730
- const g = makeG(baseEl.id, 0, 0, 0);
1731
- g.style.color = l.stroke;
1732
- const dash = l.strokeDasharray ? `stroke-dasharray="${l.strokeDasharray}"` : "";
1733
- const mStart = markerUrlFor(l.headStart, "start");
1734
- const mEnd = markerUrlFor(l.headEnd, "end");
1735
- const mAttr = `${mStart ? `marker-start="${mStart}"` : ""} ${mEnd ? `marker-end="${mEnd}"` : ""}`;
1736
- g.innerHTML = `<line x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}"
1737
- stroke="currentColor" stroke-width="${l.strokeWidth}" ${dash} ${mAttr} />`;
1738
- return g;
1739
- }
1740
- if (baseEl.type === "arrow") {
1741
- const a = state;
1742
- const coords = resolveArrowCoords(a, snap2, byId);
1743
- if (!coords) return null;
1744
- const g = makeG(baseEl.id, 0, 0, 0);
1745
- g.style.color = a.stroke;
1746
- const dash = a.strokeDasharray ? `stroke-dasharray="${a.strokeDasharray}"` : "";
1747
- const midX = (coords.x1 + coords.x2) / 2;
1748
- const midY = (coords.y1 + coords.y2) / 2;
1749
- const isCurved = (a.curvature || 0) !== 0;
1750
- const mStart = markerUrlFor(a.headStart ?? "none", "start");
1751
- const mEnd = markerUrlFor(a.headEnd ?? "arrow", "end");
1752
- const mAttr = `${mStart ? `marker-start="${mStart}"` : ""} ${mEnd ? `marker-end="${mEnd}"` : ""}`;
1753
- let pathHtml;
1754
- if (isCurved) {
1755
- const dx = coords.x2 - coords.x1;
1756
- const dy = coords.y2 - coords.y1;
1757
- const len = Math.hypot(dx, dy) || 1;
1758
- const nx = -dy / len;
1759
- const ny = dx / len;
1760
- const cpx = midX + nx * (a.curvature ?? 0);
1761
- const cpy = midY + ny * (a.curvature ?? 0);
1762
- pathHtml = `<path d="M ${coords.x1} ${coords.y1} Q ${cpx} ${cpy} ${coords.x2} ${coords.y2}" fill="none" stroke="currentColor" stroke-width="${a.strokeWidth}" ${dash} ${mAttr} />`;
1763
- } else {
1764
- pathHtml = `<line x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}" stroke="currentColor" stroke-width="${a.strokeWidth}" ${dash} ${mAttr} />`;
1765
- }
1766
- g.innerHTML = `
1767
- ${pathHtml}
1768
- ${a.label ? `<g>
1769
- <rect x="${midX - 80}" y="${midY - 11}" width="160" height="22" rx="4" fill="white" stroke="currentColor" stroke-opacity="0.4"/>
1770
- <text x="${midX}" y="${midY + 4}" text-anchor="middle" font-size="12" font-family="ui-monospace, monospace" fill="${a.labelColor}">${escapeXml(a.label)}</text>
1771
- </g>` : ""}
1772
- `;
1773
- return g;
1774
- }
1775
- if (baseEl.type === "text") {
1776
- const t = state;
1777
- const g = makeG(baseEl.id, rotation, t.x, t.y);
1778
- g.innerHTML = `<text x="${t.x}" y="${t.y}" font-size="${t.fontSize}" font-weight="${t.fontWeight}"
1779
- fill="${t.color}" text-anchor="${t.textAnchor}">${escapeXml(t.content)}</text>`;
1780
- return g;
1781
- }
1782
- if (baseEl.type === "image") {
1783
- const i = state;
1784
- const cx = i.x + i.width / 2;
1785
- const cy = i.y + i.height / 2;
1786
- const g = makeG(baseEl.id, rotation, cx, cy);
1787
- const resolved = resolveAsset(i.assetId, getDef()?.assets ?? {});
1788
- const href = resolved.status === "resolved" ? resolved.href ?? "" : "";
1789
- g.innerHTML = href ? `<image href="${escapeXml(href)}" x="${i.x}" y="${i.y}" width="${i.width}"
1790
- height="${i.height}" preserveAspectRatio="${i.preserveAspectRatio}" opacity="${i.opacity}" />` : `<rect x="${i.x}" y="${i.y}" width="${i.width}" height="${i.height}" fill="none"
1791
- stroke="currentColor" stroke-width="1" stroke-dasharray="4 4" opacity="0.4" />`;
1792
- return g;
1793
- }
1794
- if (baseEl.type === "path") {
1795
- const p = state;
1796
- const g = makeG(baseEl.id, rotation, p.x, p.y);
1797
- const dash = p.strokeDasharray ? `stroke-dasharray="${p.strokeDasharray}"` : "";
1798
- const xform = p.x || p.y ? `transform="translate(${p.x} ${p.y})"` : "";
1799
- g.innerHTML = `<path d="${escapeXml(p.d)}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="${p.strokeWidth}" ${dash} opacity="${p.opacity}" ${xform} pointer-events="all" />`;
1800
- return g;
1801
- }
1802
- if (baseEl.type === "polygon") {
1803
- const pg = state;
1804
- const g = makeG(baseEl.id, rotation, 0, 0);
1805
- g.innerHTML = `<polygon points="${escapeXml(pg.points)}" fill="${pg.fill}" stroke="${pg.stroke}" stroke-width="${pg.strokeWidth}" opacity="${pg.opacity}" pointer-events="all" />`;
1806
- return g;
1807
- }
1808
- return null;
1809
- }
1810
- function showPreview2(def) {
1811
- showPreview(canvasEl, def);
1812
- }
1813
- function hidePreview2() {
1814
- hidePreview(canvasEl);
1815
- }
1816
-
1817
- // src/legacy/element-list.ts
1818
- var listEl = null;
1819
- var toolsRootEl = null;
1820
- var searchEl = null;
1821
- var dragSourceId = null;
1822
- function initElementList(root, toolsRoot) {
1823
- listEl = root;
1824
- toolsRootEl = toolsRoot;
1825
- searchEl = document.getElementById(
1826
- "studio-element-search"
1827
- );
1828
- subscribe(render2);
1829
- root.addEventListener("click", onClick);
1830
- root.addEventListener("dragstart", onDragStart);
1831
- root.addEventListener("dragover", onDragOver);
1832
- root.addEventListener("dragleave", onDragLeave);
1833
- root.addEventListener("drop", onDrop);
1834
- root.addEventListener("dragend", onDragEnd);
1835
- toolsRootEl?.addEventListener("click", onToolsClick);
1836
- searchEl?.addEventListener("input", render2);
1837
- searchEl?.addEventListener("keydown", (e) => {
1838
- if (e.key === "Escape" && searchEl) {
1839
- searchEl.value = "";
1840
- render2();
1841
- }
1842
- });
1843
- render2();
1844
- }
1845
- function escapeHtml(s) {
1846
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1847
- }
1848
- function friendlyElementLabel(el) {
1849
- const e = el;
1850
- const visible = e.name?.trim() || e.label?.trim() || e.content?.trim();
1851
- return visible ? visible : el.id;
1852
- }
1853
- function matchesSearch(el, query) {
1854
- if (!query) return true;
1855
- const e = el;
1856
- const haystack = [el.id, el.type, e.name, e.label, e.content].filter(Boolean).join(" ").toLowerCase();
1857
- return haystack.includes(query);
1858
- }
1859
- function render2() {
1860
- if (!listEl) return;
1861
- const def = getDef();
1862
- if (!def || def.elements.length === 0) {
1863
- listEl.innerHTML = '<li style="color: var(--color-fg-muted); padding: 0.4rem;">\uC694\uC18C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</li>';
1864
- return;
2229
+ const tempLine = document.createElementNS(SVG_NS, "line");
2230
+ tempLine.setAttribute("x1", String(connectState.startX));
2231
+ tempLine.setAttribute("y1", String(connectState.startY));
2232
+ tempLine.setAttribute("x2", String(connectState.currentX));
2233
+ tempLine.setAttribute("y2", String(connectState.currentY));
2234
+ tempLine.setAttribute("stroke", "#6366f1");
2235
+ tempLine.setAttribute("stroke-width", "2");
2236
+ tempLine.setAttribute("stroke-dasharray", "5 3");
2237
+ tempLine.setAttribute("marker-end", "url(#studio-arrow)");
2238
+ tempLine.style.pointerEvents = "none";
2239
+ canvasEl.appendChild(tempLine);
1865
2240
  }
1866
- const sel = getSelection();
1867
- const query = searchEl?.value.trim().toLowerCase() ?? "";
1868
- const allReversed = [...def.elements].reverse();
1869
- const items = query ? allReversed.filter((el) => matchesSearch(el, query)) : allReversed;
1870
- const multiHint = sel.kind === "elements" ? `<div class="studio-element-list-hint">\uB2E4\uC911 \uC120\uD0DD: ${sel.elementIds.length}\uAC1C (Shift+\uD074\uB9AD\uC73C\uB85C \uCD94\uAC00/\uD574\uC81C)</div>` : "";
1871
- const filterHint = query ? `<div class="studio-element-list-hint">\uAC80\uC0C9 "${escapeHtml(query)}" \xB7 ${items.length}/${def.elements.length} \uC77C\uCE58 \xB7 Esc = \uC9C0\uC6B0\uAE30</div>` : `<div class="studio-element-list-hint">\uC704 = \uC55E\uCABD (z-index \u2191) \xB7 Shift+\uD074\uB9AD = \uB2E4\uC911 \uC120\uD0DD</div>`;
1872
- const emptyMsg = query && items.length === 0 ? `<li style="color: var(--color-fg-muted); padding: 0.4rem; font-size:0.78rem;">"${escapeHtml(query)}" \uC77C\uCE58 \uC5C6\uC74C</li>` : "";
1873
- listEl.innerHTML = filterHint + multiHint + emptyMsg + items.map((el) => {
1874
- const isSel = isElementSelected(sel, el.id);
1875
- const label = friendlyElementLabel(el);
1876
- const subId = label !== el.id ? `<span class="studio-element-list-item-sub">${escapeHtml(el.id)}</span>` : "";
1877
- return `<li class="studio-element-list-item ${isSel ? "is-selected" : ""}" data-elem-id="${escapeHtml(el.id)}" draggable="true">
1878
- <span class="studio-element-grip" aria-hidden="true">\u22EE\u22EE</span>
1879
- <span class="studio-element-list-item-label">${escapeHtml(label)} <span class="studio-element-list-item-type">${escapeHtml(el.type)}</span>${subId}</span>
1880
- <button type="button" class="studio-element-list-delete" data-delete title="\uC0AD\uC81C">\u2715</button>
1881
- </li>`;
1882
- }).join("");
1883
- }
1884
- function onClick(e) {
1885
- const target = e.target;
1886
- const li = target.closest(".studio-element-list-item");
1887
- if (!li) return;
1888
- const id = li.dataset.elemId ?? "";
1889
- if (target.closest("[data-delete]")) {
1890
- deleteElement(id);
1891
- return;
2241
+ if (toolDrawState) {
2242
+ const preview = document.createElementNS(SVG_NS, "line");
2243
+ preview.setAttribute("x1", String(toolDrawState.startX));
2244
+ preview.setAttribute("y1", String(toolDrawState.startY));
2245
+ preview.setAttribute("x2", String(toolDrawState.currentX));
2246
+ preview.setAttribute("y2", String(toolDrawState.currentY));
2247
+ preview.setAttribute("stroke", "#6366f1");
2248
+ preview.setAttribute("stroke-width", "2");
2249
+ preview.setAttribute("stroke-dasharray", "5 3");
2250
+ preview.style.pointerEvents = "none";
2251
+ canvasEl.appendChild(preview);
1892
2252
  }
1893
- const me = e;
1894
- if (me.shiftKey || me.ctrlKey || me.metaKey) {
1895
- const sel = getSelection();
1896
- setSelection(toggleSelectionFor(sel, id));
1897
- return;
2253
+ if (elementDrawState) {
2254
+ const bounds = dragBounds(elementDrawState.start, elementDrawState.current);
2255
+ const preview = document.createElementNS(
2256
+ SVG_NS,
2257
+ elementDrawState.tool === "circle" ? "circle" : elementDrawState.tool === "polygon" ? "polygon" : "rect"
2258
+ );
2259
+ if (elementDrawState.tool === "circle") {
2260
+ preview.setAttribute("cx", String(bounds.centerX));
2261
+ preview.setAttribute("cy", String(bounds.centerY));
2262
+ preview.setAttribute("r", String(Math.max(2, bounds.distance / 2)));
2263
+ } else if (elementDrawState.tool === "polygon") {
2264
+ preview.setAttribute(
2265
+ "points",
2266
+ regularPolygonPoints(
2267
+ { x: bounds.centerX, y: bounds.centerY },
2268
+ Math.max(2, bounds.distance / 2),
2269
+ elementDrawState.polygonSides
2270
+ )
2271
+ );
2272
+ } else {
2273
+ preview.setAttribute("x", String(bounds.x));
2274
+ preview.setAttribute("y", String(bounds.y));
2275
+ preview.setAttribute("width", String(Math.max(1, bounds.width)));
2276
+ preview.setAttribute("height", String(Math.max(1, bounds.height)));
2277
+ }
2278
+ preview.setAttribute("fill", "rgba(99, 102, 241, 0.12)");
2279
+ preview.setAttribute("stroke", "#6366f1");
2280
+ preview.setAttribute("stroke-width", "2");
2281
+ preview.setAttribute("stroke-dasharray", "5 3");
2282
+ preview.style.pointerEvents = "none";
2283
+ canvasEl.appendChild(preview);
1898
2284
  }
1899
- setSelection({ kind: "element", elementId: id });
1900
- }
1901
- function onDragStart(e) {
1902
- const li = e.target.closest(
1903
- ".studio-element-list-item"
1904
- );
1905
- if (!li) return;
1906
- dragSourceId = li.dataset.elemId ?? null;
1907
- if (e.dataTransfer) {
1908
- e.dataTransfer.effectAllowed = "move";
1909
- e.dataTransfer.setData("text/plain", dragSourceId ?? "");
2285
+ if (pathDraftState) {
2286
+ const points = [
2287
+ ...pathDraftState.points,
2288
+ ...pathDraftState.current ? [pathDraftState.current] : []
2289
+ ];
2290
+ const preview = document.createElementNS(SVG_NS, "polyline");
2291
+ preview.setAttribute(
2292
+ "points",
2293
+ points.map((point) => `${point.x},${point.y}`).join(" ")
2294
+ );
2295
+ preview.setAttribute("fill", "none");
2296
+ preview.setAttribute("stroke", "#6366f1");
2297
+ preview.setAttribute("stroke-width", "2");
2298
+ preview.setAttribute("stroke-dasharray", "5 3");
2299
+ preview.style.pointerEvents = "none";
2300
+ canvasEl.appendChild(preview);
2301
+ for (const point of pathDraftState.points) {
2302
+ const anchor = document.createElementNS(SVG_NS, "circle");
2303
+ anchor.setAttribute("cx", String(point.x));
2304
+ anchor.setAttribute("cy", String(point.y));
2305
+ anchor.setAttribute("r", "4");
2306
+ anchor.setAttribute("fill", "#fff");
2307
+ anchor.setAttribute("stroke", "#6366f1");
2308
+ anchor.setAttribute("stroke-width", "2");
2309
+ anchor.style.pointerEvents = "none";
2310
+ canvasEl.appendChild(anchor);
2311
+ }
2312
+ }
2313
+ if (marqueeState) {
2314
+ const mx = Math.min(marqueeState.startX, marqueeState.currentX);
2315
+ const my = Math.min(marqueeState.startY, marqueeState.currentY);
2316
+ const mw = Math.abs(marqueeState.currentX - marqueeState.startX);
2317
+ const mh = Math.abs(marqueeState.currentY - marqueeState.startY);
2318
+ if (mw > 0 || mh > 0) {
2319
+ const marqueeRect = document.createElementNS(SVG_NS, "rect");
2320
+ marqueeRect.setAttribute("x", String(mx));
2321
+ marqueeRect.setAttribute("y", String(my));
2322
+ marqueeRect.setAttribute("width", String(mw));
2323
+ marqueeRect.setAttribute("height", String(mh));
2324
+ marqueeRect.setAttribute("fill", "rgba(99, 102, 241, 0.08)");
2325
+ marqueeRect.setAttribute("stroke", "#6366f1");
2326
+ marqueeRect.setAttribute("stroke-width", "1");
2327
+ marqueeRect.setAttribute("stroke-dasharray", "4 3");
2328
+ marqueeRect.style.pointerEvents = "none";
2329
+ canvasEl.appendChild(marqueeRect);
2330
+ }
1910
2331
  }
1911
- li.classList.add("is-dragging");
1912
- }
1913
- function onDragOver(e) {
1914
- if (!dragSourceId) return;
1915
- e.preventDefault();
1916
- if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
1917
- const li = e.target.closest(
1918
- ".studio-element-list-item"
1919
- );
1920
- listEl?.querySelectorAll(".is-drop-above, .is-drop-below").forEach((n) => n.classList.remove("is-drop-above", "is-drop-below"));
1921
- if (!li) return;
1922
- const rect = li.getBoundingClientRect();
1923
- const upper = e.clientY < rect.top + rect.height / 2;
1924
- li.classList.add(upper ? "is-drop-above" : "is-drop-below");
1925
- }
1926
- function onDragLeave(e) {
1927
- const li = e.target.closest(
1928
- ".studio-element-list-item"
1929
- );
1930
- li?.classList.remove("is-drop-above", "is-drop-below");
1931
- }
1932
- function onDrop(e) {
1933
- if (!dragSourceId) return;
1934
- e.preventDefault();
1935
- const li = e.target.closest(
1936
- ".studio-element-list-item"
1937
- );
1938
- if (!li) return;
1939
- const targetId = li.dataset.elemId ?? "";
1940
- if (!targetId || targetId === dragSourceId) return;
1941
- const rect = li.getBoundingClientRect();
1942
- const upper = e.clientY < rect.top + rect.height / 2;
1943
- const positionInVisualList = upper ? "before" : "after";
1944
- const positionInDefArray = positionInVisualList === "before" ? "after" : "before";
1945
- reorderElement(dragSourceId, targetId, positionInDefArray);
1946
- dragSourceId = null;
1947
- }
1948
- function onDragEnd() {
1949
- dragSourceId = null;
1950
- listEl?.querySelectorAll(".is-drop-above, .is-drop-below, .is-dragging").forEach(
1951
- (n) => n.classList.remove("is-drop-above", "is-drop-below", "is-dragging")
1952
- );
1953
2332
  }
1954
- function onToolsClick(e) {
1955
- const target = e.target;
1956
- const btn = target.closest("[data-add-element]");
1957
- if (!btn) return;
1958
- const type = btn.dataset.addElement;
1959
- if (!type) return;
2333
+ function findSnapTarget(x, y, excludeElementId) {
1960
2334
  const def = getDef();
1961
- if (!def) return;
1962
- const cx = def.canvas.width / 2;
1963
- const cy = def.canvas.height / 2;
1964
- const id = uniqueElementId(type);
1965
- const elem = makeDefaultElement(type, id, cx, cy);
1966
- if (elem) addElement(elem);
1967
- }
1968
- function makeDefaultElement(type, id, cx, cy) {
1969
- switch (type) {
1970
- case "rect":
1971
- return {
1972
- type: "rect",
1973
- id,
1974
- rotation: 0,
1975
- appearances: [],
1976
- tracks: [],
1977
- x: cx - 60,
1978
- y: cy - 30,
1979
- width: 120,
1980
- height: 60,
1981
- fill: "#a5b4fc",
1982
- stroke: "#6366f1",
1983
- strokeWidth: 1.5,
1984
- cornerRadius: 8,
1985
- label: id,
1986
- labelColor: "#0b0b0f",
1987
- labelSize: 14
1988
- };
1989
- case "circle":
1990
- return {
1991
- type: "circle",
1992
- id,
1993
- rotation: 0,
1994
- appearances: [],
1995
- tracks: [],
1996
- cx,
1997
- cy,
1998
- r: 36,
1999
- fill: "#a5b4fc",
2000
- stroke: "#6366f1",
2001
- strokeWidth: 1.5,
2002
- label: id,
2003
- labelColor: "#0b0b0f",
2004
- labelSize: 14
2005
- };
2006
- case "line":
2007
- return {
2008
- type: "line",
2009
- id,
2010
- rotation: 0,
2011
- appearances: [],
2012
- tracks: [],
2013
- x1: cx - 80,
2014
- y1: cy,
2015
- x2: cx + 80,
2016
- y2: cy,
2017
- stroke: "#6366f1",
2018
- strokeWidth: 2,
2019
- headStart: "none",
2020
- headEnd: "none"
2021
- };
2022
- case "arrow":
2023
- return {
2024
- type: "arrow",
2025
- id,
2026
- rotation: 0,
2027
- appearances: [],
2028
- tracks: [],
2029
- x1: cx - 100,
2030
- y1: cy,
2031
- x2: cx + 100,
2032
- y2: cy,
2033
- stroke: "#6366f1",
2034
- strokeWidth: 2,
2035
- curvature: 0,
2036
- labelColor: "#0b0b0f",
2037
- labelOffsetX: 0,
2038
- labelOffsetY: 4,
2039
- headStart: "none",
2040
- headEnd: "arrow"
2041
- };
2042
- case "text":
2043
- return {
2044
- type: "text",
2045
- id,
2046
- rotation: 0,
2047
- appearances: [],
2048
- tracks: [],
2049
- x: cx,
2050
- y: cy,
2051
- content: id,
2052
- fontSize: 18,
2053
- fontWeight: 400,
2054
- color: "#18181b",
2055
- textAnchor: "middle"
2056
- };
2057
- case "image":
2058
- return {
2059
- type: "image",
2060
- id,
2061
- rotation: 0,
2062
- appearances: [],
2063
- tracks: [],
2064
- x: cx - 50,
2065
- y: cy - 50,
2066
- width: 100,
2067
- height: 100,
2068
- assetId: registerExternalAsset(placeholderImageUrl()),
2069
- preserveAspectRatio: "xMidYMid meet",
2070
- opacity: 1
2071
- };
2072
- case "path":
2073
- return {
2074
- type: "path",
2075
- id,
2076
- rotation: 0,
2077
- appearances: [],
2078
- tracks: [],
2079
- x: cx - 40,
2080
- y: cy - 40,
2081
- d: "M 0 0 L 80 0 L 40 80 Z",
2082
- fill: "#a5b4fc",
2083
- stroke: "#6366f1",
2084
- strokeWidth: 2,
2085
- opacity: 1
2086
- };
2087
- case "polygon": {
2088
- const r = 40;
2089
- const sides = 6;
2090
- const pts = Array.from({ length: sides }, (_, i) => {
2091
- const a = i * Math.PI * 2 / sides - Math.PI / 2;
2092
- return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
2093
- }).join(" ");
2094
- return {
2095
- type: "polygon",
2096
- id,
2097
- rotation: 0,
2098
- appearances: [],
2099
- tracks: [],
2100
- points: pts,
2101
- fill: "#a5b4fc",
2102
- stroke: "#6366f1",
2103
- strokeWidth: 1.5,
2104
- opacity: 1
2105
- };
2335
+ if (!def) return null;
2336
+ const snap2 = getCurrentSnapshot();
2337
+ const allAnchors = [];
2338
+ for (const baseEl of def.elements) {
2339
+ if (baseEl.id === excludeElementId) continue;
2340
+ if (baseEl.type === "line" || baseEl.type === "arrow") continue;
2341
+ const state = snap2.get(baseEl.id);
2342
+ if (!state || !state.visible) continue;
2343
+ const points = getAnchorPoints(baseEl, state);
2344
+ allAnchors.push(...points);
2345
+ }
2346
+ const nearest = findNearestAnchor({ x, y }, allAnchors, SNAP_RADIUS);
2347
+ if (!nearest) return null;
2348
+ return {
2349
+ elementId: nearest.elementId,
2350
+ anchor: nearest.anchor,
2351
+ x: nearest.x,
2352
+ y: nearest.y
2353
+ };
2354
+ }
2355
+ function renderGroupOutline(groupId) {
2356
+ const box = groupBbox(groupId);
2357
+ if (!box) return null;
2358
+ const g = document.createElementNS(SVG_NS, "g");
2359
+ g.setAttribute("data-elem-id", groupId);
2360
+ const pad = 6;
2361
+ const x = box.x - pad;
2362
+ const y = box.y - pad;
2363
+ const w = box.w + pad * 2;
2364
+ const h = box.h + pad * 2;
2365
+ const thickness = 8;
2366
+ g.innerHTML = `
2367
+ <rect x="${x}" y="${y}" width="${w}" height="${h}"
2368
+ fill="none" stroke="var(--color-accent)" stroke-width="2" stroke-dasharray="6 4" rx="4" pointer-events="none" />
2369
+ <rect x="${x - thickness}" y="${y - thickness}" width="${w + thickness * 2}" height="${thickness * 2}"
2370
+ fill="rgba(0,0,0,0.0001)" style="cursor: move" pointer-events="all" />
2371
+ <rect x="${x - thickness}" y="${y + h - thickness}" width="${w + thickness * 2}" height="${thickness * 2}"
2372
+ fill="rgba(0,0,0,0.0001)" style="cursor: move" pointer-events="all" />
2373
+ <rect x="${x - thickness}" y="${y}" width="${thickness * 2}" height="${h}"
2374
+ fill="rgba(0,0,0,0.0001)" style="cursor: move" pointer-events="all" />
2375
+ <rect x="${x + w - thickness}" y="${y}" width="${thickness * 2}" height="${h}"
2376
+ fill="rgba(0,0,0,0.0001)" style="cursor: move" pointer-events="all" />
2377
+ `;
2378
+ return g;
2379
+ }
2380
+ function makeG(elementId, rotation, cx, cy) {
2381
+ const g = document.createElementNS(SVG_NS, "g");
2382
+ g.setAttribute("data-elem-id", elementId);
2383
+ g.classList.add("element-handle");
2384
+ if (rotation) g.setAttribute("transform", `rotate(${rotation} ${cx} ${cy})`);
2385
+ return g;
2386
+ }
2387
+ function renderElement(baseEl, state, snap2, byId) {
2388
+ const rotation = state.rotation || 0;
2389
+ if (baseEl.type === "rect") {
2390
+ const r = state;
2391
+ const cx = r.x + r.width / 2;
2392
+ const cy = r.y + r.height / 2;
2393
+ const g = makeG(baseEl.id, rotation, cx, cy);
2394
+ g.innerHTML = `
2395
+ <rect x="${r.x}" y="${r.y}" width="${r.width}" height="${r.height}" rx="${r.cornerRadius}"
2396
+ fill="${r.fill}" stroke="${r.stroke}" stroke-width="${r.strokeWidth}" />
2397
+ ${r.label ? `<text x="${cx}" y="${cy + 5}" text-anchor="middle"
2398
+ font-size="${r.labelSize}" font-weight="600" fill="${r.labelColor}">${escapeXml(r.label)}</text>` : ""}
2399
+ ${r.subtitle ? `<text x="${cx}" y="${cy + r.labelSize + 8}"
2400
+ text-anchor="middle" font-size="10" fill="${r.labelColor}" opacity="0.7">${escapeXml(r.subtitle)}</text>` : ""}
2401
+ `;
2402
+ return g;
2403
+ }
2404
+ if (baseEl.type === "circle") {
2405
+ const c = state;
2406
+ const g = makeG(baseEl.id, rotation, c.cx, c.cy);
2407
+ g.innerHTML = `
2408
+ <circle cx="${c.cx}" cy="${c.cy}" r="${c.r}" fill="${c.fill}" stroke="${c.stroke}"
2409
+ stroke-width="${c.strokeWidth}" />
2410
+ ${c.label ? `<text x="${c.cx}" y="${c.cy + 5}" text-anchor="middle"
2411
+ font-size="${c.labelSize}" font-weight="600" fill="${c.labelColor}">${escapeXml(c.label)}</text>` : ""}
2412
+ `;
2413
+ return g;
2414
+ }
2415
+ if (baseEl.type === "line") {
2416
+ const l = state;
2417
+ const coords = resolveLineCoords(l, snap2, byId);
2418
+ if (!coords) return null;
2419
+ const g = makeG(baseEl.id, 0, 0, 0);
2420
+ g.style.color = l.stroke;
2421
+ const dash = l.strokeDasharray ? `stroke-dasharray="${l.strokeDasharray}"` : "";
2422
+ const mStart = markerUrlFor(l.headStart, "start");
2423
+ const mEnd = markerUrlFor(l.headEnd, "end");
2424
+ const mAttr = `${mStart ? `marker-start="${mStart}"` : ""} ${mEnd ? `marker-end="${mEnd}"` : ""}`;
2425
+ g.innerHTML = `<line x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}"
2426
+ stroke="currentColor" stroke-width="${l.strokeWidth}" ${dash} ${mAttr} />`;
2427
+ return g;
2428
+ }
2429
+ if (baseEl.type === "arrow") {
2430
+ const a = state;
2431
+ const coords = resolveArrowCoords(a, snap2, byId);
2432
+ if (!coords) return null;
2433
+ const g = makeG(baseEl.id, 0, 0, 0);
2434
+ g.style.color = a.stroke;
2435
+ const dash = a.strokeDasharray ? `stroke-dasharray="${a.strokeDasharray}"` : "";
2436
+ const midX = (coords.x1 + coords.x2) / 2;
2437
+ const midY = (coords.y1 + coords.y2) / 2;
2438
+ const isCurved = (a.curvature || 0) !== 0;
2439
+ const mStart = markerUrlFor(a.headStart ?? "none", "start");
2440
+ const mEnd = markerUrlFor(a.headEnd ?? "arrow", "end");
2441
+ const mAttr = `${mStart ? `marker-start="${mStart}"` : ""} ${mEnd ? `marker-end="${mEnd}"` : ""}`;
2442
+ let pathHtml;
2443
+ if (isCurved) {
2444
+ const dx = coords.x2 - coords.x1;
2445
+ const dy = coords.y2 - coords.y1;
2446
+ const len = Math.hypot(dx, dy) || 1;
2447
+ const nx = -dy / len;
2448
+ const ny = dx / len;
2449
+ const cpx = midX + nx * (a.curvature ?? 0);
2450
+ const cpy = midY + ny * (a.curvature ?? 0);
2451
+ pathHtml = `<path d="M ${coords.x1} ${coords.y1} Q ${cpx} ${cpy} ${coords.x2} ${coords.y2}" fill="none" stroke="currentColor" stroke-width="${a.strokeWidth}" ${dash} ${mAttr} />`;
2452
+ } else {
2453
+ pathHtml = `<line x1="${coords.x1}" y1="${coords.y1}" x2="${coords.x2}" y2="${coords.y2}" stroke="currentColor" stroke-width="${a.strokeWidth}" ${dash} ${mAttr} />`;
2106
2454
  }
2107
- default:
2108
- return null;
2455
+ g.innerHTML = `
2456
+ ${pathHtml}
2457
+ ${a.label ? `<g>
2458
+ <rect x="${midX - 80}" y="${midY - 11}" width="160" height="22" rx="4" fill="white" stroke="currentColor" stroke-opacity="0.4"/>
2459
+ <text x="${midX}" y="${midY + 4}" text-anchor="middle" font-size="12" font-family="ui-monospace, monospace" fill="${a.labelColor}">${escapeXml(a.label)}</text>
2460
+ </g>` : ""}
2461
+ `;
2462
+ return g;
2109
2463
  }
2464
+ if (baseEl.type === "text") {
2465
+ const t = state;
2466
+ const g = makeG(baseEl.id, rotation, t.x, t.y);
2467
+ g.innerHTML = `<text x="${t.x}" y="${t.y}" font-size="${t.fontSize}" font-weight="${t.fontWeight}"
2468
+ fill="${t.color}" text-anchor="${t.textAnchor}">${escapeXml(t.content)}</text>`;
2469
+ return g;
2470
+ }
2471
+ if (baseEl.type === "image") {
2472
+ const i = state;
2473
+ const cx = i.x + i.width / 2;
2474
+ const cy = i.y + i.height / 2;
2475
+ const g = makeG(baseEl.id, rotation, cx, cy);
2476
+ const resolved = resolveAsset(i.assetId, getDef()?.assets ?? {});
2477
+ const href = resolved.status === "resolved" ? resolved.href ?? "" : "";
2478
+ g.innerHTML = href ? `<image href="${escapeXml(href)}" x="${i.x}" y="${i.y}" width="${i.width}"
2479
+ height="${i.height}" preserveAspectRatio="${i.preserveAspectRatio}" opacity="${i.opacity}" />` : `<rect x="${i.x}" y="${i.y}" width="${i.width}" height="${i.height}" fill="none"
2480
+ stroke="currentColor" stroke-width="1" stroke-dasharray="4 4" opacity="0.4" />`;
2481
+ return g;
2482
+ }
2483
+ if (baseEl.type === "path") {
2484
+ const p = state;
2485
+ const g = makeG(baseEl.id, rotation, p.x, p.y);
2486
+ const dash = p.strokeDasharray ? `stroke-dasharray="${p.strokeDasharray}"` : "";
2487
+ const xform = p.x || p.y ? `transform="translate(${p.x} ${p.y})"` : "";
2488
+ g.innerHTML = `<path d="${escapeXml(p.d)}" fill="${p.fill}" stroke="${p.stroke}" stroke-width="${p.strokeWidth}" ${dash} opacity="${p.opacity}" ${xform} pointer-events="all" />`;
2489
+ return g;
2490
+ }
2491
+ if (baseEl.type === "polygon") {
2492
+ const pg = state;
2493
+ const g = makeG(baseEl.id, rotation, 0, 0);
2494
+ g.innerHTML = `<polygon points="${escapeXml(pg.points)}" fill="${pg.fill}" stroke="${pg.stroke}" stroke-width="${pg.strokeWidth}" opacity="${pg.opacity}" pointer-events="all" />`;
2495
+ return g;
2496
+ }
2497
+ return null;
2498
+ }
2499
+ function showPreview2(def, options) {
2500
+ showPreview(canvasEl, def, options);
2501
+ }
2502
+ function hidePreview2() {
2503
+ hidePreview(canvasEl);
2110
2504
  }
2111
2505
 
2112
2506
  // src/legacy/studio-focus.ts
@@ -2438,6 +2832,17 @@ function renderInner() {
2438
2832
  "\uBAA9\uCC28 \uD45C\uC2DC (chapter list)",
2439
2833
  "settings.showChapterList",
2440
2834
  def.settings.showChapterList ?? false
2835
+ ),
2836
+ selectField(
2837
+ "\uBAA9\uCC28 \uC704\uCE58",
2838
+ "settings.chapterListPosition",
2839
+ def.settings.chapterListPosition ?? "right",
2840
+ [
2841
+ { value: "left", label: "\uC88C\uCE21" },
2842
+ { value: "right", label: "\uC6B0\uCE21" },
2843
+ { value: "top", label: "\uC0C1\uB2E8" },
2844
+ { value: "bottom", label: "\uD558\uB2E8" }
2845
+ ]
2441
2846
  )
2442
2847
  ].join("");
2443
2848
  panelEl.innerHTML = `
@@ -2612,6 +3017,14 @@ function renderBaseFields(el) {
2612
3017
  numberFields.push({ label: "cy", key: "cy", value: e.cy });
2613
3018
  numberFields.push({ label: "r", key: "r", value: e.r });
2614
3019
  }
3020
+ if (el.type === "polygon") {
3021
+ const pointCount = String(e.points ?? "").trim().split(/\s+/).filter(Boolean).length;
3022
+ numberFields.push({
3023
+ label: "\uBCC0 \uAC1C\uC218",
3024
+ key: "polygonSides",
3025
+ value: pointCount
3026
+ });
3027
+ }
2615
3028
  if (el.type === "line" || el.type === "arrow") {
2616
3029
  if (typeof e.x1 === "number") {
2617
3030
  numberFields.push({ label: "x1", key: "x1", value: e.x1 });
@@ -2757,12 +3170,25 @@ function onInput(e) {
2757
3170
  if (!key) return;
2758
3171
  if (target.type === "text" && e.isComposing) return;
2759
3172
  if (target.type === "text" && isComposingFallback) return;
3173
+ if (target.type === "color") {
3174
+ const textInput = target.parentElement?.querySelector(
3175
+ `input[type="text"][data-prop-key="${CSS.escape(key)}"]`
3176
+ );
3177
+ if (textInput) textInput.value = target.value;
3178
+ return;
3179
+ }
2760
3180
  let value = target.value;
2761
3181
  if (target.type === "number") value = Number(target.value);
2762
3182
  else if (target.type === "checkbox") value = target.checked;
2763
3183
  apply(key, value);
2764
3184
  }
2765
3185
  function onChange(e) {
3186
+ const target = e.target;
3187
+ if (target instanceof HTMLInputElement && target.type === "color") {
3188
+ const key = target.dataset.propKey;
3189
+ if (key) apply(key, target.value);
3190
+ return;
3191
+ }
2766
3192
  onInput(e);
2767
3193
  }
2768
3194
  function apply(key, value) {
@@ -2784,11 +3210,33 @@ function apply(key, value) {
2784
3210
  updateSettings({ showCaption: Boolean(value) });
2785
3211
  else if (key === "settings.showChapterList")
2786
3212
  updateSettings({ showChapterList: Boolean(value) });
3213
+ else if (key === "settings.chapterListPosition")
3214
+ updateSettings({
3215
+ chapterListPosition: String(value)
3216
+ });
2787
3217
  else if (key.startsWith("el.") && sel.kind === "element") {
2788
3218
  const prop = key.slice(3);
2789
3219
  const time = getCurrentTime();
2790
3220
  const el = def.elements.find((e) => e.id === sel.elementId);
2791
3221
  if (!el) return;
3222
+ if (prop === "polygonSides" && el.type === "polygon") {
3223
+ const points = el.points.trim().split(/\s+/).map((point) => point.split(",").map(Number)).filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y));
3224
+ if (points.length < 3) return;
3225
+ const minX = Math.min(...points.map(([x]) => x));
3226
+ const maxX = Math.max(...points.map(([x]) => x));
3227
+ const minY = Math.min(...points.map(([, y]) => y));
3228
+ const maxY = Math.max(...points.map(([, y]) => y));
3229
+ const cx = (minX + maxX) / 2;
3230
+ const cy = (minY + maxY) / 2;
3231
+ const radius = Math.max(4, Math.max(maxX - minX, maxY - minY) / 2);
3232
+ const sides = Math.max(3, Math.min(24, Math.round(Number(value)) || 3));
3233
+ const next = Array.from({ length: sides }, (_, index) => {
3234
+ const angle = index * Math.PI * 2 / sides - Math.PI / 2;
3235
+ return `${(cx + radius * Math.cos(angle)).toFixed(1)},${(cy + radius * Math.sin(angle)).toFixed(1)}`;
3236
+ }).join(" ");
3237
+ updateElementBase(sel.elementId, { points: next });
3238
+ return;
3239
+ }
2792
3240
  const hasTrack = el.tracks.some((t) => t.property === prop);
2793
3241
  if (hasTrack) {
2794
3242
  setTrackKeyframe(sel.elementId, prop, time, value);
@@ -2959,6 +3407,20 @@ try {
2959
3407
  } catch {
2960
3408
  }
2961
3409
  var dragMode = null;
3410
+ var boundDocuments = /* @__PURE__ */ new WeakMap();
3411
+ function bindTimelinePointerDocument(doc) {
3412
+ const existing = boundDocuments.get(doc);
3413
+ if (existing) return existing;
3414
+ doc.addEventListener("mousemove", onMouseMove2);
3415
+ doc.addEventListener("mouseup", onMouseUp2);
3416
+ const cleanup = () => {
3417
+ doc.removeEventListener("mousemove", onMouseMove2);
3418
+ doc.removeEventListener("mouseup", onMouseUp2);
3419
+ boundDocuments.delete(doc);
3420
+ };
3421
+ boundDocuments.set(doc, cleanup);
3422
+ return cleanup;
3423
+ }
2962
3424
  function initTimeline(tracksRoot, addChapterBtn, elementTracks) {
2963
3425
  tracksEl = tracksRoot;
2964
3426
  elTracksEl = elementTracks;
@@ -2968,8 +3430,7 @@ function initTimeline(tracksRoot, addChapterBtn, elementTracks) {
2968
3430
  tracksRoot.addEventListener("mousedown", onMouseDown2);
2969
3431
  elementTracks.addEventListener("click", onElTracksClick);
2970
3432
  elementTracks.addEventListener("mousedown", onMouseDown2);
2971
- document.addEventListener("mousemove", onMouseMove2);
2972
- document.addEventListener("mouseup", onMouseUp2);
3433
+ bindTimelinePointerDocument(document);
2973
3434
  addBtn?.addEventListener("click", () => {
2974
3435
  const id = uniqueChapterId();
2975
3436
  const def = getDef();
@@ -3029,9 +3490,15 @@ function mirrorScroll(source, target) {
3029
3490
  }
3030
3491
  function showDragTooltip(clientX, clientY, text) {
3031
3492
  if (!dragTooltipEl) {
3032
- dragTooltipEl = document.createElement("div");
3493
+ const ownerDocument = tracksEl?.ownerDocument ?? document;
3494
+ dragTooltipEl = ownerDocument.createElement("div");
3033
3495
  dragTooltipEl.className = "studio-tl-drag-tooltip";
3034
- document.body.appendChild(dragTooltipEl);
3496
+ ownerDocument.body.appendChild(dragTooltipEl);
3497
+ } else if (tracksEl && dragTooltipEl.ownerDocument !== tracksEl.ownerDocument) {
3498
+ dragTooltipEl.remove();
3499
+ dragTooltipEl = null;
3500
+ showDragTooltip(clientX, clientY, text);
3501
+ return;
3035
3502
  }
3036
3503
  dragTooltipEl.textContent = text;
3037
3504
  dragTooltipEl.style.left = `${clientX + 14}px`;
@@ -3110,7 +3577,20 @@ function renderElementTracks(elements, currentTime, totalPx, sel) {
3110
3577
  elTracksEl.innerHTML = '<p class="studio-tl-empty">\uC694\uC18C \uC5C6\uC74C</p>';
3111
3578
  return;
3112
3579
  }
3580
+ const byId = new Map(elements.map((element) => [element.id, element]));
3581
+ const depthOf = (element) => {
3582
+ let depth = 0;
3583
+ let parentId = element.parentId;
3584
+ const seen = /* @__PURE__ */ new Set();
3585
+ while (parentId && !seen.has(parentId)) {
3586
+ seen.add(parentId);
3587
+ depth += 1;
3588
+ parentId = byId.get(parentId)?.parentId;
3589
+ }
3590
+ return depth;
3591
+ };
3113
3592
  const rows = elements.map((el) => {
3593
+ const depth = depthOf(el);
3114
3594
  const isSel = sel.kind === "element" && sel.elementId === el.id || sel.kind === "elements" && sel.elementIds.includes(el.id);
3115
3595
  const appearanceBars = el.appearances.map((ap, idx) => {
3116
3596
  const left = timeToPx(ap.start);
@@ -3127,8 +3607,8 @@ function renderElementTracks(elements, currentTime, totalPx, sel) {
3127
3607
  (kf) => `<div class="studio-tl-keyframe" style="left:${timeToPx(kf.time)}px" data-kf-elem-id="${escapeHtml3(el.id)}" data-kf-prop="${escapeHtml3(kf.prop)}" data-kf-time="${kf.time}" title="${escapeHtml3(kf.prop)} @ ${kf.time}ms (\uB4DC\uB798\uADF8\uB85C \uC774\uB3D9)">\u25C6</div>`
3128
3608
  ).join("");
3129
3609
  return `
3130
- <div class="studio-tl-row studio-tl-element-row ${isSel ? "is-selected" : ""}" data-elem-id="${escapeHtml3(el.id)}">
3131
- <div class="studio-tl-gutter studio-tl-element-label" title="${escapeHtml3(el.id)}">${escapeHtml3(gutterLabel(el))}</div>
3610
+ <div class="studio-tl-row studio-tl-element-row ${isSel ? "is-selected" : ""} ${el.type === "group" ? "is-group" : ""}" data-elem-id="${escapeHtml3(el.id)}">
3611
+ <div class="studio-tl-gutter studio-tl-element-label" style="--studio-tree-depth:${depth}" title="${escapeHtml3(el.id)}">${el.type === "group" ? "\u25BE " : depth > 0 ? "\u21B3 " : ""}${escapeHtml3(gutterLabel(el))}</div>
3132
3612
  <div class="studio-tl-body" style="width:${totalPx}px">
3133
3613
  <div class="studio-tl-element-track" data-tl-area="elements">
3134
3614
  ${appearanceBars}
@@ -14852,10 +15332,10 @@ async function uploadAndInsertImage(file, host) {
14852
15332
  }
14853
15333
  try {
14854
15334
  host.setStatus("\uC774\uBBF8\uC9C0 \uCC98\uB9AC \uC911\u2026");
14855
- const dataUrl = await readAsDataUrl(file);
15335
+ const source = host.resolveImage ? await host.resolveImage(file) : await readAsDataUrl(file);
14856
15336
  const cx = def.canvas.width / 2;
14857
15337
  const cy = def.canvas.height / 2;
14858
- const tempImg = await loadImageSize(dataUrl);
15338
+ const tempImg = await loadImageSize(source);
14859
15339
  const maxDim = Math.min(def.canvas.width, def.canvas.height) * 0.5;
14860
15340
  let w = tempImg?.w ?? 200;
14861
15341
  let h = tempImg?.h ?? 200;
@@ -14875,7 +15355,7 @@ async function uploadAndInsertImage(file, host) {
14875
15355
  y: Math.round(cy - h / 2),
14876
15356
  width: w,
14877
15357
  height: h,
14878
- assetId: registerDataUriAsset(dataUrl),
15358
+ assetId: source.startsWith("data:") ? registerDataUriAsset(source) : registerExternalAsset(source),
14879
15359
  preserveAspectRatio: "xMidYMid meet",
14880
15360
  opacity: 1
14881
15361
  });
@@ -14944,7 +15424,12 @@ function escapeHtml5(s) {
14944
15424
  function startDraft() {
14945
15425
  const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
14946
15426
  const id = `draft-${stamp}`;
14947
- const def = animationDocumentSchema.parse({ id, title: "", description: "" });
15427
+ const def = animationDocumentSchema.parse({
15428
+ clothoVersion: 1,
15429
+ id,
15430
+ title: "",
15431
+ description: ""
15432
+ });
14948
15433
  setDraft(def);
14949
15434
  }
14950
15435
  async function openLibrary(ui) {
@@ -15372,6 +15857,9 @@ function queryUi() {
15372
15857
  const idDisplay = $("studio-id-display");
15373
15858
  const status = $("studio-status");
15374
15859
  const saveBtn = $("studio-save");
15860
+ const exportBtn = $("studio-export-json");
15861
+ const importBtn = $("studio-import-json");
15862
+ const importFileInput = $("studio-import-file");
15375
15863
  const deleteBtn = $("studio-delete");
15376
15864
  const newBtn = $("studio-new");
15377
15865
  const openBtn = $("studio-open");
@@ -15379,6 +15867,8 @@ function queryUi() {
15379
15867
  const restartBtn = $("studio-restart");
15380
15868
  const speedInput = $("studio-speed");
15381
15869
  const speedValue = $("studio-speed-value");
15870
+ const previewLoopInput = $("studio-preview-loop");
15871
+ const detachTimelineBtn = $("studio-detach-timeline");
15382
15872
  const canvas = document.getElementById(
15383
15873
  "studio-canvas"
15384
15874
  );
@@ -15404,7 +15894,7 @@ function queryUi() {
15404
15894
  const undoBtn = $("studio-undo");
15405
15895
  const redoBtn = $("studio-redo");
15406
15896
  const gridToggleBtn = $("studio-grid-toggle");
15407
- if (!titleInput || !idDisplay || !status || !saveBtn || !deleteBtn || !newBtn || !openBtn || !playBtn || !restartBtn || !speedInput || !speedValue || !canvas || !elementList || !toolsRoot || !propsRoot || !timelineTracks || !elementTracks || !addStepBtn || !libraryDialog || !libraryList || !newDialog || !newIdInput || !newTitleInput || !newCreateBtn || !newError || !canvasWidthInput || !canvasHeightInput || !imageUploadBtn || !imageFileInput || !helpBtn || !helpDialog || !undoBtn || !redoBtn || !gridToggleBtn) {
15897
+ if (!titleInput || !idDisplay || !status || !saveBtn || !exportBtn || !importBtn || !importFileInput || !deleteBtn || !newBtn || !openBtn || !playBtn || !restartBtn || !speedInput || !speedValue || !previewLoopInput || !detachTimelineBtn || !canvas || !elementList || !toolsRoot || !propsRoot || !timelineTracks || !elementTracks || !addStepBtn || !libraryDialog || !libraryList || !newDialog || !newIdInput || !newTitleInput || !newCreateBtn || !newError || !canvasWidthInput || !canvasHeightInput || !imageUploadBtn || !imageFileInput || !helpBtn || !helpDialog || !undoBtn || !redoBtn || !gridToggleBtn) {
15408
15898
  return null;
15409
15899
  }
15410
15900
  return {
@@ -15413,6 +15903,9 @@ function queryUi() {
15413
15903
  idDisplay,
15414
15904
  status,
15415
15905
  saveBtn,
15906
+ exportBtn,
15907
+ importBtn,
15908
+ importFileInput,
15416
15909
  deleteBtn,
15417
15910
  newBtn,
15418
15911
  openBtn,
@@ -15420,6 +15913,8 @@ function queryUi() {
15420
15913
  restartBtn,
15421
15914
  speedInput,
15422
15915
  speedValue,
15916
+ previewLoopInput,
15917
+ detachTimelineBtn,
15423
15918
  canvas,
15424
15919
  elementList,
15425
15920
  toolsRoot,
@@ -15455,39 +15950,59 @@ function startInlineTextEdit(canvas, el) {
15455
15950
  "foreignObject"
15456
15951
  );
15457
15952
  fo.setAttribute("data-inline-text-editor", "");
15458
- fo.setAttribute("x", String(el.x - 8));
15459
- fo.setAttribute("y", String(el.y - fontSize));
15460
- const width = Math.max(160, (el.content?.length ?? 5) * fontSize * 0.7);
15953
+ const width = Math.max(80, (el.content?.length ?? 5) * fontSize * 0.7 + 20);
15954
+ const anchor = el.textAnchor ?? "start";
15955
+ const x = anchor === "middle" ? el.x - width / 2 : anchor === "end" ? el.x - width : el.x;
15956
+ fo.setAttribute("x", String(x));
15957
+ fo.setAttribute("y", String(el.y - fontSize * 1.15));
15461
15958
  fo.setAttribute("width", String(width));
15462
- fo.setAttribute("height", String(fontSize * 1.6));
15463
- const input = document.createElement("input");
15464
- input.type = "text";
15465
- input.value = el.content ?? "";
15466
- Object.assign(input.style, {
15959
+ fo.setAttribute("height", String(fontSize * 1.8));
15960
+ const renderedText = Array.from(
15961
+ canvas.querySelectorAll("[data-elem-id]")
15962
+ ).find((node) => node.dataset.elemId === el.id);
15963
+ const previousVisibility = renderedText?.style.visibility ?? "";
15964
+ if (renderedText) renderedText.style.visibility = "hidden";
15965
+ const editor = document.createElement("div");
15966
+ editor.contentEditable = "true";
15967
+ editor.spellcheck = false;
15968
+ editor.textContent = el.content ?? "";
15969
+ editor.setAttribute("role", "textbox");
15970
+ editor.setAttribute("aria-label", "\uD14D\uC2A4\uD2B8 \uB0B4\uC6A9 \uD3B8\uC9D1");
15971
+ Object.assign(editor.style, {
15467
15972
  width: "100%",
15468
15973
  height: "100%",
15469
15974
  boxSizing: "border-box",
15470
- border: "2px solid #6366f1",
15471
- borderRadius: "4px",
15472
- background: "rgba(255, 255, 255, 0.96)",
15473
- padding: "0 6px",
15975
+ border: "0",
15976
+ borderBottom: "2px solid #6366f1",
15977
+ background: "color-mix(in srgb, Canvas 82%, transparent)",
15978
+ padding: "0 2px",
15474
15979
  font: `${el.fontWeight ?? 400} ${fontSize}px ui-sans-serif,system-ui,sans-serif`,
15475
15980
  color: el.color ?? "#0f172a",
15476
15981
  outline: "none",
15477
- boxShadow: "0 0 0 3px rgba(99, 102, 241, 0.2)"
15982
+ whiteSpace: "pre",
15983
+ overflow: "hidden"
15478
15984
  });
15479
- fo.appendChild(input);
15985
+ fo.appendChild(editor);
15480
15986
  canvas.appendChild(fo);
15481
15987
  requestAnimationFrame(() => {
15482
- input.focus();
15483
- input.select();
15988
+ editor.focus();
15989
+ const range = document.createRange();
15990
+ range.selectNodeContents(editor);
15991
+ range.collapse(false);
15992
+ const selection = window.getSelection();
15993
+ selection?.removeAllRanges();
15994
+ selection?.addRange(range);
15484
15995
  });
15485
15996
  let settled = false;
15997
+ const restoreRenderedText = () => {
15998
+ if (renderedText) renderedText.style.visibility = previousVisibility;
15999
+ };
15486
16000
  const commit = () => {
15487
16001
  if (settled) return;
15488
16002
  settled = true;
15489
- const newContent = input.value;
16003
+ const newContent = editor.textContent ?? "";
15490
16004
  fo.remove();
16005
+ restoreRenderedText();
15491
16006
  if (newContent !== el.content) {
15492
16007
  updateElementBase(el.id, { content: newContent });
15493
16008
  }
@@ -15496,16 +16011,17 @@ function startInlineTextEdit(canvas, el) {
15496
16011
  if (settled) return;
15497
16012
  settled = true;
15498
16013
  fo.remove();
16014
+ restoreRenderedText();
15499
16015
  };
15500
16016
  let isComposingFallback2 = false;
15501
- input.addEventListener("compositionstart", () => {
16017
+ editor.addEventListener("compositionstart", () => {
15502
16018
  isComposingFallback2 = true;
15503
16019
  });
15504
- input.addEventListener("compositionend", () => {
16020
+ editor.addEventListener("compositionend", () => {
15505
16021
  isComposingFallback2 = false;
15506
16022
  });
15507
- input.addEventListener("blur", commit);
15508
- input.addEventListener("keydown", (e) => {
16023
+ editor.addEventListener("blur", commit);
16024
+ editor.addEventListener("keydown", (e) => {
15509
16025
  if (e.isComposing || isComposingFallback2) return;
15510
16026
  if (e.key === "Enter") {
15511
16027
  e.preventDefault();
@@ -15642,7 +16158,12 @@ function togglePlay(ui) {
15642
16158
  if (playState === "playing") {
15643
16159
  stopPreview(ui);
15644
16160
  } else {
15645
- showPreview2(def);
16161
+ showPreview2(def, {
16162
+ loop: ui.previewLoopInput.checked,
16163
+ speed: Number(ui.speedInput.value),
16164
+ onTime: setCurrentTime,
16165
+ onEnd: () => stopPreview(ui)
16166
+ });
15646
16167
  playState = "playing";
15647
16168
  ui.playBtn.textContent = "\u23F9 Stop";
15648
16169
  }
@@ -15832,9 +16353,12 @@ function bindKeyboardShortcuts(ui) {
15832
16353
  const inText = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLElement && e.target.isContentEditable;
15833
16354
  const mod = e.metaKey || e.ctrlKey;
15834
16355
  if (e.key === "Escape") {
15835
- const sel = getSelection();
15836
- if (sel.kind !== "none") {
15837
- setSelection({ kind: "none" });
16356
+ if (!inText) {
16357
+ setActiveTool("select");
16358
+ const sel = getSelection();
16359
+ if (sel.kind !== "none") {
16360
+ setSelection({ kind: "none" });
16361
+ }
15838
16362
  }
15839
16363
  return;
15840
16364
  }
@@ -15899,6 +16423,16 @@ function bindKeyboardShortcuts(ui) {
15899
16423
  if (selectAdjacentElement(e.shiftKey ? -1 : 1)) e.preventDefault();
15900
16424
  return;
15901
16425
  }
16426
+ const shortcutTool = toolForShortcut(e.key, {
16427
+ isEditing: inText,
16428
+ modifierKey: mod,
16429
+ altKey: e.altKey
16430
+ });
16431
+ if (shortcutTool) {
16432
+ e.preventDefault();
16433
+ setActiveTool(shortcutTool);
16434
+ return;
16435
+ }
15902
16436
  if (!inText && !mod && (e.key === "g" || e.key === "G")) {
15903
16437
  e.preventDefault();
15904
16438
  setGridEnabled(!isGridEnabled());
@@ -16002,8 +16536,176 @@ function bindKeyboardShortcuts(ui) {
16002
16536
  });
16003
16537
  }
16004
16538
 
16539
+ // src/legacy/popup-document.ts
16540
+ function prepareStudioPopupDocument(popupDocument, sourceDocument, sourceApp, options) {
16541
+ popupDocument.open();
16542
+ popupDocument.write(
16543
+ `<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml8(options.title)}</title></head><body class="editor-active ${escapeHtml8(options.bodyClass)}"></body></html>`
16544
+ );
16545
+ popupDocument.close();
16546
+ const base = popupDocument.createElement("base");
16547
+ base.href = sourceDocument.baseURI;
16548
+ popupDocument.head.prepend(base);
16549
+ for (const source of sourceDocument.querySelectorAll(
16550
+ 'link[rel="stylesheet"], style'
16551
+ )) {
16552
+ popupDocument.head.appendChild(source.cloneNode(true));
16553
+ }
16554
+ const computed = getComputedStyle(sourceApp);
16555
+ const target = popupDocument.documentElement.style;
16556
+ for (let index = 0; index < computed.length; index += 1) {
16557
+ const property = computed.item(index);
16558
+ if (property.startsWith("--")) {
16559
+ target.setProperty(property, computed.getPropertyValue(property));
16560
+ }
16561
+ }
16562
+ }
16563
+ function escapeHtml8(value) {
16564
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
16565
+ }
16566
+
16567
+ // src/legacy/timeline-popout.ts
16568
+ var POPUP_FEATURES = "popup=yes,width=1200,height=620";
16569
+ function setupTimelinePopout(ui, _togglePlayback) {
16570
+ const timeline = ui.detachTimelineBtn.closest(
16571
+ ".studio-timeline-wrap"
16572
+ );
16573
+ if (!timeline) return;
16574
+ let popup = null;
16575
+ let placeholder = null;
16576
+ let closeTimer = null;
16577
+ let unbindPopupPointerEvents = null;
16578
+ let restoring = false;
16579
+ const restoreTimeline = (closePopup) => {
16580
+ if (restoring) return;
16581
+ restoring = true;
16582
+ if (placeholder?.parentNode) {
16583
+ placeholder.parentNode.insertBefore(timeline, placeholder.nextSibling);
16584
+ placeholder.remove();
16585
+ }
16586
+ placeholder = null;
16587
+ unbindPopupPointerEvents?.();
16588
+ unbindPopupPointerEvents = null;
16589
+ if (closeTimer !== null) window.clearInterval(closeTimer);
16590
+ closeTimer = null;
16591
+ ui.app.classList.remove("is-timeline-detached");
16592
+ ui.detachTimelineBtn.setAttribute("aria-pressed", "false");
16593
+ ui.detachTimelineBtn.textContent = "\u25A3 \uD0C0\uC784\uB77C\uC778 \uBD84\uB9AC";
16594
+ const windowToClose = popup;
16595
+ popup = null;
16596
+ if (closePopup && windowToClose && !windowToClose.closed) {
16597
+ windowToClose.close();
16598
+ }
16599
+ restoring = false;
16600
+ };
16601
+ ui.detachTimelineBtn.addEventListener("click", () => {
16602
+ if (popup && !popup.closed) {
16603
+ restoreTimeline(true);
16604
+ return;
16605
+ }
16606
+ const opened = window.open("", "_blank", POPUP_FEATURES);
16607
+ if (!opened) {
16608
+ ui.status.textContent = "\uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C popup\uC744 \uD5C8\uC6A9\uD574\uC8FC\uC138\uC694.";
16609
+ return;
16610
+ }
16611
+ popup = opened;
16612
+ placeholder = document.createComment("clotho-timeline-home");
16613
+ timeline.before(placeholder);
16614
+ prepareStudioPopupDocument(opened.document, document, ui.app, {
16615
+ title: "Clotho Timeline",
16616
+ bodyClass: "studio-timeline-popout"
16617
+ });
16618
+ opened.document.body.appendChild(timeline);
16619
+ unbindPopupPointerEvents = bindTimelinePointerDocument(opened.document);
16620
+ opened.addEventListener("beforeunload", () => restoreTimeline(false), {
16621
+ once: true
16622
+ });
16623
+ closeTimer = window.setInterval(() => {
16624
+ if (!popup || popup.closed) restoreTimeline(false);
16625
+ }, 500);
16626
+ ui.app.classList.add("is-timeline-detached");
16627
+ ui.detachTimelineBtn.setAttribute("aria-pressed", "true");
16628
+ ui.detachTimelineBtn.textContent = "\u2199 \uD3B8\uC9D1\uAE30\uC5D0 \uB2E4\uC2DC \uD569\uCE58\uAE30";
16629
+ opened.focus();
16630
+ });
16631
+ }
16632
+
16633
+ // src/legacy/player-popout.ts
16634
+ function setupPlayerPopout(ui) {
16635
+ const button = document.getElementById(
16636
+ "studio-player-preview"
16637
+ );
16638
+ if (!button) return;
16639
+ let popup = null;
16640
+ let playerHandle = null;
16641
+ let unsubscribe = null;
16642
+ let renderedDocument = getDef();
16643
+ const cleanup = () => {
16644
+ playerHandle?.destroy();
16645
+ playerHandle = null;
16646
+ unsubscribe?.();
16647
+ unsubscribe = null;
16648
+ popup = null;
16649
+ button.setAttribute("aria-pressed", "false");
16650
+ };
16651
+ const render5 = async () => {
16652
+ const targetWindow = popup;
16653
+ const def = getDef();
16654
+ if (!targetWindow || targetWindow.closed || !def) return;
16655
+ const container = targetWindow.document.getElementById(
16656
+ "clotho-player-preview"
16657
+ );
16658
+ if (!container) return;
16659
+ playerHandle?.destroy();
16660
+ container.replaceChildren();
16661
+ const { mountPlayer, koreanStrings } = await import('@kokoa/clotho/dom');
16662
+ if (!popup || popup !== targetWindow || targetWindow.closed) return;
16663
+ playerHandle = mountPlayer(container, def, {
16664
+ strings: koreanStrings,
16665
+ player: {
16666
+ autoplay: def.settings.autoplay,
16667
+ loop: def.settings.loop
16668
+ }
16669
+ });
16670
+ renderedDocument = def;
16671
+ };
16672
+ button.addEventListener("click", () => {
16673
+ if (popup && !popup.closed) {
16674
+ popup.focus();
16675
+ return;
16676
+ }
16677
+ const def = getDef();
16678
+ if (!def) {
16679
+ ui.status.textContent = "\uBA3C\uC800 \uC560\uB2C8\uBA54\uC774\uC158\uC744 \uC5F4\uAC70\uB098 \uB9CC\uB4E4\uC5B4\uC8FC\uC138\uC694.";
16680
+ return;
16681
+ }
16682
+ const opened = window.open("", "_blank", "popup=yes,width=1100,height=760");
16683
+ if (!opened) {
16684
+ ui.status.textContent = "\uBE0C\uB77C\uC6B0\uC800\uC5D0\uC11C popup\uC744 \uD5C8\uC6A9\uD574\uC8FC\uC138\uC694.";
16685
+ return;
16686
+ }
16687
+ popup = opened;
16688
+ prepareStudioPopupDocument(opened.document, document, ui.app, {
16689
+ title: `${def.title || def.id} \xB7 \uC2E4\uC81C \uBBF8\uB9AC\uBCF4\uAE30`,
16690
+ bodyClass: "studio-player-popout"
16691
+ });
16692
+ const container = opened.document.createElement("main");
16693
+ container.id = "clotho-player-preview";
16694
+ opened.document.body.appendChild(container);
16695
+ button.setAttribute("aria-pressed", "true");
16696
+ renderedDocument = def;
16697
+ void render5();
16698
+ unsubscribe = subscribe(() => {
16699
+ const next = getDef();
16700
+ if (next !== renderedDocument) void render5();
16701
+ });
16702
+ opened.addEventListener("beforeunload", cleanup, { once: true });
16703
+ opened.focus();
16704
+ });
16705
+ }
16706
+
16005
16707
  // src/legacy/main.ts
16006
- function initStudio(opts) {
16708
+ function initStudio(opts = {}) {
16007
16709
  const ui = queryUi();
16008
16710
  if (!ui) {
16009
16711
  console.error("[studio] missing required elements");
@@ -16011,6 +16713,9 @@ function initStudio(opts) {
16011
16713
  }
16012
16714
  document.body.classList.add("editor-active");
16013
16715
  document.documentElement.classList.add("editor-active");
16716
+ const editorTitle = document.getElementById("studio-editor-title");
16717
+ if (editorTitle)
16718
+ editorTitle.textContent = opts.editorTitle ?? "Clotho Editor";
16014
16719
  initGrid();
16015
16720
  reflectGridUi(ui);
16016
16721
  subscribeGrid(() => reflectGridUi(ui));
@@ -16040,7 +16745,8 @@ function initStudio(opts) {
16040
16745
  });
16041
16746
  const imageHost = {
16042
16747
  app: ui.app,
16043
- setStatus: (text, kind) => setStatus(ui, text, kind)
16748
+ setStatus: (text, kind) => setStatus(ui, text, kind),
16749
+ resolveImage: opts.resolveImage
16044
16750
  };
16045
16751
  ui.imageUploadBtn.addEventListener("click", () => ui.imageFileInput.click());
16046
16752
  ui.imageFileInput.addEventListener("change", () => {
@@ -16141,6 +16847,29 @@ function initStudio(opts) {
16141
16847
  { id: "help", label: "\u2328 \uB2E8\uCD95\uD0A4 \uBCF4\uAE30", hint: "? / Shift+/", run: openHelp }
16142
16848
  ]);
16143
16849
  ui.saveBtn.addEventListener("click", () => void saveCurrent(ui));
16850
+ ui.exportBtn.addEventListener("click", () => {
16851
+ const def = getDef();
16852
+ if (!def) return;
16853
+ downloadAnimationJson(def);
16854
+ setStatus(ui, `${def.id}.json \uD30C\uC77C\uC744 \uB0B4\uB824\uBC1B\uC558\uC2B5\uB2C8\uB2E4.`, "ok");
16855
+ });
16856
+ ui.importBtn.addEventListener("click", () => ui.importFileInput.click());
16857
+ ui.importFileInput.addEventListener("change", () => {
16858
+ const file = ui.importFileInput.files?.[0];
16859
+ ui.importFileInput.value = "";
16860
+ if (!file) return;
16861
+ void file.text().then((text) => animationDocumentSchema.parse(JSON.parse(text))).then((def) => importAnimation(def)).then((def) => {
16862
+ setDef(def);
16863
+ markClean();
16864
+ setStatus(ui, `${def.id}.json \uD30C\uC77C\uC744 \uAC00\uC838\uC654\uC2B5\uB2C8\uB2E4.`, "ok");
16865
+ }).catch((error) => {
16866
+ setStatus(
16867
+ ui,
16868
+ `JSON \uAC00\uC838\uC624\uAE30 \uC2E4\uD328: ${error instanceof Error ? error.message : String(error)}`,
16869
+ "error"
16870
+ );
16871
+ });
16872
+ });
16144
16873
  ui.deleteBtn.addEventListener("click", () => void deleteCurrent(ui));
16145
16874
  ui.undoBtn.addEventListener("click", () => undo());
16146
16875
  ui.redoBtn.addEventListener("click", () => redo());
@@ -16157,7 +16886,20 @@ function initStudio(opts) {
16157
16886
  });
16158
16887
  ui.speedInput.addEventListener("input", () => {
16159
16888
  ui.speedValue.textContent = Number(ui.speedInput.value).toFixed(2) + "x";
16889
+ if (isPlaying()) {
16890
+ stopPreview(ui);
16891
+ togglePlay(ui);
16892
+ }
16160
16893
  });
16894
+ ui.previewLoopInput.addEventListener("change", () => {
16895
+ updateSettings({ loop: ui.previewLoopInput.checked });
16896
+ if (isPlaying()) {
16897
+ stopPreview(ui);
16898
+ togglePlay(ui);
16899
+ }
16900
+ });
16901
+ setupTimelinePopout(ui);
16902
+ setupPlayerPopout(ui);
16161
16903
  ui.newCreateBtn.addEventListener("click", () => void createNew(ui));
16162
16904
  document.addEventListener("click", (e) => {
16163
16905
  const closer = e.target.closest(
@@ -16169,11 +16911,15 @@ function initStudio(opts) {
16169
16911
  dialog.close();
16170
16912
  if (dialog === ui.newDialog) dialog.removeAttribute("data-mode");
16171
16913
  });
16914
+ document.addEventListener("click", (e) => {
16915
+ const dialog = e.target;
16916
+ if (dialog instanceof HTMLDialogElement && dialog.open) dialog.close();
16917
+ });
16172
16918
  bindKeyboardShortcuts(ui);
16173
16919
  window.addEventListener("beforeunload", (e) => {
16174
16920
  if (isDirty()) e.preventDefault();
16175
16921
  });
16176
- if (opts?.initialId) {
16922
+ if (opts.initialId) {
16177
16923
  void loadAnimation2(ui, opts.initialId);
16178
16924
  } else if (!getDef()) {
16179
16925
  startDraft();
@@ -16187,6 +16933,9 @@ function reflectState(ui) {
16187
16933
  ui.redoBtn.disabled = !canRedo();
16188
16934
  const def = getDef();
16189
16935
  if (def) {
16936
+ ui.titleInput.disabled = false;
16937
+ ui.canvasWidthInput.disabled = false;
16938
+ ui.canvasHeightInput.disabled = false;
16190
16939
  if (document.activeElement !== ui.titleInput) {
16191
16940
  ui.titleInput.value = def.title;
16192
16941
  }
@@ -16198,17 +16947,25 @@ function reflectState(ui) {
16198
16947
  }
16199
16948
  ui.idDisplay.textContent = isDraft() ? "Draft" : def.id ?? "";
16200
16949
  ui.saveBtn.disabled = false;
16950
+ ui.exportBtn.disabled = false;
16201
16951
  ui.deleteBtn.disabled = isDraft();
16952
+ if (document.activeElement !== ui.previewLoopInput) {
16953
+ ui.previewLoopInput.checked = def.settings.loop;
16954
+ }
16202
16955
  } else {
16956
+ ui.titleInput.disabled = true;
16957
+ ui.canvasWidthInput.disabled = true;
16958
+ ui.canvasHeightInput.disabled = true;
16203
16959
  ui.titleInput.value = "";
16204
16960
  ui.canvasWidthInput.value = "";
16205
16961
  ui.canvasHeightInput.value = "";
16206
16962
  ui.idDisplay.textContent = "";
16207
16963
  ui.saveBtn.disabled = true;
16964
+ ui.exportBtn.disabled = true;
16208
16965
  ui.deleteBtn.disabled = true;
16209
16966
  }
16210
16967
  }
16211
16968
 
16212
16969
  export { getVisibleElementIds, initStudio };
16213
- //# sourceMappingURL=main-SWHHYTLM.js.map
16214
- //# sourceMappingURL=main-SWHHYTLM.js.map
16970
+ //# sourceMappingURL=main-5YXLTYPQ.js.map
16971
+ //# sourceMappingURL=main-5YXLTYPQ.js.map