@kokoa/clotho-editor 0.1.3 → 0.2.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.
package/README.md CHANGED
@@ -1,113 +1,158 @@
1
1
  # @kokoa/clotho-editor
2
2
 
3
- [clotho](../clotho) 애니메이션 문서를 작성하고 미리 있는 시각 편집기다.
3
+ `@kokoa/clotho-editor`는 [Clotho](https://github.com/shinkeonkim/clotho) 애니메이션 문서를 만드는 React 기반 시각 편집기입니다. 저장소, 이미지 업로드, 예제 목록과 저장 동작을 host application에서 주입할 있으므로 독립형 편집기와 기존 관리 화면에서 같은 package를 사용할 수 있습니다.
4
4
 
5
- ## 상태
5
+ 실제 화면은 [Clotho Editor](https://clotho-editor.shinkeonkim.com/)에서 확인할 수 있습니다.
6
6
 
7
- 블로그 저장소에서 사용하던 Studio 약 8,900줄을 clotho 기반의 독립 패키지로 옮겼다. 타입 검사, 빌드, 테스트를 모두 통과한다.
8
-
9
- 가장 큰 변화는 **미리보기에도 clotho를 사용한다**는 점이다. 이전 Studio는 자체 canvas renderer를 사용했기 때문에 편집 화면과 실제 사이트의 화면이 다를 수 있었다. 이제 두 화면 모두 `buildScene`과 `patchScene`을 거치므로 같은 문서는 같은 결과로 표시된다.
10
-
11
- 자세한 내용과 남은 정리 항목은 [`docs/PORTING.md`](./docs/PORTING.md).
12
-
13
- ## 사용
7
+ ## 설치
14
8
 
15
9
  ```bash
16
10
  npm install @kokoa/clotho @kokoa/clotho-editor react react-dom
17
- # yarn add / pnpm add / bun add 도 같은 패키지 목록을 사용한다.
18
11
  ```
19
12
 
20
13
  ```tsx
21
- import { Studio, configureApi, configureHost } from "@kokoa/clotho-editor";
14
+ import {
15
+ StudioMount,
16
+ createLocalStorageRepository,
17
+ } from "@kokoa/clotho-editor";
18
+ import "@kokoa/clotho/styles.css";
22
19
  import "@kokoa/clotho-editor/styles.css";
23
20
 
24
- // 문서가 어디 저장되는지는 호스트가 정한다. 기본값은 원래 Studio가 쓰던 경로다.
25
- configureApi({ baseUrl: "/api/admin/animations" });
26
- configureHost({ placeholderImageUrl: "/uploads/placeholder.png" });
21
+ const repository = createLocalStorageRepository({
22
+ storageKey: "my-app.animations",
23
+ examples,
24
+ });
27
25
 
28
- <Studio initial={doc} onSave={handleSave} />;
26
+ export function AnimationEditor() {
27
+ return <StudioMount editorTitle="Clotho Editor" repository={repository} />;
28
+ }
29
29
  ```
30
30
 
31
- `Studio`는 문서를 저장하는 방법을 사용하는 application에서 정할 있는 내장형 컴포넌트다. 기존 API 경로를 그대로 사용하는 완성된 화면이 필요하면 `<StudioMount initialId="document-id" />`를 사용한다. 두 컴포넌트 모두 Vite, Next.js, React SPA에 넣을 있으며 브라우저에서만 필요한 코드는 컴포넌트가 화면에 연결된 뒤 초기화된다.
31
+ `StudioMount`는 목록 조회, 문서 생성, 저장, 삭제와 JSON 내보내기를 포함한 편집기 화면입니다. 편집기 shell을 application이 직접 구성해야 한다면 `Studio`를 사용할있습니다.
32
32
 
33
- `StudioMount`의 저장 방식은 `AnimationRepository`로 교체할 수 있다. 서버에서 관리하는 application은 `configureApi`로 기존 HTTP API를 연결할 수 있고, 독립 실행형 편집기는 `createLocalStorageRepository`를 주입할 수 있다. 별도 데이터베이스나 Git 기반 저장소를 사용하려면 `list`, `load`, `create`, `save`, `delete`를 구현한 repository를 전달하면 된다.
33
+ ## 저장소 연결
34
34
 
35
- ```tsx
36
- const repository = createLocalStorageRepository({
37
- storageKey: "my-editor.animations",
38
- examples,
39
- });
35
+ 편집기는 특정 backend에 의존하지 않습니다. `AnimationRepository`를 구현하면 REST API, browser storage, Git 기반 저장소 등 원하는 데이터 계층을 연결할 수 있습니다.
40
36
 
41
- <StudioMount repository={repository} />;
37
+ ```ts
38
+ import type { AnimationRepository } from "@kokoa/clotho-editor";
39
+
40
+ const repository: AnimationRepository = {
41
+ list: async () => api.list(),
42
+ load: async (id) => api.load(id),
43
+ create: async (id, title) => api.create(id, title),
44
+ save: async (document) => api.save(document),
45
+ delete: async (id) => api.delete(id),
46
+ };
42
47
  ```
43
48
 
44
- 상단 제목과 이미지 저장 방식도 사용하는 환경에서 정할 수 있다. `editorTitle`의 기본값은 `Clotho Editor`이며, `resolveImage`는 업로드한 파일을 외부 URL 또는 `data:` URL로 바꾸는 hook이다. hook을 전달하지 않으면 파일을 base64 data URL로 변환해 문서의 `assets`에 저장한다. 같은 이미지는 하나의 asset을 공유하고 각 image 요소는 `assetId`만 참조한다.
49
+ 간단한 demo나 offline 편집기는 `createLocalStorageRepository`를 사용하면 됩니다. 기존 HTTP API를 사용하는 application은 `configureApi({ baseUrl })`로 기본 adapter를 설정할 수도 있습니다.
50
+
51
+ ## Host hooks
52
+
53
+ 상단 제목과 이미지 처리 방식은 실행 환경에서 정할 수 있습니다. `resolveImage`를 지정하지 않으면 이미지를 data URL로 변환해 문서의 `assets`에 저장합니다. 같은 이미지는 하나의 asset을 공유하며, 사용되지 않는 asset은 JSON을 내보낼 때 제거됩니다.
45
54
 
46
55
  ```tsx
47
56
  <StudioMount
48
57
  editorTitle="블로그 애니메이션 편집기"
58
+ repository={repository}
49
59
  resolveImage={async (file) => uploadToMediaServer(file)}
50
60
  />
51
61
  ```
52
62
 
53
- 미리보기의 `무한 재생` 설정은 문서의 `settings.loop`에 저장된다. `타임라인 분리` 버튼을 누르면 기존 타임라인 컴포넌트가 이벤트와 상태를 유지한 별도 브라우저 창으로 이동한다. 분리된 창에서도 재생, Chapter와 keyframe 편집, drag, 확대와 스크롤을 같은 방식으로 사용할 수 있다. `편집기에 다시 합치기` 버튼을 누르거나 창을 닫으면 컴포넌트가 원래 위치로 돌아온다.
63
+ 저장 버튼의 동작 자체를 바꾸려면 repository의 `create`와 `save`를 구현합니다. 예제 목록은 repository의 `list`와 `load`에서 제공하므로 editor package 안에 application별 API 경로를 넣을 필요가 없습니다.
54
64
 
55
- `실제 미리보기`는 편집용 canvas가 아니라 `@kokoa/clotho/dom`의 `mountPlayer`로 완성된 애니메이션 컴포넌트를 별도 창에 표시한다. 편집 중 문서가 바뀌면 미리보기 창도 최신 문서로 다시 렌더링한다. 사용하는 application에서는 editor stylesheet와 함께 Clotho player stylesheet도 불러와야 한다.
65
+ ## Plugin host
56
66
 
57
- ```ts
58
- import "@kokoa/clotho-editor/styles.css";
59
- import "@kokoa/clotho/styles.css";
60
- ```
67
+ application 전용 도구는 `plugins`로 추가할 수 있습니다. plugin은 toolbar, 왼쪽 panel, inspector와 command palette에 기능을 붙일 수 있지만, 문서를 읽거나 바꾸려면 host가 권한을 명시적으로 허용해야 합니다. 저장소, selection, undo/redo와 같은 편집기의 기본 기능은 plugin으로 분리하지 않습니다.
61
68
 
62
- 왼쪽 도구는 먼저 활성화한 뒤 canvas에서 사용한다. 기존 요소를 누르면 자동으로 `선택 · 이동 · 크기` 도구로 돌아간다. `V`, `R`, `O`, `L`, `A`, `T`, `I`, `B`, `Y`로 선택, 사각형, 원, 선, 화살표, 텍스트, 이미지, Path, 다각형 도구를 활성화할 수 있으며 input, textarea, contenteditable에 입력하는 동안에는 단축키가 동작하지 않는다. 텍스트는 canvas에서 더블클릭해 바로 편집한다. Path는 점을 차례대로 누르고 더블클릭하거나 Enter를 눌러 완성하며 Esc를 누르면 작성을 취소한다.
69
+ ```tsx
70
+ import { StudioMount, type EditorPluginDefinition } from "@kokoa/clotho-editor";
71
+
72
+ const reviewPlugin: EditorPluginDefinition = {
73
+ manifest: {
74
+ id: "com.example.review",
75
+ version: "1.0.0",
76
+ capabilities: ["editor"],
77
+ editor: { toolbarItems: ["review"] },
78
+ },
79
+ toolbarItems: {
80
+ review: ({ container, document }) => {
81
+ const button = document.createElement("button");
82
+ button.textContent = "검토 요청";
83
+ container.append(button);
84
+ },
85
+ },
86
+ };
63
87
 
64
- 사각형, 원과 다각형 도구는 pointer를 놓을 때 요소를 확정한다. 짧게 누르면 기본 크기로 만들고 drag하면 화면에 표시되는 임시 윤곽의 크기로 만든다. 임시 윤곽은 animation frame마다 최대 한 번만 갱신하므로 빠른 pointer 이벤트가 문서 변경이나 렌더를 반복해서 발생시키지 않는다. 색상 입력은 picker를 조작하는 동안 현재 input을 유지하고 선택을 확정했을 때 문서에 반영한다.
88
+ <StudioMount
89
+ plugins={[reviewPlugin]}
90
+ resolvePluginPermissions={(manifest) =>
91
+ manifest.id === "com.example.review" ? { ui: true, documentRead: true } : {}
92
+ }
93
+ />;
94
+ ```
65
95
 
66
- Shift, Command 또는 Ctrl을 누른 canvas나 요소 목록을 누르면 여러 요소를 선택하거나 선택에서 제외할 있다. 그룹의 자식은 요소 목록과 timeline에서 들여쓰기되어 표시된다. 그룹 timeline은 여러 자식에 적용되는 transform과 visibility animation을 작성할 때 사용한다.
96
+ Clotho compiler plugin이 새로운 JSON 입력 형식을 처리해야 한다면 `importDocument`에서 compiler pipeline을 연결합니다. 경계 덕분에 Editor는 application의 plugin registry나 backend에 의존하지 않습니다.
67
97
 
68
- JSON을 내보낼 때는 어떤 image 요소에서도 참조하지 않는 `assets` 항목을 자동으로 제거한다. 이미지를 삭제한 뒤 별도의 정리 작업을 실행할 필요가 없다.
98
+ ```tsx
99
+ import { createPluginRegistry, runPluginPipeline } from "@kokoa/clotho/plugins";
69
100
 
70
- Cloudflare demo의 `열기` 화면에는 Clotho gallery의 JSON 문서 9개가 모두 들어 있다. JSON은 Clotho 저장소의 gallery source에서 생성하며 다음 명령으로 다시 동기화한다.
101
+ const registry = createPluginRegistry(compilerPlugins);
71
102
 
72
- ```bash
73
- cd ../clotho
74
- bun examples/gallery/build.ts ../clotho-editor/app/gallery --documents-only
103
+ <StudioMount
104
+ importDocument={(input) => {
105
+ const result = runPluginPipeline(input, { registry });
106
+ if (!result.ok) throw result.error;
107
+ return result.document;
108
+ }}
109
+ />;
75
110
  ```
76
111
 
77
- 에디터의 `JSON 내보내기` 버튼은 현재 문서를 검증한 `{문서 ID}.json` 파일로 내려받는다. 화면을 직접 만들 때는 `animationDocumentToJson`, `animationDocumentFileName`, `downloadAnimationJson`을 사용할 있다.
112
+ 현재 plugin API는 신뢰할 있는 application code를 위한 실험적 API입니다. 외부에서 받은 plugin은 별도 Worker나 격리 환경에서 실행한 JSON 결과만 Editor로 전달해야 합니다.
113
+
114
+ ## 주요 기능
115
+
116
+ - Clotho v1 JSON 문서 작성과 검증
117
+ - 사각형, 원, 선, 화살표, 텍스트, 이미지, Path, 다각형과 그룹 편집
118
+ - anchor를 유지하는 선 연결과 다중 선택
119
+ - keyframe, effect, chapter와 재생 설정 편집
120
+ - light/dark theme와 단계 목록 위치 설정
121
+ - 별도 브라우저 창으로 분리할 수 있는 timeline
122
+ - 실제 `@kokoa/clotho/dom` player를 사용하는 독립 미리보기
123
+ - JSON 가져오기와 내보내기, 참조되지 않는 image asset 정리
78
124
 
79
- 재생 설정 화면에서는 현재 단계 설명과 전체 단계 목록을 표시할지 선택할 있다. 단계 목록의 위치도 `좌측 | 우측 | 상단 | 하단` 중에서 고를 수 있다. 네 가지 배치를 한 화면에서 확인하려면 다음 명령을 실행한다.
125
+ 도구 단축키는 편집기 안에서 확인할있으며 input, textarea와 contenteditable에 입력하는 동안에는 동작하지 않습니다.
126
+
127
+ ## 독립 실행형 앱
128
+
129
+ 저장소의 `app`은 package 통합 예제이자 Cloudflare Workers Static Assets용 demo입니다.
80
130
 
81
131
  ```bash
82
- bun run visual-check
132
+ bun install
133
+ bun run app:dev
134
+ bun run build:app
135
+ bun run dev:cloudflare
83
136
  ```
84
137
 
85
- npm 배포는 `vX.Y.Z` GitHub Release와 `.github/workflows/publish.yml`을 통해 진행한다. 먼저 npm Trusted Publisher와 GitHub의 `npm-production` 승인 environment를 설정해야 한다. 같은 버전의 `@kokoa/clotho`를 먼저 공개한 뒤 editor의 GitHub Release를 공개한다.
138
+ Cloudflare Dashboard에서는 Build command를 `bun run build:app`, Deploy command를 `npx wrangler deploy`로 지정합니다. custom domain은 Dashboard에서 별도로 연결합니다.
86
139
 
87
140
  ## 개발
88
141
 
89
142
  ```bash
90
- bun install
143
+ bun install --frozen-lockfile
91
144
  bun run typecheck
92
145
  bun test
93
146
  bun run build
94
147
  ```
95
148
 
96
- 개발 환경의 devDependency이웃한 `../clotho`를 가리킨다. 배포 패키지에는 이 로컬 경로가 들어가지 않으며 `^0.1.0` peer dependency만 공개된다. 배포 순서와 검증 방법은 [`../clotho/docs/RELEASING.md`](../clotho/docs/RELEASING.md)에 정리되어 있다.
97
-
98
- ## Cloudflare Workers 배포
99
-
100
- 독립 에디터는 `app`에서 시작하며 Cloudflare Workers Static Assets용 설정은 [`wrangler.jsonc`](./wrangler.jsonc)에 들어 있다. 로컬에서 실제 화면을 확인하거나 배포 파일을 만들려면 다음 명령을 사용한다.
101
-
102
- ```bash
103
- bun run app:dev
104
- bun run build:app
105
- bun run dev:cloudflare
106
- ```
149
+ npm releaseGitHub Release와 [publish workflow](./.github/workflows/publish.yml)를 통해 진행합니다. `@kokoa/clotho`의 호환 버전이 먼저 공개되어 있어야 합니다.
107
150
 
108
- Cloudflare Dashboard의 Build command에는 `bun run build:app`, Deploy command에는 `npx wrangler deploy`를 지정한다. 로컬에서 직접 배포할 때만 `bun run deploy:cloudflare`를 실행한다. npm 패키지를 만드는 `bun run build`와 Cloudflare 앱을 만드는 `bun run build:app`은 서로 독립적이다.
151
+ ## 문서
109
152
 
110
- 저장소의 설정에는 custom domain을 넣지 않았으므로 Cloudflare Dashboard의 해당 Worker에서 `clotho-editor.shinkeonkim.com`을 Custom Domain으로 연결하면 된다. Dashboard에서 관리한 설정이 이후 CLI 배포로 덮어써지지 않도록 `routes` 항목도 두지 않았다.
153
+ - [Clotho 저장소](https://github.com/shinkeonkim/clotho)
154
+ - [Clotho Editor demo](https://clotho-editor.shinkeonkim.com/)
155
+ - [Porting 기록](./docs/PORTING.md)
111
156
 
112
157
  ## 라이선스
113
158
 
@@ -1,4 +1,4 @@
1
- import { animationDocumentSchema, computeSnapshot, encodeImageAsset, inlineAssetFromDataUri } from '@kokoa/clotho';
1
+ import { animationDocumentSchema, computeSnapshot, compileDataBindings, encodeImageAsset, inlineAssetFromDataUri, compileLayouts } from '@kokoa/clotho';
2
2
 
3
3
  // src/export-json.ts
4
4
  function animationDocumentToJson(def) {
@@ -201,7 +201,19 @@ function mutateDef(fn, label = "edit", kind = "other") {
201
201
  console.warn("[studio.state] invalid mutation", parsed.error.issues);
202
202
  return;
203
203
  }
204
- state.def = parsed.data;
204
+ const localized = cloned;
205
+ const next = parsed.data;
206
+ if (localized.locales) next.locales = localized.locales;
207
+ next.elements = next.elements.map((element, index) => {
208
+ const source = localized.elements[index];
209
+ if (source?.id !== element.id || element.type !== "text") return element;
210
+ return {
211
+ ...element,
212
+ ...source.locales ? { locales: source.locales } : {},
213
+ ...source.translations ? { translations: source.translations } : {}
214
+ };
215
+ });
216
+ state.def = next;
205
217
  state.dirty = true;
206
218
  emit();
207
219
  }
@@ -280,7 +292,10 @@ function toggleSelectionFor(sel, id) {
280
292
  }
281
293
  function getCurrentSnapshot() {
282
294
  if (!state.def) return /* @__PURE__ */ new Map();
283
- return computeSnapshot(state.def, state.currentTime);
295
+ return computeSnapshot(
296
+ compileDataBindings(state.def).document,
297
+ state.currentTime
298
+ );
284
299
  }
285
300
 
286
301
  // src/legacy/state/history.ts
@@ -705,6 +720,33 @@ function updateMeta(patch) {
705
720
  "meta"
706
721
  );
707
722
  }
723
+ function updateLocales(locales) {
724
+ mutateDef(
725
+ (def) => {
726
+ def.locales = locales;
727
+ },
728
+ "\uBB38\uC11C \uC5B8\uC5B4 \uBCC0\uACBD",
729
+ "meta"
730
+ );
731
+ }
732
+ function updateData(data) {
733
+ mutateDef(
734
+ (def) => {
735
+ def.data = data;
736
+ },
737
+ "\uC0D8\uD50C \uB370\uC774\uD130 \uBCC0\uACBD",
738
+ "meta"
739
+ );
740
+ }
741
+ function updateResponsive(responsive) {
742
+ mutateDef(
743
+ (def) => {
744
+ def.responsive = responsive;
745
+ },
746
+ "Responsive Stage \uBCC0\uACBD",
747
+ "canvas"
748
+ );
749
+ }
708
750
  function updateCanvas(patch) {
709
751
  const keys = Object.keys(patch).join(", ");
710
752
  mutateDef(
@@ -809,6 +851,125 @@ function registerDataUriAsset(dataUri) {
809
851
  );
810
852
  return id;
811
853
  }
854
+ function uniqueLayoutId(def) {
855
+ const used = new Set(def.layouts.map((layout) => layout.id));
856
+ let index = 1;
857
+ while (used.has(`layout-${index}`)) index += 1;
858
+ return `layout-${index}`;
859
+ }
860
+ function createLayout(elementIds, mode) {
861
+ if (elementIds.length === 0) return;
862
+ mutateDef(
863
+ (def) => {
864
+ const selected = def.elements.filter(
865
+ (element) => elementIds.includes(element.id)
866
+ );
867
+ if (selected.length === 0) return;
868
+ const measured = compileLayouts({ ...def, layouts: [] }).boxes;
869
+ const boxes = selected.flatMap((element) => {
870
+ const box = measured[element.id];
871
+ return box ? [box] : [];
872
+ });
873
+ const x = boxes.length > 0 ? Math.min(...boxes.map((box) => box.x)) : 0;
874
+ const y = boxes.length > 0 ? Math.min(...boxes.map((box) => box.y)) : 0;
875
+ def.layouts = def.layouts.filter(
876
+ (layout) => !layout.elementIds.some((id) => elementIds.includes(id))
877
+ );
878
+ def.layouts.push({
879
+ id: uniqueLayoutId(def),
880
+ mode,
881
+ elementIds: selected.map((element) => element.id),
882
+ x,
883
+ y,
884
+ gap: 16,
885
+ align: "start",
886
+ constraints: []
887
+ });
888
+ def.elements = compileLayouts(def).document.elements;
889
+ },
890
+ `${mode} layout \uC0DD\uC131`,
891
+ "layout"
892
+ );
893
+ }
894
+ function detachFromLayout(elementIds) {
895
+ const ids = new Set(elementIds);
896
+ mutateDef(
897
+ (def) => {
898
+ def.layouts = def.layouts.flatMap((layout) => {
899
+ const remaining = layout.elementIds.filter((id) => !ids.has(id));
900
+ return remaining.length === 0 ? [] : [{ ...layout, elementIds: remaining }];
901
+ });
902
+ },
903
+ "layout\uC5D0\uC11C \uBD84\uB9AC",
904
+ "layout"
905
+ );
906
+ }
907
+ function layoutIdsFor(elementIds) {
908
+ if (!state.def) return [];
909
+ const ids = new Set(elementIds);
910
+ return state.def.layouts.filter((layout) => layout.elementIds.some((id) => ids.has(id))).map((layout) => layout.id);
911
+ }
912
+ function findLayoutCollisions(def) {
913
+ const { boxes } = compileLayouts(def);
914
+ const collisions = [];
915
+ for (const layout of def.layouts) {
916
+ for (let firstIndex = 0; firstIndex < layout.elementIds.length; firstIndex += 1) {
917
+ for (let secondIndex = firstIndex + 1; secondIndex < layout.elementIds.length; secondIndex += 1) {
918
+ const firstId = layout.elementIds[firstIndex];
919
+ const secondId = layout.elementIds[secondIndex];
920
+ const first = boxes[firstId];
921
+ const second = boxes[secondId];
922
+ if (!first || !second) continue;
923
+ const overlaps = first.x < second.x + second.width && first.x + first.width > second.x && first.y < second.y + second.height && first.y + first.height > second.y;
924
+ if (overlaps) collisions.push({ firstId, secondId });
925
+ }
926
+ }
927
+ }
928
+ return collisions;
929
+ }
930
+
931
+ // src/legacy/state/checkpoints.ts
932
+ function addCheckpoint(checkpoint) {
933
+ mutateDef(
934
+ (def) => {
935
+ def.checkpoints.push(checkpoint);
936
+ def.checkpoints.sort((a, b) => a.time - b.time);
937
+ },
938
+ `Checkpoint \uCD94\uAC00: ${checkpoint.id}`,
939
+ "checkpoint"
940
+ );
941
+ }
942
+ function updateCheckpoint(id, patch) {
943
+ mutateDef(
944
+ (def) => {
945
+ const index = def.checkpoints.findIndex(
946
+ (checkpoint) => checkpoint.id === id
947
+ );
948
+ if (index < 0) return;
949
+ def.checkpoints[index] = {
950
+ ...def.checkpoints[index],
951
+ ...patch
952
+ };
953
+ def.checkpoints.sort((a, b) => a.time - b.time);
954
+ },
955
+ `Checkpoint \uC218\uC815: ${id}`,
956
+ "checkpoint"
957
+ );
958
+ }
959
+ function deleteCheckpoint(id) {
960
+ mutateDef(
961
+ (def) => {
962
+ def.checkpoints = def.checkpoints.filter(
963
+ (checkpoint) => checkpoint.id !== id
964
+ );
965
+ },
966
+ `Checkpoint \uC0AD\uC81C: ${id}`,
967
+ "checkpoint"
968
+ );
969
+ }
970
+ function uniqueCheckpointId() {
971
+ return `checkpoint-${Date.now().toString(36)}`;
972
+ }
812
973
 
813
974
  // src/legacy/studio-groups.ts
814
975
  function isGroup(el) {
@@ -953,21 +1114,6 @@ function groupElements(ids) {
953
1114
  const valid = ids.filter((id) => def.elements.some((e) => e.id === id));
954
1115
  if (valid.length < 2) return null;
955
1116
  const newId = uniqueElementId("group");
956
- (() => {
957
- let minX = Infinity, minY = Infinity;
958
- for (const id of valid) {
959
- const el = def.elements.find((e) => e.id === id);
960
- if (!el) continue;
961
- const b = elementBbox(el);
962
- if (!b) continue;
963
- if (b.x < minX) minX = b.x;
964
- if (b.y < minY) minY = b.y;
965
- }
966
- return {
967
- x: Number.isFinite(minX) ? minX : 0,
968
- y: Number.isFinite(minY) ? minY : 0
969
- };
970
- })();
971
1117
  const group = {
972
1118
  type: "group",
973
1119
  id: newId,
@@ -975,6 +1121,7 @@ function groupElements(ids) {
975
1121
  rotation: 0,
976
1122
  appearances: [],
977
1123
  tracks: [],
1124
+ bindings: [],
978
1125
  // Children keep their absolute coordinates, so the group's own transform starts at
979
1126
  // the identity. Setting x/y here would shift every member on the next render.
980
1127
  x: 0,
@@ -1012,6 +1159,122 @@ function placeholderImageUrl() {
1012
1159
  return placeholderUrl;
1013
1160
  }
1014
1161
 
1015
- export { addAppearance, addChapter, addEffect, addElement, animationDocumentFileName, animationDocumentToJson, apiBaseUrl, beginTransient, canRedo, canUndo, childIdsOf, configureAnimationRepository, configureApi, configureHost, createAnimation, deleteAnimation, deleteChapter, deleteEffect, deleteElement, downloadAnimationJson, duplicateAnimation, endTransient, findContainingGroup, garbageCollectAnimationAssets, getCurrentSnapshot, getCurrentTime, getDef, getHistory, getSelectedElementIds, getSelection, groupBbox, groupElements, importAnimation, isDirty, isDraft, isElementSelected, isGroup, jumpBack, jumpForward, listAnimations, loadAnimation, markClean, moveElementToEnd, moveElementToFront, moveGroupBy, placeholderImageUrl, promoteDraftToSaved, redo, registerDataUriAsset, registerExternalAsset, registerInlineAsset, removeAppearance, removeTrack, removeTrackKeyframe, reorderElement, resetHistory, saveAnimation, setCurrentTime, setDef, setDraft, setElementValueAtTime, setSelection, setTrackKeyframe, subscribe, toggleSelectionFor, undo, ungroupElement, uniqueChapterId, uniqueEffectId, uniqueElementId, updateAppearance, updateCanvas, updateChapter, updateDuration, updateEffect, updateElementBase, updateMeta, updateSettings };
1016
- //# sourceMappingURL=chunk-Z2K5HFHI.js.map
1017
- //# sourceMappingURL=chunk-Z2K5HFHI.js.map
1162
+ // src/plugin-host.ts
1163
+ var SLOT_KEYS = {
1164
+ toolbar: "toolbarItems",
1165
+ panel: "panels",
1166
+ inspector: "inspectors"
1167
+ };
1168
+ function viewsFor(plugin, slot) {
1169
+ if (slot === "toolbar") return plugin.toolbarItems ?? {};
1170
+ if (slot === "panel") return plugin.panels ?? {};
1171
+ return plugin.inspectors ?? {};
1172
+ }
1173
+ function validateEditorPlugin(plugin) {
1174
+ const issues = [];
1175
+ const { id, capabilities, editor } = plugin.manifest;
1176
+ if (!capabilities.includes("editor")) {
1177
+ issues.push({ pluginId: id, message: "editor capability\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4." });
1178
+ }
1179
+ for (const slot of ["toolbar", "panel", "inspector"]) {
1180
+ const declared = new Set(editor?.[SLOT_KEYS[slot]] ?? []);
1181
+ const implemented = new Set(Object.keys(viewsFor(plugin, slot)));
1182
+ for (const viewId of declared) {
1183
+ if (!implemented.has(viewId)) {
1184
+ issues.push({
1185
+ pluginId: id,
1186
+ message: `${slot} ${viewId} \uAD6C\uD604\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.`
1187
+ });
1188
+ }
1189
+ }
1190
+ for (const viewId of implemented) {
1191
+ if (!declared.has(viewId)) {
1192
+ issues.push({
1193
+ pluginId: id,
1194
+ message: `${slot} ${viewId}\uAC00 manifest\uC5D0 \uC5C6\uC2B5\uB2C8\uB2E4.`
1195
+ });
1196
+ }
1197
+ }
1198
+ }
1199
+ return issues;
1200
+ }
1201
+ function permissionError(pluginId, permission) {
1202
+ return new Error(
1203
+ `Editor plugin ${pluginId}\uC5D0\uB294 ${permission} \uAD8C\uD55C\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.`
1204
+ );
1205
+ }
1206
+ function createEditorPluginContext(pluginId, permissions, state2) {
1207
+ return Object.freeze({
1208
+ pluginId,
1209
+ getDocument() {
1210
+ if (!permissions.documentRead)
1211
+ throw permissionError(pluginId, "documentRead");
1212
+ const document2 = state2.getDocument();
1213
+ if (!document2) throw new Error("\uC5F4\uB9B0 \uC560\uB2C8\uBA54\uC774\uC158\uC774 \uC5C6\uC2B5\uB2C8\uB2E4.");
1214
+ return structuredClone(document2);
1215
+ },
1216
+ replaceDocument(document2) {
1217
+ if (!permissions.documentWrite)
1218
+ throw permissionError(pluginId, "documentWrite");
1219
+ state2.replaceDocument(structuredClone(document2));
1220
+ },
1221
+ getSelection() {
1222
+ if (!permissions.documentRead)
1223
+ throw permissionError(pluginId, "documentRead");
1224
+ return structuredClone(state2.getSelection());
1225
+ },
1226
+ setSelection(selection) {
1227
+ if (!permissions.documentWrite)
1228
+ throw permissionError(pluginId, "documentWrite");
1229
+ state2.setSelection(structuredClone(selection));
1230
+ }
1231
+ });
1232
+ }
1233
+ function mountEditorPlugins(root, plugins, resolvePermissions, state2) {
1234
+ const cleanups = [];
1235
+ const commands = [];
1236
+ const seen = /* @__PURE__ */ new Set();
1237
+ for (const plugin of plugins) {
1238
+ const { id } = plugin.manifest;
1239
+ if (seen.has(id))
1240
+ throw new Error(`Editor plugin ${id}\uAC00 \uC911\uBCF5 \uB4F1\uB85D\uB418\uC5C8\uC2B5\uB2C8\uB2E4.`);
1241
+ seen.add(id);
1242
+ const issues = validateEditorPlugin(plugin);
1243
+ if (issues.length > 0)
1244
+ throw new Error(issues.map((issue) => issue.message).join(" "));
1245
+ const permissions = resolvePermissions(plugin.manifest);
1246
+ if (!permissions.ui) continue;
1247
+ const context = createEditorPluginContext(id, permissions, state2);
1248
+ commands.push(...plugin.commands ?? []);
1249
+ for (const slot of ["toolbar", "panel", "inspector"]) {
1250
+ const target = root.querySelector(
1251
+ `[data-editor-plugin-slot="${slot}"]`
1252
+ );
1253
+ if (!target) continue;
1254
+ for (const viewId of plugin.manifest.editor?.[SLOT_KEYS[slot]] ?? []) {
1255
+ const view = viewsFor(plugin, slot)[viewId];
1256
+ if (!view) continue;
1257
+ const container = document.createElement(
1258
+ slot === "toolbar" ? "span" : "section"
1259
+ );
1260
+ container.dataset.editorPlugin = id;
1261
+ container.dataset.editorPluginView = viewId;
1262
+ container.setAttribute("aria-label", view.label);
1263
+ target.append(container);
1264
+ const cleanup = view.mount(container, context);
1265
+ if (cleanup) cleanups.push(cleanup);
1266
+ }
1267
+ }
1268
+ }
1269
+ return {
1270
+ commands,
1271
+ dispose() {
1272
+ cleanups.reverse().forEach((cleanup) => cleanup());
1273
+ root.querySelectorAll("[data-editor-plugin]").forEach((element) => element.remove());
1274
+ }
1275
+ };
1276
+ }
1277
+
1278
+ export { addAppearance, addChapter, addCheckpoint, addEffect, addElement, animationDocumentFileName, animationDocumentToJson, apiBaseUrl, beginTransient, canRedo, canUndo, childIdsOf, configureAnimationRepository, configureApi, configureHost, createAnimation, createEditorPluginContext, createLayout, deleteAnimation, deleteChapter, deleteCheckpoint, deleteEffect, deleteElement, detachFromLayout, downloadAnimationJson, duplicateAnimation, endTransient, findContainingGroup, findLayoutCollisions, garbageCollectAnimationAssets, getCurrentSnapshot, getCurrentTime, getDef, getHistory, getSelectedElementIds, getSelection, groupBbox, groupElements, importAnimation, isDirty, isDraft, isElementSelected, isGroup, jumpBack, jumpForward, layoutIdsFor, listAnimations, loadAnimation, markClean, mountEditorPlugins, moveElementToEnd, moveElementToFront, moveGroupBy, placeholderImageUrl, promoteDraftToSaved, redo, registerDataUriAsset, registerExternalAsset, registerInlineAsset, removeAppearance, removeTrack, removeTrackKeyframe, reorderElement, resetHistory, saveAnimation, setCurrentTime, setDef, setDraft, setElementValueAtTime, setSelection, setTrackKeyframe, subscribe, toggleSelectionFor, undo, ungroupElement, uniqueChapterId, uniqueCheckpointId, uniqueEffectId, uniqueElementId, updateAppearance, updateCanvas, updateChapter, updateCheckpoint, updateData, updateDuration, updateEffect, updateElementBase, updateLocales, updateMeta, updateResponsive, updateSettings, validateEditorPlugin };
1279
+ //# sourceMappingURL=chunk-FAI2G3XK.js.map
1280
+ //# sourceMappingURL=chunk-FAI2G3XK.js.map