@moloodev/editor-sdk 0.9.1 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moloodev/editor-sdk",
3
- "version": "0.9.1",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/view.ts",
@@ -29,8 +29,8 @@
29
29
  "y-protocols": "^1.0.7",
30
30
  "yjs": "^13.6.32",
31
31
  "zod": "^4.5.4",
32
- "@moloodev/platform": "0.9.1",
33
- "@moloodev/ui": "0.9.1"
32
+ "@moloodev/platform": "0.11.0",
33
+ "@moloodev/ui": "0.11.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "react": "^19.0.0",
@@ -46,9 +46,9 @@
46
46
  "jsdom": "30.1.1",
47
47
  "react": "^19.2.8",
48
48
  "react-dom": "^19.2.8",
49
- "tsx": "^4.23.13",
49
+ "vitest": "5.1.0",
50
50
  "typescript": "^7.0.2",
51
- "@moloodev/typescript-config": "0.9.1"
51
+ "@moloodev/typescript-config": "0.11.0"
52
52
  },
53
53
  "publishConfig": {
54
54
  "access": "public"
@@ -60,7 +60,7 @@
60
60
  ],
61
61
  "scripts": {
62
62
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.origin-headers.json",
63
- "test": "tsx --test 'src/**/*.test.ts' 'src/**/*.test.tsx' && pnpm test:browser",
64
- "test:browser": "tsx --test 'browser/**/*.test.ts'"
63
+ "test": "vitest run --root ../.. --project @moloodev/editor-sdk --project @moloodev/editor-sdk:browser",
64
+ "test:browser": "vitest run --root ../.. --project @moloodev/editor-sdk:browser"
65
65
  }
66
66
  }
@@ -10,6 +10,8 @@ const HOST_COMMANDS = new Set([
10
10
  "editor.new",
11
11
  "editor.open",
12
12
  "editor.preferences",
13
+ "editor.about",
14
+ "editor.pinToSidebar",
13
15
  ]);
14
16
 
15
17
  const whenClause = z
@@ -1,6 +1,14 @@
1
1
  import type { ComponentType } from "react";
2
2
  import { parseTablerIconUri } from "../manifest/editor-document";
