@wipco/sdui-editor 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.d.ts +1412 -12
  2. package/dist/index.js +8821 -44
  3. package/package.json +6 -4
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
- import * as react from 'react';
2
- import react__default from 'react';
3
- import { SpecIssue } from '@wipco/sdui';
1
+ import * as React from 'react';
2
+ import React__default from 'react';
3
+ import { SduiRegistry, SduiAdapter, SduiCanSetting, SpecIssue, SduiPage, SduiPropValue, SduiChild } from '@wipco/sdui';
4
+ import { SduiComponentMeta, SduiPropMeta } from '@wipco/sdui/meta';
4
5
 
5
6
  /**
6
7
  * 스펙 저장소 어댑터 계약.
@@ -276,11 +277,57 @@ interface SduiEditorProps {
276
277
  defaultView?: SduiEditorView;
277
278
  /** 편집기 자체 헤더(저장소 배지)를 그릴지. 소비 프로젝트 크롬이 이미 있으면 false */
278
279
  chrome?: boolean;
280
+ /**
281
+ * 스펙을 그릴 때 쓸 컴포넌트 어휘. **소비 프로젝트가 자기 것을 넣는 자리다.**
282
+ *
283
+ * 생략하면 라이브러리가 아는 66종(기본 39 + DS 6 + 표시 위젯 21)으로 그린다.
284
+ * 위젯은 지연 로드라 안 쓰면 내려오지 않는다(`editorRegistry` 주석 참고).
285
+ *
286
+ * 소비 프로젝트가 `createRegistry`로 어휘를 넓혔다면 **그 레지스트리를 그대로 넘긴다.**
287
+ * 그러지 않으면 앱 고유 이름(ERP의 `ExcelUploadCard`·`Logo` 등)이 편집 화면에서만
288
+ * 자리 표시로 나오고, 편집자는 자기가 고치는 화면이 실제로 어떻게 보이는지 모른 채
289
+ * 저장하게 된다.
290
+ *
291
+ * ```tsx
292
+ * import { registry } from './lib/sdui/registry'; // 앱이 이미 쓰는 그것
293
+ * <SduiEditor store={store} registry={registry} catalog={catalog} />
294
+ * ```
295
+ */
296
+ registry?: SduiRegistry;
297
+ /**
298
+ * 팔레트·속성 패널이 볼 메타데이터. 생략하면 라이브러리 어휘 69종.
299
+ *
300
+ * `registry`를 넓혔다면 여기도 같이 넓혀야 한다 — 레지스트리는 "그릴 수 있는 이름",
301
+ * 이쪽은 "빌더가 프롭을 그릴 줄 아는 이름"이라 둘은 따로 논다.
302
+ * `{ ...allComponentMeta, ...myMeta }`처럼 합쳐 넘기면 된다.
303
+ */
304
+ catalog?: Record<string, SduiComponentMeta>;
279
305
  /**
280
306
  * 뷰어에서 스펙의 `navigate` 액션이 나왔을 때. 주지 않으면 뷰어 안에서 그 페이지를
281
307
  * 열어 본다(절대 URL은 무시한다 — 편집기를 마운트한 화면을 날려 버리지 않기 위해).
282
308
  */
283
309
  onNavigate?: (to: string) => void;
310
+ /**
311
+ * 뷰어("보기")가 쓸 호스트 연결점. **생략하면 캔버스와 같은 봉인 어댑터**다.
312
+ *
313
+ * 편집기의 세 화면 중 뷰어만 봉인되지 않아, 편집 화면 툴바의 "보기" 버튼 한 번으로
314
+ * `submitForm`의 DELETE가 운영 API까지 나간 적이 있다. 지금은 기본값이 막는다.
315
+ * 실제 데이터가 붙은 화면까지 보려는 호스트만 여기에 자기 어댑터를 준다.
316
+ *
317
+ * 캔버스(빌더)에는 이 통로를 열지 않는다 — 캔버스는 **저장도 안 된 편집 중 텍스트**를
318
+ * 그리는 자리라 호스트가 무엇을 주든 열려서는 안 된다.
319
+ *
320
+ * @see ViewerViewProps.adapter
321
+ */
322
+ viewerAdapter?: SduiAdapter;
323
+ /**
324
+ * 권한 판정 — 뷰어의 `Can` 게이트가 질의한다.
325
+ *
326
+ * 안 주면 모든 `<Can>`이 통과해, 실제로는 그 사용자에게 안 보였을 버튼이 "실제 모습
327
+ * 확인" 화면에 그려진다. 캔버스는 `Can`을 조건이 보이는 배지 컴포넌트로 갈아 끼우므로
328
+ * 이 값이 없어도 편집자를 오도하지 않는다 — 그래서 캔버스에는 넘기지 않는다.
329
+ */
330
+ can?: SduiCanSetting;
284
331
  className?: string;
285
332
  }
286
333
  /**
@@ -299,22 +346,40 @@ interface SduiEditorProps {
299
346
  * <SduiEditor store={store} />
300
347
  * ```
301
348
  */
302
- declare function SduiEditor({ store, view, onViewChange, defaultView, chrome, onNavigate, className, }: SduiEditorProps): react.JSX.Element;
349
+ declare function SduiEditor({ store, view, onViewChange, defaultView, chrome, registry, catalog, onNavigate, viewerAdapter, can, className, }: SduiEditorProps): React.JSX.Element;
303
350
 
304
351
  interface PageListViewProps {
305
352
  store: SpecStore;
306
353
  onEdit: (id: string) => void;
307
354
  onPreview: (id: string) => void;
308
355
  }
309
- declare function PageListView({ store, onEdit, onPreview }: PageListViewProps): react.JSX.Element;
356
+ declare function PageListView({ store, onEdit, onPreview }: PageListViewProps): React.JSX.Element;
310
357
 
311
358
  interface EditorViewProps {
312
359
  store: SpecStore;
313
360
  id: string;
314
361
  onBack: () => void;
315
362
  onPreview: (id: string) => void;
363
+ /**
364
+ * 이 화면(빌더 캔버스·JSON 미리보기)이 스펙을 그릴 때 쓸 레지스트리.
365
+ *
366
+ * 생략하면 {@link editorRegistry} — 라이브러리가 아는 어휘 66종이다. 소비 앱이
367
+ * `createRegistry`로 자기 컴포넌트를 얹었다면 **그것을 그대로 넘겨야** 편집 화면과
368
+ * 운영 화면이 같은 그림이 된다. 두 렌더러(빌더·미리보기)에 같은 값이 간다 —
369
+ * 한쪽만 넓으면 모드를 바꿀 때 화면이 달라진다.
370
+ */
371
+ registry?: SduiRegistry;
372
+ /**
373
+ * 팔레트·속성 패널이 볼 메타데이터. 생략하면 라이브러리 어휘 69종
374
+ * (`@wipco/sdui/meta`의 `allComponentMeta`).
375
+ *
376
+ * `registry`에 앱 고유 컴포넌트를 얹었다면 그 메타데이터도 여기 합쳐 넘겨야
377
+ * 팔레트에서 집을 수 있다. 레지스트리에만 있고 여기 없는 이름은 스펙에 있으면
378
+ * 그려지되 팔레트에는 안 나온다.
379
+ */
380
+ catalog?: Record<string, SduiComponentMeta>;
316
381
  }
317
- declare function EditorView({ store, id, onBack, onPreview }: EditorViewProps): react__default.JSX.Element;
382
+ declare function EditorView({ store, id, onBack, onPreview, registry, catalog }: EditorViewProps): React__default.JSX.Element;
318
383
 
