@moloodev/editor-sdk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/package.json +62 -3
  2. package/src/channel.ts +287 -0
  3. package/src/chrome-manifest.ts +111 -0
  4. package/src/commands/clipboard/router.ts +99 -0
  5. package/src/commands/clipboard/types.ts +20 -0
  6. package/src/commands/history/stack.ts +53 -0
  7. package/src/commands/icons/catalog.ts +172 -0
  8. package/src/commands/index.ts +100 -0
  9. package/src/commands/keys/display.ts +16 -0
  10. package/src/commands/keys/normalize.ts +55 -0
  11. package/src/commands/keys/reserved.ts +62 -0
  12. package/src/commands/keys/validate.ts +77 -0
  13. package/src/commands/manifest/configuration.ts +59 -0
  14. package/src/commands/manifest/contribution-manifest-schema.ts +56 -0
  15. package/src/commands/manifest/editor-document.ts +501 -0
  16. package/src/commands/manifest/ids.ts +2 -0
  17. package/src/commands/manifest/merge.ts +215 -0
  18. package/src/commands/manifest/types.ts +113 -0
  19. package/src/commands/manifest/validate-manifest.ts +45 -0
  20. package/src/commands/react/context.tsx +249 -0
  21. package/src/commands/react/hotkey-defaults.ts +11 -0
  22. package/src/commands/react/scope-context.tsx +31 -0
  23. package/src/commands/react/scope-provider.tsx +123 -0
  24. package/src/commands/react/store.ts +182 -0
  25. package/src/commands/registry/create.ts +55 -0
  26. package/src/commands/registry/types.ts +12 -0
  27. package/src/commands/when/evaluate.ts +80 -0
  28. package/src/commands/when/index.ts +19 -0
  29. package/src/commands/when/parse.ts +276 -0
  30. package/src/commands/when/types.ts +24 -0
  31. package/src/commands/when/validate.ts +20 -0
  32. package/src/crdt/awareness.ts +29 -0
  33. package/src/crdt/bytes.ts +26 -0
  34. package/src/crdt/connect.ts +285 -0
  35. package/src/crdt/diff.ts +86 -0
  36. package/src/crdt/doc.ts +67 -0
  37. package/src/crdt/front-matter.ts +45 -0
  38. package/src/crdt/index.ts +76 -0
  39. package/src/crdt/persist.ts +40 -0
  40. package/src/crdt/protocol.ts +119 -0
  41. package/src/crdt/shared.ts +104 -0
  42. package/src/crdt/text-body.ts +125 -0
  43. package/src/crdt/undo.ts +59 -0
  44. package/src/crdt/vite-plugin.ts +42 -0
  45. package/src/crdt/yjs.ts +16 -0
  46. package/src/editor-common.ts +160 -0
  47. package/src/editor-id.ts +56 -0
  48. package/src/frame-attributes.ts +9 -0
  49. package/src/index.ts +229 -0
  50. package/src/json-schema.ts +13 -0
  51. package/src/layout/bind-toolbar.ts +60 -0
  52. package/src/layout/catalog.ts +82 -0
  53. package/src/layout/chrome/command-affordances.ts +47 -0
  54. package/src/layout/chrome/command-layout-state.ts +42 -0
  55. package/src/layout/chrome/editor-menu.tsx +358 -0
  56. package/src/layout/chrome/editor-shortcut-keys.tsx +27 -0
  57. package/src/layout/chrome/menu-styles.ts +3 -0
  58. package/src/layout/chrome/mid-menu-nodes.ts +27 -0
  59. package/src/layout/chrome/use-editor-command-affordances.ts +33 -0
  60. package/src/layout/chrome/visible-menu-leaves.ts +15 -0
  61. package/src/layout/content-error-notice.tsx +31 -0
  62. package/src/layout/define-editor-layout.ts +60 -0
  63. package/src/layout/define-editor-layout.types.ts +57 -0
  64. package/src/layout/doc-undo-bridge.tsx +22 -0
  65. package/src/layout/dock-zones.tsx +61 -0
  66. package/src/layout/drop.ts +39 -0
  67. package/src/layout/editor-layout.tsx +287 -0
  68. package/src/layout/editor-loading.tsx +23 -0
  69. package/src/layout/frame-layout-binding.tsx +74 -0
  70. package/src/layout/frame.ts +23 -0
  71. package/src/layout/hide.ts +103 -0
  72. package/src/layout/index.ts +14 -0
  73. package/src/layout/layout-binding.tsx +42 -0
  74. package/src/layout/layout-storage-key.ts +6 -0
  75. package/src/layout/layout-values.ts +48 -0
  76. package/src/layout/mutate.ts +486 -0
  77. package/src/layout/nesting-context.tsx +20 -0
  78. package/src/layout/panel-header-action.tsx +29 -0
  79. package/src/layout/panel-header.tsx +168 -0
  80. package/src/layout/panel-hide-menu.tsx +6 -0
  81. package/src/layout/panel-menu.tsx +85 -0
  82. package/src/layout/panel-section.tsx +114 -0
  83. package/src/layout/panel-size.ts +94 -0
  84. package/src/layout/panel-title.tsx +5 -0
  85. package/src/layout/registry.tsx +333 -0
  86. package/src/layout/restore-panel.ts +26 -0
  87. package/src/layout/slots-context.tsx +189 -0
  88. package/src/layout/tab-button.tsx +60 -0
  89. package/src/layout/toolbar-scroll.ts +22 -0
  90. package/src/layout/toolbar.tsx +168 -0
  91. package/src/layout/types.ts +41 -0
  92. package/src/layout/use-collapsed-sections.ts +50 -0
  93. package/src/layout/validate-layout-spec.ts +143 -0
  94. package/src/message-samples.ts +377 -0
  95. package/src/origin-headers.ts +57 -0
  96. package/src/print/index.ts +10 -0
  97. package/src/print/inspect.ts +16 -0
  98. package/src/print/schema.ts +122 -0
  99. package/src/protocol-entry.ts +19 -0
  100. package/src/protocol.ts +471 -0
  101. package/src/render-result.ts +25 -0
  102. package/src/view/editor-chrome.ts +28 -0
  103. package/src/view/hooks.ts +260 -0
  104. package/src/view/mount-editor-view.ts +36 -0
  105. package/src/view/port-binding.ts +127 -0
  106. package/src/view/port-commands.ts +36 -0
  107. package/src/view/port.ts +35 -0
  108. package/src/view/theme-class.ts +11 -0
  109. package/src/view.ts +313 -0
  110. package/README.md +0 -3
