@kokoa/clotho-editor 0.1.4 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
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, updateLocales, updateDuration, updateAppearance, updateEffect, childIdsOf } from './chunk-GMGB7NIL.js';
2
- import { activeAppearance, animationDocumentSchema, resolveAsset } from '@kokoa/clotho';
1
+ import { updateCanvas, getDef, subscribe, updateMeta, mountEditorPlugins, setSelection, getSelection, setDef, undo, redo, downloadAnimationJson, importAnimation, markClean, updateSettings, isDirty, isDraft, canUndo, canRedo, uniqueChapterId, getCurrentTime, addChapter, uniqueElementId, registerExternalAsset, addElement, registerDataUriAsset, listAnimations, setDraft, saveAnimation, deleteAnimation, duplicateAnimation, createAnimation, getSelectedElementIds, deleteElement, deleteChapter, deleteEffect, updateChapter, isGroup, ungroupElement, groupElements, moveElementToEnd, moveElementToFront, reorderElement, loadAnimation, getCurrentSnapshot, setElementValueAtTime, findContainingGroup, isElementSelected, updateElementBase, createLayout, detachFromLayout, addCheckpoint, uniqueCheckpointId, deleteCheckpoint, addCameraFocus, updateCameraFocus, deleteCameraFocus, setCameraKeyframe, removeCameraKeyframe, removeCameraTrack, setCurrentTime, clearCamera, uniqueEffectId, addEffect, removeAppearance, removeTrack, removeTrackKeyframe, setTrackKeyframe, addAppearance, beginTransient, endTransient, getCamera, jumpBack, jumpForward, getHistory, promoteDraftToSaved, toggleSelectionFor, cameraControlAt, moveGroupBy, placeholderImageUrl, groupBbox, hasCamera, layoutIdsFor, findLayoutCollisions, updateLocales, updateData, updateResponsive, updateDuration, updateCheckpoint, setCameraStrokeScaling, updateAppearance, updateEffect, moveCameraKeyframe, childIdsOf } from './chunk-N47HF4VB.js';
2
+ import { activeAppearance, animationDocumentSchema, bindablePropertiesFor, computeCamera, resolveAsset, compileDataBindings } from '@kokoa/clotho';
3
3
 
4
4
  // src/legacy/grid.ts
5
5
  var STORAGE_KEY = "studio.grid";
@@ -46,6 +46,35 @@ function snapPoint(pt) {
46
46
  return { x: snap(pt.x), y: snap(pt.y) };
47
47
  }
48
48
 
49
+ // src/legacy/camera-interactions.ts
50
+ function cameraDragMode(part, control) {
51
+ if (control.kind === "none" || part === "border") return null;
52
+ if (part === "resize") return control.kind === "focus" ? "padding" : "zoom";
53
+ return control.kind === "tracks" ? "pan" : null;
54
+ }
55
+ function centreDistance(point, centreX, centreY) {
56
+ return Math.max(1, Math.hypot(point.x - centreX, point.y - centreY));
57
+ }
58
+ function cameraDragResult(drag, point) {
59
+ if (drag.mode === "pan") {
60
+ return {
61
+ centerX: Math.round(drag.startCenterX - (point.x - drag.startX)),
62
+ centerY: Math.round(drag.startCenterY - (point.y - drag.startY))
63
+ };
64
+ }
65
+ const ratio = centreDistance(point, drag.startCenterX, drag.startCenterY) / drag.startDistance;
66
+ if (drag.mode === "zoom") {
67
+ const zoom = Math.max(0.05, Math.min(20, drag.startZoom / ratio));
68
+ return { zoom: Number(zoom.toFixed(3)) };
69
+ }
70
+ return {
71
+ padding: Math.max(
72
+ 0,
73
+ Math.round(drag.startPadding * ratio + (ratio - 1) * 40)
74
+ )
75
+ };
76
+ }
77
+
49
78
  // src/legacy/canvas-utils.ts
50
79
  var SVG_NS = "http://www.w3.org/2000/svg";