319
384
  interface ViewerViewProps {
320
385
  store: SpecStore;
@@ -329,16 +394,62 @@ interface ViewerViewProps {
329
394
  * 통째로 날아가고 저장 안 한 초안이 함께 사라진다.
330
395
  */
331
396
  onNavigate: (to: string) => void;
397
+ /**
398
+ * 렌더링 어휘. **캔버스와 같은 값을 받아야 한다** — 편집 중에는 그려지던 위젯이
399
+ * "보기"로 넘어가면 사라지는 것이 사용자에게는 저장이 깨진 것으로 보인다.
400
+ *
401
+ * 생략하면 {@link editorRegistry} 66종 — 캔버스의 기본값과 같은 값이다.
402
+ * 예전에는 여기가 비면 `SduiRenderer`의 기본값(`defaultRegistry` 39종)으로 떨어졌다.
403
+ * `SduiEditor` 셸이 `registry ?? editorRegistry`로 때워 두었지만 `ViewerView`는
404
+ * 개별 export라, **자기 셸을 짜서 단독 마운트한 소비자에게는 그 때움이 안 닿았다.**
405
+ * 때우는 자리를 셸에서 프롭 기본값으로 내린 이유가 그것이다.
406
+ */
407
+ registry?: SduiRegistry;
408
+ /**
409
+ * 호스트 연결점. **생략하면 캔버스와 같은 봉인 어댑터로 그린다.**
410
+ *
411
+ * 예전 기본값은 "아무것도 막지 않음"이었다. 그 선택의 근거는 "뷰어는 저장된 스펙을
412
+ * 실제 소비 화면처럼 보는 자리"였고 그 자체는 지금도 맞다. 틀린 것은 **주입 통로를
413
+ * 열어 두지 않은 채로 그 기본값을 골랐다는 점**이다 — 이 파일의 예전 주석은
414
+ * "소비 프로젝트가 어댑터를 주입해 원하는 만큼만 열어 주면 된다"고 적어 두었지만
415
+ * `ViewerViewProps`에도 `SduiEditorProps`에도 그 프롭이 없었다. 그래서 소비
416
+ * 프로젝트에는 열고 닫을 손잡이가 아예 없었고, 실측에서 편집 화면 툴바의 "보기"
417
+ * 버튼 한 번이 `DELETE /assets/AS-0042`와 `GET /assets/export`를 **운영 API로**
418
+ * 내보냈다. "본다"는 낱말이 붙은 자리가 실제로 지웠다.
419
+ *
420
+ * 그래서 기본값을 뒤집는다. 편집기의 세 화면(캔버스·JSON 미리보기·뷰어)은 모두
421
+ * "편집자의 특권 세션으로, 사용자가 그 동작을 하겠다고 밝힌 적 없이" 스펙을 그리는
422
+ * 자리다. 파괴적 기본값을 셋 중 하나만 남겨 둘 이유가 없다.
423
+ *
424
+ * 뷰어가 캔버스와 다른 점은 **어댑터가 아니라 렌더링 논리**에 있다 — 캔버스는
425
+ * `Show`·`Can`·`ForEach`를 조건 무시하고 펼치고 `Dialog`를 흐름 안에 눕히지만,
426
+ * 뷰어는 진짜 조건·진짜 모달로 그린다. 그 차이는 여기서 하나도 잃지 않는다.
427
+ *
428
+ * 실제 데이터까지 보고 싶은 호스트는 자기 어댑터를 주입한다. 그 선택은 호스트가
429
+ * 자기 위험 감수 범위를 알고 내리는 것이라 라이브러리 기본값이 대신 정하지 않는다.
430
+ */
431
+ adapter?: SduiAdapter;
432
+ /**
433
+ * 권한 판정 — 뷰어가 `SduiRenderer`에 그대로 넘긴다.
434
+ *
435
+ * 안 주면 렌더러가 "판정 없음"으로 보고 **모든 `<Can>`을 통과**시킨다(콘솔에 경고를
436
+ * 남긴다). 캔버스는 `Can`을 항상 펼치는 컴포넌트로 갈아 끼우고 "권한 | asset · create"
437
+ * 배지로 조건을 **보여 주므로** 편집자가 그 사실을 안다. 뷰어에는 그 배지가 없어서,
438
+ * 원래 안 보였어야 할 버튼이 조건 없이 그냥 그려진다 — "실제 모습 확인"이 목적인
439
+ * 화면에서 권한 재단만 빠진다.
440
+ */
441
+ can?: SduiCanSetting;
332
442
  }
333
443
  /**
334
444
  * 페이지 뷰어 — 저장된 스펙을 실제 소비 화면처럼 렌더링한다.
335
445
  *
336
- * 편집기 미리보기와 달리 **어댑터를 막지 않는다**. 여기서 보는 것은 편집 중인 텍스트가
337
- * 아니라 이미 저장된 스펙이고, 소비 프로젝트가 실제로 어떻게 그릴지 확인하는 자리다.
338
- * 다만 부작용(submitForm 등)은 진짜로 나가므로 소비 프로젝트가 어댑터를 주입해
339
- * 원하는 만큼만 열어 주면 된다.
446
+ * 캔버스와 다른 것은 **렌더링 논리**다: 조건(`Show`)·권한(`Can`)·반복(`ForEach`)이
447
+ * 실제로 판정되고 `Dialog`가 진짜 모달로 뜬다. 캔버스가 편집을 위해 일부러 무시하는
448
+ * 것들을 여기서 확인한다.
449
+ *
450
+ * 부작용 차단은 캔버스와 **같다**. {@link ViewerViewProps.adapter} 참고.
340
451
  */
341
- declare function ViewerView({ store, id, onBack, onEdit, onNavigate }: ViewerViewProps): react__default.JSX.Element;
452
+ declare function ViewerView({ store, id, onBack, onEdit, onNavigate, registry, adapter: hostAdapter, can, }: ViewerViewProps): React__default.JSX.Element;
342
453
 
343
454
  /**
344
455
  * 새 페이지 생성 템플릿 — 스펙 문법 견본을 겸한다.
@@ -361,4 +472,1293 @@ type GradedIssue = SpecIssue & {
361
472
  };
362
473
  declare function gradeIssues(spec: unknown): GradedIssue[];
363
474
 
364
- export { EditorView, type EditorViewProps, type GradedIssue, PAGE_ID_PATTERN, PageExistsError, PageGoneError, PageIdConflictError, PageIdRejectedError, PageListView, type PageListViewProps, type PageMeta, type PageRecord, type PageRevision, type PageTemplate, RestSpecStore, type RestStoreConfig, SduiEditor, type SduiEditorProps, type SduiEditorView, type SpecStore, type StoreCapabilities, StoreUnauthorizedError, StoreUnavailableError, StoreUnsupportedError, TEMPLATES, type VersionExpectation, VersionMismatchError, ViewerView, type ViewerViewProps, type WriteOptions, createRestSpecStore, gradeIssues, isValidPageId, titleOf };
475
+ /**
476
+ * 시각 빌더의 **문서 모델** — 편집 중인 페이지의 자료구조와 변형 연산.
477
+ *
478
+ * 이 파일에는 React도 DOM도 없다. 팔레트·캔버스·트리·속성 패널이 전부 여기 있는
479
+ * 순수 함수만 호출하도록 만든 것이라, 편집 규칙을 화면 없이 `node --test`로 검증할 수 있다.
480
+ * (실제로 그렇게 했다 — `test/builder-model.test.ts`)
481
+ *
482
+ * ## 왜 스펙(SduiPage)을 그대로 편집하지 않는가
483
+ *
484
+ * `SduiNode`에는 **안정적인 식별자가 없다.** `key`는 선택적이고(실측 3,124개 노드 중
485
+ * 149개에만 있다) 배열 인덱스는 형제를 하나만 지워도 전부 밀린다. 그런데 편집기는
486
+ * "지금 선택한 노드", "트리에서 펼쳐 둔 노드", "실행취소 후에도 같은 노드"를 계속
487
+ * 가리켜야 한다. 그래서 편집 중에는 **id가 박힌 별도 트리**({@link BuilderNode})를 쓰고,
488
+ * 렌더러·검증기·저장소에 넘길 때만 {@link toPage}로 스펙을 만든다.
489
+ *
490
+ * 후보 세 가지를 두고 이렇게 정했다.
491
+ *
492
+ * 1. **경로 기반**(`body[0].children[2]`) — 자료구조를 하나도 안 늘려도 되지만,
493
+ * 형제 재정렬·삽입·삭제마다 선택과 실행취소 기록이 통째로 어긋난다. 탈락.
494
+ * 2. **스펙에 id를 심기**(`props.__id`) — 저장하는 순간 운영 DB에 편집기 부산물이
495
+ * 들어간다. 렌더러가 모르는 프롭이라 컴포넌트에 그대로 흘러가기도 한다. 탈락.
496
+ * 3. **id를 가진 편집용 트리 + 스펙 투영** — 채택. id가 자료의 일부라 변형 연산이
497
+ * 전부 순수 함수가 되고(전역 id 발급기도 WeakMap 부작용도 없다), 텍스트 자식까지
498
+ * id를 가질 수 있어 "카드 본문의 저 글자"를 트리에서 가리킬 수 있다.
499
+ *
500
+ * 3번의 유일한 대가는 투영 비용인데, {@link toPage}가 노드 단위로 결과를 캐시해서
501
+ * **바뀐 경로만 새 객체**가 되도록 맞춰 뒀다. 변형 연산이 구조 공유(structural sharing)를
502
+ * 지키므로 안 바뀐 `BuilderNode`는 참조가 그대로고, 그러면 캐시가 맞아 같은 `SduiNode`가
503
+ * 다시 나온다. 렌더러의 참조 동일성 기대가 그대로 유지된다.
504
+ *
505
+ * ## 슬롯 주소 통일
506
+ *
507
+ * 스펙은 기본 자식을 `children`에, 이름 붙은 자리를 `slots.<이름>`에 나눠 담는다.
508
+ * 편집기에서는 둘 다 "부모 · 슬롯 · 인덱스"라는 한 가지 주소로 다뤄야 드롭 처리와
509
+ * 트리 렌더가 두 벌이 되지 않는다. 그래서 내부에서는 기본 자식도 슬롯 하나로 보고
510
+ * {@link DEFAULT_SLOT} 키에 담는다. `$`로 시작해서 실제 슬롯 이름과 부딪히지 않는다
511
+ * (스펙의 슬롯 이름은 프롭 이름이라 식별자 문법을 따른다).
512
+ */
513
+
514
+ /**
515
+ * 편집기가 아는 컴포넌트 메타데이터 전부 — 라이브러리 어휘에 소비 앱 것을 합친 것.
516
+ *
517
+ * ## 왜 인자로 들고 다니는가
518
+ *
519
+ * 이 모듈의 판정 함수는 전부 **순수 함수**다. React 컨텍스트를 쓸 수 없고, 모듈 전역에
520
+ * 담아 두면 편집기를 두 개 띄웠을 때 서로를 덮는다(테스트 격리도 깨진다). 그래서
521
+ * 후행 선택 인자로 받아 아래로 넘긴다 — 장황하지만 이것만이 두 인스턴스가 서로 다른
522
+ * 어휘를 볼 수 있는 유일한 방법이다.
523
+ *
524
+ * 기본값(`allComponentMeta`)을 남겨 둔 것은 하위 호환 때문이지, 안 넘겨도 된다는 뜻이
525
+ * **아니다**. 기본값이 있으면 배선을 빠뜨려도 조용히 라이브러리 어휘로 돌아가 사고가
526
+ * 드러나지 않으므로, 편집기 안에서 인자를 빠뜨린 호출부가 있으면 실패하는 소스 스캔
527
+ * 테스트(`test/catalog-wiring.test.ts`)로 따로 막는다.
528
+ */
529
+ type SduiCatalog = Record<string, SduiComponentMeta>;
530
+ /**
531
+ * 편집 세션 안에서만 의미가 있는 노드 식별자. 저장되는 스펙에는 나가지 않는다.
532
+ * 브랜드 타입으로 만들지 않은 이유는 이 값이 곧장 React key·DOM data 속성·Map 키로
533
+ * 흘러가기 때문이다 — 그 자리마다 캐스팅을 요구하면 얻는 것보다 잃는 게 많다.
534
+ */
535
+ type NodeId = string;
536
+ /** 페이지 본문(`body`)을 가리키는 가상 부모. 드롭 대상으로 최상위를 지목할 때 쓴다. */
537
+ declare const ROOT_ID: NodeId;
538
+ /** 기본 자식(`children`)을 담는 슬롯 키. 스펙의 슬롯 이름과 부딪히지 않도록 `$`로 시작한다. */
539
+ declare const DEFAULT_SLOT = "$children";
540
+ /**
541
+ * 문자열 자식을 컴포넌트 이름 자리에 놓아야 할 때 쓰는 표식
542
+ * (드롭 판정·빈도표의 키). 실측 스펙의 자식 3,124건 중 1,163건이 문자열이라
543
+ * "텍스트도 드롭 대상"을 1급으로 다루지 않으면 규칙에 구멍이 생긴다.
544
+ */
545
+ declare const TEXT_CHILD = "#text";
546
+ /** 드롭·삽입 지점. `parent`가 {@link ROOT_ID}면 페이지 본문이다. */
547
+ interface DropTarget {
548
+ readonly parent: NodeId;
549
+ readonly slot: string;
550
+ /** 삽입 위치. 슬롯 길이를 넘으면 끝에 붙는다(음수는 0). */
551
+ readonly index: number;
552
+ }
553
+ interface BuilderText {
554
+ readonly kind: 'text';
555
+ readonly id: NodeId;
556
+ readonly text: string;
557
+ }
558
+ interface BuilderNode {
559
+ readonly kind: 'node';
560
+ readonly id: NodeId;
561
+ /** 스펙의 `component`. 레지스트리에 없는 이름일 수도 있다(소비 앱이 얹은 컴포넌트). */
562
+ readonly component: string;
563
+ /** 스펙의 `key` — React key. 편집기가 만드는 값이 아니라 작성자가 정한 값이다. */
564
+ readonly key?: string;
565
+ readonly props: Readonly<Record<string, SduiPropValue>>;
566
+ /** 비어 있는 슬롯은 키 자체를 두지 않는다 — 투영 결과에 `slots: { footer: [] }`가 남지 않게. */
567
+ readonly slots: Readonly<Record<string, readonly BuilderChild[]>>;
568
+ }
569
+ type BuilderChild = BuilderNode | BuilderText;
570
+ /**
571
+ * `body`를 뺀 페이지 필드.
572
+ *
573
+ * 인덱스 시그니처를 교차시켜 **타입에 없는 필드도 담기게** 했다. 실측 스펙에는
574
+ * `SduiPage`에 없는 `require`(페이지 단위 권한 게이트, 소비 앱이 해석한다)가 들어 있고,
575
+ * 빌더가 이런 필드를 조용히 떨어뜨리면 편집 한 번에 페이지 권한이 풀린다.
576
+ */
577
+ type BuilderPageMeta = Omit<SduiPage, 'body'> & Record<string, unknown>;
578
+ interface BuilderDoc {
579
+ readonly meta: BuilderPageMeta;
580
+ readonly body: readonly BuilderChild[];
581
+ /**
582
+ * 다음에 발급할 id 번호. **문서가 들고 있는 이유**는 변형 연산을 순수 함수로 두기
583
+ * 위해서다 — 모듈 전역 카운터를 쓰면 같은 입력에 다른 결과가 나와 테스트가 불안정해지고,
584
+ * 문서를 두 개 열면 서로의 id를 밀어 버린다.
585
+ */
586
+ readonly nextId: number;
587
+ }
588
+ /** 변형 연산의 결과. 실패해도 던지지 않는다 — 편집기는 드롭 실패를 예외로 다루지 않는다. */
589
+ interface TransformResult {
590
+ readonly doc: BuilderDoc;
591
+ /** 문서가 실제로 바뀌었는가. false면 호출부는 실행취소 기록을 쌓지 않는다. */
592
+ readonly changed: boolean;
593
+ /** 이번 변형이 만들거나 옮긴 노드 — 편집기가 이어서 선택한다. */
594
+ readonly affected: readonly NodeId[];
595
+ /** 거절 사유(한국어). 없으면 "바뀐 게 없었다"는 뜻일 뿐이다. */
596
+ readonly rejected?: string;
597
+ }
598
+ /**
599
+ * 스펙을 편집 트리로 연다. id는 문서 순서대로 `n1`부터 붙는다 —
600
+ * 결정적이라 같은 스펙을 두 번 열면 같은 id가 나오고, 테스트가 흔들리지 않는다.
601
+ *
602
+ * 스펙이 아예 객체가 아니어도 빈 문서를 돌려준다. "열 수 없는 스펙"을 예외로 만들면
603
+ * 편집기가 그 페이지를 고칠 유일한 창구를 스스로 닫는 셈이다.
604
+ */
605
+ declare function fromPage(page: unknown): BuilderDoc;
606
+ /** 노드 하나만 스펙으로. 미리보기·복사·JSON 편집에 쓴다. */
607
+ declare function toSpec(child: BuilderChild): SduiChild;
608
+ /**
609
+ * 편집 트리를 스펙으로 되돌린다. 렌더러·`validateSpec`·저장소에 넘기는 유일한 통로다.
610
+ * 안 바뀐 가지는 이전 호출과 **같은 객체**가 나온다(위 캐시 참고).
611
+ *
612
+ * 딱 하나 정규화하는 것이 있다: **빈 `children: []`와 빈 슬롯은 키째로 사라진다.**
613
+ * 렌더러에게 "빈 배열"과 "없음"은 같은 뜻이라 화면이 달라지지 않고, 남겨 두면
614
+ * 슬롯을 비울 때마다 `slots: { footer: [] }` 같은 껍데기가 스펙에 쌓인다.
615
+ * (실측 스펙 84개에는 빈 컨테이너가 하나도 없어서, 기존 페이지를 열고 저장해도
616
+ * 이 정규화로 바뀌는 바이트는 없다 — 확인했다.)
617
+ */
618
+ declare function toPage(doc: BuilderDoc): SduiPage;
619
+ /** 빈 문서. 새 페이지를 만들 때 쓴다. */
620
+ declare function emptyDoc(title?: string): BuilderDoc;
621
+ /** 문서 전체를 부모·슬롯·인덱스와 함께 훑는다. `visit`이 false를 돌려주면 그 가지는 건너뛴다. */
622
+ declare function walkDoc(doc: BuilderDoc, visit: (child: BuilderChild, addr: DropTarget) => void | boolean): void;
623
+ /** id로 자식(노드 또는 텍스트)을 찾는다. */
624
+ declare function getChild(doc: BuilderDoc, id: NodeId): BuilderChild | null;
625
+ /** id로 컴포넌트 노드를 찾는다. 텍스트 자식이면 null. */
626
+ declare function getNode(doc: BuilderDoc, id: NodeId): BuilderNode | null;
627
+ /** 자식이 놓인 자리. 없으면 null. 최상위 자식은 `parent`가 {@link ROOT_ID}다. */
628
+ declare function addressOf(doc: BuilderDoc, id: NodeId): DropTarget | null;
629
+ /** 루트에 가까운 순서의 조상 id 목록(자기 자신은 빼고). */
630
+ declare function ancestorIdsOf(doc: BuilderDoc, id: NodeId): readonly NodeId[];
631
+ /** `id`가 `ancestor`의 자손인가(자기 자신은 false). */
632
+ declare function isDescendantOf(doc: BuilderDoc, ancestor: NodeId, id: NodeId): boolean;
633
+ /** 여러 id를 문서 순서로 정렬한다. 다중 선택을 옮기거나 지울 때 순서가 뒤집히지 않게. */
634
+ declare function inDocumentOrder(doc: BuilderDoc, ids: readonly NodeId[]): readonly NodeId[];
635
+ /**
636
+ * 이미 선택된 id 중 **조상이 함께 선택된 것**을 걷어낸다.
637
+ * 부모와 자식을 같이 잡고 옮기면 자식이 두 번 이동해 사라지므로, 이동·삭제·복제는
638
+ * 전부 이 결과를 대상으로 삼는다.
639
+ */
640
+ declare function topmost(doc: BuilderDoc, ids: readonly NodeId[]): readonly NodeId[];
641
+ /**
642
+ * 스펙 조각을 대상 자리에 넣는다. 팔레트 드롭(`meta.preset`)과 붙여넣기가 같은 길을 쓴다.
643
+ * 어휘 규칙 판정({@link canDrop})은 **호출부의 몫**이다 — 트리에서 강제로 넣어야 하는
644
+ * 상황(잘못 만들어진 스펙 고치기)까지 여기서 막으면 편집기가 자기 발을 묶는다.
645
+ */
646
+ declare function insert(doc: BuilderDoc, target: DropTarget, specs: readonly SduiChild[]): TransformResult;
647
+ /** 노드를 지운다. 조상이 함께 선택돼 있으면 조상만 지운다. */
648
+ declare function remove(doc: BuilderDoc, ids: readonly NodeId[]): TransformResult;
649
+ /**
650
+ * 노드를 다른 자리로 옮긴다.
651
+ *
652
+ * 두 가지가 조용히 틀리기 쉬워 따로 다룬다.
653
+ * - **자기 자손 안으로 옮기기**: 트리에서 뿌리째 사라진다. 막는다.
654
+ * - **같은 슬롯 안 재정렬**: `index`는 *빼내기 전* 기준이라, 앞쪽에서 빠진 개수만큼
655
+ * 당겨 줘야 한다. 안 그러면 "한 칸 위로"가 제자리걸음이 된다.
656
+ */
657
+ declare function move(doc: BuilderDoc, ids: readonly NodeId[], target: DropTarget): TransformResult;
658
+ /**
659
+ * 노드를 바로 뒤에 복제한다. **id는 전부 새로 발급한다** — 원본과 사본이 같은 id를 쓰면
660
+ * 선택이 두 곳을 가리키고 이후 편집이 엉뚱한 쪽에 적용된다.
661
+ */
662
+ declare function duplicate(doc: BuilderDoc, ids: readonly NodeId[]): TransformResult;
663
+ /** 프롭 하나를 바꾼다. `undefined`를 주면 프롭을 **지운다**(기본값으로 되돌리기). */
664
+ declare function setProp(doc: BuilderDoc, id: NodeId, name: string, value: SduiPropValue | undefined): TransformResult;
665
+ /**
666
+ * 여러 프롭을 한 번에. 크기 조정 핸들처럼 두 프롭이 같이 움직이는 자리에서
667
+ * 실행취소가 반쪽만 되돌리지 않게 하려면 이쪽을 써야 한다.
668
+ */
669
+ declare function setProps(doc: BuilderDoc, id: NodeId, patch: Readonly<Record<string, SduiPropValue | undefined>>): TransformResult;
670
+ /** React key(스펙의 `key`)를 바꾼다. 빈 문자열·undefined면 지운다. */
671
+ declare function setNodeKey(doc: BuilderDoc, id: NodeId, key: string | undefined): TransformResult;
672
+ /**
673
+ * 슬롯의 자식 목록을 통째로 교체한다. 붙여넣기·JSON 편집·"이 슬롯 비우기"가 쓴다.
674
+ * 넘긴 조각에는 **새 id가 붙는다** — 기존 자식을 그대로 두려면 {@link move}를 써라.
675
+ */
676
+ declare function setChildren(doc: BuilderDoc, parent: NodeId, slot: string, specs: readonly SduiChild[]): TransformResult;
677
+ /** 텍스트 자식의 내용을 바꾼다. */
678
+ declare function setText(doc: BuilderDoc, id: NodeId, text: string): TransformResult;
679
+ /**
680
+ * 노드 하나를 다른 스펙으로 갈아 끼운다(고급 모드의 "이 노드만 JSON으로 편집").
681
+ * 새 id가 붙으므로 호출부는 결과의 `affected`로 선택을 옮겨야 한다.
682
+ */
683
+ declare function replaceChild(doc: BuilderDoc, id: NodeId, spec: SduiChild): TransformResult;
684
+ /**
685
+ * 페이지 수준 필드(title·subtitle·urlState·subscribe·그리고 타입에 없는 `require` 등)를 바꾼다.
686
+ * `undefined`를 주면 필드를 지운다.
687
+ */
688
+ declare function setPageMeta(doc: BuilderDoc, patch: Readonly<Record<string, unknown>>): TransformResult;
689
+ interface DropVerdict {
690
+ /** 놓을 수 있는가. **막는 것은 렌더러가 절대 그리지 못하는 조합뿐**이다. */
691
+ readonly ok: boolean;
692
+ /** 실측 스펙 84개에서 이 자리에 쓰인 횟수. 0은 "전례 없음"이지 "금지"가 아니다. */
693
+ readonly seen: number;
694
+ /** 강조해서 안내할 만한 자리인가(= ok이고 전례가 있다). */
695
+ readonly recommended: boolean;
696
+ /** 한국어 사유. ok=false면 막은 이유, ok=true면 주의 문구(없을 수 있다). */
697
+ readonly reason?: string;
698
+ }
699
+ /**
700
+ * 판정에 쓸 DOM 맥락. 전부 선택 항목이다 — 컴포넌트 이름만 아는 호출부
701
+ * (팔레트·캔버스의 슬롯 칩)도 그대로 쓸 수 있어야 하기 때문이다.
702
+ *
703
+ * 맥락이 없으면 요소를 바꾸는 프롭(`Text.as`·`Button.as`)을 못 읽어 **구현의 기본값**으로
704
+ * 본다. 실측 스펙에서 `as`를 준 자리는 `Text as="span"` 69회와 `as="p"` 3회뿐이고
705
+ * 둘 다 flow를 못 담는 것은 같아서, 기본값(`<p>`)으로 봐도 "넣지 마라"는 답이 바뀌지 않는다.
706
+ * `as="div"`처럼 판정이 실제로 뒤집히는 값은 실측에 없고, 있더라도 손해는 팔레트가 한 칸
707
+ * 더 막는 정도다(클릭 삽입은 막힌 자리를 만나면 형제로 넣는다). 진짜 드롭이 지나는
708
+ * {@link canDropAt}·{@link canMoveTo}는 문서에서 프롭을 읽으므로 정확하다.
709
+ */
710
+ interface DropDomContext {
711
+ /** 부모 노드의 프롭 — `Text.as`처럼 요소를 바꾸는 프롭을 읽는다. */
712
+ readonly parentProps?: Readonly<Record<string, SduiPropValue>>;
713
+ /** 놓으려는 노드의 프롭. 팔레트 드롭이면 preset의 프롭이다. */
714
+ readonly childProps?: Readonly<Record<string, SduiPropValue>>;
715
+ /** 부모보다 위쪽 조상들이 만든 호스트 요소들(바깥→안쪽). `fragment`는 들어 있지 않다. */
716
+ readonly ancestorHosts?: readonly string[];
717
+ }
718
+ /**
719
+ * 이 조합을 놓아도 되는가.
720
+ *
721
+ * ## 화이트리스트를 쓰지 않는 이유 (실측)
722
+ *
723
+ * 스펙 84개에 나타난 부모→자식 조합은 **232종**(슬롯까지 구분하면 242종)이고, 그중
724
+ * 컴포넌트 이름이 이 라이브러리 어휘 69종 밖인 것도 스물 몇 가지다(소비 앱이 레지스트리에
725
+ * 얹은 `ExcelUploadCard`·`ApprovalStepper` 같은 것들). 손으로 유지되는 허용 목록은
726
+ * 하루면 낡는다. 그래서 **막는 규칙은 최소로, 나머지는 추천으로** 나눴다.
727
+ *
728
+ * ## 막는 것 (전부 "렌더러가 그리지 않음"이 근거다. 실측 위반 0건)
729
+ *
730
+ * - `children.accepts === false`인 부모의 기본 슬롯 — Table·Tabs·PageHead처럼
731
+ * 자식을 아예 렌더하지 않는 컴포넌트. 넣으면 조용히 사라진다.
732
+ * - `text === false`인 자리에 문자열 — 같은 이유.
733
+ * - 메타데이터에 없는 슬롯 이름 — 렌더러가 그 이름을 프롭으로 넘기고 컴포넌트가 무시한다.
734
+ * - `children.deny` — 메타데이터가 명시적으로 "안 된다"고 적어 둔 것.
735
+ * - **브라우저 파서가 태그를 강제로 닫는 DOM 중첩** — `<p>` 안의 `<hr>`, 버튼 안의 버튼 등.
736
+ * 스펙이 말한 구조가 화면에 나오지 않는다({@link domNesting}). 실측 위반 0건.
737
+ *
738
+ * ## 막지 않고 경고만 하는 것 (실측이 규칙을 반증했다)
739
+ *
740
+ * - `slots[].allow` 화이트리스트 — 실측이 이미 어긴다(`Card.actions`에 Icon 6회,
741
+ * `SearchResults.headerActions`에 Text 1회). 잘 도는 화면을 편집기가 거절하면 안 된다.
742
+ * - `slotOnly`(Actions) — 슬롯 밖에서도 4번 쓰인다.
743
+ * - **콘텐츠 모델만 어기는 DOM 중첩** — `<span>` 안의 `<div>` 같은 것. 실측 7건.
744
+ * - 메타데이터를 모르는 컴포넌트 — **판단 근거가 없다는 이유로 막지 않는다.**
745
+ * `dom` 메타가 없는 컴포넌트도 같다.
746
+ *
747
+ * @param parent 부모 컴포넌트 이름. `null`이면 페이지 본문(최상위).
748
+ * @param child 자식 컴포넌트 이름. 문자열 자식이면 {@link TEXT_CHILD}.
749
+ * @param slot 이름 붙은 슬롯. 생략하거나 {@link DEFAULT_SLOT}이면 기본 자식 자리.
750
+ * @param ctx DOM 중첩을 조상까지 보려면 주는 맥락({@link canDropAt}이 문서에서 만들어
751
+ * 넘긴다). 없으면 부모 한 칸만 보고, 요소는 구현의 기본값으로 본다.
752
+ * @param catalog 소비 앱 어휘를 합친 카탈로그({@link SduiCatalog}). 안 넘기면 라이브러리
753
+ * 어휘만 보므로, 앱 고유 컴포넌트는 "규칙을 알 수 없음"으로 떨어져 전부 통과한다.
754
+ */
755
+ declare function canDrop(parent: string | null, child: string, slot?: string, ctx?: DropDomContext, catalog?: SduiCatalog): DropVerdict;
756
+ /**
757
+ * 문서 맥락에서의 드롭 판정 — 부모 컴포넌트 이름을 문서에서 읽어 {@link canDrop}에 넘긴다.
758
+ *
759
+ * DOM 중첩은 **부모만 봐서는 모자란다**(`Text > Badge > Divider`는 `<p>` 안의 `<hr>`이다).
760
+ * 문서가 있는 이 경로에서만 조상 요소 체인과 부모의 프롭까지 넘겨 준다.
761
+ *
762
+ * @param childProps 놓으려는 노드의 프롭. 팔레트 드롭이면 preset의 프롭,
763
+ * 이동이면 옮기는 노드의 프롭이다(`Text.as` 같은 것을 읽는다).
764
+ */
765
+ declare function canDropAt(doc: BuilderDoc, target: DropTarget, child: string, childProps?: Readonly<Record<string, SduiPropValue>>, catalog?: SduiCatalog): DropVerdict;
766
+ /**
767
+ * 이미 문서에 있는 노드를 옮겨도 되는가. 어휘 규칙에 **순환 검사**를 얹는다 —
768
+ * 자기 자손 안으로 옮기면 트리에서 뿌리째 사라진다.
769
+ */
770
+ declare function canMoveTo(doc: BuilderDoc, ids: readonly NodeId[], target: DropTarget, catalog?: SduiCatalog): DropVerdict;
771
+ /** 실측 빈도 조회. 슬롯을 준 조합이 0이면 슬롯을 뺀 조합으로 한 번 더 본다. */
772
+ declare function dropFrequency(parent: string | null, child: string, slot?: string): number;
773
+ /**
774
+ * 이 자리에 실제로 자주 놓이는 컴포넌트를 많은 순으로. 팔레트 정렬·"빠른 추가" 메뉴용이다.
775
+ * 막히는 조합은 빼고 돌려준다.
776
+ */
777
+ declare function recommendedChildren(parent: string | null, slot?: string, limit?: number, catalog?: SduiCatalog): ReadonlyArray<{
778
+ component: string;
779
+ seen: number;
780
+ }>;
781
+ /**
782
+ * ERP 스펙 84개에서 뽑은 **부모→자식 실사용 빈도**.
783
+ *
784
+ * psql -tA -d wipco_erp_dev_specs -c "select json_agg(spec) from ui_specs where id not like '\\_%'"
785
+ *
786
+ * 키는 `부모>자식` 또는 `부모#슬롯>자식`, 최상위는 `$body>자식`, 문자열 자식은 `#text`다.
787
+ * 어휘 69종 밖의 이름(ExcelUploadCard·ApprovalStepper·Disclosure…)이 섞여 있는데
788
+ * 일부러 남겨 뒀다 — 소비 앱이 레지스트리에 얹은 컴포넌트도 편집 대상이고,
789
+ * 이 표는 **금지 목록이 아니라 추천 근거**라서 모르는 이름이 있어도 해롭지 않다.
790
+ *
791
+ * 낡는 데이터다. 스펙이 크게 바뀌면 위 쿼리로 다시 뽑아 갈아 끼우면 되고,
792
+ * 갈아 끼우지 않아도 드롭이 막히지는 않는다(추천이 낡을 뿐이다).
793
+ */
794
+ declare const SPEC_PAIR_FREQ: Readonly<Record<string, number>>;
795
+
796
+ /**
797
+ * 빌더의 **편집 세션 상태** — 문서 + 선택 + 실행취소.
798
+ *
799
+ * React가 없다. 순수 리듀서라서 화면 없이 시나리오를 그대로 돌려 볼 수 있고,
800
+ * 화면 쪽은 `useReducer(editorReducer, createEditorState(spec))` 한 줄이면 된다.
801
+ * 상태 관리 라이브러리를 얹지 않은 이유도 같다 — 이 규모에서 얻는 것보다
802
+ * "편집 규칙이 어디 사는지"가 흐려지는 손해가 크다.
803
+ *
804
+ * ## 역할 분담
805
+ *
806
+ * 변형은 전부 `model.ts`가 하고, 여기서는 **그 결과를 기록**만 한다.
807
+ * 호출부는 이렇게 쓴다:
808
+ *
809
+ * dispatch({
810
+ * type: 'apply',
811
+ * result: setProp(state.doc, id, 'title', next),
812
+ * label: '제목 변경',
813
+ * coalesce: `prop:${id}:title`,
814
+ * });
815
+ *
816
+ * 리듀서가 `model`을 직접 부르지 않게 한 것은 의도적이다. 변형이 늘 때마다
817
+ * 액션 타입을 늘리면 리듀서가 편집 규칙의 두 번째 사본이 되고, 두 사본은 반드시 어긋난다.
818
+ */
819
+
820
+ /**
821
+ * 지금 **무엇을 고치고**(ids·primary) **어디에 넣나**(insertAt).
822
+ *
823
+ * ## 왜 삽입 지점이 여기 있는가
824
+ *
825
+ * 둘은 뜻이 다르다. 선택은 노드를 가리키고, 삽입 지점은 노드가 아니라 **자리**를
826
+ * 가리킨다(`부모·슬롯·위치`). 트리의 슬롯 행 — 실측 최다 슬롯 `Dialog.footer`가
827
+ * 227회다 — 이 바로 그 경우다: 고를 노드는 없고 넣을 자리만 있다. 그래서 `ids`에
828
+ * 담을 수 없고 `DropTarget`으로 따로 든다.
829
+ *
830
+ * 그런데도 **한 값 안에** 둔 이유는 둘이 늘 함께 바뀌기 때문이다. 별도 상태로 나누면
831
+ * "슬롯 행을 골랐다"가 두 액션(선택 갱신 + 삽입 지점 갱신)이 되고, 그 둘 사이에는
832
+ * 반드시 어긋난 순간이 생긴다 — 옛 삽입 지점 + 새 선택으로 팔레트가 엉뚱한 자리에
833
+ * 넣는 사고가 정확히 그 틈에서 난다. 한 값이면 리듀서가 그 조합을 원자적으로 보장하고,
834
+ * 이미 `selection`을 받고 있는 화면(팔레트·캔버스·속성)이 배선 없이 같은 답을 본다.
835
+ */
836
+ interface Selection {
837
+ /** 선택된 노드 전부. 문서 순서를 유지한다. */
838
+ readonly ids: readonly NodeId[];
839
+ /**
840
+ * 속성 패널이 보는 노드. 다중 선택에서도 "지금 편집 중인 하나"는 있어야 한다
841
+ * (없으면 패널이 매번 무엇을 띄울지 자기 규칙을 만들게 된다).
842
+ */
843
+ readonly primary: NodeId | null;
844
+ /**
845
+ * 사용자가 **직접 지목한** 삽입 지점. 팔레트 클릭·붙여넣기가 여기에 넣는다.
846
+ *
847
+ * `null`이면 "지목한 자리 없음"이고, 그때는 넣는 쪽이 선택에서 자리를 추정한다
848
+ * (팔레트의 규칙: 선택한 노드가 받아 주면 그 안, 아니면 그 뒤 형제).
849
+ * 지목한 자리는 추정보다 **언제나 우선**한다 — 사용자가 방금 손으로 짚은 자리다.
850
+ */
851
+ readonly insertAt: DropTarget | null;
852
+ }
853
+ declare const EMPTY_SELECTION: Selection;
854
+ declare function isSelected(selection: Selection, id: NodeId): boolean;
855
+ /** 다중 선택인가 — 정렬 도구·일괄 삭제 버튼을 켜는 조건. */
856
+ declare function isMultiSelection(selection: Selection): boolean;
857
+ /** 같은 자리를 가리키는가. 삽입 지점은 값이라 참조 비교로는 헛된 리렌더가 난다. */
858
+ declare function sameInsertionPoint(a: DropTarget | null, b: DropTarget | null): boolean;
859
+ /**
860
+ * 한 덩어리로 묶을 시간 창(ms).
861
+ *
862
+ * 실행취소의 단위가 이 파일에서 제일 손이 많이 간 부분이다. 한 글자마다 쌓으면
863
+ * Ctrl+Z를 스무 번 눌러야 문장 하나가 지워지고, 편집 세션 전체를 한 덩어리로 묶으면
864
+ * 되돌리는 순간 30분치가 날아간다. 그래서 두 장치를 뒀다.
865
+ *
866
+ * 1. **coalesce 키** — 같은 대상의 같은 프롭을 연달아 바꾸면 하나로 합친다.
867
+ * 이 창을 넘겨 손이 멈추면 다음 입력부터 새 덩어리가 된다. 700ms는 사람이
868
+ * "이어서 치는 중"이라고 느끼는 대략의 상한이다(타자 사이 간격보다는 넉넉하고,
869
+ * 생각하고 다시 치는 간격보다는 짧다).
870
+ * 2. **명시적 트랜잭션** — 드래그처럼 시작과 끝을 아는 조작은 시간에 맡기지 않는다.
871
+ * `txn: begin`으로 열고 `end`로 닫으면 그 사이가 통째로 한 덩어리다.
872
+ * 크기 조정 핸들을 1초 넘게 끌어도 되돌리기는 한 번이다.
873
+ *
874
+ * 구조 변경(삽입·이동·삭제·복제)은 **절대 합치지 않는다.** 되돌리는 사람이
875
+ * "방금 그 하나"를 기대하기 때문이다.
876
+ */
877
+ declare const COALESCE_WINDOW_MS = 700;
878
+ /** 기록 상한. 문서가 구조 공유라 한 칸의 비용이 작아 넉넉히 잡았다. */
879
+ declare const HISTORY_LIMIT = 200;
880
+ interface HistoryEntry {
881
+ /** 이 변경 **직전**의 문서. 실행취소는 여기로 돌아간다. */
882
+ readonly doc: BuilderDoc;
883
+ /** 직전의 선택 — 되돌렸을 때 보고 있던 자리로 같이 돌아가야 납득이 간다. */
884
+ readonly selection: Selection;
885
+ /** 사람이 읽는 한국어 이름("카드 삭제"). 실행취소 메뉴에 그대로 나간다. */
886
+ readonly label: string;
887
+ /** 같은 값이 연달아 오면 합친다. null이면 절대 합치지 않는다. */
888
+ readonly coalesceKey: string | null;
889
+ /** 마지막으로 합쳐진 시각(ms). 시간 창 판정에만 쓴다. */
890
+ readonly at: number;
891
+ }
892
+ /** 진행 중인 트랜잭션 — 드래그 한 번을 한 칸으로 묶는다. */
893
+ interface PendingTxn {
894
+ readonly doc: BuilderDoc;
895
+ readonly selection: Selection;
896
+ readonly label: string;
897
+ }
898
+ interface EditorState {
899
+ readonly doc: BuilderDoc;
900
+ readonly selection: Selection;
901
+ /** 캔버스/트리 호버. 둘이 같은 값을 보므로 한쪽에 올리면 다른 쪽도 밝아진다. */
902
+ readonly hover: NodeId | null;
903
+ /** 드래그 중 미리보기 자리. 드롭 선을 그리는 쪽이 읽는다. */
904
+ readonly dropHint: DropTarget | null;
905
+ readonly past: readonly HistoryEntry[];
906
+ readonly future: readonly HistoryEntry[];
907
+ readonly pending: PendingTxn | null;
908
+ /** 마지막 저장 이후 문서가 바뀌었는가. 저장 버튼과 이탈 경고가 읽는다. */
909
+ readonly dirty: boolean;
910
+ }
911
+ declare function createEditorState(spec: unknown): EditorState;
912
+ /** 저장·미리보기·검증에 넘길 스펙. 안 바뀐 가지는 이전과 같은 객체다. */
913
+ declare function specOf(state: EditorState): SduiPage;
914
+ declare function canUndo(state: EditorState): boolean;
915
+ declare function canRedo(state: EditorState): boolean;
916
+ /** 실행취소/다시실행 버튼의 툴팁("실행취소: 카드 삭제"). */
917
+ declare function undoLabel(state: EditorState): string | null;
918
+ declare function redoLabel(state: EditorState): string | null;
919
+ type EditorAction =
920
+ /**
921
+ * 선택을 통째로 바꾼다.
922
+ *
923
+ * `insertAt`을 함께 주면 "이 노드를 고른 채 저 자리에 넣는다"가 한 번에 선다
924
+ * (트리의 슬롯 행 클릭). 주지 않으면 삽입 지점은 **비워진다** — 다른 곳을 고르고도
925
+ * 이전 슬롯이 남아 있으면 팔레트가 엉뚱한 자리에 넣는다.
926
+ */
927
+ {
928
+ type: 'select';
929
+ ids: readonly NodeId[];
930
+ primary?: NodeId | null;
931
+ insertAt?: DropTarget | null;
932
+ }
933
+ /** Ctrl/⌘ 클릭 — 있으면 빼고 없으면 더한다. 삽입 지점은 놓아준다(노드를 고르는 조작이다). */
934
+ | {
935
+ type: 'selectToggle';
936
+ id: NodeId;
937
+ } | {
938
+ type: 'hover';
939
+ id: NodeId | null;
940
+ } | {
941
+ type: 'dropHint';
942
+ target: DropTarget | null;
943
+ }
944
+ /**
945
+ * `model.ts`의 변형 결과를 반영한다.
946
+ * - `label` 실행취소 메뉴에 나갈 한국어 이름
947
+ * - `coalesce` 같은 값이 연달아 오면 한 칸으로 합친다(타이핑·슬라이더)
948
+ * - `select` 기본 `affected` — 삽입/복제 직후 새 노드를 선택한다
949
+ */
950
+ | {
951
+ type: 'apply';
952
+ result: TransformResult;
953
+ label: string;
954
+ coalesce?: string;
955
+ select?: 'affected' | 'keep' | 'clear';
956
+ }
957
+ /** 드래그처럼 경계를 아는 조작을 한 칸으로 묶는다. */
958
+ | {
959
+ type: 'txn';
960
+ phase: 'begin';
961
+ label: string;
962
+ } | {
963
+ type: 'txn';
964
+ phase: 'end' | 'cancel';
965
+ } | {
966
+ type: 'undo';
967
+ } | {
968
+ type: 'redo';
969
+ }
970
+ /** 고급 모드(JSON 편집기)가 스펙을 통째로 갈아 끼운다. */
971
+ | {
972
+ type: 'replaceSpec';
973
+ spec: unknown;
974
+ label: string;
975
+ }
976
+ /** 저장 성공 — dirty를 내린다. 기록은 그대로 둔다(저장 후에도 되돌릴 수 있어야 한다). */
977
+ | {
978
+ type: 'markSaved';
979
+ };
980
+ declare function editorReducer(state: EditorState, action: EditorAction, now?: number): EditorState;
981
+
982
+ /**
983
+ * 시각 페이지 빌더의 **셸** — 팔레트·캔버스·속성·트리를 한 화면에 앉히고,
984
+ * 그 넷이 공유하는 편집 상태(`editorReducer`) 하나를 들고 있는다.
985
+ *
986
+ * 이 파일이 스스로 하는 일은 넷뿐이다. 나머지는 조각들의 몫이다.
987
+ *
988
+ * 1. **자리 배치** — 넷을 어디에 두고, 좁아지면 무엇을 접을 것인가.
989
+ * 2. **다리 놓기** — 속성 패널의 `onRequestBinding`을 바인딩·액션 편집기로 잇는다.
990
+ * 둘은 서로를 모르게 만들어져 있어(각자 다른 담당) 접합은 셸의 일이다.
991
+ * 3. **바깥과의 왕복** — 스펙을 받아 열고, 편집이 일어나면 스펙을 돌려준다.
992
+ * 4. **표현 가능성 판정** — 빌더가 그대로 왕복시키지 못하는 스펙을 미리 가려낸다
993
+ * ({@link builderFidelity}). 이 판정을 보고 셸을 여는 쪽(EditorView)이 JSON으로 돌린다.
994
+ *
995
+ * ## 왜 스펙을 "비제어"로 받는가
996
+ *
997
+ * 문서는 편집 트리(`BuilderDoc`)로 살아 있고 스펙은 그 투영이다. 바깥이 준 `spec`을
998
+ * 매 렌더 반영하면 편집 한 번마다 문서를 새로 만드는 셈이라 선택·실행취소·트리 펼침이
999
+ * 통째로 날아간다. 그래서 `spec`은 **마운트 시점과 `syncKey`가 바뀔 때만** 읽는다.
1000
+ * 바깥(서버 재로딩·JSON 모드 복귀)이 내용을 갈아끼웠다는 사실은 그쪽만 아는 정보라,
1001
+ * 값이 아니라 키로 받는다.
1002
+ */
1003
+
1004
+ /**
1005
+ * 두 스펙이 **같은 페이지를 뜻하는가.** 키 순서와 빈 컨테이너 차이는 무시한다.
1006
+ *
1007
+ * 빌더는 편집할 때마다 편집 트리에서 스펙을 다시 찍어 내므로 키 순서가 저장본과
1008
+ * 달라진다(`{props, children, component}` → `{component, props, slots, children}`).
1009
+ * 그 차이를 "바뀌었다"로 세면 되돌리기로 원래대로 돌려 놔도 "저장 안 됨"이 안 꺼진다.
1010
+ */
1011
+ declare function sameSpecMeaning(a: unknown, b: unknown): boolean;
1012
+ interface BuilderFidelity {
1013
+ /** 빌더로 열었다 저장해도 내용이 그대로인가. */
1014
+ readonly ok: boolean;
1015
+ /** ok=false일 때 어디가 달라지는지. 최대 6건까지만 센다(안내 문구가 목적이라). */
1016
+ readonly issues: readonly string[];
1017
+ }
1018
+ /**
1019
+ * 이 스펙을 빌더로 열었다가 저장하면 무엇이 달라지는가.
1020
+ *
1021
+ * 실측 스펙 85개 중 84개는 완전히 왕복한다(확인함). 남은 하나는 `_nav` — 페이지가
1022
+ * 아니라 내비게이션 **배열**이라 빌더가 통째로 못 연다. 그런 스펙에 빌더를 열어 주면
1023
+ * 저장 한 번에 내용이 사라진다. 경고만 띄우고 열어 두는 쪽이 친절해 보이지만, 이
1024
+ * 편집기는 운영 정본을 직접 고치는 자리라 "실수로 저장"이 가장 비싼 사고다.
1025
+ * **못 여는 스펙은 열지 않고 JSON으로 보낸다.**
1026
+ */
1027
+ declare function builderFidelity(spec: unknown): BuilderFidelity;
1028
+ interface BuilderViewProps {
1029
+ /** 편집할 스펙. **마운트와 `syncKey` 변경 때만** 읽는다(파일 머리말 참고). */
1030
+ readonly spec: unknown;
1031
+ /** 값이 바뀌면 문서를 `spec`으로 다시 채운다. 바깥이 내용을 갈아끼웠을 때만 올린다. */
1032
+ readonly syncKey?: number | string;
1033
+ /** 편집이 일어날 때마다 새 스펙. 안 바뀐 가지는 이전 호출과 같은 객체다. */
1034
+ readonly onSpecChange: (spec: SduiPage) => void;
1035
+ /** 고급(JSON) 모드로 넘긴다. 이유가 있으면 함께 준다. */
1036
+ readonly onOpenJson?: (reason?: string) => void;
1037
+ /** ⌘S. 주지 않으면 가로채지 않는다(브라우저 기본 동작이 그대로 산다). */
1038
+ readonly onSave?: () => void;
1039
+ /** `navigate` 액션 자동완성에 쓸 다른 페이지 id들 — `SpecStore.list()`의 결과. */
1040
+ readonly pageIds?: readonly string[];
1041
+ /**
1042
+ * 캔버스가 그릴 때 쓸 레지스트리. 생략하면 {@link editorRegistry}(라이브러리 어휘
1043
+ * 66종 + 렌더러 내장 `ForEach`)다. 소비 앱이 자기 컴포넌트를 얹었다면 **그 레지스트리를
1044
+ * 그대로 넘겨야** 캔버스에 앱과 같은 그림이 나온다 — 안 넘기면 앱 고유 이름
1045
+ * (`ExcelUploadCard` 등)이 "등록되지 않은 컴포넌트" 자리 표시가 된다(선택·이동은 된다).
1046
+ */
1047
+ readonly registry?: SduiRegistry;
1048
+ /** 팔레트·속성 패널이 볼 메타데이터. 소비 앱 것을 합쳐 넘기면 그쪽도 나온다. */
1049
+ readonly catalog?: Record<string, SduiComponentMeta>;
1050
+ readonly can?: SduiCanSetting;
1051
+ readonly className?: string;
1052
+ }
1053
+ declare function BuilderView({ spec, syncKey, onSpecChange, onOpenJson, onSave, pageIds, registry, catalog, can, className, }: BuilderViewProps): React__default.JSX.Element;
1054
+
1055
+ /**
1056
+ * 편집 화면이 레지스트리를 못 받았을 때 쓰는 **기본 어휘** — 그리고 그것 하나뿐이다.
1057
+ *
1058
+ * ## 왜 별도 모듈인가 (BuilderView.tsx에서 떼어 냈다)
1059
+ *
1060
+ * 기본값을 정하는 자리가 넷이었다 — `SduiEditor`·`EditorView`·`BuilderView`는 66종을
1061
+ * 쓰는데 `Canvas`만 39종으로 떨어졌다. 원인은 값이 **`BuilderView.tsx` 안에** 살았다는
1062
+ * 것이다. `Canvas`는 `BuilderView`의 **자식**이라 그걸 import하면 순환이 생긴다:
1063
+ *
1064
+ * BuilderView.tsx → Canvas.tsx → BuilderView.tsx
1065
+ *
1066
+ * 그래서 캔버스만 이 값에 손이 닿지 않았고, 자기 셸을 짜서 `Canvas`를 단독 마운트한
1067
+ * 소비자는 팔레트가 내건 것의 43%가 "등록되지 않은 컴포넌트"로 나오는 화면을 봤다.
1068
+ * `ViewerView`도 같은 이유로 같은 구멍이 있었다(`SduiEditor`가 셸에서 때워 두었을 뿐,
1069
+ * 단독 export라 단독 마운트에는 안 닿았다).
1070
+ *
1071
+ * 값을 **잎 모듈**로 내리면 그 순환이 구조적으로 불가능해진다. 그래서 이 파일은
1072
+ * **지역 import가 0개**다(`@wipco/sdui`와 `react`만 본다). 그게 이 파일의 설계
1073
+ * 제약이고, 여기에 `./model.js` 하나를 들이는 순간 위 순환이 되살아난다 —
1074
+ * 편의를 위해서라도 넣지 말 것.
1075
+ *
1076
+ * `Canvas.tsx`에 두는 안도 검토했다가 버렸다. 소비 지점이 캔버스 **아래**(edit-runtime)와
1077
+ * 캔버스 **옆**(SduiEditor·EditorView)으로 갈려 있어서, 거기 두면 셸이 어휘를 캔버스에서
1078
+ * 꺼내 오는 모양이 된다 — 어휘 정의가 캔버스 구현에 딸린 것처럼 읽힌다.
1079
+ *
1080
+ * ## 왜 `defaultRegistry`로는 모자라나
1081
+ *
1082
+ * 팔레트는 메타데이터(`@wipco/sdui/meta`)가 아는 어휘 **69종**을 내건다(`hidden`인
1083
+ * `Toast`만 빠져 화면에는 68종). 그런데
1084
+ * `defaultRegistry`는 39종(+ 렌더러 내장 `ForEach`)뿐이라, 드롭인 사용자는 팔레트에서
1085
+ * 집을 수 있는 것의 42%를 집어 **아무것도 그려지지 않는 페이지**를 만들 수 있었다.
1086
+ * 팔레트에 있는데 안 그려지는 것은 빌더의 전제를 깨는 종류의 어긋남이라 기본을 넓힌다.
1087
+ *
1088
+ * 39 + DS 6 + 표시 위젯 21 = **66종**이고, `defaultRegistry`를 통째로 품는다
1089
+ * (`test/registry-default.test.ts`가 포함 관계를 실제로 센다).
1090
+ *
1091
+ * ## 넓히되 코드 분할은 깨지 않는다
1092
+ *
1093
+ * 위젯 21종을 `defaultRegistry`처럼 정적으로 물면 그 21종이 편집기를 쓰는 모든 소비자의
1094
+ * 초기 청크에 붙는다(측정치 77.4kB — 아래 청크 합). 0.3.0에서 위젯별 진입점을 만든 이유가
1095
+ * 정확히 그것을 막기 위해서였고, 묶음(`@wipco/sdui/widgets`)을 한 번이라도 정적으로 물면
1096
+ * 개별 진입점의 `lazy`까지 도로 합쳐진다(widgets/index.ts 주석 참고).
1097
+ *
1098
+ * 그래서 **위젯은 전부 개별 진입점 + 동적 import**다. 편집기 번들에는 `import()` 호출만
1099
+ * 남고, 실제 코드는 그 위젯이 캔버스에 처음 그려질 때 청크로 내려온다.
1100
+ * DS 6종은 반대로 정적이다 — core(`@wipco/sdui`)에 이미 있어 개별 진입점이 없고,
1101
+ * 동적으로 불러 봐야 core 청크는 어차피 초기에 내려오므로 갈라지지 않는다.
1102
+ *
1103
+ * **재봤다**(apps/demo · vite build, 이 레지스트리를 넣기 전후를 같은 트리에서 비교):
1104
+ * 초기 청크 1,047.22kB → 1,056.16kB로 **+8.94kB**(gzip +3.06kB). 그중 DS 6종이 2.33kB,
1105
+ * 나머지 6.61kB가 `import()` 21개와 배선이다. 위젯 본체는 21개 청크로 갈라져 나갔다 —
1106
+ * EventCalendar 13.47kB · OrgChart 6.00kB · ColumnBoard 5.92kB … Strip 0.98kB
1107
+ * (21개 합 77.4kB, 방금 다시 셌다). 정적으로 물었다면 그게 초기 청크에 통째로 더해졌을 자리다.
1108
+ *
1109
+ * 이 모듈로 값을 옮기고 `Canvas`·`ViewerView`의 기본값으로 삼은 변경은 **초기 청크를
1110
+ * 사실상 움직이지 않았다** — 1,062.16kB → 1,062.15kB(−0.01kB, gzip 328.70 → 328.46kB).
1111
+ * 위젯 21개 청크는 크기가 하나도 안 변했다(해시만 바뀐다). 당연한 결과다 — 옮긴 것은
1112
+ * 정의 위치일 뿐이고, 캔버스는 이미 `BuilderView` 경유로 이 값을 보고 있었다.
1113
+ * **기본값을 넓히는 것과 번들이 커지는 것은 별개**라는 게 이 수치의 요점이다.
1114
+ *
1115
+ * (절대값은 라이브러리가 자라면 같이 움직인다. 이 파일을 고칠 때 확인할 것은 그 수가
1116
+ * 아니라 **청크 목록에 위젯 이름이 그대로 남아 있는가**다 — 하나라도 초기 청크로
1117
+ * 합쳐졌다면 정적 경로가 어디선가 생긴 것이다.)
1118
+ *
1119
+ * ## 여기 없는 것 — `Chart`·`MapPreview`
1120
+ *
1121
+ * 메타데이터 69종에서 이 둘과 `ForEach`를 빼면 66이다. **69가 아닌 이유가 그 셋이고,
1122
+ * 셋 다 이유가 다르다.** `ForEach`는 렌더러 내장이라 레지스트리를 애초에 안 거친다.
1123
+ * 나머지 둘은 이렇다.
1124
+ *
1125
+ * 그 둘은 `@wipco/sdui/rich`에 있고 recharts·ol이 **optional peerDependency**다
1126
+ * (`packages/sdui/package.json`의 `peerDependenciesMeta`). 기본값이 그 진입점을 물면
1127
+ * 두 라이브러리가 모든 편집기 소비자의 의존성 해석 대상이 되어, "선택 의존성"이라는
1128
+ * 성질 자체가 없어진다 — 안 깔린 프로젝트는 위젯이 안내 플레이스홀더로 떨어지는 게
1129
+ * 아니라 **빌드가 막힌다**(번들러가 `import('recharts')`를 해석하지 못한다).
1130
+ *
1131
+ * 넣어 보면 apps/demo는 멀쩡히 빌드된다(초기 청크 +0.70kB, recharts·ol은 따로 갈린다).
1132
+ * **그걸 근거로 삼으면 안 된다** — demo는 워크스페이스 심볼릭 링크라 번들러가
1133
+ * `packages/sdui/node_modules`에서 둘을 찾아낸다. 진짜 소비자에게는 그 경로가 없다.
1134
+ *
1135
+ * 두 이름은 소비자가 `registry` 프롭으로 얹고(`richComponents`가 그 스위치다),
1136
+ * 얹지 않으면 팔레트가 "등록되지 않음"으로 표시한다. **이 2종이 팔레트와 캔버스 사이에
1137
+ * 남는 유일한 격차이고, 그건 메우는 게 아니라 의도한 것이다.**
1138
+ *
1139
+ * ## `AutocompleteField`는 넣는다
1140
+ *
1141
+ * 라이브러리의 `widgetComponents` 묶음은 이 이름을 일부러 뺐다 — 조회(`endpoint`)가
1142
+ * 앱 어댑터 주입을 받는데, 얹어 두면 그 스펙이 조용히 로컬 필터링으로 강등되기 때문이다.
1143
+ * **편집 화면에는 그 위험이 없다.** 캔버스도 미리보기도 어댑터의 fetch를 막아 두어
1144
+ * 조회가 애초에 일어나지 않는다. 여기서 뺐을 때 남는 것은 "조용한 강등"이 아니라
1145
+ * 자리 표시뿐이고, 그건 자동완성 필드가 어떻게 생겼는지조차 못 보게 한다.
1146
+ */
1147
+
1148
+ /**
1149
+ * 편집 화면(캔버스·미리보기·뷰어)의 기본 어휘 66종.
1150
+ *
1151
+ * 기본값을 정하는 자리는 이 값 **하나**다. 파일 머리말의 순환 문제 때문에 값이
1152
+ * `BuilderView.tsx`에 갇혀 있던 동안 `Canvas`와 `ViewerView`가 각자 39종으로 떨어졌고,
1153
+ * 그 어긋남을 화면에서 설명하는 것이 아무 데도 없었다. 새 기본값이 필요하면
1154
+ * 여기서만 바꾸고, 새 화면을 만들면 여기서 가져다 쓴다.
1155
+ */
1156
+ declare const editorRegistry: SduiRegistry;
1157
+
1158
+ interface CanvasProps {
1159
+ /** 소비 앱 어휘를 합친 카탈로그. 안 넘기면 라이브러리 어휘만 안다. */
1160
+ catalog?: SduiCatalog;
1161
+ doc: BuilderDoc;
1162
+ selection: Selection;
1163
+ hover: NodeId | null;
1164
+ /** `editorReducer`의 dispatch. 캔버스는 변형 결과를 `apply`로만 넘긴다. */
1165
+ dispatch: (action: EditorAction) => void;
1166
+ /**
1167
+ * 소비 앱이 자기 컴포넌트를 얹은 레지스트리.
1168
+ *
1169
+ * 생략하면 {@link editorRegistry} — 라이브러리 어휘 **66종**(렌더러 기본 39 + DS 6 +
1170
+ * 표시 위젯 21, 여기에 렌더러 내장 `ForEach`)이다. 팔레트가 내거는 68종 중 여기 없는
1171
+ * 것은 `Chart`·`MapPreview` 둘뿐이고, 그건 recharts·ol이 **optional peerDependency**라
1172
+ * 일부러 뺀 것이다(editor-registry.tsx 주석). 소비자가 그 둘을 쓰려면 직접 얹어야 한다.
1173
+ *
1174
+ * 어휘 밖 컴포넌트(ExcelUploadCard 등)를 쓰는 스펙을 편집하려면 반드시 넘겨야 한다 —
1175
+ * 안 넘기면 그 노드는 "등록되지 않은 컴포넌트" 자리 표시로 그려진다(선택·이동은 된다).
1176
+ * 넓힐 때는 `createRegistry({ ...editorRegistry, ...myComponents })`로 **얹어야** 한다.
1177
+ * `createRegistry(myComponents)`는 바탕이 `defaultRegistry` 39종이라 위젯 27종이 빠진다.
1178
+ */
1179
+ registry?: SduiRegistry;
1180
+ /** `Can`의 판정. 캔버스는 게이트를 열어 두므로 보통 넘길 일이 없다. */
1181
+ can?: SduiCanSetting;
1182
+ className?: string;
1183
+ }
1184
+ declare function Canvas({ doc, catalog, selection, hover, dispatch,
1185
+ /**
1186
+ * 기본값이 여기 붙어 있는 이유 — 이 자리가 비면 한 단계 아래
1187
+ * `createEditRegistry(base = defaultRegistry)`가 받아 **39종**으로 떨어진다.
1188
+ * `BuilderView`로 열면 셸이 66종을 내려 주지만, 자기 셸을 짜서 `Canvas`를 단독으로
1189
+ * 마운트한 소비자에게는 그 셸이 없다. 그러면 팔레트가 `Timeline`을 멀쩡히 내걸고
1190
+ * 캔버스는 "등록되지 않은 컴포넌트: Timeline"이라 답하는데, 왜 다른지 설명하는 것이
1191
+ * 화면 어디에도 없다 — 소비자는 "라이브러리에 없구나"로 오귀인한다.
1192
+ */
1193
+ registry, can, className, }: CanvasProps): React.JSX.Element;
1194
+
1195
+ interface PaletteProps {
1196
+ doc: BuilderDoc;
1197
+ selection: Selection;
1198
+ dispatch: (action: EditorAction) => void;
1199
+ /**
1200
+ * 팔레트에 띄울 메타데이터. 생략하면 라이브러리 어휘 69종(`allComponentMeta`).
1201
+ * 소비 앱이 얹은 컴포넌트도 팔레트에 내려면 그 메타데이터를 합쳐 넘긴다.
1202
+ */
1203
+ catalog?: Record<string, SduiComponentMeta>;
1204
+ /**
1205
+ * 캔버스가 실제로 그릴 때 쓰는 레지스트리 — **표시 전용**이다(팔레트는 아무것도 렌더하지
1206
+ * 않는다). 여기 없는 이름을 "등록되지 않음"으로 밝히는 근거로만 쓴다.
1207
+ *
1208
+ * 생략하면 아무 표시도 하지 않는다. 무엇이 그려질지 모르는 채로 경고를 붙이면
1209
+ * 멀쩡한 조각에 거짓 경고가 달리는데, 그건 표시가 없는 것보다 나쁘다.
1210
+ * 셸(`BuilderView`)은 캔버스에 넘기는 것과 **같은 값**을 여기에도 넘긴다.
1211
+ */
1212
+ registry?: SduiRegistry;
1213
+ className?: string;
1214
+ }
1215
+ declare function Palette({ doc, selection, dispatch, catalog, registry, className }: PaletteProps): React.JSX.Element;
1216
+
1217
+ /**
1218
+ * 편집 한 번. `dispatch({ type: 'apply', ...change })`에 그대로 넣을 수 있는 모양이다.
1219
+ */
1220
+ interface InspectorChange {
1221
+ readonly result: TransformResult;
1222
+ /** 실행취소 메뉴에 나갈 한국어 이름 */
1223
+ readonly label: string;
1224
+ /** 연속 입력을 한 칸으로 합칠 키. 없으면 항상 새 칸. */
1225
+ readonly coalesce?: string;
1226
+ /**
1227
+ * 속성 편집은 **선택을 건드리지 않는다**. 리듀서의 기본값(`affected`)을 그냥 두면
1228
+ * 여러 노드를 골라 둔 상태에서 프롭 하나만 고쳐도 선택이 기준 노드 하나로 줄어든다.
1229
+ */
1230
+ readonly select: 'keep';
1231
+ }
1232
+ interface InspectorProps {
1233
+ /** 소비 앱 어휘를 합친 카탈로그. 안 넘기면 라이브러리 어휘만 안다. */
1234
+ readonly catalog?: SduiCatalog;
1235
+ readonly doc: BuilderDoc;
1236
+ readonly selection: Selection;
1237
+ readonly onApply: (change: InspectorChange) => void;
1238
+ /**
1239
+ * 바인딩 편집기 위임 — 다른 담당이 만든다.
1240
+ *
1241
+ * 주지 않으면 바인딩 값은 JSON으로 편집할 수 있는 상태로 남는다(값을 못 고치게
1242
+ * 막지는 않는다). 인자는 프롭 이름 하나이고, 대상 노드는 `selection.primary`다.
1243
+ */
1244
+ readonly onRequestBinding?: (propName: string) => void;
1245
+ /** 노드의 React `key`까지 편집할 수 있게 한다. 기본 true. */
1246
+ readonly allowKeyEdit?: boolean;
1247
+ readonly className?: string;
1248
+ }
1249
+ declare function Inspector({ doc, catalog, selection, onApply, onRequestBinding, allowKeyEdit, className, }: InspectorProps): React.JSX.Element;
1250
+
1251
+ /**
1252
+ * 편집 문서에서 **화면이 필요로 하는 것들을 뽑아내는** 파생 조회.
1253
+ *
1254
+ * 문서를 바꾸지 않는다(전부 읽기 전용). 트리 패널·속성 패널·바인딩 자동완성이
1255
+ * 여기서 같은 답을 가져가야 세 화면이 서로 다른 말을 하지 않는다.
1256
+ *
1257
+ * 결과를 캐시하지 않는 이유는 문서가 불변이라서다 — 호출부가
1258
+ * `useMemo(() => flattenTree(doc), [doc])`로 감싸면 그게 곧 정확한 캐시다.
1259
+ * 여기서 WeakMap을 하나 더 두면 무효화 규칙만 두 벌이 된다.
1260
+ */
1261
+
1262
+ interface ChildLabel {
1263
+ /** 한국어 표시명. 메타데이터가 없으면 컴포넌트 이름 그대로. */
1264
+ readonly label: string;
1265
+ /** 무엇이 들어 있는지 한 줄 힌트. 없을 수 있다. */
1266
+ readonly hint?: string;
1267
+ /** 팔레트 아이콘 이름(`ICON_NODES` 기준). 메타데이터가 없으면 undefined. */
1268
+ readonly icon?: string;
1269
+ /** 메타데이터를 아는 어휘인가. false면 소비 앱이 레지스트리에 얹은 컴포넌트다. */
1270
+ readonly known: boolean;
1271
+ }
1272
+ /** 트리·브레드크럼·선택 배지가 함께 쓰는 표시 이름. */
1273
+ declare function describeChild(child: BuilderChild, catalog?: SduiCatalog): ChildLabel;
1274
+ /** 선택된 노드가 어디에 있는지 — 바깥에서 안쪽 순서. 마지막 항목이 자기 자신이다. */
1275
+ declare function breadcrumbOf(doc: BuilderDoc, id: NodeId, catalog?: SduiCatalog): ReadonlyArray<{
1276
+ id: NodeId;
1277
+ label: string;
1278
+ }>;
1279
+ interface SlotDescriptor {
1280
+ readonly slot: string;
1281
+ /** 한국어 표시명. 기본 슬롯은 메타데이터의 `children.label`이 있으면 그걸 쓴다. */
1282
+ readonly label: string;
1283
+ readonly description?: string;
1284
+ readonly count: number;
1285
+ /** 메타데이터가 아는 슬롯인가. false면 스펙에만 있는 슬롯 — 지우지 말고 그대로 보여 준다. */
1286
+ readonly known: boolean;
1287
+ /** 여기에 무언가 놓을 수 있는가. */
1288
+ readonly accepts: boolean;
1289
+ /** 실측 스펙에서 이 슬롯이 쓰인 총 횟수 — 많이 쓰는 자리를 위로 올린다. */
1290
+ readonly seen: number;
1291
+ }
1292
+ /**
1293
+ * 이 노드가 가진 드롭 자리 전부.
1294
+ *
1295
+ * **비어 있는 슬롯도 돌려준다.** 실측에서 `Dialog.footer`는 227회 쓰이는 최다 슬롯인데,
1296
+ * 지금 비어 있다는 이유로 목록에서 빼면 사용자가 그 자리를 찾을 방법이 없다.
1297
+ * 반대로 메타데이터가 모르는 슬롯이 스펙에 있으면 그것도 함께 돌려준다 —
1298
+ * 편집기가 모르는 자리를 감추면 그 안의 내용은 편집도 삭제도 못 하는 유령이 된다.
1299
+ *
1300
+ * 정렬은 기본 슬롯 → 실측 빈도 순이다.
1301
+ */
1302
+ declare function slotsOf(node: BuilderNode, catalog?: SduiCatalog): readonly SlotDescriptor[];
1303
+ type TreeRowKind = 'node' | 'text' | 'slot';
1304
+ interface TreeRow {
1305
+ readonly kind: TreeRowKind;
1306
+ /** 행 고유 키. 노드/텍스트는 id, 슬롯 행은 `부모id#슬롯`. 펼침 상태의 키이기도 하다. */
1307
+ readonly key: string;
1308
+ /** 슬롯 행이면 null. */
1309
+ readonly id: NodeId | null;
1310
+ readonly depth: number;
1311
+ /** 이 행이 놓인 자리. 슬롯 행은 index가 -1이다. */
1312
+ readonly parent: NodeId;
1313
+ readonly slot: string;
1314
+ readonly index: number;
1315
+ readonly label: string;
1316
+ readonly hint?: string;
1317
+ readonly icon?: string;
1318
+ readonly component?: string;
1319
+ /** 펼칠 것이 있는가. */
1320
+ readonly expandable: boolean;
1321
+ /**
1322
+ * 화면에 자기 모습이 없는 논리 래퍼(Show·Can·ForEach)인가.
1323
+ * 실측에서 `body` 직속 최상위의 1위가 `Show`(153회)라, 트리에서 이걸 구분해 주지 않으면
1324
+ * "아무것도 없는 페이지"처럼 보인다.
1325
+ */
1326
+ readonly logical: boolean;
1327
+ /** 메타데이터를 아는 어휘인가. */
1328
+ readonly known: boolean;
1329
+ }
1330
+ interface FlattenOptions {
1331
+ /**
1332
+ * 펼쳐진 행의 키 집합. 생략하면 전부 펼친다.
1333
+ * 슬롯 행을 접으면 그 안의 자식도 나오지 않는다.
1334
+ */
1335
+ readonly expanded?: ReadonlySet<string>;
1336
+ /**
1337
+ * 슬롯 행을 낼 것인가. 기본 true —
1338
+ * 슬롯이 1급 드롭 타깃이라 트리에도 자리가 있어야 한다.
1339
+ * 자식이 기본 슬롯에만 있는 노드는 슬롯 행을 생략해 트리를 얕게 유지한다.
1340
+ */
1341
+ readonly showSlots?: boolean;
1342
+ /** 소비 앱 어휘를 합친 카탈로그. 안 넘기면 앱 고유 컴포넌트가 이름 그대로 나온다. */
1343
+ readonly catalog?: SduiCatalog;
1344
+ }
1345
+ /**
1346
+ * 트리 패널이 그릴 평평한 행 목록. 가상 스크롤을 붙이기 쉽도록 중첩 대신 `depth`를 준다.
1347
+ */
1348
+ declare function flattenTree(doc: BuilderDoc, options?: FlattenOptions): readonly TreeRow[];
1349
+ /**
1350
+ * 이 노드 근처에서 실제로 놓을 수 있는 자리들 — 캔버스가 드래그 중 후보를 띄울 때 쓴다.
1351
+ * 대상 컴포넌트 이름(문자열 자식이면 model의 `TEXT_CHILD`)을 주면 막히는 자리는 빼고 돌려준다.
1352
+ */
1353
+ declare function dropTargetsAround(doc: BuilderDoc, hoveredId: NodeId, child: string, catalog?: SduiCatalog): ReadonlyArray<{
1354
+ target: DropTarget;
1355
+ label: string;
1356
+ recommended: boolean;
1357
+ }>;
1358
+ /**
1359
+ * 키가 어디서 왔는가.
1360
+ *
1361
+ * - `form` Form의 initial/rules/labels/initialFromUrl에 이름이 있는 필드
1362
+ * - `formSystem` 렌더러가 직접 쓰는 `{폼id}.$errors` · `$submitting` · `$dirty`
1363
+ * - `setState` setState/open/close/array* 액션이 쓰는 키
1364
+ * - `default` `{ "$state": k, "default": ... }` — 기본값을 준 참조는 선언에 준한다
1365
+ * - `urlState` 페이지의 `urlState` 목록
1366
+ * - `read` 읽히기만 하는 키
1367
+ */
1368
+ type StateKeyOrigin = 'form' | 'formSystem' | 'setState' | 'default' | 'urlState' | 'read';
1369
+ interface StateKeyEntry {
1370
+ readonly key: string;
1371
+ readonly origins: readonly StateKeyOrigin[];
1372
+ /** `read` 말고 다른 출처가 하나라도 있는가. */
1373
+ readonly declared: boolean;
1374
+ readonly reads: number;
1375
+ readonly writes: number;
1376
+ /** 이 키를 언급하는 노드들 — 트리에서 "이 키를 쓰는 곳으로" 이동하는 데 쓴다. */
1377
+ readonly nodes: readonly NodeId[];
1378
+ }
1379
+ interface StateKeyNode {
1380
+ readonly segment: string;
1381
+ /** 루트부터의 전체 키. */
1382
+ readonly key: string;
1383
+ /** 이 경로 자체가 문서에 등장했으면 그 항목. 중간 마디는 없을 수 있다. */
1384
+ readonly entry?: StateKeyEntry;
1385
+ /**
1386
+ * 조상 중에 선언된 키가 있는가.
1387
+ *
1388
+ * **`selectedClient`를 setState로 넣고 `selectedClient.address`를 읽는 것은 정상이다.**
1389
+ * 하위 필드는 API 응답 모양에서 오지 스펙에 있지 않다 — 스펙만 봐서는 알 수 없다.
1390
+ * 이 플래그가 true인 키는 절대 오류로 표시하면 안 된다.
1391
+ */
1392
+ readonly derived: boolean;
1393
+ readonly children: readonly StateKeyNode[];
1394
+ }
1395
+ interface StateKeyIndex {
1396
+ readonly entries: ReadonlyMap<string, StateKeyEntry>;
1397
+ /** 점(`.`)으로 쪼갠 계층 트리. 자동완성 드롭다운이 그대로 그린다. */
1398
+ readonly tree: readonly StateKeyNode[];
1399
+ /** 문서에 있는 Form들 — submitForm 편집기와 폼 필드 자동완성이 쓴다. */
1400
+ readonly forms: ReadonlyMap<string, {
1401
+ readonly node: NodeId;
1402
+ readonly fields: readonly string[];
1403
+ }>;
1404
+ }
1405
+ /**
1406
+ * 문서를 훑어 `$state` 키를 모은다.
1407
+ *
1408
+ * 실측에서 바인딩 1,464회 중 `$state`가 1,100회(75%)고 서로 다른 키가 **786개**다.
1409
+ * 이 정도면 자유 입력으로는 오타가 반드시 나고, 오타 난 키는 예외 없이
1410
+ * "화면에 아무것도 안 나옴"으로만 드러난다(렌더러는 없는 키를 `default`로 조용히 대체한다).
1411
+ * 그래서 자동완성이 선택이 아니라 필수다.
1412
+ */
1413
+ declare function buildStateKeyIndex(doc: BuilderDoc): StateKeyIndex;
1414
+ /**
1415
+ * 키 하나의 상태.
1416
+ *
1417
+ * - `declared` 문서 어딘가가 이 키를 만든다(setState·Form·urlState·default)
1418
+ * - `derived` 조상 키가 선언돼 있고 이건 그 하위 필드다 — **정상이다**
1419
+ * - `read` 읽히기만 한다. 다른 페이지나 앱 코드가 넣어 줄 수도 있다
1420
+ * - `unknown` 문서 어디에도 없다
1421
+ *
1422
+ * 넷 중 무엇도 **오류가 아니다.** `unknown`조차 "혹시 오타인가요?" 수준의 귀띔이 상한선이다 —
1423
+ * 상태를 채우는 주체가 스펙 밖(호스트 앱·API 응답)에 있는 것이 이 계약의 정상 동작이다.
1424
+ */
1425
+ declare function stateKeyStatus(index: StateKeyIndex, key: string): 'declared' | 'derived' | 'read' | 'unknown';
1426
+ /**
1427
+ * 자동완성 후보. 선언된 키를 먼저, 그다음 많이 쓰인 순으로 준다.
1428
+ * `query`가 비면 전체를 같은 순서로 준다.
1429
+ */
1430
+ declare function suggestStateKeys(index: StateKeyIndex, query?: string, limit?: number): readonly StateKeyEntry[];
1431
+
1432
+ /**
1433
+ * 트리의 드래그 앤 드롭 규약과 **놓을 자리 계산**.
1434
+ *
1435
+ * 편집기 안에서 오가는 드래그(팔레트→트리, 트리→캔버스)는 세 패널이 함께 보는
1436
+ * 모듈 보관소(`../canvas/dnd.js`)를 통한다 — `./bridge.js`가 그 다리다.
1437
+ * 여기 있는 MIME 규약은 **바깥에서 들어오는 드래그의 폴백**이다: 다른 창이나 아직
1438
+ * 공용 보관소를 모르는 도구가 스펙 조각을 떨궈도 트리가 받아 낼 수 있게 한다.
1439
+ *
1440
+ * ## 왜 payload를 모듈 변수에도 들고 있는가
1441
+ *
1442
+ * HTML5 드래그는 `dragover` 동안 `dataTransfer.getData()`를 **빈 문자열로 막는다**
1443
+ * (보호 모드 — 드롭하기 전에는 내용을 못 읽는다). 읽을 수 있는 것은 `types` 목록뿐이다.
1444
+ * 그런데 트리는 끌고 오는 동안 "여기 놓아도 되는가"를 색으로 답해야 하고, 그 판정에는
1445
+ * 컴포넌트 이름이 필요하다. 그래서 드래그를 **시작한 쪽이** {@link publishDrag}로
1446
+ * 같은 문서 안의 모듈 변수에 payload를 남기고, 받는 쪽은 그것을 읽어 미리 판정한다.
1447
+ * (같은 번들 안에서만 통한다. 다른 창이나 바깥 앱에서 온 드래그는 `types`만 보고
1448
+ * 낙관적으로 받아 준 뒤 `drop`에서 실제 내용으로 다시 판정한다 — 이때는 거절 사유를
1449
+ * 화면에 남긴다.)
1450
+ *
1451
+ * ## MIME
1452
+ *
1453
+ * 팔레트·캔버스와 나눠 쓰는 이름이라 상수로 고정한다. 팔레트가 다른 형식을 쓰면
1454
+ * 트리에 `readDrag`를 갈아 끼우는 대신 `TreeView`의 `readDrag` 프롭으로 넘기면 된다.
1455
+ *
1456
+ * - `application/x-sdui-nodes` 문서 안 노드 이동 — id 배열
1457
+ * - `application/x-sdui-spec` 새 조각 삽입 — `SduiChild` 또는 그 배열
1458
+ * - `application/x-sdui-component` 새 조각 삽입 — 컴포넌트 이름만(메타데이터 기본 조각을 쓴다)
1459
+ */
1460
+
1461
+ /** 문서 안의 노드를 옮기는 드래그. */
1462
+ interface MoveDrag {
1463
+ readonly kind: 'move';
1464
+ readonly ids: readonly NodeId[];
1465
+ /** 드롭 판정에 쓰는 이름들(문자열 자식은 {@link TEXT_CHILD}). */
1466
+ readonly components: readonly string[];
1467
+ }
1468
+ /** 새 조각을 넣는 드래그(팔레트·붙여넣기). */
1469
+ interface InsertDrag {
1470
+ readonly kind: 'insert';
1471
+ readonly specs: readonly SduiChild[];
1472
+ readonly components: readonly string[];
1473
+ }
1474
+ type TreeDrag = MoveDrag | InsertDrag;
1475
+
1476
+ /**
1477
+ * 트리 패널 — 페이지 구조를 계층으로 보여 주고, 거기서 바로 고른다·옮긴다.
1478
+ *
1479
+ * ## 왜 트리가 선택 사항이 아닌가 (실측)
1480
+ *
1481
+ * - 중첩이 **최대 14단계**다. 캔버스에서 그 깊이의 노드를 정확히 집는 것은 사실상 불가능하다.
1482
+ * - `body` 직속 최상위 1위가 **`Show` 153회**고 그 안이 `Dialog` 129회다. 논리 래퍼와
1483
+ * 닫힌 다이얼로그는 **캔버스에 아무 모습도 남기지 않는다** — 트리가 없으면 그 노드들은
1484
+ * 편집기에서 존재하지 않는 것과 같다.
1485
+ * - 슬롯은 캔버스에서 부모와 겹쳐 보인다. `Dialog.footer`는 실측 최다 슬롯(227회)인데
1486
+ * 비어 있을 때는 캔버스에 클릭할 픽셀조차 없다. 그래서 여기서는 **슬롯이 별도 가지**이고,
1487
+ * 비어 있어도 자리를 남긴다(`tree/rows.ts`).
1488
+ *
1489
+ * ## 상태를 여기서 들지 않는다
1490
+ *
1491
+ * 문서·선택·실행취소는 전부 `state.ts`의 리듀서가 든다. 이 컴포넌트가 스스로 가진 상태는
1492
+ * **트리에만 있는 것**뿐이다 — 펼침 집합, 검색어, 드래그 중 미리보기. 그래야 캔버스와
1493
+ * 속성 패널이 같은 선택을 보고, 되돌리기가 트리 조작까지 한 칸으로 되돌린다.
1494
+ */
1495
+
1496
+ interface TreeViewProps {
1497
+ /** 소비 앱 어휘를 합친 카탈로그. 안 넘기면 라이브러리 어휘만 안다. */
1498
+ readonly catalog?: SduiCatalog;
1499
+ readonly state: EditorState;
1500
+ readonly dispatch: React__default.Dispatch<EditorAction>;
1501
+ /**
1502
+ * 바깥에서 온 드래그를 해석한다. 기본값은 `tree/dnd.js`의 규약(MIME 세 가지)이다.
1503
+ * 팔레트가 다른 형식을 쓰면 여기에 해석기를 끼우면 된다.
1504
+ */
1505
+ readonly readDrag?: (dataTransfer: DataTransfer) => TreeDrag | null;
1506
+ /** 행을 더블클릭했을 때 — 고급 모드(JSON)로 넘기는 등 바깥이 정한다. */
1507
+ readonly onActivate?: (id: NodeId) => void;
1508
+ readonly className?: string;
1509
+ }
1510
+ declare function TreeView({ state, catalog, dispatch, readDrag, onActivate, className, }: TreeViewProps): React__default.JSX.Element;
1511
+
1512
+ /**
1513
+ * 실행취소 바 — 되돌리기/다시하기 버튼과 단축키, 그리고 **무엇이 되돌려지는지**.
1514
+ *
1515
+ * 되돌리기의 단위는 `state.ts`가 정한다(같은 프롭 연속 타이핑은 700ms 창으로 한 칸,
1516
+ * 드래그는 `txn begin/end`로 한 칸, 구조 변경은 절대 합치지 않는다). 여기서는 그 기록을
1517
+ * **읽어서 보여 주기만** 한다 — 단위를 화면 쪽에서 한 번 더 정하면 두 규칙이 어긋난다.
1518
+ *
1519
+ * ## 왜 이름을 계속 띄우는가
1520
+ *
1521
+ * 시각 빌더에서 되돌리기는 "방금 뭘 했는지 기억나지 않을 때" 눌린다. 드래그 한 번이
1522
+ * 어디까지 한 칸인지 화면에 없으면 사용자는 Ctrl+Z를 서너 번 연타해 필요한 것까지
1523
+ * 지운다. 그래서 버튼 옆에 **다음에 되돌려질 것의 이름**을 상시로 두고, 누른 뒤에는
1524
+ * 잠시 "무엇을 되돌렸는지"로 바꿔 준다. 여러 칸을 한 번에 되돌릴 수 있게 기록 목록도 낸다.
1525
+ */
1526
+
1527
+ interface HistoryBarProps {
1528
+ readonly state: EditorState;
1529
+ readonly dispatch: React__default.Dispatch<EditorAction>;
1530
+ /** ⌘Z/⌘⇧Z(Ctrl+Z/Ctrl+Y)를 문서 전역에 걸 것인가. 기본 true. */
1531
+ readonly shortcuts?: boolean;
1532
+ /** 여러 칸을 한 번에 되돌리는 기록 목록 버튼. 기본 true. */
1533
+ readonly menu?: boolean;
1534
+ readonly className?: string;
1535
+ }
1536
+ /**
1537
+ * 지금 글자를 치고 있는 자리인가.
1538
+ *
1539
+ * 입력·textarea·contenteditable, 그리고 **CodeMirror(고급 JSON 모드)** 안에서는
1540
+ * ⌘Z를 가로채면 안 된다. 그 안에는 각자의 실행취소가 있고, 그것을 빼앗으면
1541
+ * "한 글자 되돌리려다 방금 만든 카드가 사라지는" 일이 생긴다.
1542
+ *
1543
+ * **이 함수는 "여기가 입력인가"만 답한다 — "그래서 양보하는가"는 키마다 다르다.**
1544
+ * 처음에는 이 판정 하나로 모든 단축키를 잘랐는데, 그건 ⌘Z에만 맞는 답이었다.
1545
+ * ⌘S는 입력 안에 경쟁하는 뜻이 아예 없고, Escape는 뜻이 있어도 편집기 쪽 처리가
1546
+ * 더 자연스럽다. 키별 판단은 그것을 거는 쪽(`BuilderView`)이 한다.
1547
+ */
1548
+ declare function isTypingTarget(target: EventTarget | null): boolean;
1549
+ /**
1550
+ * ⌘Z/⌘⇧Z(그리고 Ctrl+Y)를 창 전체에 건다.
1551
+ * 트리·캔버스·속성 패널 어디에 초점이 있어도 같은 기록을 되돌리게 하려는 것이다.
1552
+ */
1553
+ declare function useUndoRedoShortcuts(state: EditorState, dispatch: React__default.Dispatch<EditorAction>, enabled?: boolean): void;
1554
+ declare function HistoryBar({ state, dispatch, shortcuts, menu, className, }: HistoryBarProps): React__default.JSX.Element;
1555
+
1556
+ /**
1557
+ * 액션 필드의 **후보 목록**을 문서에서 캐낸다.
1558
+ *
1559
+ * 오타 하나가 조용히 아무 일도 안 일어나게 만드는 자리들이라
1560
+ * (`close`가 없는 키를 닫아도 렌더러는 그냥 false를 쓴다) 자유 입력만 두면 안 된다.
1561
+ * 다만 어느 목록도 **완전하지 않다** — 상태를 채우는 주체가 스펙 밖(호스트 앱)에 있는 것이
1562
+ * 이 계약의 정상 동작이다. 그래서 전부 "고를 수도 있고 직접 칠 수도 있는" 목록으로 낸다.
1563
+ */
1564
+
1565
+ interface Candidate {
1566
+ readonly value: string;
1567
+ /** 목록에 보일 한국어 설명. 없으면 값만 보여 준다. */
1568
+ readonly hint?: string;
1569
+ /** 문서에서 근거를 찾은 값인가. false면 "다른 데서 온 값"이라 경고하지 않는다. */
1570
+ readonly grounded: boolean;
1571
+ readonly node?: NodeId;
1572
+ }
1573
+ /**
1574
+ * 액션이 들어가는 프롭인가.
1575
+ *
1576
+ * 렌더러는 `on`으로 시작하는 프롭만 핸들러로 바꾸지만, `SearchResults.rowClick`처럼
1577
+ * **컴포넌트가 자기 손으로 dispatch하는 프롭**이 실측에 있다(메타데이터는 `kind: 'action'`).
1578
+ * 둘 다 액션 자리로 본다. 메타데이터를 모르는 컴포넌트는 이름 규칙만으로 판단한다.
1579
+ */
1580
+ declare function looksLikeActionProp(component: string, prop: string, catalog?: SduiCatalog): boolean;
1581
+
1582
+ /**
1583
+ * `$item`/`$index`가 **의미를 갖는 자리인지** 판정한다.
1584
+ *
1585
+ * 렌더러는 ForEach의 기본 자식만 반복 스코프로 감싼다(`SduiRenderer`의 `rowScope`).
1586
+ * 그 밖에서 `$item`을 쓰면 예외도 경고도 없이 `null`이 되고, 화면에는 빈칸만 남는다 —
1587
+ * 실측 스펙 84개의 `$item`/`$index` 206회가 **예외 없이** ForEach 안이었던 것도
1588
+ * 밖에서 쓴 것이 조용히 죽어 살아남지 못했기 때문이라고 봐야 한다.
1589
+ * 그래서 스코프 밖에서는 선택지로 **제시하지 않는다**(이미 들어 있으면 감추지 않는다 —
1590
+ * 감추면 고칠 수가 없다).
1591
+ *
1592
+ * 주의할 자리가 둘 있다.
1593
+ * - `ForEach.source` 자체는 바깥 스코프에서 풀린다. 그래서 ForEach **자기 프롭**은 스코프 밖이다.
1594
+ * - `ForEach#empty` 슬롯은 행이 없을 때 그리므로 행이 없다. 역시 스코프 밖이다.
1595
+ */
1596
+
1597
+ interface ForEachScope {
1598
+ /** 감싼 ForEach 개수. 0이면 스코프 밖이다. */
1599
+ readonly depth: number;
1600
+ /** 가장 안쪽 ForEach — `$item`이 가리키는 행의 주인이다. */
1601
+ readonly nearest: NodeId | null;
1602
+ readonly inScope: boolean;
1603
+ /** 스코프 밖일 때의 한국어 사유. 화면에 그대로 띄우면 된다. */
1604
+ readonly reason?: string;
1605
+ /** `$item`의 필드 후보 — 확정 목록이 아니라 귀띔이다(자유 입력을 막지 않는다). */
1606
+ readonly fields: readonly string[];
1607
+ }
1608
+
1609
+ /**
1610
+ * 바인딩·액션 편집기가 자동완성에 쓰는 재료 묶음.
1611
+ *
1612
+ * **두 층으로 나눠 두었다.** 상태 키 인덱스와 후보 목록은 문서 전체를 훑는 일이라
1613
+ * 문서가 바뀔 때만 다시 만들면 되고, ForEach 스코프 판정은 조상 사슬만 타는 일이라
1614
+ * 선택이 바뀔 때마다 해도 싸다. 한 함수로 합치면 노드를 클릭할 때마다 786개짜리
1615
+ * 키 인덱스를 다시 세우게 된다.
1616
+ *
1617
+ * ```ts
1618
+ * const source = useMemo(() => createBindingSource(doc, pageIds), [doc, pageIds]);
1619
+ * const ctx = useMemo(() => bindingContextFor(source, selection.primary), [source, selection.primary]);
1620
+ * ```
1621
+ */
1622
+
1623
+ /** 문서에만 의존하는 재료. 문서가 바뀔 때만 다시 만든다. */
1624
+ interface BindingSource {
1625
+ readonly doc: BuilderDoc;
1626
+ readonly stateKeys: StateKeyIndex;
1627
+ /** Dialog를 품은 Show의 게이트 키 — open/close 후보 */
1628
+ readonly dialogKeys: readonly Candidate[];
1629
+ /** navigate 후보 (스펙 id + 이 페이지가 이미 쓰는 경로) */
1630
+ readonly pages: readonly Candidate[];
1631
+ /** submitForm/download 엔드포인트 후보 */
1632
+ readonly endpoints: readonly Candidate[];
1633
+ /** 폼 id → 필드 목록 */
1634
+ readonly forms: StateKeyIndex['forms'];
1635
+ }
1636
+ declare function createBindingSource(doc: BuilderDoc, pageIds?: readonly string[], catalog?: SduiCatalog): BindingSource;
1637
+ /** 편집 중인 노드까지 얹은 최종 컨텍스트. 선택이 바뀔 때마다 만들어도 싸다. */
1638
+ interface BindingContext extends BindingSource {
1639
+ /** 값이 놓일 노드. null이면 `$item`/`$index`를 제시하지 않는다. */
1640
+ readonly nodeId: NodeId | null;
1641
+ readonly scope: ForEachScope;
1642
+ }
1643
+ declare function bindingContextFor(source: BindingSource, nodeId: NodeId | null): BindingContext;
1644
+ /** 한 번에 만들고 싶을 때(테스트·간단한 호출부). 위 두 단계를 그냥 붙인 것이다. */
1645
+ declare function createBindingContext(doc: BuilderDoc, nodeId: NodeId | null, pageIds?: readonly string[]): BindingContext;
1646
+
1647
+ /**
1648
+ * 값 하나를 편집한다 — **상수인가 바인딩인가**를 고르고, 바인딩이면 종류까지.
1649
+ *
1650
+ * 배치는 쉽고 바인딩이 어렵다. 여기서 대충 하면 사용자는 결국 JSON을 연다.
1651
+ * 실측 84개에서 나온 세 가지 사실이 이 화면의 모양을 결정했다.
1652
+ *
1653
+ * 1. **바인딩 1,464회 중 `$state`가 1,100회(75%)**다. 종류 고르기는 한 번의 클릭이어야 하고,
1654
+ * 상태 키 칸은 처음부터 자동완성이 붙어 있어야 한다(→ `StateKeyPicker`).
1655
+ * 2. **`$item`/`$index` 206회가 전부 ForEach 안**이었다. 밖에서 쓰면 조용히 null이 되므로
1656
+ * 스코프 밖에서는 아예 제시하지 않는다. 다만 **이미 들어 있으면 감추지 않는다** —
1657
+ * 감추면 잘못 들어간 값을 고칠 방법이 사라진다.
1658
+ * 3. **`$calc`는 인자가 다시 바인딩**이라 최대 3단까지 중첩됐다. 재귀 편집이 필수고,
1659
+ * 깊어지면 폼만 봐서는 못 읽으므로 한 줄 수식 미리보기를 항상 띄운다.
1660
+ *
1661
+ * 종류를 바꿔도 값을 잃지 않는다(`ValueStash`). 왕복해서 원래 값이 안 돌아오면
1662
+ * 사용자는 전환 자체를 피하게 되고, 그럼 이 편집기는 없는 것과 같다.
1663
+ */
1664
+
1665
+ interface BindingEditorProps {
1666
+ value: SduiPropValue | undefined;
1667
+ onChange: (value: SduiPropValue | undefined) => void;
1668
+ /** 자동완성 재료. `createBindingSource`/`bindingContextFor`로 만든다. */
1669
+ ctx: BindingContext;
1670
+ /** 프롭 정의 — 상수 입력의 모양과 `bindable` 권고를 여기서 읽는다. */
1671
+ meta?: SduiPropMeta | undefined;
1672
+ autoFocus?: boolean;
1673
+ /** 재귀 깊이(내부용). 너무 깊어지면 JSON으로 안내한다. */
1674
+ depth?: number;
1675
+ /** 바인딩을 받을 수 없는 자리(예: `$state`의 default)에서 종류 선택을 감춘다. */
1676
+ literalOnly?: boolean;
1677
+ 'aria-label'?: string;
1678
+ }
1679
+ declare function BindingEditor({ value, onChange, ctx, meta, autoFocus, depth, literalOnly, 'aria-label': ariaLabel, }: BindingEditorProps): React__default.JSX.Element;
1680
+
1681
+ /**
1682
+ * `on*` 프롭에 붙는 **액션 목록**을 편집한다.
1683
+ *
1684
+ * 실측 1,685회 중 `setState` 636 · `close` 390 · `open` 175로 이 셋이 66%다. 그래서
1685
+ * 빠른 추가 버튼을 이 셋으로 두고 목록도 빈도순으로 정렬했다. 그러나 나머지 어휘도
1686
+ * **전부 편집 가능하다** — 어휘를 좁히면 그걸 쓰는 기존 페이지를 이 편집기로 못 연다.
1687
+ *
1688
+ * 세 가지를 특히 조심했다.
1689
+ *
1690
+ * 1. **모양 보존.** 실측 액션 프롭 1,140개 중 930개가 배열이 아니라 객체 하나다.
1691
+ * 하나로 남는 한 객체로 되돌려 쓴다(→ `writeActionList`). 안 그러면 한 글자도 안 고친
1692
+ * 페이지를 열고 저장하는 것만으로 930군데가 바뀐 diff가 나온다.
1693
+ * 2. **어휘 밖 액션·모르는 필드 보존.** 스키마에 없으면 원시 JSON으로 떨어뜨리되
1694
+ * 절대 버리지 않는다. 편집기가 모르는 값을 지우는 것이 이 도구의 최악의 오작동이다.
1695
+ * 3. **순서.** 한 프롭에 액션이 18개까지 붙는다(실측 최대). 접힌 목록 + 위/아래가 기본이다.
1696
+ */
1697
+
1698
+ interface ActionEditorProps {
1699
+ value: SduiPropValue | undefined;
1700
+ onChange: (value: SduiPropValue | undefined) => void;
1701
+ ctx: BindingContext;
1702
+ /** 프롭 정의 — `eventArg`(핸들러에 들어오는 값) 설명을 여기서 읽는다. */
1703
+ meta?: SduiPropMeta | undefined;
1704
+ depth?: number;
1705
+ 'aria-label'?: string;
1706
+ }
1707
+ declare function ActionEditor({ value, onChange, ctx, meta, depth, 'aria-label': ariaLabel, }: ActionEditorProps): React__default.JSX.Element;
1708
+
1709
+ /**
1710
+ * 액션 어휘의 **편집 스키마** — 어떤 필드를 어떤 입력으로 그릴지.
1711
+ *
1712
+ * 실측 1,685회 중 `setState` 636 · `close` 390 · `open` 175로 셋이 66%다. 그래서 이 셋은
1713
+ * 1급으로 올리되(빈도순 정렬 + 빠른 추가), **나머지도 전부 편집 가능해야 한다** —
1714
+ * 어휘를 좁히면 그걸 쓰는 기존 페이지를 편집기로 못 연다.
1715
+ *
1716
+ * 스키마에 없는 필드와 스키마에 없는 액션을 **버리지 않는 것**이 이 파일의 핵심 계약이다.
1717
+ * 편집기가 모르는 값을 지우는 것이 이 도구가 낼 수 있는 최악의 오작동이다.
1718
+ */
1719
+
1720
+ type ActionValue = Readonly<Record<string, SduiPropValue>>;
1721
+ interface ActionList {
1722
+ readonly actions: readonly ActionValue[];
1723
+ /**
1724
+ * 원래 스펙이 배열이 아니라 **객체 하나**였는가.
1725
+ *
1726
+ * 실측 액션 프롭 1,140개 중 930개가 객체 하나다. 편집기가 이걸 전부 배열로 펴면
1727
+ * 한 글자도 안 고친 페이지를 열고 저장하는 것만으로 930군데가 바뀐 diff가 나온다.
1728
+ * 그래서 개수가 하나로 남는 한 원래 모양을 지킨다.
1729
+ */
1730
+ readonly single: boolean;
1731
+ /** 액션이 아닌 것이 섞여 있었는가 — 그대로 보존하되 화면에 알린다. */
1732
+ readonly strays: readonly SduiPropValue[];
1733
+ }
1734
+ declare function readActionList(value: SduiPropValue | undefined): ActionList;
1735
+ /**
1736
+ * 다시 스펙 값으로. 액션이 하나도 없으면 `undefined`를 돌려주고,
1737
+ * 호출부는 그걸로 프롭 자체를 지운다(`setProp(doc, id, name, undefined)`).
1738
+ */
1739
+ declare function writeActionList(list: ActionList): SduiPropValue | undefined;
1740
+
1741
+ /**
1742
+ * 값과 액션을 **한 줄로** 읽히게 만든다.
1743
+ *
1744
+ * `$calc`는 인자가 다시 바인딩일 수 있어서 실측 최대 3단까지 중첩된다
1745
+ * (`concat(count($state:items), "개 품목")` 같은 것). 접힌 폼을 펼쳐 가며 읽는 것보다
1746
+ * `개수({items}) ⧺ "개 품목"` 한 줄이 훨씬 빠르다 — 편집은 폼에서 하고, 확인은 이 줄로 한다.
1747
+ */
1748
+
1749
+ /**
1750
+ * 값 하나를 수식처럼. 리터럴은 JSON에 가깝게, 바인딩은 중괄호 표기로 낸다.
1751
+ * `depth`는 재귀 폭주 방지용이다(스펙은 신뢰할 수 없는 입력이라 순환을 배제하지 못한다).
1752
+ */
1753
+ declare function formatValue(value: SduiPropValue | undefined, depth?: number): string;
1754
+ /**
1755
+ * 액션 한 줄 요약. 접힌 목록에서 **무엇을 하는 액션인지**만 보이면 된다.
1756
+ *
1757
+ * 실측에서 한 프롭에 액션이 18개까지 붙는다. 그 목록을 전부 펼쳐 놓으면 화면이 죽으므로
1758
+ * 접힌 상태가 기본이고, 그때 이 줄이 유일한 정보다.
1759
+ */
1760
+ declare function formatAction(action: ActionValue): string;
1761
+ /** 액션 목록 전체를 한 줄로 — 프롭 목록에서 "무엇이 붙어 있나"를 볼 때. */
1762
+ declare function formatActionList(actions: readonly ActionValue[]): string;
1763
+
1764
+ export { ActionEditor, type ActionEditorProps, type ActionList, type ActionValue, type BindingContext, BindingEditor, type BindingEditorProps, type BindingSource, type BuilderChild, type BuilderDoc, type BuilderFidelity, type BuilderNode, type BuilderPageMeta, type BuilderText, BuilderView, type BuilderViewProps, COALESCE_WINDOW_MS, Canvas, type CanvasProps, type ChildLabel, DEFAULT_SLOT, type DropDomContext, type DropTarget, type DropVerdict, EMPTY_SELECTION, type EditorAction, type EditorState, EditorView, type EditorViewProps, type FlattenOptions, type GradedIssue, HISTORY_LIMIT, HistoryBar, type HistoryBarProps, type HistoryEntry, Inspector, type InspectorChange, type InspectorProps, type NodeId, PAGE_ID_PATTERN, PageExistsError, PageGoneError, PageIdConflictError, PageIdRejectedError, PageListView, type PageListViewProps, type PageMeta, type PageRecord, type PageRevision, type PageTemplate, Palette, type PaletteProps, ROOT_ID, RestSpecStore, type RestStoreConfig, SPEC_PAIR_FREQ, type SduiCatalog, SduiEditor, type SduiEditorProps, type SduiEditorView, type Selection, type SlotDescriptor, type SpecStore, type StateKeyEntry, type StateKeyIndex, type StateKeyNode, type StateKeyOrigin, type StoreCapabilities, StoreUnauthorizedError, StoreUnavailableError, StoreUnsupportedError, TEMPLATES, TEXT_CHILD, type TransformResult, type TreeRow, type TreeRowKind, TreeView, type TreeViewProps, type VersionExpectation, VersionMismatchError, ViewerView, type ViewerViewProps, type WriteOptions, addressOf, ancestorIdsOf, bindingContextFor, breadcrumbOf, buildStateKeyIndex, builderFidelity, canDrop, canDropAt, canMoveTo, canRedo, canUndo, createBindingContext, createBindingSource, createEditorState, createRestSpecStore, describeChild, dropFrequency, dropTargetsAround, duplicate, editorReducer, editorRegistry, emptyDoc, flattenTree, formatAction, formatActionList, formatValue, fromPage, getChild, getNode, gradeIssues, inDocumentOrder, insert, isDescendantOf, isMultiSelection, isSelected, isTypingTarget, isValidPageId, looksLikeActionProp, move, readActionList, recommendedChildren, redoLabel, remove, replaceChild, sameInsertionPoint, sameSpecMeaning, setChildren, setNodeKey, setPageMeta, setProp, setProps, setText, slotsOf, specOf, stateKeyStatus, suggestStateKeys, titleOf, toPage, toSpec, topmost, undoLabel, useUndoRedoShortcuts, walkDoc, writeActionList };