@@ -0,0 +1,168 @@
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
3
+ import { Button, Separator, Tooltip, TooltipContent, TooltipTrigger, cn } from "@moloodev/ui";
4
+ import { useLayoutBinding } from "./layout-binding";
5
+ import { toolbarPageDelta, toolbarScrollEdges } from "./toolbar-scroll";
6
+
7
+ const scrollHideClass =
8
+ "no-scrollbar h-[calc(100%+1.25rem)] overflow-x-auto overflow-y-hidden overscroll-x-contain";
9
+
10
+ const cueButtonClass =
11
+ "h-10 w-5 shrink-0 rounded-none bg-card p-0 text-foreground shadow-none hover:bg-muted hover:text-foreground";
12
+
13
+ export function LayoutToolbar({ children }: { children?: ReactNode }) {
14
+ const scrollerRef = useRef<HTMLDivElement>(null);
15
+ const [canLeft, setCanLeft] = useState(false);
16
+ const [canRight, setCanRight] = useState(false);
17
+
18
+ useEffect(() => {
19
+ const node = scrollerRef.current;
20
+ if (!node) return;
21
+
22
+ const syncEdges = () => {
23
+ const next = toolbarScrollEdges(
24
+ node.scrollLeft,
25
+ node.clientWidth,
26
+ node.scrollWidth,
27
+ );
28
+ setCanLeft(next.canLeft);
29
+ setCanRight(next.canRight);
30
+ };
31
+
32
+ const onWheel = (event: WheelEvent) => {
33
+ if (event.deltaY === 0 || Math.abs(event.deltaX) > Math.abs(event.deltaY)) {
34
+ return;
35
+ }
36
+ if (node.scrollWidth <= node.clientWidth) return;
37
+ event.preventDefault();
38
+ node.scrollLeft += event.deltaY;
39
+ };
40
+
41
+ node.addEventListener("scroll", syncEdges, { passive: true });
42
+ node.addEventListener("wheel", onWheel, { passive: false });
43
+ const observer = new ResizeObserver(syncEdges);
44
+ observer.observe(node);
45
+ const inner = node.firstElementChild;
46
+ if (inner) observer.observe(inner);
47
+ syncEdges();
48
+
49
+ return () => {
50
+ node.removeEventListener("scroll", syncEdges);
51
+ node.removeEventListener("wheel", onWheel);
52
+ observer.disconnect();
53
+ };
54
+ }, []);
55
+
56
+ function scrollByPage(direction: -1 | 1) {
57
+ const node = scrollerRef.current;
58
+ if (!node) return;
59
+ node.scrollBy({
60
+ left: direction * toolbarPageDelta(node.clientWidth),
61
+ behavior: "smooth",
62
+ });
63
+ }
64
+
65
+ return (
66
+ <div
67
+ data-slot="editor-toolbar"
68
+ className="flex h-10 min-w-0 shrink-0 items-center overflow-hidden border-b border-border bg-card"
69
+ aria-label="에디터 도구"
70
+ >
71
+ {canLeft ? (
72
+ <Button
73
+ type="button"
74
+ variant="ghost"
75
+ aria-label="이전 도구"
76
+ className={cn(cueButtonClass, "border-r border-border")}
77
+ onClick={() => scrollByPage(-1)}
78
+ >
79
+ <IconChevronLeft className="size-4" strokeWidth={1.75} />
80
+ </Button>
81
+ ) : null}
82
+ <div className="relative min-w-0 flex-1 self-stretch px-1">
83
+ <div className="h-full overflow-hidden">
84
+ <div ref={scrollerRef} className={scrollHideClass}>
85
+ <div className="flex h-10 min-w-full w-max items-center gap-1 [&>:not([aria-hidden])]:shrink-0">
86
+ {children}
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </div>
91
+ {canRight ? (
92
+ <Button
93
+ type="button"
94
+ variant="ghost"
95
+ aria-label="다음 도구"
96
+ className={cn(cueButtonClass, "border-l border-border")}
97
+ onClick={() => scrollByPage(1)}
98
+ >
99
+ <IconChevronRight className="size-4" strokeWidth={1.75} />
100
+ </Button>
101
+ ) : null}
102
+ </div>
103
+ );
104
+ }
105
+
106
+ type ToolbarButtonProps = {
107
+ commandId: string;
108
+ icon?: string | null;
109
+ disabled?: boolean;
110
+ pressed?: boolean;
111
+ label?: string;
112
+ };
113
+
114
+ export function LayoutToolbarButton({
115
+ props,
116
+ emit,
117
+ }: {
118
+ props: ToolbarButtonProps;
119
+ emit: (event: string) => void;
120
+ }) {
121
+ const { resolveIcon, shortcutFor } = useLayoutBinding();
122
+ const Icon = resolveIcon?.(props.icon ?? undefined, props.commandId);
123
+ const label = props.label || props.commandId;
124
+ const shortcut = shortcutFor?.(props.commandId);
125
+ const wide = !Icon;
126
+ return (
127
+ <Tooltip>
128
+ <TooltipTrigger className="inline-flex shrink-0" render={<span />}>
129
+ <Button
130
+ type="button"
131
+ size={wide ? "sm" : "icon-sm"}
132
+ variant={props.pressed ? "secondary" : "ghost"}
133
+ aria-label={label}
134
+ aria-pressed={props.pressed ? true : undefined}
135
+ disabled={props.disabled === true}
136
+ className={
137
+ wide
138
+ ? "h-8 shrink-0 px-2 font-display text-xs font-bold"
139
+ : undefined
140
+ }
141
+ onClick={() => emit("press")}
142
+ >
143
+ {Icon ? (
144
+ <Icon className="size-4" strokeWidth={1.75} />
145
+ ) : (
146
+ <span className="px-1 font-display text-[0.65rem] font-bold">
147
+ {label}
148
+ </span>
149
+ )}
150
+ </Button>
151
+ </TooltipTrigger>
152
+ <TooltipContent side="bottom" className="text-xs">
153
+ {label}
154
+ {shortcut ? (
155
+ <span className="ml-1.5 text-muted-foreground">{shortcut}</span>
156
+ ) : null}
157
+ </TooltipContent>
158
+ </Tooltip>
159
+ );
160
+ }
161
+
162
+ export function LayoutToolbarSeparator() {
163
+ return <Separator orientation="vertical" className="shrink-0 self-stretch" />;
164
+ }
165
+
166
+ export function LayoutToolbarSpacer() {
167
+ return <div className="min-w-0 flex-1" aria-hidden />;
168
+ }
@@ -0,0 +1,41 @@
1
+ import type { ReactNode } from "react";
2
+ import type { ActionBinding, Spec, VisibilityCondition } from "@json-render/core";
3
+
4
+ export type DockEdge = "north" | "south" | "east" | "west";
5
+
6
+ export type LayoutElement = {
7
+ type: string;
8
+ props: Record<string, unknown>;
9
+ children?: string[];
10
+ /** json-render element field. `false` is collapsed; omit / `true` is shown. */
11
+ visible?: VisibilityCondition;
12
+ on?: Record<string, ActionBinding | ActionBinding[]>;
13
+ };
14
+
15
+ export type PanelToggle = {
16
+ id: string;
17
+ label: string;
18
+ checked: boolean;
19
+ onCheckedChange: (visible: boolean) => void;
20
+ };
21
+
22
+ /** Gap shown in a strip while a panel is dragged over it. */
23
+ export type TabInsertPreview = {
24
+ panelKey: string;
25
+ index: number;
26
+ width: number;
27
+ };
28
+
29
+ /** Resolved panel body and header from a layout binding. */
30
+ export type PanelSlot = {
31
+ name: string;
32
+ title: string;
33
+ /** Icon + title nodes that label the header. */
34
+ dragNodes: ReactNode[];
35
+ action?: ReactNode;
36
+ menu: ReactNode | null;
37
+ body: ReactNode;
38
+ hasHeader: boolean;
39
+ };
40
+
41
+ export type { Spec };
@@ -0,0 +1,50 @@
1
+ import { useCallback, useRef, useState } from "react";
2
+ import type { Spec } from "@json-render/core";
3
+ import { applyLayoutValues, layoutValuesOf, type LayoutValues } from "./layout-values";
4
+
5
+ function collapsedTrue(collapsed: LayoutValues["collapsed"]): Record<string, boolean> {
6
+ const next: Record<string, boolean> = {};
7
+ if (!collapsed) return next;
8
+ for (const [id, value] of Object.entries(collapsed)) {
9
+ if (value) next[id] = true;
10
+ }
11
+ return next;
12
+ }
13
+
14
+ export function useCollapsedSections({
15
+ layout,
16
+ saveLayout,
17
+ defaultSpec,
18
+ }: {
19
+ layout: LayoutValues | null;
20
+ saveLayout: (values: LayoutValues) => void;
21
+ defaultSpec: Spec;
22
+ }): {
23
+ initialSpec: Spec;
24
+ collapsed: Readonly<Record<string, boolean>>;
25
+ onCollapsedChange: (id: string, collapsed: boolean) => void;
26
+ onSpecChange: (spec: Spec) => void;
27
+ } {
28
+ const [initialSpec] = useState(() =>
29
+ layout ? applyLayoutValues(defaultSpec, layout) : defaultSpec,
30
+ );
31
+ const specRef = useRef(initialSpec);
32
+ const collapsedRef = useRef(collapsedTrue(layout?.collapsed));
33
+ const [collapsed, setCollapsed] = useState(collapsedRef.current);
34
+ const publish = useCallback((spec: Spec, next: Readonly<Record<string, boolean>>) => {
35
+ saveLayout(layoutValuesOf(spec, defaultSpec, next));
36
+ }, [defaultSpec, saveLayout]);
37
+ const onSpecChange = useCallback((spec: Spec) => {
38
+ specRef.current = spec;
39
+ publish(spec, collapsedRef.current);
40
+ }, [publish]);
41
+ const onCollapsedChange = useCallback((id: string, isCollapsed: boolean) => {
42
+ const next = { ...collapsedRef.current };
43
+ if (isCollapsed) next[id] = true;
44
+ else delete next[id];
45
+ collapsedRef.current = next;
46
+ setCollapsed(next);
47
+ publish(specRef.current, next);
48
+ }, [publish]);
49
+ return { initialSpec, collapsed, onCollapsedChange, onSpecChange };
50
+ }
@@ -0,0 +1,143 @@
1
+ import {
2
+ formatSpecIssues,
3
+ isNonEmptySpec,
4
+ validateSpec,
5
+ type Spec,
6
+ } from "@json-render/core";
7
+ import { catalog, TOOLBAR_CHROME, TOOLBAR_ITEMS } from "./catalog";
8
+
9
+ export function sectionIdsOf(props: unknown): readonly string[] {
10
+ if (typeof props !== "object" || props === null || !Object.hasOwn(props, "sectionIds")) {
11
+ return [];
12
+ }
13
+ const value = Reflect.get(props, "sectionIds");
14
+ if (!Array.isArray(value)) return [];
15
+ const ids: string[] = [];
16
+ for (const item of value) {
17
+ if (typeof item !== "string") return [];
18
+ ids.push(item);
19
+ }
20
+ return ids;
21
+ }
22
+
23
+ function formatZodIssues(error: {
24
+ issues: { path: PropertyKey[]; message: string }[];
25
+ }): string {
26
+ return error.issues
27
+ .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`)
28
+ .join("; ");
29
+ }
30
+
31
+ type CatalogComponent = {
32
+ props: {
33
+ safeParse: (
34
+ value: unknown,
35
+ ) =>
36
+ | { success: true }
37
+ | { success: false; error: { issues: { path: PropertyKey[]; message: string }[] } };
38
+ };
39
+ };
40
+
41
+ /**
42
+ * `catalog.validate` checks element `type` against the catalog, but with
43
+ * multiple components `@json-render/core` does not discriminate `propsOf` —
44
+ * so we parse each element's props with that component's Zod schema.
45
+ */
46
+ export function validateLayoutSpec(input: unknown): Spec {
47
+ if (!isNonEmptySpec(input)) {
48
+ throw new Error("Invalid layout spec: expected non-empty { root, elements }");
49
+ }
50
+
51
+ const structural = validateSpec(input);
52
+ if (!structural.valid) {
53
+ throw new Error(
54
+ `Invalid layout spec (structure): ${formatSpecIssues(structural.issues)}`,
55
+ );
56
+ }
57
+
58
+ const catalogResult = catalog.validate(input);
59
+ if (!catalogResult.success || catalogResult.data == null) {
60
+ const detail = catalogResult.error
61
+ ? formatZodIssues(catalogResult.error)
62
+ : "unknown catalog validation error";
63
+ throw new Error(`Invalid layout spec (catalog): ${detail}`);
64
+ }
65
+
66
+ const components = catalog.data.components as Record<string, CatalogComponent>;
67
+ const contentIds = new Map<string, string>();
68
+ const sectionIds = new Set<string>();
69
+
70
+ for (const [key, element] of Object.entries(input.elements)) {
71
+ const component = components[element.type];
72
+ if (!component) {
73
+ throw new Error(
74
+ `Invalid layout spec (catalog): elements.${key}.type: unknown type "${element.type}"`,
75
+ );
76
+ }
77
+ const propsResult = component.props.safeParse(element.props);
78
+ if (!propsResult.success) {
79
+ throw new Error(
80
+ `Invalid layout spec (catalog): elements.${key}.props: ${formatZodIssues(propsResult.error)}`,
81
+ );
82
+ }
83
+ if (element.type === "PanelGroup") {
84
+ const props = element.props as {
85
+ layout: "split" | "tabs";
86
+ orientation?: string | null;
87
+ active?: string | null;
88
+ };
89
+ const children = element.children ?? [];
90
+ if (props.layout === "split" && props.orientation == null) {
91
+ throw new Error(
92
+ `Invalid layout spec (catalog): elements.${key}.props.orientation: required when layout is split`,
93
+ );
94
+ }
95
+ if (props.layout === "tabs") {
96
+ const active = props.active ?? children[0];
97
+ if (active && !children.includes(active)) {
98
+ throw new Error(
99
+ `Invalid layout spec (catalog): elements.${key}.props.active: "${active}" is not in children`,
100
+ );
101
+ }
102
+ }
103
+ }
104
+ if (element.type === "Panel") {
105
+ const props = element.props as { contentId: string };
106
+ const existing = contentIds.get(props.contentId);
107
+ if (existing != null) {
108
+ throw new Error(
109
+ `Invalid layout spec (catalog): elements.${key}.props.contentId: "${props.contentId}" is already used by elements.${existing}`,
110
+ );
111
+ }
112
+ contentIds.set(props.contentId, key);
113
+ for (const sectionId of sectionIdsOf(element.props)) {
114
+ if (sectionIds.has(sectionId)) {
115
+ throw new Error(
116
+ `Invalid layout spec (catalog): elements.${key}.props.sectionIds: "${sectionId}" is already used`,
117
+ );
118
+ }
119
+ sectionIds.add(sectionId);
120
+ }
121
+ for (const childKey of element.children ?? []) {
122
+ const child = input.elements[childKey];
123
+ if (!child || !TOOLBAR_CHROME.has(child.type)) {
124
+ throw new Error(
125
+ `Invalid layout spec (catalog): elements.${key}.children: "${childKey}" must be toolbar chrome`,
126
+ );
127
+ }
128
+ }
129
+ }
130
+ if (element.type === "Toolbar") {
131
+ for (const childKey of element.children ?? []) {
132
+ const child = input.elements[childKey];
133
+ if (!child || !TOOLBAR_ITEMS.has(child.type)) {
134
+ throw new Error(
135
+ `Invalid layout spec (catalog): elements.${key}.children: "${childKey}" must be a ToolbarButton, ToolbarSeparator, or ToolbarSpacer`,
136
+ );
137
+ }
138
+ }
139
+ }
140
+ }
141
+
142
+ return catalogResult.data as Spec;
143
+ }