51
80
  function escapeXml(s) {
@@ -646,6 +675,7 @@ function makeDefaultElement(type, id, cx, cy, polygonSides = 6) {
646
675
  rotation: 0,
647
676
  appearances: [],
648
677
  tracks: [],
678
+ bindings: [],
649
679
  x: cx - 60,
650
680
  y: cy - 30,
651
681
  width: 120,
@@ -665,6 +695,7 @@ function makeDefaultElement(type, id, cx, cy, polygonSides = 6) {
665
695
  rotation: 0,
666
696
  appearances: [],
667
697
  tracks: [],
698
+ bindings: [],
668
699
  cx,
669
700
  cy,
670
701
  r: 36,
@@ -682,6 +713,7 @@ function makeDefaultElement(type, id, cx, cy, polygonSides = 6) {
682
713
  rotation: 0,
683
714
  appearances: [],
684
715
  tracks: [],
716
+ bindings: [],
685
717
  x1: cx - 80,
686
718
  y1: cy,
687
719
  x2: cx + 80,
@@ -698,6 +730,7 @@ function makeDefaultElement(type, id, cx, cy, polygonSides = 6) {
698
730
  rotation: 0,
699
731
  appearances: [],
700
732
  tracks: [],
733
+ bindings: [],
701
734
  x1: cx - 100,
702
735
  y1: cy,
703
736
  x2: cx + 100,
@@ -718,10 +751,12 @@ function makeDefaultElement(type, id, cx, cy, polygonSides = 6) {
718
751
  rotation: 0,
719
752
  appearances: [],
720
753
  tracks: [],
754
+ bindings: [],
721
755
  x: cx,
722
756
  y: cy,
723
757
  content: id,
724
758
  translations: {},
759
+ references: {},
725
760
  fontSize: 18,
726
761
  fontWeight: 400,
727
762
  color: "#18181b",
@@ -734,6 +769,7 @@ function makeDefaultElement(type, id, cx, cy, polygonSides = 6) {
734
769
  rotation: 0,
735
770
  appearances: [],
736
771
  tracks: [],
772
+ bindings: [],
737
773
  x: cx - 50,
738
774
  y: cy - 50,
739
775
  width: 100,
@@ -749,6 +785,7 @@ function makeDefaultElement(type, id, cx, cy, polygonSides = 6) {
749
785
  rotation: 0,
750
786
  appearances: [],
751
787
  tracks: [],
788
+ bindings: [],
752
789
  x: cx - 40,
753
790
  y: cy - 40,
754
791
  d: "M 0 0 L 80 0 L 40 80 Z",
@@ -770,6 +807,7 @@ function makeDefaultElement(type, id, cx, cy, polygonSides = 6) {
770
807
  rotation: 0,
771
808
  appearances: [],
772
809
  tracks: [],
810
+ bindings: [],
773
811
  points: pts,
774
812
  fill: "#a5b4fc",
775
813
  stroke: "#6366f1",
@@ -1045,8 +1083,6 @@ function renderSelectionOutline(canvasEl2, elId, snap2, byId) {
1045
1083
  rect.classList.add("element-selected-outline");
1046
1084
  return rect;
1047
1085
  }
1048
-
1049
- // src/legacy/canvas-preview.ts
1050
1086
  var previewRoot = null;
1051
1087
  var previewHandle = null;
1052
1088
  var unsubscribePreview = null;
@@ -1075,7 +1111,7 @@ function showPreview(canvasEl2, def, options) {
1075
1111
  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;`;
1076
1112
  parent.style.position = "relative";
1077
1113
  parent.appendChild(previewRoot);
1078
- void mountPreview(previewRoot, def, options);
1114
+ void mountPreview(previewRoot, compileDataBindings(def).document, options);
1079
1115
  }
1080
1116
  function hidePreview(canvasEl2) {
1081
1117
  if (canvasEl2) canvasEl2.style.visibility = "";
@@ -1147,11 +1183,29 @@ var endpointDragState = null;
1147
1183
  var vertexDragState = null;
1148
1184
  var resizeState = null;
1149
1185
  var marqueeState = null;
1186
+ var cameraDragState = null;
1150
1187
  var marqueeJustFinished = false;
1151
1188
  var toolJustFinished = false;
1152
1189
  var canvasEl = null;
1153
1190
  var hoveredElementId = null;
1154
1191
  var canvasZoom = 1;
1192
+ var showCameraFrame = true;
1193
+ var CAMERA_FRAME_KEY = "studio.canvas.cameraFrame";
1194
+ try {
1195
+ showCameraFrame = localStorage.getItem(CAMERA_FRAME_KEY) !== "off";
1196
+ } catch {
1197
+ }
1198
+ function isCameraFrameVisible() {
1199
+ return showCameraFrame;
1200
+ }
1201
+ function setCameraFrameVisible(value) {
1202
+ showCameraFrame = value;
1203
+ try {
1204
+ localStorage.setItem(CAMERA_FRAME_KEY, value ? "on" : "off");
1205
+ } catch {
1206
+ }
1207
+ requestCanvasRender();
1208
+ }
1155
1209
  var toolDrawState = null;
1156
1210
  var elementDrawState = null;
1157
1211
  var canvasRenderFrame = null;
@@ -1284,7 +1338,7 @@ function onCanvasClick(e) {
1284
1338
  return;
1285
1339
  }
1286
1340
  if (target?.closest(
1287
- "[data-rotate-handle], [data-anchor-handle], [data-resize-handle]"
1341
+ "[data-rotate-handle], [data-anchor-handle], [data-resize-handle], [data-camera-frame]"
1288
1342
  ))
1289
1343
  return;
1290
1344
  const id = findElementId(e.target);
@@ -1297,6 +1351,8 @@ function onCanvasClick(e) {
1297
1351
  function onMouseDown(e) {
1298
1352
  if (e.button !== 0) return;
1299
1353
  const target = e.target;
1354
+ const cameraPart = target?.closest("[data-camera-frame]");
1355
+ if (cameraPart && beginCameraDrag(e, cameraPart)) return;
1300
1356
  const def = getDef();
1301
1357
  if (!def) return;
1302
1358
  const activeTool2 = getActiveTool();
@@ -1561,6 +1617,11 @@ function collectDragExtras(anchorId, def, snap2) {
1561
1617
  return extras;
1562
1618
  }
1563
1619
  function onMouseMove(e) {
1620
+ if (cameraDragState) {
1621
+ e.preventDefault();
1622
+ moveCameraDrag(e);
1623
+ return;
1624
+ }
1564
1625
  if (pathDraftState && getActiveTool() === "path") {
1565
1626
  const point = svgPoint(e.clientX, e.clientY);
1566
1627
  if (point) {
@@ -1761,6 +1822,11 @@ function finishPathDraft() {
1761
1822
  render2();
1762
1823
  }
1763
1824
  function onMouseUp(e) {
1825
+ if (cameraDragState) {
1826
+ cameraDragState = null;
1827
+ endTransient();
1828
+ return;
1829
+ }
1764
1830
  if (elementDrawState) {
1765
1831
  const draw = elementDrawState;
1766
1832
  const point = svgPoint(e.clientX, e.clientY);
@@ -1824,6 +1890,7 @@ function onMouseUp(e) {
1824
1890
  rotation: 0,
1825
1891
  appearances: [],
1826
1892
  tracks: [],
1893
+ bindings: [],
1827
1894
  fromId: connectState.fromId,
1828
1895
  toId: elemId,
1829
1896
  fromAnchor: connectState.fromAnchor,
@@ -2168,6 +2235,8 @@ function render2() {
2168
2235
  canvasEl.appendChild(g);
2169
2236
  }
2170
2237
  }
2238
+ const cameraFrame = renderCameraFrame(def);
2239
+ if (cameraFrame) canvasEl.appendChild(cameraFrame);
2171
2240
  const selection = getSelection();
2172
2241
  if (selection.kind === "element") {
2173
2242
  const selEl = elementsById.get(selection.elementId);
@@ -2378,6 +2447,141 @@ function renderGroupOutline(groupId) {
2378
2447
  `;
2379
2448
  return g;
2380
2449
  }
2450
+ function renderCameraFrame(def) {
2451
+ if (!showCameraFrame) return null;
2452
+ const view = computeCamera(def, getCurrentTime());
2453
+ if (!view) return null;
2454
+ const { width, height } = def.canvas;
2455
+ const selected = getSelection().kind === "camera";
2456
+ const control = cameraControlAt(getCurrentTime());
2457
+ const g = document.createElementNS(SVG_NS, "g");
2458
+ g.setAttribute(
2459
+ "class",
2460
+ `studio-camera-frame${selected ? " is-selected" : ""}`
2461
+ );
2462
+ g.setAttribute("pointer-events", "none");
2463
+ const scrim = document.createElementNS(SVG_NS, "path");
2464
+ scrim.setAttribute(
2465
+ "d",
2466
+ `M0 0H${width}V${height}H0Z M${view.x} ${view.y}H${view.x + view.width}V${view.y + view.height}H${view.x}Z`
2467
+ );
2468
+ scrim.setAttribute("fill-rule", "evenodd");
2469
+ scrim.setAttribute("class", "studio-camera-frame-scrim");
2470
+ g.appendChild(scrim);
2471
+ const rect = document.createElementNS(SVG_NS, "rect");
2472
+ rect.setAttribute("x", String(view.x));
2473
+ rect.setAttribute("y", String(view.y));
2474
+ rect.setAttribute("width", String(view.width));
2475
+ rect.setAttribute("height", String(view.height));
2476
+ rect.setAttribute("class", "studio-camera-frame-rect");
2477
+ rect.setAttribute("vector-effect", "non-scaling-stroke");
2478
+ rect.setAttribute("data-camera-frame", "border");
2479
+ rect.setAttribute("pointer-events", "stroke");
2480
+ g.appendChild(rect);
2481
+ if (selected && control.kind !== "none") {
2482
+ g.appendChild(cameraHandles(view, control));
2483
+ }
2484
+ const label = document.createElementNS(SVG_NS, "text");
2485
+ label.setAttribute("x", String(view.x + 6));
2486
+ label.setAttribute("y", String(view.y + 16));
2487
+ label.setAttribute("class", "studio-camera-frame-label");
2488
+ label.textContent = selected ? `\u{1F3A5} ${view.zoom.toFixed(2)}\xD7 \xB7 ${Math.round(view.centerX)}, ${Math.round(view.centerY)} \xB7 ${control.kind === "focus" ? `focus #${control.index + 1} \u2014 \uBAA8\uC11C\uB9AC\uB97C \uB04C\uBA74 padding` : "\uB04C\uC5B4\uC11C \uC774\uB3D9 \xB7 \uBAA8\uC11C\uB9AC\uB85C zoom"}` : `\u{1F3A5} ${view.zoom.toFixed(2)}\xD7 \xB7 ${Math.round(view.centerX)}, ${Math.round(view.centerY)}`;
2489
+ g.appendChild(label);
2490
+ if (view.issues.length > 0) {
2491
+ const warning = document.createElementNS(SVG_NS, "text");
2492
+ warning.setAttribute("x", String(view.x + 6));
2493
+ warning.setAttribute("y", String(view.y + 32));
2494
+ warning.setAttribute("class", "studio-camera-frame-warning");
2495
+ warning.textContent = `\u26A0 ${view.issues[0].message}`;
2496
+ g.appendChild(warning);
2497
+ }
2498
+ return g;
2499
+ }
2500
+ function beginCameraDrag(e, part) {
2501
+ const def = getDef();
2502
+ if (!def) return false;
2503
+ const time = getCurrentTime();
2504
+ const view = computeCamera(def, time);
2505
+ if (!view) return false;
2506
+ const wasSelected = getSelection().kind === "camera";
2507
+ if (!wasSelected) setSelection({ kind: "camera" });
2508
+ e.preventDefault();
2509
+ e.stopPropagation();
2510
+ const kind = part.dataset.cameraFrame;
2511
+ if (kind === "border" || !wasSelected) return true;
2512
+ const control = cameraControlAt(time);
2513
+ const mode = cameraDragMode(kind, control);
2514
+ if (!mode) return true;
2515
+ const point = svgPoint(e.clientX, e.clientY);
2516
+ if (!point) return true;
2517
+ const focusIndex = control.kind === "focus" ? control.index : -1;
2518
+ const padding = focusIndex >= 0 ? getCamera().focus[focusIndex]?.padding ?? 24 : 0;
2519
+ cameraDragState = {
2520
+ mode,
2521
+ focusIndex,
2522
+ time,
2523
+ startX: point.x,
2524
+ startY: point.y,
2525
+ startCenterX: view.centerX,
2526
+ startCenterY: view.centerY,
2527
+ startZoom: view.zoom,
2528
+ startPadding: padding,
2529
+ startDistance: centreDistance(point, view.centerX, view.centerY)
2530
+ };
2531
+ beginTransient("\uCE74\uBA54\uB77C \uC870\uC815", "camera");
2532
+ return true;
2533
+ }
2534
+ function moveCameraDrag(e) {
2535
+ const drag = cameraDragState;
2536
+ if (!drag) return;
2537
+ const point = svgPoint(e.clientX, e.clientY);
2538
+ if (!point) return;
2539
+ const next = cameraDragResult(drag, point);
2540
+ if (next.centerX !== void 0) {
2541
+ setCameraKeyframe("x", drag.time, next.centerX);
2542
+ }
2543
+ if (next.centerY !== void 0) {
2544
+ setCameraKeyframe("y", drag.time, next.centerY);
2545
+ }
2546
+ if (next.zoom !== void 0) setCameraKeyframe("zoom", drag.time, next.zoom);
2547
+ if (next.padding !== void 0) {
2548
+ updateCameraFocus(drag.focusIndex, { padding: next.padding });
2549
+ }
2550
+ }
2551
+ function cameraHandles(view, control) {
2552
+ const g = document.createElementNS(SVG_NS, "g");
2553
+ if (control.kind === "tracks") {
2554
+ const body = document.createElementNS(SVG_NS, "rect");
2555
+ body.setAttribute("x", String(view.x));
2556
+ body.setAttribute("y", String(view.y));
2557
+ body.setAttribute("width", String(view.width));
2558
+ body.setAttribute("height", String(view.height));
2559
+ body.setAttribute("class", "studio-camera-frame-body");
2560
+ body.setAttribute("data-camera-frame", "pan");
2561
+ body.setAttribute("pointer-events", "fill");
2562
+ g.appendChild(body);
2563
+ }
2564
+ const corners = [
2565
+ ["nw", view.x, view.y],
2566
+ ["ne", view.x + view.width, view.y],
2567
+ ["sw", view.x, view.y + view.height],
2568
+ ["se", view.x + view.width, view.y + view.height]
2569
+ ];
2570
+ const size = 9 / canvasZoom;
2571
+ for (const [corner, cx, cy] of corners) {
2572
+ const handle = document.createElementNS(SVG_NS, "rect");
2573
+ handle.setAttribute("x", String(cx - size / 2));
2574
+ handle.setAttribute("y", String(cy - size / 2));
2575
+ handle.setAttribute("width", String(size));
2576
+ handle.setAttribute("height", String(size));
2577
+ handle.setAttribute("class", "studio-camera-frame-handle");
2578
+ handle.setAttribute("data-camera-frame", "resize");
2579
+ handle.setAttribute("data-camera-corner", corner);
2580
+ handle.setAttribute("pointer-events", "all");
2581
+ g.appendChild(handle);
2582
+ }
2583
+ return g;
2584
+ }
2381
2585
  function makeG(elementId, rotation, cx, cy) {
2382
2586
  const g = document.createElementNS(SVG_NS, "g");
2383
2587
  g.setAttribute("data-elem-id", elementId);
@@ -2708,6 +2912,41 @@ function parseLocales(value) {
2708
2912
  return true;
2709
2913
  });
2710
2914
  }
2915
+ function annotationTokens(...values) {
2916
+ const tokens = values.flatMap(
2917
+ (value) => [...value.matchAll(/\{([a-z][a-z0-9_-]*)\}/g)].map((match) => match[1])
2918
+ );
2919
+ return [...new Set(tokens)];
2920
+ }
2921
+ function annotationReferenceFields(keyPrefix, values, references = {}) {
2922
+ const tokens = annotationTokens(...values);
2923
+ if (tokens.length === 0) {
2924
+ return [
2925
+ '<p class="studio-props-empty studio-annotation-hint">\uBB38\uAD6C\uC5D0 {token}\uC744 \uC785\uB825\uD558\uBA74 \uC7A5\uBA74 \uC694\uC18C\uC640 \uC5F0\uACB0\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.</p>'
2926
+ ];
2927
+ }
2928
+ return [
2929
+ '<p class="studio-props-empty studio-annotation-hint">\uB300\uC0C1 element id\uB97C \uC27C\uD45C\uB85C \uAD6C\uBD84\uD558\uC138\uC694. \uC5EC\uB7EC \uC694\uC18C\uB97C \uD568\uAED8 \uAC15\uC870\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.</p>',
2930
+ ...tokens.map((token) => {
2931
+ const target = references[token];
2932
+ const value = typeof target === "string" ? target : target?.join(", ") ?? "";
2933
+ return textField(
2934
+ `{${token}} \uB300\uC0C1`,
2935
+ `${keyPrefix}.reference.${token}`,
2936
+ value
2937
+ );
2938
+ })
2939
+ ];
2940
+ }
2941
+ function parseReferenceTargets(value) {
2942
+ const targets = [
2943
+ ...new Set(
2944
+ value.split(",").map((id) => id.trim()).filter(Boolean)
2945
+ )
2946
+ ];
2947
+ if (targets.length === 0) return void 0;
2948
+ return targets.length === 1 ? targets[0] : targets;
2949
+ }
2711
2950
  function initProperties(root) {
2712
2951
  panelEl = root;
2713
2952
  subscribe(render3);
@@ -2719,8 +2958,23 @@ function initProperties(root) {
2719
2958
  root.addEventListener("compositionend", onCompositionEnd);
2720
2959
  root.addEventListener("toggle", onSectionToggle, true);
2721
2960
  root.addEventListener("wheel", onNumberWheel, { passive: false });
2961
+ root.addEventListener("pointerdown", onSliderGrab);
2962
+ root.addEventListener("pointerup", onSliderRelease);
2963
+ root.addEventListener("pointercancel", onSliderRelease);
2722
2964
  render3();
2723
2965
  }
2966
+ var slidingKey = null;
2967
+ function onSliderGrab(e) {
2968
+ const target = e.target;
2969
+ if (target.type !== "range" || !target.dataset.propKey) return;
2970
+ slidingKey = target.dataset.propKey;
2971
+ beginTransient(`\uC870\uC815: ${slidingKey}`, "camera");
2972
+ }
2973
+ function onSliderRelease() {
2974
+ if (slidingKey === null) return;
2975
+ slidingKey = null;
2976
+ endTransient();
2977
+ }
2724
2978
  function onCompositionStart() {
2725
2979
  isComposingFallback = true;
2726
2980
  }
@@ -2779,6 +3033,9 @@ function textField(label, key, value) {
2779
3033
  <input type="text" data-prop-key="${escapeHtml2(key)}" value="${escapeHtml2(value ?? "")}" />
2780
3034
  </label>`;
2781
3035
  }
3036
+ function textareaField(label, key, value) {
3037
+ return `<label class="studio-field"><span>${escapeHtml2(label)}</span><textarea rows="7" spellcheck="false" data-prop-key="${escapeHtml2(key)}">${escapeHtml2(value)}</textarea></label>`;
3038
+ }
2782
3039
  function numberField(label, key, value, step = 1) {
2783
3040
  return `<label class="studio-field">
2784
3041
  <span>${escapeHtml2(label)}</span>
@@ -2833,7 +3090,18 @@ function renderInner() {
2833
3090
  "canvas.background",
2834
3091
  "canvas.background",
2835
3092
  def.canvas.background
2836
- )
3093
+ ),
3094
+ textareaField(
3095
+ "\uC0D8\uD50C \uB370\uC774\uD130 (JSON)",
3096
+ "meta.data",
3097
+ JSON.stringify(def.data, null, 2)
3098
+ ),
3099
+ textareaField(
3100
+ "Responsive variants (JSON)",
3101
+ "meta.responsive",
3102
+ JSON.stringify(def.responsive ?? [], null, 2)
3103
+ ),
3104
+ `<p class="studio-props-empty">JSON Pointer\uB85C \uC694\uC18C \uC18D\uC131\uC5D0 \uC5F0\uACB0\uD569\uB2C8\uB2E4. \uC774 \uB370\uC774\uD130\uB294 \uBBF8\uB9AC\uBCF4\uAE30\uC640 \uB0B4\uBCF4\uB0B4\uAE30\uC5D0 \uD568\uAED8 \uC800\uC7A5\uB429\uB2C8\uB2E4.</p>`
2837
3105
  ].join("");
2838
3106
  const settingsHeader = `<span class="studio-props-header-title">\uC124\uC815</span>`;
2839
3107
  const settingsBody = [
@@ -2861,10 +3129,61 @@ function renderInner() {
2861
3129
  ]
2862
3130
  )
2863
3131
  ].join("");
3132
+ const checkpoints = def.checkpoints.map((checkpoint, index) => {
3133
+ const specific = checkpoint.interaction === "choice" ? textField(
3134
+ "\uC120\uD0DD\uC9C0 (value:label, \uC27C\uD45C \uAD6C\uBD84)",
3135
+ `checkpoint.${index}.options`,
3136
+ checkpoint.options.map(({ value, label }) => `${value}:${label}`).join(", ")
3137
+ ) : checkpoint.interaction === "select-element" ? textField(
3138
+ "\uC120\uD0DD \uAC00\uB2A5\uD55C element id",
3139
+ `checkpoint.${index}.elementIds`,
3140
+ checkpoint.elementIds.join(", ")
3141
+ ) : checkpoint.interaction === "number-input" ? [
3142
+ numberField(
3143
+ "\uCD5C\uC19F\uAC12",
3144
+ `checkpoint.${index}.min`,
3145
+ checkpoint.min
3146
+ ),
3147
+ numberField(
3148
+ "\uCD5C\uB313\uAC12",
3149
+ `checkpoint.${index}.max`,
3150
+ checkpoint.max
3151
+ ),
3152
+ numberField(
3153
+ "\uAC04\uACA9",
3154
+ `checkpoint.${index}.step`,
3155
+ checkpoint.step
3156
+ )
3157
+ ].join("") : "";
3158
+ const predicate = "predicate" in checkpoint && checkpoint.predicate?.type === "equals" ? textField(
3159
+ "\uC815\uB2F5 (equals)",
3160
+ `checkpoint.${index}.answer`,
3161
+ String(checkpoint.predicate.value)
3162
+ ) : "";
3163
+ return `<div class="studio-checkpoint-card" data-checkpoint-id="${escapeHtml2(checkpoint.id)}">
3164
+ <div class="studio-props-header"><span class="studio-props-header-title">${escapeHtml2(checkpoint.id)}</span><button type="button" class="studio-btn studio-btn-danger" data-delete-checkpoint="${escapeHtml2(checkpoint.id)}">\uC0AD\uC81C</button></div>
3165
+ ${numberField("time (ms)", `checkpoint.${index}.time`, checkpoint.time, 50)}
3166
+ ${textField("\uC9C8\uBB38", `checkpoint.${index}.prompt`, checkpoint.prompt)}
3167
+ ${selectField(
3168
+ "\uC0C1\uD638\uC791\uC6A9",
3169
+ `checkpoint.${index}.interaction`,
3170
+ checkpoint.interaction,
3171
+ [
3172
+ { value: "continue", label: "\uACC4\uC18D" },
3173
+ { value: "choice", label: "\uC120\uD0DD\uC9C0" },
3174
+ { value: "select-element", label: "\uC694\uC18C \uC120\uD0DD" },
3175
+ { value: "number-input", label: "\uC22B\uC790 \uC785\uB825" }
3176
+ ]
3177
+ )}
3178
+ ${checkboxField("\uC751\uB2F5 \uD544\uC218", `checkpoint.${index}.required`, checkpoint.required)}
3179
+ ${specific}${predicate}
3180
+ </div>`;
3181
+ }).join("");
2864
3182
  panelEl.innerHTML = `
2865
3183
  ${timeHint}
2866
3184
  ${section("meta", metaHeader, metaBody)}
2867
3185
  ${section("settings", settingsHeader, settingsBody)}
3186
+ ${section("checkpoints", `<span class="studio-props-header-title">Checkpoint (${def.checkpoints.length})</span>`, `${checkpoints}<button type="button" class="studio-btn" data-add-checkpoint>\uFF0B \uD604\uC7AC \uC2DC\uAC04\uC5D0 checkpoint \uCD94\uAC00</button>`)}
2868
3187
  <div class="studio-props-header" style="margin-top:0.6rem"><span class="studio-props-header-title">\uBAA9\uCC28 (${def.chapters.length})</span></div>
2869
3188
  <button type="button" class="studio-btn" data-add-chapter>\uFF0B \uD604\uC7AC \uC2DC\uAC04\uC5D0 chapter \uCD94\uAC00</button>
2870
3189
  <div class="studio-props-header" style="margin-top:0.6rem"><span class="studio-props-header-title">\uD6A8\uACFC (${def.effects.length})</span></div>
@@ -2876,12 +3195,18 @@ function renderInner() {
2876
3195
  </select>
2877
3196
  <button type="button" class="studio-btn" data-add-effect>\uFF0B \uD6A8\uACFC</button>
2878
3197
  </div>
3198
+ <div class="studio-props-header" style="margin-top:0.6rem"><span class="studio-props-header-title">\uCE74\uBA54\uB77C</span><span class="studio-props-header-type">${hasCamera() ? `focus ${getCamera().focus.length} \xB7 track ${getCamera().tracks.length}` : "\uC5C6\uC74C"}</span></div>
3199
+ <button type="button" class="studio-btn" data-open-camera>\u{1F3A5} \uCE74\uBA54\uB77C \uD3B8\uC9D1</button>
2879
3200
  `;
2880
3201
  return;
2881
3202
  }
2882
3203
  if (sel.kind === "elements") {
2883
3204
  const alignBtn = (kind, label, title) => `<button type="button" class="studio-btn studio-align-btn" data-align="${kind}" title="${escapeHtml2(title)}" aria-label="${escapeHtml2(title)}">${label}</button>`;
2884
3205
  const distributeDisabled = sel.elementIds.length < 3 ? "disabled" : "";
3206
+ const layoutIds = layoutIdsFor(sel.elementIds);
3207
+ const collisions = findLayoutCollisions(def).filter(
3208
+ ({ firstId, secondId }) => sel.elementIds.includes(firstId) || sel.elementIds.includes(secondId)
3209
+ );
2885
3210
  panelEl.innerHTML = `
2886
3211
  ${timeHint}
2887
3212
  <div class="studio-props-header"><span class="studio-props-header-title">\uB2E4\uC911 \uC120\uD0DD</span><span class="studio-props-header-type">${sel.elementIds.length} elements</span></div>
@@ -2905,6 +3230,16 @@ function renderInner() {
2905
3230
  <button type="button" class="studio-btn studio-align-btn" data-distribute="vertical" title="\uC138\uB85C \uADE0\uB4F1 \uBD84\uD3EC" ${distributeDisabled}>\u2195</button>
2906
3231
  </div>
2907
3232
  </div>
3233
+ <div class="studio-align-section studio-layout-section">
3234
+ <div class="studio-align-title">Constraint Layout</div>
3235
+ <div class="studio-align-row">
3236
+ <button type="button" class="studio-btn" data-create-layout="row">\uAC00\uB85C</button>
3237
+ <button type="button" class="studio-btn" data-create-layout="column">\uC138\uB85C</button>
3238
+ <button type="button" class="studio-btn" data-create-layout="grid">\uACA9\uC790</button>
3239
+ </div>
3240
+ ${layoutIds.length > 0 ? `<p class="studio-layout-status">\uC801\uC6A9 \uC911: ${layoutIds.map(escapeHtml2).join(", ")}</p><button type="button" class="studio-btn" data-detach-layout>\uD604\uC7AC \uC88C\uD45C\uB85C \uACE0\uC815</button>` : '<p class="studio-layout-status">\uC120\uD0DD\uD55C \uC694\uC18C\uC5D0 \uC801\uC6A9\uB41C layout\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.</p>'}
3241
+ ${collisions.length > 0 ? `<p class="studio-layout-collision" role="alert">\uACB9\uCE68: ${collisions.map(({ firstId, secondId }) => `${escapeHtml2(firstId)} \u2194 ${escapeHtml2(secondId)}`).join(", ")}</p>` : ""}
3242
+ </div>
2908
3243
  <ul style="font-family:var(--font-mono);font-size:0.72rem;color:var(--color-fg-muted);padding-left:1rem;margin:0.6rem 0 0">
2909
3244
  ${sel.elementIds.map((id) => `<li>${escapeHtml2(id)}</li>`).join("")}
2910
3245
  </ul>
@@ -2920,18 +3255,24 @@ function renderInner() {
2920
3255
  renderElementForm(def, el);
2921
3256
  return;
2922
3257
  }
3258
+ if (sel.kind === "camera") {
3259
+ renderCameraForm(def, sel.focusIndex);
3260
+ return;
3261
+ }
2923
3262
  if (sel.kind === "chapter") {
2924
3263
  const ch = def.chapters.find((c) => c.id === sel.chapterId);
2925
3264
  if (!ch) {
2926
3265
  setSelection({ kind: "none" });
2927
3266
  return;
2928
3267
  }
3268
+ const chapterReferences = ch.references;
2929
3269
  panelEl.innerHTML = `
2930
3270
  ${timeHint}
2931
3271
  <div class="studio-props-header"><span class="studio-props-header-title">${escapeHtml2(ch.id)}</span><span class="studio-props-header-type">chapter</span></div>
2932
3272
  ${numberField("time (ms)", "chapter.time", ch.time, 50)}
2933
3273
  ${textField("label", "chapter.label", ch.label)}
2934
3274
  ${textField("subtitle", "chapter.subtitle", ch.subtitle)}
3275
+ ${annotationReferenceFields("chapter", [ch.label, ch.subtitle], chapterReferences).join("")}
2935
3276
  <button type="button" class="studio-btn studio-btn-danger" data-delete-chapter style="margin-top:0.6rem">\u{1F5D1} chapter \uC0AD\uC81C</button>
2936
3277
  `;
2937
3278
  return;
@@ -2962,6 +3303,114 @@ function renderInner() {
2962
3303
  return;
2963
3304
  }
2964
3305
  }
3306
+ function rangeField(label, key, value, min, max, step, hint) {
3307
+ return `<div class="studio-camera-range">
3308
+ <div class="studio-camera-range-head"><span>${escapeHtml2(label)}</span><span class="studio-camera-range-value">${value}</span></div>
3309
+ <div class="studio-camera-range-row">
3310
+ <input type="range" data-prop-key="${escapeHtml2(key)}" min="${min}" max="${max}" step="${step}" value="${value}" />
3311
+ <input type="number" data-prop-key="${escapeHtml2(key)}" step="${step}" value="${value}" />
3312
+ </div>
3313
+ ${hint ? `<p class="studio-camera-hint">${escapeHtml2(hint)}</p>` : ""}
3314
+ </div>`;
3315
+ }
3316
+ function focusTargetPicker(def, index, selected) {
3317
+ const chosen = new Set(selected);
3318
+ const rows = def.elements.map((element) => {
3319
+ const name = friendlyElementLabel(element);
3320
+ const same = name === element.id;
3321
+ return `<label class="studio-camera-target ${chosen.has(element.id) ? "is-on" : ""}">
3322
+ <input type="checkbox" data-camera-target="${index}" value="${escapeHtml2(element.id)}" ${chosen.has(element.id) ? "checked" : ""} />
3323
+ <span class="studio-camera-target-name">${escapeHtml2(name)}</span>
3324
+ ${same ? "" : `<span class="studio-camera-target-id">${escapeHtml2(element.id)}</span>`}
3325
+ <span class="studio-camera-target-type">${element.type}</span>
3326
+ </label>`;
3327
+ }).join("");
3328
+ const missing = selected.filter(
3329
+ (id) => !def.elements.some((element) => element.id === id)
3330
+ );
3331
+ return `<div class="studio-camera-targets">
3332
+ ${rows || '<p class="studio-props-empty">\uC694\uC18C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</p>'}
3333
+ ${missing.length > 0 ? `<p class="studio-camera-issue">\u26A0 \uBB38\uC11C\uC5D0 \uC5C6\uB294 id: ${escapeHtml2(missing.join(", "))}</p>` : ""}
3334
+ </div>`;
3335
+ }
3336
+ function renderCameraForm(def, focusIndex) {
3337
+ if (!panelEl) return;
3338
+ const camera = getCamera();
3339
+ const time = getCurrentTime();
3340
+ const view = computeCamera(def, time);
3341
+ const control = cameraControlAt(time);
3342
+ const scalingOpts = ["scale", "fixed"].map(
3343
+ (value) => `<option value="${value}" ${value === camera.strokeScaling ? "selected" : ""}>${value}${value === "scale" ? " (\uAE30\uBCF8)" : ""}</option>`
3344
+ ).join("");
3345
+ const controlNote = control.kind === "focus" ? `focus #${control.index + 1}\uC774(\uAC00) \uC774 \uC2DC\uAC01\uC744 \uACB0\uC815\uD569\uB2C8\uB2E4 \xB7 \uCE94\uBC84\uC2A4 \uBAA8\uC11C\uB9AC\uB97C \uB04C\uBA74 padding` : control.kind === "tracks" ? "track\uC774 \uC774 \uC2DC\uAC01\uC744 \uACB0\uC815\uD569\uB2C8\uB2E4 \xB7 \uCE94\uBC84\uC2A4\uC5D0\uC11C \uB04C\uC5B4 \uC774\uB3D9, \uBAA8\uC11C\uB9AC\uB85C zoom" : "\uCE74\uBA54\uB77C \uC5C6\uC74C";
3346
+ const readout = view ? `<div class="studio-camera-readout">zoom ${view.zoom.toFixed(2)}\xD7 \xB7 center ${Math.round(view.centerX)}, ${Math.round(view.centerY)}
3347
+ <br/><span class="studio-camera-control">${escapeHtml2(controlNote)}</span>${view.issues.length > 0 ? `<br/><span class="studio-camera-issue">\u26A0 ${escapeHtml2(view.issues[0].message)}</span>` : ""}</div>` : '<div class="studio-camera-readout">\uC774 \uBB38\uC11C\uC5D0\uB294 \uCE74\uBA54\uB77C\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. \uC544\uB798\uC5D0\uC11C focus\uB098 track\uC744 \uCD94\uAC00\uD558\uBA74 \uC0DD\uAE41\uB2C8\uB2E4.</div>';
3348
+ const focusRows = camera.focus.map((entry, index) => {
3349
+ const open = index === focusIndex;
3350
+ const targets = entry.elementIds.join(", ");
3351
+ const preset = (value, label) => `<button type="button" class="studio-chip ${entry.duration === value ? "is-on" : ""}" data-camera-duration="${index}:${value}">${label}</button>`;
3352
+ return `
3353
+ <details class="studio-camera-focus" ${open ? "open" : ""}>
3354
+ <summary>#${index + 1} \xB7 ${entry.time}ms \u2192 ${escapeHtml2(targets)}</summary>
3355
+ <div class="studio-camera-focus-actions">
3356
+ <button type="button" class="studio-btn" data-camera-seek="${entry.time}" title="\uC774 focus\uAC00 \uC2DC\uC791\uD558\uB294 \uC2DC\uAC01\uC73C\uB85C \uC774\uB3D9">\u23F1 ${entry.time}ms\uB85C \uC774\uB3D9</button>
3357
+ <button type="button" class="studio-btn" data-camera-focus-now="${index}" title="\uD604\uC7AC \uC7AC\uC0DD \uC704\uCE58\uB97C \uC774 focus\uC758 \uC2DC\uC791 \uC2DC\uAC01\uC73C\uB85C">\uD604\uC7AC \uC2DC\uAC01\uC73C\uB85C</button>
3358
+ </div>
3359
+ ${numberField("time (ms)", `camera.focus.${index}.time`, entry.time, 50)}
3360
+ <div class="studio-camera-range">
3361
+ <div class="studio-camera-range-head"><span>duration (ms)</span><span class="studio-camera-range-value">${entry.duration}</span></div>
3362
+ <div class="studio-camera-chips">${preset(0, "\uCEF7")}${preset(300, "300")}${preset(600, "600")}${preset(1e3, "1000")}${preset(1600, "1600")}</div>
3363
+ <div class="studio-camera-range-row">
3364
+ <input type="range" data-prop-key="camera.focus.${index}.duration" min="0" max="3000" step="50" value="${entry.duration}" />
3365
+ <input type="number" data-prop-key="camera.focus.${index}.duration" step="50" value="${entry.duration}" />
3366
+ </div>
3367
+ </div>
3368
+ <div class="studio-camera-field-label">\uB300\uC0C1 \uC694\uC18C (${entry.elementIds.length})</div>
3369
+ ${focusTargetPicker(def, index, entry.elementIds)}
3370
+ ${rangeField("padding", `camera.focus.${index}.padding`, entry.padding, 0, 200, 2, "\uB300\uC0C1 \uC8FC\uBCC0 \uC5EC\uBC31. \uD074\uC218\uB85D \uB113\uAC8C \uC7A1\uC2B5\uB2C8\uB2E4.")}
3371
+ ${rangeField("maxZoom", `camera.focus.${index}.maxZoom`, entry.maxZoom, 1, 8, 0.1, "\uD655\uB300 \uC0C1\uD55C. \uC791\uC740 \uC694\uC18C \uD558\uB098\uAC00 \uD654\uBA74\uC744 \uCC44\uC6B0\uB294 \uAC83\uC744 \uB9C9\uC2B5\uB2C8\uB2E4.")}
3372
+ <button type="button" class="studio-btn studio-btn-danger" data-delete-camera-focus="${index}">\u{1F5D1} focus \uC0AD\uC81C</button>
3373
+ </details>`;
3374
+ }).join("");
3375
+ const trackRows = ["zoom", "x", "y"].map((property) => {
3376
+ const track = camera.tracks.find((t) => t.property === property);
3377
+ const current = property === "zoom" ? view?.zoom ?? 1 : property === "x" ? view?.centerX ?? def.canvas.width / 2 : view?.centerY ?? def.canvas.height / 2;
3378
+ const keyframes = (track?.keyframes ?? []).map(
3379
+ (kf) => `<li><button type="button" class="studio-camera-kf" data-camera-seek="${kf.time}">${kf.time}ms</button><span>${kf.value}</span><button type="button" class="studio-camera-kf-del" data-camera-kf-del="${property}:${kf.time}" title="keyframe \uC0AD\uC81C">\u2715</button></li>`
3380
+ ).join("");
3381
+ const bounds = property === "zoom" ? { min: 0.1, max: 8, step: 0.05 } : property === "x" ? { min: 0, max: def.canvas.width, step: 1 } : { min: 0, max: def.canvas.height, step: 1 };
3382
+ return `
3383
+ <div class="studio-camera-track">
3384
+ <div class="studio-camera-track-head">
3385
+ <span>${property}</span>
3386
+ <span class="studio-camera-track-now">\uD604\uC7AC ${current.toFixed(property === "zoom" ? 2 : 0)}</span>
3387
+ ${track ? `<button type="button" class="studio-btn studio-btn-danger" data-del-camera-track="${property}" title="track \uC0AD\uC81C">\u{1F5D1}</button>` : ""}
3388
+ </div>
3389
+ ${control.kind === "focus" ? `<div class="studio-camera-range-row">
3390
+ <input type="number" data-prop-key="camera.kf.${property}" step="${bounds.step}" value="${current.toFixed(property === "zoom" ? 2 : 0)}" />
3391
+ </div>
3392
+ <p class="studio-camera-hint">focus #${control.index + 1}\uC774(\uAC00) \uC774 \uC2DC\uAC01\uC744 \uACB0\uC815\uD558\uBBC0\uB85C \uC9C0\uAE08\uC740 \uD654\uBA74\uC774 \uBC14\uB00C\uC9C0 \uC54A\uC2B5\uB2C8\uB2E4. \uAC12\uC740 t = ${time}ms \uC5D0 \uAE30\uB85D\uB429\uB2C8\uB2E4.</p>` : `<div class="studio-camera-range-row">
3393
+ <input type="range" data-prop-key="camera.kf.${property}" min="${bounds.min}" max="${bounds.max}" step="${bounds.step}" value="${current.toFixed(property === "zoom" ? 2 : 0)}" />
3394
+ <input type="number" data-prop-key="camera.kf.${property}" step="${bounds.step}" value="${current.toFixed(property === "zoom" ? 2 : 0)}" />
3395
+ </div>
3396
+ <p class="studio-camera-hint">\uC6C0\uC9C1\uC774\uBA74 t = ${time}ms \uC5D0 keyframe\uC744 \uC501\uB2C8\uB2E4.</p>`}
3397
+ ${keyframes ? `<ul class="studio-camera-kf-list">${keyframes}</ul>` : '<p class="studio-props-empty">keyframe \uC5C6\uC74C</p>'}
3398
+ </div>`;
3399
+ }).join("");
3400
+ panelEl.innerHTML = `
3401
+ <span class="studio-step-hint">\u{1F4CD} t = ${time} ms / ${def.duration} ms</span>
3402
+ <div class="studio-props-header"><span class="studio-props-header-title">\uCE74\uBA54\uB77C</span><span class="studio-props-header-type">camera</span></div>
3403
+ ${readout}
3404
+ <label class="studio-field"><span>strokeScaling</span><select data-prop-key="camera.strokeScaling">${scalingOpts}</select></label>
3405
+ <div class="studio-props-header" style="margin-top:0.6rem"><span class="studio-props-header-title">focus (${camera.focus.length})</span></div>
3406
+ <p class="studio-props-empty" style="margin:0 0 0.4rem">\uC694\uC18C\uB97C \uC9C0\uC815\uD558\uBA74 \uADF8 \uC694\uC18C\uAC00 \uD654\uBA74\uC5D0 \uB2F4\uAE30\uB3C4\uB85D \uCE74\uBA54\uB77C\uB97C \uACC4\uC0B0\uD569\uB2C8\uB2E4. \uB300\uC0C1\uC774 \uC6C0\uC9C1\uC774\uBA74 \uB530\uB77C\uAC11\uB2C8\uB2E4.</p>
3407
+ ${focusRows}
3408
+ <button type="button" class="studio-btn" data-add-camera-focus style="margin-top:0.4rem">\uFF0B \uD604\uC7AC \uC2DC\uAC01\uC5D0 focus</button>
3409
+ <div class="studio-props-header" style="margin-top:0.6rem"><span class="studio-props-header-title">tracks</span></div>
3410
+ ${trackRows}
3411
+ ${hasCamera() ? '<button type="button" class="studio-btn studio-btn-danger" data-clear-camera style="margin-top:0.8rem">\u{1F5D1} \uCE74\uBA54\uB77C \uC81C\uAC70</button>' : ""}
3412
+ `;
3413
+ }
2965
3414
  function renderElementForm(def, el) {
2966
3415
  if (!panelEl) return;
2967
3416
  const timeHint = `<span class="studio-step-hint">\u{1F4CD} t = ${getCurrentTime()} ms</span>`;
@@ -2988,6 +3437,7 @@ function renderElementForm(def, el) {
2988
3437
  const baseFields = renderBaseFields(def, el);
2989
3438
  const appearances = renderAppearances(def, el);
2990
3439
  const tracks = renderTracks(el);
3440
+ const bindings = renderBindings(el);
2991
3441
  const baseHeader = `<span class="studio-props-header-title">${escapeHtml2(el.id)}</span><span class="studio-props-header-type">${escapeHtml2(el.type)}</span>`;
2992
3442
  const apHeader = `<span class="studio-props-header-title">\uCD9C\uD604 (Appearances)</span><button type="button" class="studio-btn studio-btn-small" data-add-appearance>\uFF0B</button>`;
2993
3443
  const tracksHeader = `<span class="studio-props-header-title">\uD0A4\uD504\uB808\uC784 \uD2B8\uB799 (${el.tracks.length})</span>`;
@@ -2996,12 +3446,45 @@ function renderElementForm(def, el) {
2996
3446
  ${section("el-base", baseHeader, baseFields)}
2997
3447
  ${section("el-appearances", apHeader, appearances)}
2998
3448
  ${section("el-tracks", tracksHeader, tracks)}
3449
+ ${section("el-bindings", `<span class="studio-props-header-title">\uB370\uC774\uD130 \uC5F0\uACB0 (${el.bindings.length})</span>`, bindings)}
2999
3450
  <div class="studio-props-empty" style="font-size:0.72rem;margin-top:0.5rem">
3000
3451
  base \uC18D\uC131\uC744 \uBCC0\uACBD\uD558\uBA74 \u2192 t=${getCurrentTime()} ms \uC5D0 keyframe \uCD94\uAC00<br/>
3001
3452
  \uD2B8\uB799\uC774 \uC5C6\uB294 \uC18D\uC131\uC740 base \uAC12\uC774 \uD56D\uC0C1 \uC0AC\uC6A9\uB428
3002
3453
  </div>
3003
3454
  `;
3004
3455
  }
3456
+ function renderBindings(el) {
3457
+ const properties = bindablePropertiesFor(el);
3458
+ const rows = el.bindings.map(
3459
+ (binding, index) => `<div class="studio-appearance-row">
3460
+ <div class="studio-appearance-row-head"><span class="studio-appearance-row-title">#${index + 1}</span><button type="button" class="studio-btn studio-btn-small studio-btn-danger" data-delete-binding="${index}">\u2715</button></div>
3461
+ ${selectField(
3462
+ "\uC18D\uC131",
3463
+ `binding.${index}.property`,
3464
+ binding.property,
3465
+ properties.map((value) => ({ value }))
3466
+ )}
3467
+ ${textField("JSON Pointer", `binding.${index}.pointer`, binding.pointer)}
3468
+ ${selectField(
3469
+ "\uD45C\uD604 \uBC29\uC2DD",
3470
+ `binding.${index}.formatter`,
3471
+ binding.formatter,
3472
+ [
3473
+ "identity",
3474
+ "string",
3475
+ "number",
3476
+ "fixed",
3477
+ "percent",
3478
+ "uppercase",
3479
+ "lowercase",
3480
+ "color"
3481
+ ].map((value) => ({ value }))
3482
+ )}
3483
+ ${textField("\uB300\uCCB4 \uAC12", `binding.${index}.fallback`, binding.fallback === void 0 ? "" : String(binding.fallback))}
3484
+ </div>`
3485
+ ).join("");
3486
+ return `${rows || '<p class="studio-props-empty">\uC5F0\uACB0\uB41C \uB370\uC774\uD130\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</p>'}<button type="button" class="studio-btn" data-add-binding ${properties.length === 0 ? "disabled" : ""}>\uFF0B \uB370\uC774\uD130 \uC5F0\uACB0</button>`;
3487
+ }
3005
3488
  function renderBaseFields(def, el) {
3006
3489
  const numberFields = [];
3007
3490
  const colorFields = [];
@@ -3133,6 +3616,11 @@ function renderBaseFields(def, el) {
3133
3616
  `el.translation.${locale}`,
3134
3617
  localized.translations?.[locale] ?? ""
3135
3618
  )
3619
+ ),
3620
+ ...annotationReferenceFields(
3621
+ "el",
3622
+ [el.content, ...Object.values(localized.translations ?? {})],
3623
+ localized.references
3136
3624
  )
3137
3625
  ];
3138
3626
  })();
@@ -3204,11 +3692,16 @@ function renderTracks(el) {
3204
3692
  }
3205
3693
  function onInput(e) {
3206
3694
  const target = e.target;
3695
+ const focusTarget = target.dataset.cameraTarget;
3696
+ if (focusTarget !== void 0 && target instanceof HTMLInputElement) {
3697
+ toggleFocusTarget(Number(focusTarget), target.value, target.checked);
3698
+ return;
3699
+ }
3207
3700
  const key = target.dataset.propKey;
3208
3701
  if (!key) return;
3209
3702
  if (target.type === "text" && e.isComposing) return;
3210
3703
  if (target.type === "text" && isComposingFallback) return;
3211
- if (target.type === "color") {
3704
+ if (target instanceof HTMLInputElement && target.type === "color") {
3212
3705
  const textInput = target.parentElement?.querySelector(
3213
3706
  `input[type="text"][data-prop-key="${CSS.escape(key)}"]`
3214
3707
  );
@@ -3216,10 +3709,22 @@ function onInput(e) {
3216
3709
  return;
3217
3710
  }
3218
3711
  let value = target.value;
3219
- if (target.type === "number") value = Number(target.value);
3220
- else if (target.type === "checkbox") value = target.checked;
3712
+ if (target instanceof HTMLInputElement && target.type === "number")
3713
+ value = Number(target.value);
3714
+ else if (target instanceof HTMLInputElement && target.type === "checkbox")
3715
+ value = target.checked;
3221
3716
  apply(key, value);
3222
3717
  }
3718
+ function toggleFocusTarget(index, id, on) {
3719
+ const entry = getCamera().focus[index];
3720
+ if (!entry) return;
3721
+ const next = on ? [...entry.elementIds, id] : entry.elementIds.filter((value) => value !== id);
3722
+ if (next.length === 0) {
3723
+ render3();
3724
+ return;
3725
+ }
3726
+ updateCameraFocus(index, { elementIds: next });
3727
+ }
3223
3728
  function onChange(e) {
3224
3729
  const target = e.target;
3225
3730
  if (target instanceof HTMLInputElement && target.type === "color") {
@@ -3239,6 +3744,22 @@ function apply(key, value) {
3239
3744
  else if (key === "meta.locales") {
3240
3745
  const locales = parseLocales(String(value));
3241
3746
  if (locales.length > 0) updateLocales(locales);
3747
+ } else if (key === "meta.data") {
3748
+ try {
3749
+ const parsed = JSON.parse(String(value));
3750
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed))
3751
+ updateData(parsed);
3752
+ } catch {
3753
+ }
3754
+ } else if (key === "meta.responsive") {
3755
+ try {
3756
+ const parsed = JSON.parse(String(value));
3757
+ if (Array.isArray(parsed))
3758
+ updateResponsive(
3759
+ parsed
3760
+ );
3761
+ } catch {
3762
+ }
3242
3763
  } else if (key === "meta.duration") updateDuration(Number(value));
3243
3764
  else if (key === "canvas.width") updateCanvas({ width: Number(value) });
3244
3765
  else if (key === "canvas.height") updateCanvas({ height: Number(value) });
@@ -3255,7 +3776,83 @@ function apply(key, value) {
3255
3776
  updateSettings({
3256
3777
  chapterListPosition: String(value)
3257
3778
  });
3258
- else if (key.startsWith("el.") && sel.kind === "element") {
3779
+ else if (key.startsWith("checkpoint.")) {
3780
+ const [, indexText, property] = key.split(".");
3781
+ const checkpoint = def.checkpoints[Number(indexText)];
3782
+ if (!checkpoint || !property) return;
3783
+ if (property === "interaction") {
3784
+ const base = {
3785
+ id: checkpoint.id,
3786
+ time: checkpoint.time,
3787
+ prompt: checkpoint.prompt,
3788
+ required: checkpoint.required
3789
+ };
3790
+ const interaction = String(value);
3791
+ const replacement = interaction === "choice" ? {
3792
+ ...base,
3793
+ interaction,
3794
+ options: [{ value: "option", label: "\uC120\uD0DD\uC9C0" }]
3795
+ } : interaction === "select-element" ? {
3796
+ ...base,
3797
+ interaction,
3798
+ elementIds: [def.elements[0]?.id ?? "element"]
3799
+ } : interaction === "number-input" ? { ...base, interaction } : { ...base, interaction: "continue" };
3800
+ updateCheckpoint(checkpoint.id, replacement);
3801
+ } else if (property === "time")
3802
+ updateCheckpoint(checkpoint.id, { time: Number(value) });
3803
+ else if (property === "prompt")
3804
+ updateCheckpoint(checkpoint.id, { prompt: String(value) });
3805
+ else if (property === "required")
3806
+ updateCheckpoint(checkpoint.id, { required: Boolean(value) });
3807
+ else if (property === "options" && checkpoint.interaction === "choice") {
3808
+ const options = String(value).split(",").map((entry) => entry.trim()).filter(Boolean).map((entry) => {
3809
+ const [optionValue, ...label] = entry.split(":");
3810
+ return {
3811
+ value: optionValue || "option",
3812
+ label: label.join(":") || optionValue || "\uC120\uD0DD\uC9C0"
3813
+ };
3814
+ });
3815
+ if (options.length > 0) updateCheckpoint(checkpoint.id, { options });
3816
+ } else if (property === "elementIds" && checkpoint.interaction === "select-element") {
3817
+ const elementIds = String(value).split(",").map((id) => id.trim()).filter(Boolean);
3818
+ if (elementIds.length > 0)
3819
+ updateCheckpoint(checkpoint.id, { elementIds });
3820
+ } else if (["min", "max", "step"].includes(property) && checkpoint.interaction === "number-input") {
3821
+ updateCheckpoint(checkpoint.id, { [property]: Number(value) });
3822
+ } else if (property === "answer" && checkpoint.interaction !== "continue") {
3823
+ const answer = checkpoint.interaction === "number-input" ? Number(value) : String(value);
3824
+ updateCheckpoint(checkpoint.id, {
3825
+ predicate: { type: "equals", value: answer }
3826
+ });
3827
+ }
3828
+ } else if (key === "camera.strokeScaling")
3829
+ setCameraStrokeScaling(String(value));
3830
+ else if (key.startsWith("camera.kf.")) {
3831
+ const property = key.slice("camera.kf.".length);
3832
+ const raw = Number(value);
3833
+ if (!Number.isFinite(raw)) return;
3834
+ setCameraKeyframe(
3835
+ property,
3836
+ getCurrentTime(),
3837
+ property === "zoom" ? Math.max(0.05, raw) : Math.round(raw)
3838
+ );
3839
+ } else if (key.startsWith("camera.focus.")) {
3840
+ const [, , indexText, field] = key.split(".");
3841
+ const index = Number(indexText);
3842
+ if (!Number.isInteger(index)) return;
3843
+ if (field === "elementIds") {
3844
+ const ids = String(value).split(",").map((id) => id.trim()).filter(Boolean);
3845
+ if (ids.length > 0) updateCameraFocus(index, { elementIds: ids });
3846
+ return;
3847
+ }
3848
+ if (field === "time") updateCameraFocus(index, { time: Number(value) });
3849
+ else if (field === "duration")
3850
+ updateCameraFocus(index, { duration: Math.max(0, Number(value)) });
3851
+ else if (field === "padding")
3852
+ updateCameraFocus(index, { padding: Math.max(0, Number(value)) });
3853
+ else if (field === "maxZoom")
3854
+ updateCameraFocus(index, { maxZoom: Math.max(0.01, Number(value)) });
3855
+ } else if (key.startsWith("el.") && sel.kind === "element") {
3259
3856
  const prop = key.slice(3);
3260
3857
  const time = getCurrentTime();
3261
3858
  const el = def.elements.find((e) => e.id === sel.elementId);
@@ -3276,6 +3873,16 @@ function apply(key, value) {
3276
3873
  updateElementBase(sel.elementId, { translations });
3277
3874
  return;
3278
3875
  }
3876
+ if (prop.startsWith("reference.") && el.type === "text") {
3877
+ const token = prop.slice("reference.".length);
3878
+ const annotated = el;
3879
+ const references = { ...annotated.references ?? {} };
3880
+ const targets = parseReferenceTargets(String(value));
3881
+ if (targets) references[token] = targets;
3882
+ else delete references[token];
3883
+ updateElementBase(sel.elementId, { references });
3884
+ return;
3885
+ }
3279
3886
  if (prop === "polygonSides" && el.type === "polygon") {
3280
3887
  const points = el.points.trim().split(/\s+/).map((point) => point.split(",").map(Number)).filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y));
3281
3888
  if (points.length < 3) return;
@@ -3300,6 +3907,18 @@ function apply(key, value) {
3300
3907
  } else {
3301
3908
  updateElementBase(sel.elementId, { [prop]: value });
3302
3909
  }
3910
+ } else if (key.startsWith("binding.") && sel.kind === "element") {
3911
+ const [, indexText, property] = key.split(".");
3912
+ const el = def.elements.find((item) => item.id === sel.elementId);
3913
+ const binding = el?.bindings[Number(indexText)];
3914
+ if (!el || !binding || !property) return;
3915
+ const bindings = el.bindings.map(
3916
+ (item, index) => index === Number(indexText) ? {
3917
+ ...item,
3918
+ [property]: property === "fallback" ? parseBindingFallback(String(value)) : String(value)
3919
+ } : item
3920
+ );
3921
+ updateElementBase(el.id, { bindings });
3303
3922
  } else if (key.startsWith("ap.") && sel.kind === "element") {
3304
3923
  const [, idxStr, prop] = key.split(".");
3305
3924
  const idx = Number(idxStr);
@@ -3317,6 +3936,16 @@ function apply(key, value) {
3317
3936
  updateChapter(sel.chapterId, { label: String(value) });
3318
3937
  } else if (key === "chapter.subtitle" && sel.kind === "chapter") {
3319
3938
  updateChapter(sel.chapterId, { subtitle: String(value) });
3939
+ } else if (key.startsWith("chapter.reference.") && sel.kind === "chapter") {
3940
+ const chapter = def.chapters.find(({ id }) => id === sel.chapterId);
3941
+ if (!chapter) return;
3942
+ const token = key.slice("chapter.reference.".length);
3943
+ const annotated = chapter;
3944
+ const references = { ...annotated.references ?? {} };
3945
+ const targets = parseReferenceTargets(String(value));
3946
+ if (targets) references[token] = targets;
3947
+ else delete references[token];
3948
+ updateChapter(sel.chapterId, { references });
3320
3949
  } else if (key.startsWith("effect.") && sel.kind === "effect") {
3321
3950
  const prop = key.slice(7);
3322
3951
  const patch = {};
@@ -3328,11 +3957,44 @@ function apply(key, value) {
3328
3957
  updateEffect(sel.effectId, patch);
3329
3958
  }
3330
3959
  }
3960
+ function parseBindingFallback(value) {
3961
+ if (value === "") return void 0;
3962
+ if (value === "true") return true;
3963
+ if (value === "false") return false;
3964
+ const number = Number(value);
3965
+ return Number.isFinite(number) && value.trim() !== "" ? number : value;
3966
+ }
3331
3967
  function onClick2(e) {
3332
3968
  const target = e.target;
3333
3969
  const def = getDef();
3334
3970
  if (!def) return;
3335
3971
  const sel = getSelection();
3972
+ if (target.closest("[data-add-binding]") && sel.kind === "element") {
3973
+ const el = def.elements.find((item) => item.id === sel.elementId);
3974
+ if (!el) return;
3975
+ const property = bindablePropertiesFor(el).find(
3976
+ (candidate) => !el.bindings.some((binding) => binding.property === candidate)
3977
+ );
3978
+ if (property)
3979
+ updateElementBase(el.id, {
3980
+ bindings: [
3981
+ ...el.bindings,
3982
+ { property, pointer: "", formatter: "identity" }
3983
+ ]
3984
+ });
3985
+ return;
3986
+ }
3987
+ const deleteBinding = target.closest("[data-delete-binding]");
3988
+ if (deleteBinding && sel.kind === "element") {
3989
+ const el = def.elements.find((item) => item.id === sel.elementId);
3990
+ if (el)
3991
+ updateElementBase(el.id, {
3992
+ bindings: el.bindings.filter(
3993
+ (_, index) => index !== Number(deleteBinding.dataset.deleteBinding)
3994
+ )
3995
+ });
3996
+ return;
3997
+ }
3336
3998
  const alignBtn = target.closest("[data-align]");
3337
3999
  if (alignBtn) {
3338
4000
  alignSelected(alignBtn.dataset.align);
@@ -3343,6 +4005,37 @@ function onClick2(e) {
3343
4005
  distributeSelected(distBtn.dataset.distribute);
3344
4006
  return;
3345
4007
  }
4008
+ const createLayoutButton = target.closest(
4009
+ "[data-create-layout]"
4010
+ );
4011
+ if (createLayoutButton && sel.kind === "elements") {
4012
+ createLayout(
4013
+ sel.elementIds,
4014
+ createLayoutButton.dataset.createLayout
4015
+ );
4016
+ return;
4017
+ }
4018
+ if (target.closest("[data-detach-layout]") && sel.kind === "elements") {
4019
+ detachFromLayout(sel.elementIds);
4020
+ return;
4021
+ }
4022
+ if (target.closest("[data-add-checkpoint]")) {
4023
+ addCheckpoint({
4024
+ id: uniqueCheckpointId(),
4025
+ time: getCurrentTime(),
4026
+ prompt: "\uACC4\uC18D \uC9C4\uD589\uD560\uAE4C\uC694?",
4027
+ required: true,
4028
+ interaction: "continue"
4029
+ });
4030
+ return;
4031
+ }
4032
+ const deleteCheckpointButton = target.closest(
4033
+ "[data-delete-checkpoint]"
4034
+ );
4035
+ if (deleteCheckpointButton?.dataset.deleteCheckpoint) {
4036
+ deleteCheckpoint(deleteCheckpointButton.dataset.deleteCheckpoint);
4037
+ return;
4038
+ }
3346
4039
  if (target.closest("[data-ungroup]") && sel.kind === "element") {
3347
4040
  ungroupElement(sel.elementId);
3348
4041
  return;
@@ -3356,13 +4049,82 @@ function onClick2(e) {
3356
4049
  });
3357
4050
  return;
3358
4051
  }
4052
+ if (target.closest("[data-open-camera]")) {
4053
+ setSelection({ kind: "camera" });
4054
+ return;
4055
+ }
4056
+ if (target.closest("[data-add-camera-focus]")) {
4057
+ const seed = sel.kind === "element" ? [sel.elementId] : sel.kind === "elements" ? [...sel.elementIds] : def.elements[0] ? [def.elements[0].id] : [];
4058
+ if (seed.length === 0) return;
4059
+ addCameraFocus({
4060
+ time: getCurrentTime(),
4061
+ duration: 600,
4062
+ elementIds: seed,
4063
+ padding: 24,
4064
+ maxZoom: 4
4065
+ });
4066
+ return;
4067
+ }
4068
+ const durationChip = target.closest("[data-camera-duration]");
4069
+ if (durationChip) {
4070
+ const [index, duration] = (durationChip.dataset.cameraDuration ?? "").split(
4071
+ ":"
4072
+ );
4073
+ updateCameraFocus(Number(index), { duration: Number(duration) });
4074
+ return;
4075
+ }
4076
+ const focusNow = target.closest("[data-camera-focus-now]");
4077
+ if (focusNow) {
4078
+ updateCameraFocus(Number(focusNow.dataset.cameraFocusNow), {
4079
+ time: getCurrentTime()
4080
+ });
4081
+ return;
4082
+ }
4083
+ const deleteFocus = target.closest("[data-delete-camera-focus]");
4084
+ if (deleteFocus) {
4085
+ deleteCameraFocus(Number(deleteFocus.dataset.deleteCameraFocus));
4086
+ return;
4087
+ }
4088
+ const addCameraKf = target.closest("[data-add-camera-kf]");
4089
+ if (addCameraKf) {
4090
+ const property = addCameraKf.dataset.addCameraKf;
4091
+ const view = computeCamera(def, getCurrentTime());
4092
+ const value = property === "zoom" ? view?.zoom ?? 1 : property === "x" ? view?.centerX ?? def.canvas.width / 2 : view?.centerY ?? def.canvas.height / 2;
4093
+ setCameraKeyframe(
4094
+ property,
4095
+ getCurrentTime(),
4096
+ Number(value.toFixed(property === "zoom" ? 3 : 1))
4097
+ );
4098
+ return;
4099
+ }
4100
+ const delCameraKf = target.closest("[data-camera-kf-del]");
4101
+ if (delCameraKf) {
4102
+ const [property, at] = (delCameraKf.dataset.cameraKfDel ?? "").split(":");
4103
+ removeCameraKeyframe(property, Number(at));
4104
+ return;
4105
+ }
4106
+ const delCameraTrack = target.closest("[data-del-camera-track]");
4107
+ if (delCameraTrack) {
4108
+ removeCameraTrack(delCameraTrack.dataset.delCameraTrack);
4109
+ return;
4110
+ }
4111
+ const cameraSeek = target.closest("[data-camera-seek]");
4112
+ if (cameraSeek) {
4113
+ setCurrentTime(Number(cameraSeek.dataset.cameraSeek));
4114
+ return;
4115
+ }
4116
+ if (target.closest("[data-clear-camera]")) {
4117
+ clearCamera();
4118
+ return;
4119
+ }
3359
4120
  if (target.closest("[data-add-chapter]")) {
3360
4121
  const id = uniqueChapterId();
3361
4122
  addChapter({
3362
4123
  id,
3363
4124
  time: getCurrentTime(),
3364
4125
  label: `Chapter ${id.split("-")[1]}`,
3365
- subtitle: ""
4126
+ subtitle: "",
4127
+ references: {}
3366
4128
  });
3367
4129
  return;
3368
4130
  }
@@ -3497,7 +4259,8 @@ function initTimeline(tracksRoot, addChapterBtn, elementTracks) {
3497
4259
  id,
3498
4260
  time: newTime,
3499
4261
  label: `Chapter ${id.split("-")[1]}`,
3500
- subtitle: ""
4262
+ subtitle: "",
4263
+ references: {}
3501
4264
  });
3502
4265
  setSelection({ kind: "chapter", chapterId: id });
3503
4266
  });
@@ -3602,6 +4365,7 @@ function render4() {
3602
4365
  <div class="studio-tl-chapter-label">${escapeHtml3(c.label || c.id)}</div>
3603
4366
  </div>`;
3604
4367
  }).join("");
4368
+ const cameraRows = renderCameraRows(totalPx, sel);
3605
4369
  const playheadCol = `<div class="studio-tl-playhead-col" style="left:${GUTTER_PX}px;width:${totalPx}px"><div class="studio-tl-playhead" style="left:${timeToPx(currentTime)}px" title="t=${currentTime}ms"></div></div>`;
3606
4370
  tracksEl.innerHTML = `
3607
4371
  <div class="studio-tl-chart">
@@ -3619,12 +4383,48 @@ function render4() {
3619
4383
  </div>
3620
4384
  </div>
3621
4385
  </div>
4386
+ ${cameraRows}
3622
4387
  ${playheadCol}
3623
4388
  </div>
3624
4389
  <div class="studio-tl-total">\uC804\uCCB4 ${def.duration} ms \xB7 ${sortedChapters.length} chapters \xB7 ${def.elements.length} elements \xB7 \uD604\uC7AC ${currentTime} ms</div>
3625
4390
  `;
3626
4391
  renderElementTracks(def.elements, currentTime, totalPx, sel);
3627
4392
  }
4393
+ function renderCameraRows(totalPx, sel) {
4394
+ const camera = getCamera();
4395
+ const isCameraSel = sel.kind === "camera";
4396
+ const focusBars = camera.focus.map((entry, index) => {
4397
+ const left = timeToPx(entry.time);
4398
+ const width = Math.max(6, timeToPx(entry.duration));
4399
+ const selected = isCameraSel && sel.focusIndex === index;
4400
+ const targets = entry.elementIds.join(", ");
4401
+ return `<div class="studio-tl-camera-focus ${selected ? "is-selected" : ""}" style="left:${left}px;width:${width}px" data-camera-focus="${index}" title="focus \u2192 ${escapeHtml3(targets)} @ ${entry.time}ms (${entry.duration}ms, \uB4DC\uB798\uADF8\uB85C \uC774\uB3D9)">
4402
+ <span class="studio-tl-camera-focus-label">${escapeHtml3(targets)}</span>
4403
+ </div>`;
4404
+ }).join("");
4405
+ const focusRow = `
4406
+ <div class="studio-tl-row studio-tl-camera-row ${isCameraSel && sel.focusIndex === void 0 ? "is-selected" : ""}">
4407
+ <div class="studio-tl-gutter studio-tl-camera-gutter" data-camera-select title="\uCE74\uBA54\uB77C \uC18D\uC131 \uC5F4\uAE30">\u{1F3A5} Camera<button type="button" class="studio-tl-camera-add" data-add-camera-focus title="\uD604\uC7AC \uC2DC\uAC01\uC5D0 focus \uCD94\uAC00">\uFF0B</button></div>
4408
+ <div class="studio-tl-body" style="width:${totalPx}px">
4409
+ <div class="studio-tl-camera-track" data-tl-area="camera">
4410
+ ${focusBars || '<span class="studio-tl-camera-empty">focus \uC5C6\uC74C \xB7 \uFF0B \uB85C \uCD94\uAC00</span>'}
4411
+ </div>
4412
+ </div>
4413
+ </div>`;
4414
+ const trackRows = camera.tracks.map((track) => {
4415
+ const marks = track.keyframes.map(
4416
+ (kf) => `<div class="studio-tl-keyframe studio-tl-camera-keyframe" style="left:${timeToPx(kf.time)}px" data-camera-kf-prop="${track.property}" data-camera-kf-time="${kf.time}" title="${track.property} = ${kf.value} @ ${kf.time}ms (\uB4DC\uB798\uADF8\uB85C \uC774\uB3D9)">\u25C6</div>`
4417
+ ).join("");
4418
+ return `
4419
+ <div class="studio-tl-row studio-tl-camera-row">
4420
+ <div class="studio-tl-gutter studio-tl-camera-gutter is-sub" data-camera-select>\u21B3 ${track.property}</div>
4421
+ <div class="studio-tl-body" style="width:${totalPx}px">
4422
+ <div class="studio-tl-camera-track" data-tl-area="camera">${marks}</div>
4423
+ </div>
4424
+ </div>`;
4425
+ }).join("");
4426
+ return focusRow + trackRows;
4427
+ }
3628
4428
  function gutterLabel(el) {
3629
4429
  return `${friendlyElementLabel(el)} \xB7 ${el.type}`;
3630
4430
  }
@@ -3680,6 +4480,32 @@ function renderElementTracks(elements, currentTime, totalPx, sel) {
3680
4480
  }
3681
4481
  function onTracksClick(e) {
3682
4482
  const target = e.target;
4483
+ if (target.closest("[data-add-camera-focus]")) {
4484
+ const time = getCurrentTime();
4485
+ const selected = getSelection();
4486
+ const elementIds = selected.kind === "element" ? [selected.elementId] : selected.kind === "elements" ? [...selected.elementIds] : (getDef()?.elements[0]?.id ?? "").length > 0 ? [getDef().elements[0].id] : [];
4487
+ if (elementIds.length === 0) return;
4488
+ addCameraFocus({
4489
+ time,
4490
+ duration: 600,
4491
+ elementIds,
4492
+ padding: 24,
4493
+ maxZoom: 4
4494
+ });
4495
+ return;
4496
+ }
4497
+ const focusBar = target.closest("[data-camera-focus]");
4498
+ if (focusBar) {
4499
+ setSelection({
4500
+ kind: "camera",
4501
+ focusIndex: Number(focusBar.dataset.cameraFocus)
4502
+ });
4503
+ return;
4504
+ }
4505
+ if (target.closest("[data-camera-select]")) {
4506
+ setSelection({ kind: "camera" });
4507
+ return;
4508
+ }
3683
4509
  const chapterMarker = target.closest("[data-chapter-id]");
3684
4510
  if (chapterMarker) {
3685
4511
  setSelection({
@@ -3769,6 +4595,36 @@ function onMouseDown2(e) {
3769
4595
  }
3770
4596
  return;
3771
4597
  }
4598
+ const cameraKf = target.closest("[data-camera-kf-prop]");
4599
+ if (cameraKf) {
4600
+ e.preventDefault();
4601
+ e.stopPropagation();
4602
+ const startTime = Number(cameraKf.dataset.cameraKfTime);
4603
+ dragMode = {
4604
+ kind: "camera-keyframe",
4605
+ prop: cameraKf.dataset.cameraKfProp,
4606
+ startTime,
4607
+ startMouseX: e.clientX,
4608
+ currentTime: startTime
4609
+ };
4610
+ return;
4611
+ }
4612
+ const cameraFocus = target.closest("[data-camera-focus]");
4613
+ if (cameraFocus) {
4614
+ e.preventDefault();
4615
+ e.stopPropagation();
4616
+ const index = Number(cameraFocus.dataset.cameraFocus);
4617
+ const entry = getCamera().focus[index];
4618
+ if (entry) {
4619
+ dragMode = {
4620
+ kind: "camera-focus",
4621
+ index,
4622
+ startTime: entry.time,
4623
+ startMouseX: e.clientX
4624
+ };
4625
+ }
4626
+ return;
4627
+ }
3772
4628
  const chapterMarker = target.closest("[data-chapter-id]");
3773
4629
  if (chapterMarker) {
3774
4630
  e.preventDefault();
@@ -3829,6 +4685,29 @@ function onMouseMove2(e) {
3829
4685
  showDragTooltip(e.clientX, e.clientY, `\u25C6 ${dragMode.prop} @ ${newTime} ms`);
3830
4686
  return;
3831
4687
  }
4688
+ if (dragMode.kind === "camera-keyframe") {
4689
+ const dt = Math.round((e.clientX - dragMode.startMouseX) / pxPerMs);
4690
+ const newTime = Math.max(0, dragMode.startTime + dt);
4691
+ if (newTime !== dragMode.currentTime) {
4692
+ moveCameraKeyframe(dragMode.prop, dragMode.currentTime, newTime);
4693
+ dragMode.currentTime = newTime;
4694
+ }
4695
+ showDragTooltip(
4696
+ e.clientX,
4697
+ e.clientY,
4698
+ `\u{1F3A5} ${dragMode.prop} @ ${newTime} ms`
4699
+ );
4700
+ return;
4701
+ }
4702
+ if (dragMode.kind === "camera-focus") {
4703
+ const dt = Math.round((e.clientX - dragMode.startMouseX) / pxPerMs);
4704
+ const newTime = Math.max(0, dragMode.startTime + dt);
4705
+ updateCameraFocus(dragMode.index, { time: newTime });
4706
+ const moved = getCamera().focus.findIndex((f) => f.time === newTime);
4707
+ if (moved >= 0) dragMode.index = moved;
4708
+ showDragTooltip(e.clientX, e.clientY, `\u{1F3A5} focus @ ${newTime} ms`);
4709
+ return;
4710
+ }
3832
4711
  if (dragMode.kind === "appearance") {
3833
4712
  const dxPx = e.clientX - dragMode.startMouseX;
3834
4713
  const dt = Math.round(dxPx / pxPerMs);
@@ -15348,6 +16227,7 @@ var IconLibraryDialog = class _IconLibraryDialog {
15348
16227
  rotation: 0,
15349
16228
  appearances: [],
15350
16229
  tracks: [],
16230
+ bindings: [],
15351
16231
  x: cx - 32,
15352
16232
  y: cy - 32,
15353
16233
  width: 64,
@@ -15408,6 +16288,7 @@ async function uploadAndInsertImage(file, host) {
15408
16288
  rotation: 0,
15409
16289
  appearances: [],
15410
16290
  tracks: [],
16291
+ bindings: [],
15411
16292
  x: Math.round(cx - w / 2),
15412
16293
  y: Math.round(cy - h / 2),
15413
16294
  width: w,
@@ -15829,8 +16710,11 @@ var KIND_ICON = {
15829
16710
  appearance: "\u25D0",
15830
16711
  chapter: "\u{1F4CD}",
15831
16712
  effect: "\u2728",
16713
+ camera: "\u{1F3A5}",
15832
16714
  asset: "\u{1F5BC}",
15833
16715
  group: "\u2B1A",
16716
+ layout: "\u25A6",
16717
+ checkpoint: "\u25C6",
15834
16718
  other: "\xB7"
15835
16719
  };
15836
16720
  var dialogEl2 = null;
@@ -15951,7 +16835,8 @@ function queryUi() {
15951
16835
  const undoBtn = $("studio-undo");
15952
16836
  const redoBtn = $("studio-redo");
15953
16837
  const gridToggleBtn = $("studio-grid-toggle");
15954
- 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) {
16838
+ const cameraFrameBtn = $("studio-camera-frame-toggle");
16839
+ 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 || !cameraFrameBtn) {
15955
16840
  return null;
15956
16841
  }
15957
16842
  return {
@@ -15994,7 +16879,8 @@ function queryUi() {
15994
16879
  helpDialog,
15995
16880
  undoBtn,
15996
16881
  redoBtn,
15997
- gridToggleBtn
16882
+ gridToggleBtn,
16883
+ cameraFrameBtn
15998
16884
  };
15999
16885
  }
16000
16886
 
@@ -16102,6 +16988,14 @@ function reflectGridUi(ui) {
16102
16988
  const label = document.getElementById("studio-grid-label");
16103
16989
  if (label) label.textContent = on ? `\uACA9\uC790 ${size}px` : "\uACA9\uC790 \uB054";
16104
16990
  }
16991
+ function reflectCameraFrameUi(ui) {
16992
+ const on = isCameraFrameVisible();
16993
+ ui.cameraFrameBtn.setAttribute("aria-pressed", on ? "true" : "false");
16994
+ ui.cameraFrameBtn.classList.toggle("is-active", on);
16995
+ ui.cameraFrameBtn.title = on ? "\uCE74\uBA54\uB77C \uC601\uC5ED \uD45C\uC2DC \uC911 \u2014 \uCE94\uBC84\uC2A4\uC5D0 \uD604\uC7AC \uC2DC\uAC01\uC758 \uCE74\uBA54\uB77C \uC0AC\uAC01\uD615\uC744 \uADF8\uB9BD\uB2C8\uB2E4" : "\uCE74\uBA54\uB77C \uC601\uC5ED \uD45C\uC2DC\uD558\uAE30";
16996
+ const label = document.getElementById("studio-camera-frame-label");
16997
+ if (label) label.textContent = on ? "\uCE74\uBA54\uB77C \uC601\uC5ED" : "\uCE74\uBA54\uB77C \uC601\uC5ED \uB054";
16998
+ }
16105
16999
  function setupTimelineResizer(ui) {
16106
17000
  const resizer = document.getElementById("studio-timeline-resizer");
16107
17001
  if (!resizer) return;
@@ -16766,7 +17660,8 @@ function initStudio(opts = {}) {
16766
17660
  const ui = queryUi();
16767
17661
  if (!ui) {
16768
17662
  console.error("[studio] missing required elements");
16769
- return;
17663
+ return () => {
17664
+ };
16770
17665
  }
16771
17666
  document.body.classList.add("editor-active");
16772
17667
  document.documentElement.classList.add("editor-active");
@@ -16776,6 +17671,7 @@ function initStudio(opts = {}) {
16776
17671
  initGrid();
16777
17672
  reflectGridUi(ui);
16778
17673
  subscribeGrid(() => reflectGridUi(ui));
17674
+ reflectCameraFrameUi(ui);
16779
17675
  initCanvas(ui.canvas);
16780
17676
  initElementList(ui.elementList, ui.toolsRoot);
16781
17677
  initProperties(ui.propsRoot);
@@ -16856,6 +17752,18 @@ function initStudio(opts = {}) {
16856
17752
  ui.newBtn.addEventListener("click", () => openNewDialog(ui));
16857
17753
  initPalette();
16858
17754
  initHistoryPanel();
17755
+ if (!getDef()) startDraft();
17756
+ const pluginMount = mountEditorPlugins(
17757
+ ui.app,
17758
+ opts.plugins ?? [],
17759
+ opts.resolvePluginPermissions ?? (() => ({})),
17760
+ {
17761
+ getDocument: getDef,
17762
+ replaceDocument: setDef,
17763
+ getSelection,
17764
+ setSelection
17765
+ }
17766
+ );
16859
17767
  registerCommands([
16860
17768
  {
16861
17769
  id: "open",
@@ -16901,7 +17809,8 @@ function initStudio(opts = {}) {
16901
17809
  hint: "G",
16902
17810
  run: () => setGridEnabled(!isGridEnabled())
16903
17811
  },
16904
- { id: "help", label: "\u2328 \uB2E8\uCD95\uD0A4 \uBCF4\uAE30", hint: "? / Shift+/", run: openHelp }
17812
+ { id: "help", label: "\u2328 \uB2E8\uCD95\uD0A4 \uBCF4\uAE30", hint: "? / Shift+/", run: openHelp },
17813
+ ...pluginMount.commands
16905
17814
  ]);
16906
17815
  ui.saveBtn.addEventListener("click", () => void saveCurrent(ui));
16907
17816
  ui.exportBtn.addEventListener("click", () => {
@@ -16915,7 +17824,9 @@ function initStudio(opts = {}) {
16915
17824
  const file = ui.importFileInput.files?.[0];
16916
17825
  ui.importFileInput.value = "";
16917
17826
  if (!file) return;
16918
- void file.text().then((text) => animationDocumentSchema.parse(JSON.parse(text))).then((def) => importAnimation(def)).then((def) => {
17827
+ void file.text().then((text) => JSON.parse(text)).then(
17828
+ (input) => opts.importDocument ? opts.importDocument(input) : animationDocumentSchema.parse(input)
17829
+ ).then((def) => importAnimation(def)).then((def) => {
16919
17830
  setDef(def);
16920
17831
  markClean();
16921
17832
  setStatus(ui, `${def.id}.json \uD30C\uC77C\uC744 \uAC00\uC838\uC654\uC2B5\uB2C8\uB2E4.`, "ok");
@@ -16934,6 +17845,10 @@ function initStudio(opts = {}) {
16934
17845
  "click",
16935
17846
  () => setGridEnabled(!isGridEnabled())
16936
17847
  );
17848
+ ui.cameraFrameBtn.addEventListener("click", () => {
17849
+ setCameraFrameVisible(!isCameraFrameVisible());
17850
+ reflectCameraFrameUi(ui);
17851
+ });
16937
17852
  ui.playBtn.addEventListener("click", () => togglePlay(ui));
16938
17853
  ui.restartBtn.addEventListener("click", () => {
16939
17854
  if (isPlaying()) {
@@ -16978,12 +17893,12 @@ function initStudio(opts = {}) {
16978
17893
  });
16979
17894
  if (opts.initialId) {
16980
17895
  void loadAnimation2(ui, opts.initialId);
16981
- } else if (!getDef()) {
16982
- startDraft();
17896
+ } else if (isDraft()) {
16983
17897
  setStatus(ui, "\uC784\uC2DC \uC791\uC5C5 (Draft), \uC800\uC7A5 \uC2DC ID/\uC81C\uBAA9 \uC785\uB825", "ok");
16984
17898
  } else {
16985
17899
  setStatus(ui, "\uC900\uBE44\uB428", "ok");
16986
17900
  }
17901
+ return () => pluginMount.dispose();
16987
17902
  }
16988
17903
  function reflectState(ui) {
16989
17904
  ui.undoBtn.disabled = !canUndo();
@@ -17024,5 +17939,5 @@ function reflectState(ui) {
17024
17939
  }
17025
17940
 
17026
17941
  export { getVisibleElementIds, initStudio };
17027
- //# sourceMappingURL=main-NCPWDB2U.js.map
17028
- //# sourceMappingURL=main-NCPWDB2U.js.map
17942
+ //# sourceMappingURL=main-DC6D6RNQ.js.map
17943
+ //# sourceMappingURL=main-DC6D6RNQ.js.map