3
3
  import {
4
+ IconArchive,
5
+ IconCube,
6
+ IconDatabase,
7
+ IconFile,
8
+ IconFolder,
9
+ IconHelp,
10
+ IconMovie,
11
+ IconTypography,
4
12
  IconArrowBackUp,
5
13
  IconArrowForwardUp,
6
14
  IconBold,
@@ -69,6 +77,14 @@ export type EditorIconComponent = ComponentType<IconProps>;
69
77
 
70
78
  /** Host-resolved Tabler icon names. */
71
79
  export const EDITOR_ICON_CATALOG = {
80
+ archive: IconArchive,
81
+ cube: IconCube,
82
+ database: IconDatabase,
83
+ file: IconFile,
84
+ folder: IconFolder,
85
+ help: IconHelp,
86
+ movie: IconMovie,
87
+ typography: IconTypography,
72
88
  menu: IconMenu2,
73
89
  "arrow-back-up": IconArrowBackUp,
74
90
  restore: IconRestore,
@@ -10,7 +10,7 @@ import { validateManifest } from "./validate-manifest";
10
10
 
11
11
  export const TABLER_ICON_URI_PREFIX = "tabler:";
12
12
  const TABLER_ICON_NAME = /^[a-z0-9]+(-[a-z0-9]+)*$/;
13
- const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
13
+ export const HEX_COLOR_PATTERN = /^#[0-9a-fA-F]{6}$/;
14
14
 
15
15
  export function parseTablerIconUri(value: string): string | null {
16
16
  if (!value.startsWith(TABLER_ICON_URI_PREFIX)) return null;
@@ -28,6 +28,8 @@ export type EditorContributes = Pick<
28
28
  >;
29
29
 
30
30
  export type EditorFileMatcher = {
31
+ /** Explicit asset kind id for type-only displays, independent of acceptedTypes. */
32
+ type?: string;
31
33
  /** File extensions with leading dot, supporting compound extensions (e.g. [".layout.json"], [".md", ".markdown"]). */
32
34
  extensions?: string[];
33
35
  /** MIME types or wildcard families (e.g. ["text/markdown"], ["application/json"]). */
@@ -56,6 +58,8 @@ export type EditorManifest = {
56
58
  categories?: string[];
57
59
  /** Host-resolved Tabler icon URI (`tabler:{name}`). */
58
60
  icon?: string;
61
+ /** Editor identity tint (`#rrggbb`), independent of fileTypes. */
62
+ color?: string;
59
63
  /** Optional host I/O the editor implements (`editor.import` / `editor.download`). */
60
64
  capabilities?: EditorCapability[];
61
65
  importAccept?: string;
@@ -117,6 +121,13 @@ function parseEditorFileMatchers(input: unknown): EditorFileMatcher[] {
117
121
  `Invalid editor manifest: fileTypes.${index} must be an object`,
118
122
  );
119
123
  }
124
+ let type: string | undefined;
125
+ if (entry.type != null) {
126
+ type = requireString(entry.type, `fileTypes.${index}.type`);
127
+ if (!isQualifiedName(type)) {
128
+ throw new Error(`Invalid editor manifest: fileTypes.${index}.type must be publisher.name`);
129
+ }
130
+ }
120
131
  let extensions: string[] | undefined;
121
132
  if (entry.extensions != null) {
122
133
  if (!Array.isArray(entry.extensions) || entry.extensions.length === 0) {
@@ -198,18 +209,19 @@ function parseEditorFileMatchers(input: unknown): EditorFileMatcher[] {
198
209
  let icon: AssetTypeIcon | undefined;
199
210
  if (entry.icon != null) {
200
211
  const rawIcon = requireString(entry.icon, `fileTypes.${index}.icon`);
201
- if (parseTablerIconUri(rawIcon) === null) {
212
+ const name = parseTablerIconUri(rawIcon);
213
+ if (name === null) {
202
214
  throw new Error(
203
215
  `Invalid editor manifest: fileTypes.${index}.icon must be tabler:{name}`,
204
216
  );
205
217
  }
206
- icon = rawIcon as AssetTypeIcon;
218
+ icon = `tabler:${name}`;
207
219
  }
208
220
 
209
221
  let color: string | undefined;
210
222
  if (entry.color != null) {
211
223
  const rawColor = requireString(entry.color, `fileTypes.${index}.color`);
212
- if (!HEX_COLOR.test(rawColor)) {
224
+ if (!HEX_COLOR_PATTERN.test(rawColor)) {
213
225
  throw new Error(
214
226
  `Invalid editor manifest: fileTypes.${index}.color must be #rrggbb`,
215
227
  );
@@ -218,6 +230,7 @@ function parseEditorFileMatchers(input: unknown): EditorFileMatcher[] {
218
230
  }
219
231
 
220
232
  return {
233
+ ...(type ? { type } : {}),
221
234
  ...(extensions ? { extensions } : {}),
222
235
  ...(mimeTypes ? { mimeTypes } : {}),
223
236
  ...(schema ? { schema } : {}),
@@ -370,6 +383,14 @@ export function validateEditorManifest(
370
383
  throw new Error("Invalid editor manifest: icon must be tabler:{name}");
371
384
  }
372
385
  }
386
+ let color: string | undefined;
387
+ if (input.color != null) {
388
+ const rawColor = requireString(input.color, "color");
389
+ if (!HEX_COLOR_PATTERN.test(rawColor)) {
390
+ throw new Error("Invalid editor manifest: color must be #rrggbb");
391
+ }
392
+ color = rawColor.toLowerCase();
393
+ }
373
394
  if ("order" in input) {
374
395
  throw new Error(
375
396
  "Invalid editor manifest: order belongs on workspace favorites, not editor documents",
@@ -438,6 +459,7 @@ export function validateEditorManifest(
438
459
  ...(fileTypes ? { fileTypes } : {}),
439
460
  ...(templates ? { templates } : {}),
440
461
  ...(icon ? { icon } : {}),
462
+ ...(color ? { color } : {}),
441
463
  ...(categories ? { categories } : {}),
442
464
  ...(capabilities ? { capabilities } : {}),
443
465
  ...(importAccept ? { importAccept } : {}),
@@ -111,6 +111,7 @@ function buildMenuTree(
111
111
  }
112
112
 
113
113
  const SEGMENT_META: Record<string, { label: string; order: number }> = {
114
+ app: { label: "앱", order: 0 },
114
115
  file: { label: "파일", order: 10 },
115
116
  edit: { label: "편집", order: 30 },
116
117
  insert: { label: "삽입", order: 35 },
@@ -63,7 +63,7 @@ export const EDITOR_COMMON_CONTRIBUTES = {
63
63
  },
64
64
  {
65
65
  id: "editor.preferences",
66
- title: "에디터 설정...",
66
+ title: "에디터 설정…",
67
67
  group: "앱",
68
68
  },
69
69
  {
@@ -140,10 +140,10 @@ export const EDITOR_COMMON_CONTRIBUTES = {
140
140
  shortcut: "Backspace",
141
141
  },
142
142
  {
143
- path: ["edit", "preferences"],
143
+ path: ["app", "preferences"],
144
144
  commandId: "editor.preferences",
145
- group: "9_settings",
146
- order: 20,
145
+ group: "2_settings",
146
+ order: 10,
147
147
  },
148
148
  {
149
149
  path: ["view", "resetLayout"],
@@ -22,6 +22,9 @@ export function isHostReservedKey(key: string): boolean {
22
22
 
23
23
  const HOST_CHROME_SCOPE = "host-chrome";
24
24
 
25
+ /** The host owns this label; published views still send their own title ("환경설정"). */
26
+ export const PREFERENCES_LABEL = "에디터 설정…";
27
+
25
28
  export function scopeManifest(
26
29
  manifest: ChromeManifest,
27
30
  activation?: "exact",
@@ -33,7 +36,13 @@ export function scopeManifest(
33
36
  ? { commands: manifest.contributes.commands }
34
37
  : {}),
35
38
  ...(manifest.contributes?.menus
36
- ? { menus: manifest.contributes.menus }
39
+ ? { menus: manifest.contributes.menus.map((menu) =>
40
+ // Compat for published views that still contribute the old Edit-menu path.
41
+ // Remove once no published editor targets an SDK older than this release.
42
+ menu.commandId === "editor.preferences"
43
+ ? { ...menu, path: ["app", "preferences"], group: "2_settings", order: 10, label: PREFERENCES_LABEL }
44
+ : menu,
45
+ ) }
37
46
  : {}),
38
47
  ...(manifest.contributes?.keybindings
39
48
  ? { keybindings: manifest.contributes.keybindings }
@@ -43,8 +52,19 @@ export function scopeManifest(
43
52
 
44
53
  export function hostChromeMergeInput(
45
54
  manifest: ChromeManifest,
55
+ options?: { displayName: string },
46
56
  ): ContributionManifest[] {
47
- return [scopeManifest(manifest)];
57
+ return [{
58
+ id: "host-app-menu",
59
+ commands: [
60
+ { id: "editor.about", title: `${options?.displayName ?? "앱"}에 대하여` },
61
+ { id: "editor.pinToSidebar", title: "사이드바에 고정" },
62
+ ],
63
+ menus: [
64
+ { path: ["app", "about"], commandId: "editor.about", group: "1_about", order: 10 },
65
+ { path: ["app", "pin"], commandId: "editor.pinToSidebar", group: "3_sidebar", order: 10 },
66
+ ],
67
+ }, scopeManifest(manifest)];
48
68
  }
49
69
 
50
70
  export type CommandStateBody = Extract<
@@ -14,6 +14,7 @@ import { KindIcon } from "@moloodev/ui/kind-icon";
14
14
  import { TooltipProvider } from "@moloodev/ui";
15
15
  import {
16
16
  hostChromeMergeInput,
17
+ PREFERENCES_LABEL,
17
18
  scopeManifest,
18
19
  validateHostChrome,
19
20
  type CommandStateBody,
@@ -25,6 +26,8 @@ export type HostChromeHandlers = {
25
26
  openStartDialog: () => void;
26
27
  openBrowser: () => void;
27
28
  openPreferences?: () => void;
29
+ openAbout?: () => void;
30
+ togglePinToSidebar?: () => void;
28
31
  /** When set, `editor.import` opens the host file picker instead of reaching the view. */
29
32
  openImport?: () => void;
30
33
  /** Runs before `editor.resetLayout` is forwarded to the view. */
@@ -33,6 +36,7 @@ export type HostChromeHandlers = {
33
36
 
34
37
  export type EditorHostChromeProps = {
35
38
  value: unknown;
39
+ appMenu?: { displayName: string; pinned: boolean };
36
40
  host: HostChromeHandlers;
37
41
  onViewCommand: (commandId: string) => void;
38
42
  commandState?: Readonly<Record<string, CommandStateBody>>;
@@ -104,7 +108,7 @@ function overlayCommandState(
104
108
  visible: state.visible,
105
109
  disabled: state.disabled,
106
110
  pressed: state.pressed,
107
- label: state.label,
111
+ label: id === "editor.preferences" ? PREFERENCES_LABEL : state.label,
108
112
  };
109
113
  }
110
114
  return next ?? commands;
@@ -117,6 +121,14 @@ export function dispatchHostCommand(
117
121
  onViewCommand: (commandId: string) => void,
118
122
  ): void {
119
123
  if (!declared.has(commandId)) return;
124
+ if (commandId === "editor.about") {
125
+ host.openAbout?.();
126
+ return;
127
+ }
128
+ if (commandId === "editor.pinToSidebar") {
129
+ host.togglePinToSidebar?.();
130
+ return;
131
+ }
120
132
  if (commandId === "editor.new") {
121
133
  host.openStartDialog();
122
134
  return;
@@ -152,18 +164,16 @@ export function chromeCommandHandlers(
152
164
  }
153
165
 
154
166
  function HostCommandHandlers({
155
- manifest,
156
167
  host,
157
168
  onViewCommand,
158
169
  }: {
159
- manifest: ChromeManifest;
160
170
  host: HostChromeHandlers;
161
171
  onViewCommand: (commandId: string) => void;
162
172
  }) {
163
- const { registerHandler } = useCommands();
173
+ const { commands, registerHandler } = useCommands();
164
174
  const commandIds = useMemo(
165
- () => manifest.contributes?.commands?.map((command) => command.id) ?? [],
166
- [manifest],
175
+ () => commands.map((command) => command.id),
176
+ [commands],
167
177
  );
168
178
  const declared = useMemo(() => new Set(commandIds), [commandIds]);
169
179
  useEffect(() => {
@@ -198,18 +208,20 @@ function HostCommands({
198
208
  <ScopeProvider manifest={scopeManifest(manifest, "exact")}>
199
209
  {children}
200
210
  </ScopeProvider>
201
- <HostCommandHandlers manifest={manifest} host={host} onViewCommand={onViewCommand} />
211
+ <HostCommandHandlers host={host} onViewCommand={onViewCommand} />
202
212
  </>
203
213
  );
204
214
  }
205
215
 
206
216
  function ChromeFrame({
207
217
  spec,
218
+ appMenu,
208
219
  host,
209
220
  onViewCommand,
210
221
  commandState,
211
222
  }: {
212
223
  spec: Spec;
224
+ appMenu?: EditorHostChromeProps["appMenu"];
213
225
  host: HostChromeHandlers;
214
226
  onViewCommand: (commandId: string) => void;
215
227
  commandState?: Readonly<Record<string, CommandStateBody>>;
@@ -221,8 +233,18 @@ function ChromeFrame({
221
233
  [commands],
222
234
  );
223
235
  const layoutCommands = useMemo(
224
- () => overlayCommandState(commandLayoutState(commands, ctx).commands, commandState),
225
- [commandState, commands, ctx],
236
+ () => ({
237
+ ...overlayCommandState(commandLayoutState(commands, ctx).commands, commandState),
238
+ "editor.about": {
239
+ visible: true, disabled: !host.openAbout, pressed: false,
240
+ label: `${appMenu?.displayName ?? "앱"}에 대하여`,
241
+ },
242
+ "editor.pinToSidebar": {
243
+ visible: true, disabled: !host.togglePinToSidebar,
244
+ pressed: appMenu?.pinned ?? false, label: "사이드바에 고정",
245
+ },
246
+ }),
247
+ [appMenu, commandState, commands, ctx, host.openAbout, host.togglePinToSidebar],
226
248
  );
227
249
  const store = useMemo(() => createStateStore({ commands: layoutCommands }), []);
228
250
  useLayoutEffect(() => {
@@ -264,6 +286,7 @@ function ChromeFrame({
264
286
  return (
265
287
  <LayoutBindingProvider value={binding}>
266
288
  <EditorMenuBar
289
+ appMenuLabel={appMenu?.displayName}
267
290
  commandState={layoutCommands}
268
291
  onExecute={(commandId) => handlers.executeCommand({ commandId })}
269
292
  />
@@ -273,14 +296,15 @@ function ChromeFrame({
273
296
 
274
297
  export function EditorHostChrome({
275
298
  value,
299
+ appMenu,
276
300
  host,
277
301
  onViewCommand,
278
302
  commandState,
279
303
  }: EditorHostChromeProps) {
280
304
  const validated = useMemo(() => validateHostChrome(value), [value]);
281
305
  const seeded = useMemo(
282
- () => (validated.ok ? hostChromeMergeInput(validated.manifest) : []),
283
- [validated],
306
+ () => (validated.ok ? hostChromeMergeInput(validated.manifest, appMenu) : []),
307
+ [validated, appMenu?.displayName],
284
308
  );
285
309
  if (!validated.ok) return null;
286
310
  return (
@@ -293,6 +317,7 @@ export function EditorHostChrome({
293
317
  >
294
318
  <ChromeFrame
295
319
  spec={validated.spec}
320
+ appMenu={appMenu}
296
321
  host={host}
297
322
  onViewCommand={onViewCommand}
298
323
  commandState={commandState}
package/src/index.ts CHANGED
@@ -207,7 +207,12 @@ export type EditorResponse<Type extends EditorRequestType = EditorRequestType> =
207
207
  | EditorSuccess<Type>
208
208
  | EditorFailure;
209
209
 
210
+ export type EditorSettingValue = boolean | number | string;
211
+ export type EditorSettingsValues = Record<string, EditorSettingValue>;
212
+
210
213
  export type EditorEvent =
214
+ | { v: 1; type: "settings.snapshot"; body: { values: EditorSettingsValues } }
215
+ | { v: 1; type: "settings.changed"; body: { values: EditorSettingsValues } }
211
216
  | { v: 1; type: "file.import"; body: { name: string; data: string } }
212
217
  | { v: 1; type: "session"; body: EditorSession }
213
218
  | { v: 1; type: "access"; body: EditorAccess }
@@ -34,9 +34,11 @@ type MenuCommandState = Readonly<Record<string, CommandLayoutEntry>>;
34
34
 
35
35
  /** Horizontal editor menubar driven by merged contribution manifests. */
36
36
  export function EditorMenuBar({
37
+ appMenuLabel,
37
38
  commandState,
38
39
  onExecute,
39
40
  }: {
41
+ appMenuLabel?: string;
40
42
  commandState?: MenuCommandState;
41
43
  onExecute?: (commandId: string) => unknown;
42
44
  } = {}) {
@@ -103,7 +105,7 @@ export function EditorMenuBar({
103
105
  />
104
106
  }
105
107
  >
106
- {node.label}
108
+ {node.segment === "app" ? appMenuLabel ?? node.label : node.label}
107
109
  </DropdownMenuTrigger>
108
110
  <DropdownMenuContent
109
111
  align="start"
@@ -298,7 +300,7 @@ export function MenuLeaf({
298
300
  </>
299
301
  );
300
302
 
301
- if (isPanelToggleCommand(node.commandId)) {
303
+ if (isPanelToggleCommand(node.commandId) || node.commandId === "editor.pinToSidebar") {
302
304
  return (
303
305
  <DropdownMenuCheckboxItem
304
306
  className={`${editorMenuItemClass} pr-7`}
@@ -313,6 +313,8 @@ export const session = {
313
313
  } satisfies EditorSession;
314
314
 
315
315
  export const events = {
316
+ "settings.snapshot": { v: 1, type: "settings.snapshot", body: { values: { "notes.indent": 2 } } },
317
+ "settings.changed": { v: 1, type: "settings.changed", body: { values: { "notes.indent": 4 } } },
316
318
  "file.import": { v: 1, type: "file.import", body: { name: "notes.md", data: "" } },
317
319
  session: {
318
320
  v: 1,
@@ -14,6 +14,8 @@ export type {
14
14
  EditorRevision,
15
15
  EditorSession,
16
16
  EditorSessionAsset,
17
+ EditorSettingValue,
18
+ EditorSettingsValues,
17
19
  EditorSuccess,
18
20
  Json,
19
21
  } from "./index";
package/src/protocol.ts CHANGED
@@ -125,6 +125,10 @@ const themeSchema = z.strictObject({
125
125
  theme: z.enum(["dark", "light"]),
126
126
  });
127
127
 
128
+ const settingsSchema = z.strictObject({
129
+ values: z.record(z.string(), z.union([z.boolean(), z.number(), z.string()])),
130
+ });
131
+
128
132
  const commandIdSchema = z.string().min(1);
129
133
 
130
134
  const commandInvokeBodySchema = z.strictObject({
@@ -319,6 +323,8 @@ const handshakeReplySchema = z.discriminatedUnion("ok", [
319
323
  export type HandshakeReply = z.infer<typeof handshakeReplySchema>;
320
324
 
321
325
  export const editorEventSchema = z.discriminatedUnion("type", [
326
+ z.strictObject({ v: z.literal(1), type: z.literal("settings.snapshot"), body: settingsSchema }),
327
+ z.strictObject({ v: z.literal(1), type: z.literal("settings.changed"), body: settingsSchema }),
322
328
  z.strictObject({
323
329
  v: z.literal(1),
324
330
  type: z.literal("file.import"),
@@ -0,0 +1,5 @@
1
+ import { createContext } from "react";
2
+ import type { FileAppearanceMatcher } from "@moloodev/platform";
3
+
4
+ /** Active view manifest declarations, supplied by mountEditorView. */
5
+ export const FileTypesContext = createContext<readonly FileAppearanceMatcher[]>([]);
package/src/view/hooks.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createContext, createElement, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
2
- import { KNOWN_PATTERN_IDS, resolvePatternAppearance } from "@moloodev/platform";
2
+ import { KNOWN_PATTERN_IDS, resolveTypeAppearance, type FileAppearanceMatcher } from "@moloodev/platform";
3
3
  import type { Doc } from "../crdt";
4
4
  import type { Composition, Publication } from "@moloodev/platform";
5
5
  import type {
@@ -9,6 +9,7 @@ import type {
9
9
  EditorRevision,
10
10
  EditorSession,
11
11
  EditorSessionAsset,
12
+ EditorSettingValue,
12
13
  } from "../index";
13
14
 
14
15
  export type { EditorAsset, EditorSessionAsset, EditorLayoutValues } from "../index";
@@ -30,6 +31,7 @@ export type EditorOpenBrowserArgs = {
30
31
  };
31
32
 
32
33
  export type EditorViewBinding = {
34
+ useEditorSetting(key: string): EditorSettingValue | undefined;
33
35
  useTheme(): { theme: "dark" | "light" };
34
36
  useKind(type: string): { id: string; label: string; color: string; icon: string };
35
37
  useKinds(): { id: string; label: string; color: string; icon: string }[];
@@ -118,8 +120,8 @@ export function shouldShowContentError(
118
120
  return contentError && isEditorContentReady(mode, hasDoc);
119
121
  }
120
122
 
121
- export function kindOf(type: string) {
122
- const entry = resolvePatternAppearance(type);
123
+ export function kindOf(type: string, matchers: readonly FileAppearanceMatcher[] = []) {
124
+ const entry = resolveTypeAppearance(type, matchers);
123
125
  return {
124
126
  id: type,
125
127
  label: entry.label,
@@ -128,14 +130,19 @@ export function kindOf(type: string) {
128
130
  };
129
131
  }
130
132
 
131
- export function kindsOf() {
132
- return KNOWN_PATTERN_IDS.map(kindOf);
133
+ export function kindsOf(matchers: readonly FileAppearanceMatcher[] = []) {
134
+ const types = new Set<string>([...KNOWN_PATTERN_IDS, ...matchers.flatMap((rule) => rule.type ? [rule.type] : [])]);
135
+ return [...types].map((type) => kindOf(type, matchers));
133
136
  }
134
137
 
135
138
  export function useTheme() {
136
139
  return useBinding("useTheme").useTheme();
137
140
  }
138
141
 
142
+ export function useEditorSetting(key: string): EditorSettingValue | undefined {
143
+ return useBinding("useEditorSetting").useEditorSetting(key);
144
+ }
145
+
139
146
  export function useKind(type: string) {
140
147
  return useBinding("useKind").useKind(type);
141
148
  }
@@ -2,6 +2,7 @@ import { createElement, type ComponentType } from "react";
2
2
  import type { EditorManifest } from "../commands";
3
3
  import { connectEditorView, type EditorView } from "../view";
4
4
  import { bindThemeClass } from "./theme-class";
5
+ import { FileTypesContext } from "./file-types-context";
5
6
  import { createEditorChrome } from "./editor-chrome";
6
7
 
7
8
  /** Imports outside an iframe stay inert, including server and DOM unit tests. */
@@ -24,7 +25,8 @@ export function mountEditorView(options: {
24
25
  root.id = "root";
25
26
  document.body.append(root);
26
27
  void import("react-dom/client").then(({ createRoot }) => {
27
- createRoot(root).render(createElement(options.Root, { view }));
28
+ createRoot(root).render(createElement(FileTypesContext.Provider, { value: options.manifest.fileTypes ?? [] },
29
+ createElement(options.Root, { view })));
28
30
  });
29
31
  void view.handshake.then(async (reply) => {
30
32
  if (!reply.ok) throw new Error(reply.code);
@@ -1,5 +1,6 @@
1
- import { createElement, useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
1
+ import { createElement, useContext, useCallback, useMemo, useSyncExternalStore, type ReactNode } from "react";
2
2
  import type { EditorOperation } from "../index";
3
+ import { FileTypesContext } from "./file-types-context";
3
4
  import { RenderFailure, renderCode } from "../render-result";
4
5
  import type { EditorView } from "../view";
5
6
  import { EditorViewProvider, kindOf, type EditorOpenBrowserArgs, type EditorViewBinding } from "./hooks";
@@ -57,13 +58,17 @@ function createPortBinding(view: EditorView, acceptedTypes: readonly string[]):
57
58
  }
58
59
 
59
60
  return {
61
+ useEditorSetting(key) {
62
+ return useSyncExternalStore(view.onSettingsChange, () => view.getSetting(key), () => view.getSetting(key));
63
+ },
60
64
  useTheme() {
61
65
  const theme = useSnapshot().theme ?? "dark";
62
66
  return useMemo(() => ({ theme }), [theme]);
63
67
  },
64
68
  useKind(type) {
65
69
  useSnapshot();
66
- return useMemo(() => kindOf(type), [type]);
70
+ const matchers = useContext(FileTypesContext);
71
+ return useMemo(() => kindOf(type, matchers), [type, matchers]);
67
72
  },
68
73
  useKinds: () => unavailable("useKinds"),
69
74
  useList: () => unavailable("useList"),
package/src/view.ts CHANGED
@@ -25,10 +25,14 @@ import type {
25
25
  EditorRequestType,
26
26
  EditorResponse,
27
27
  EditorSession,
28
+ EditorSettingValue,
29
+ EditorSettingsValues,
28
30
  } from "./index";
29
31
  import { EDITOR_IMPORT_BYTES_MAX, parseEditorId, type ChromeManifest } from "./protocol";
30
32
  import { editorPort } from "./view/port";
31
33
 
34
+ export type { EditorSettingValue, EditorSettingsValues } from "./index";
35
+
32
36
  export type EditorViewSnapshot = {
33
37
  session: EditorSession | null;
34
38
  access: EditorAccess | null;
@@ -59,6 +63,8 @@ export type EditorView = {
59
63
  subscribe(listener: () => void): () => void;
60
64
  onImportFile(listener: (file: File) => void): () => void;
61
65
  onCommand(listener: (commandId: string) => void): () => void;
66
+ getSetting(key: string): EditorSettingValue | undefined;
67
+ onSettingsChange(listener: (values: Readonly<EditorSettingsValues>) => void): () => void;
62
68
  sendCommandState(state: CommandStateBody): boolean;
63
69
  sendShortcut(body: ShortcutBody): boolean;
64
70
  close(): void;
@@ -124,6 +130,8 @@ export function connectEditorView(deps: EditorViewDeps): EditorView {
124
130
  const importFileListeners = new Set<(file: File) => void>();
125
131
  const pendingImportFiles: File[] = [];
126
132
  const commandListeners = new Set<(commandId: string) => void>();
133
+ let settings: Readonly<EditorSettingsValues> = {};
134
+ const settingsListeners = new Set<(values: Readonly<EditorSettingsValues>) => void>();
127
135
  let submitted: SubmittedChrome | null = null;
128
136
  let stopped = false;
129
137
  let channel: EditorChannel | undefined;
@@ -158,6 +166,12 @@ export function connectEditorView(deps: EditorViewDeps): EditorView {
158
166
  function onEvent(event: EditorEvent) {
159
167
  if (stopped) return;
160
168
  switch (event.type) {
169
+ case "settings.snapshot":
170
+ case "settings.changed": {
171
+ settings = Object.freeze(event.body.values);
172
+ for (const listener of [...settingsListeners]) listener(settings);
173
+ return;
174
+ }
161
175
  case "file.import": {
162
176
  const bytes = fromBase64(event.body.data);
163
177
  if (bytes.byteLength > EDITOR_IMPORT_BYTES_MAX) return;
@@ -281,6 +295,14 @@ export function connectEditorView(deps: EditorViewDeps): EditorView {
281
295
  commandListeners.delete(listener);
282
296
  };
283
297
  },
298
+ getSetting(key) {
299
+ return Object.hasOwn(settings, key) ? settings[key] : undefined;
300
+ },
301
+ onSettingsChange(listener) {
302
+ if (stopped) return () => {};
303
+ settingsListeners.add(listener);
304
+ return () => { settingsListeners.delete(listener); };
305
+ },
284
306
  sendCommandState(state) {
285
307
  if (!submitted?.commandIds.has(state.id)) return false;
286
308
  return channel?.emit({ v: 1, type: "command.state", body: state }) ?? false;
@@ -300,6 +322,7 @@ export function connectEditorView(deps: EditorViewDeps): EditorView {
300
322
  doc.off("update", onDocUpdate);
301
323
  listeners.clear();
302
324
  commandListeners.clear();
325
+ settingsListeners.clear();
303
326
  importFileListeners.clear();
304
327
  pendingImportFiles.length = 0;
305
328
  channel?.close();