@lvce-editor/extension-host-worker 8.34.0 → 8.36.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.
@@ -2,6 +2,7 @@ import type { VirtualDomNode } from '@lvce-editor/virtual-dom-worker';
2
2
  export type ViewKind = 'virtualDom';
3
3
  export interface ViewContext {
4
4
  readonly requestRerender: () => Promise<void>;
5
+ readonly showContextMenu: (menuId: string, x: number, y: number) => Promise<void>;
5
6
  readonly state?: unknown;
6
7
  readonly uid: number;
7
8
  readonly viewId: string;
@@ -10,16 +11,37 @@ export interface ViewEvent {
10
11
  readonly name?: string;
11
12
  readonly type: string;
12
13
  readonly value?: unknown;
14
+ readonly x?: number;
15
+ readonly y?: number;
16
+ }
17
+ export interface MenuEntry {
18
+ readonly args?: readonly unknown[];
19
+ readonly command: string;
20
+ readonly flags?: number;
21
+ readonly id: string;
22
+ readonly label: string;
23
+ }
24
+ export interface DomEventListener {
25
+ readonly capture?: boolean;
26
+ readonly name: string | number;
27
+ readonly params: readonly string[];
28
+ readonly passive?: boolean;
29
+ readonly preventDefault?: boolean;
30
+ readonly stopPropagation?: boolean;
13
31
  }
14
32
  export interface VirtualDomViewInstance {
15
33
  readonly dispose?: () => unknown;
34
+ readonly getContext?: () => Readonly<Record<string, boolean>>;
35
+ readonly getMenuEntries?: (menuId: string) => readonly MenuEntry[] | Promise<readonly MenuEntry[]>;
16
36
  readonly handleEvent?: (event: ViewEvent) => unknown;
17
37
  readonly render: () => readonly VirtualDomNode[] | Promise<readonly VirtualDomNode[]>;
38
+ readonly renderFocus?: (oldContext: Readonly<Record<string, boolean>>, newContext: Readonly<Record<string, boolean>>) => string | Promise<string>;
18
39
  readonly saveState?: () => unknown;
19
40
  }
20
41
  export interface View {
21
42
  readonly create: (context?: ViewContext) => unknown;
22
43
  readonly displayName?: string;
44
+ readonly eventListeners?: readonly DomEventListener[];
23
45
  readonly icon?: string;
24
46
  readonly id: string;
25
47
  readonly kind?: ViewKind;
@@ -28,6 +50,7 @@ export interface View {
28
50
  }
29
51
  export interface RegisteredView {
30
52
  readonly displayName?: string;
53
+ readonly eventListeners?: readonly DomEventListener[];
31
54
  readonly icon?: string;
32
55
  readonly id: string;
33
56
  readonly kind?: ViewKind;
@@ -39,9 +62,11 @@ export interface ViewRegistrySnapshot {
39
62
  }
40
63
  export interface ViewRenderResultDom {
41
64
  readonly dom: readonly VirtualDomNode[];
65
+ readonly focusSelector?: string;
42
66
  readonly type: 'setDom';
43
67
  }
44
68
  export interface ViewRenderResultPatches {
69
+ readonly focusSelector?: string;
45
70
  readonly patches: readonly unknown[];
46
71
  readonly type: 'setPatches';
47
72
  }
@@ -1,5 +1,5 @@
1
1
  import type { Disposable } from '../Disposable/Disposable.ts';
2
- import type { View, ViewContext, ViewEvent, ViewRegistrySnapshot, ViewRenderResult } from '../View/View.ts';
2
+ import type { MenuEntry, View, ViewContext, ViewEvent, ViewRegistrySnapshot, ViewRenderResult } from '../View/View.ts';
3
3
  export declare const registerView: (view: View) => Disposable;
4
4
  export declare const executeViewProvider: (id: string) => unknown;
5
5
  export declare const createViewInstance: (viewId: string, uid: number, context?: ViewContext) => Promise<ViewRenderResult>;
@@ -7,5 +7,6 @@ export declare const dispatchViewEvent: (uid: number, event: ViewEvent) => Promi
7
7
  export declare const renderViewInstance: (uid: number) => Promise<ViewRenderResult>;
8
8
  export declare const disposeViewInstance: (uid: number) => Promise<void>;
9
9
  export declare const saveViewInstanceState: (uid: number) => Promise<unknown>;
10
+ export declare const getViewMenuEntries: (uid: number, menuId: string) => Promise<readonly MenuEntry[]>;
10
11
  export declare const getViewRegistrySnapshot: () => ViewRegistrySnapshot;
11
12
  export declare const resetViewRegistry: () => void;
@@ -4,6 +4,50 @@ import { ExtensionApiError } from "../ExtensionApiError/ExtensionApiError.js";
4
4
  const views = Object.create(null);
5
5
  const instances = Object.create(null);
6
6
  const renderedDoms = Object.create(null);
7
+ const contexts = Object.create(null);
8
+ const contextViewIds = Object.create(null);
9
+ const assertBoolean = (value, message) => {
10
+ if (value !== undefined && typeof value !== 'boolean') {
11
+ throw new ExtensionApiError(message);
12
+ }
13
+ };
14
+ const assertNumber = (value, message) => {
15
+ if (typeof value !== 'number') {
16
+ throw new ExtensionApiError(message);
17
+ }
18
+ };
19
+ const assertString = (value, message) => {
20
+ if (typeof value !== 'string' || value.length === 0) {
21
+ throw new ExtensionApiError(message);
22
+ }
23
+ };
24
+ const assertEventListener = (viewId, listener, index) => {
25
+ if (!listener || typeof listener !== 'object') {
26
+ throw new ExtensionApiError(`view ${viewId} event listener ${index} must be an object`);
27
+ }
28
+ const eventListener = listener;
29
+ if (typeof eventListener.name !== 'string' && typeof eventListener.name !== 'number') {
30
+ throw new ExtensionApiError(`view ${viewId} event listener ${index} is missing name`);
31
+ }
32
+ if (!Array.isArray(eventListener.params) || eventListener.params.some((param) => typeof param !== 'string')) {
33
+ throw new ExtensionApiError(`view ${viewId} event listener ${index} is missing params`);
34
+ }
35
+ assertBoolean(eventListener.capture, `view ${viewId} event listener ${index} has invalid capture`);
36
+ assertBoolean(eventListener.passive, `view ${viewId} event listener ${index} has invalid passive`);
37
+ assertBoolean(eventListener.preventDefault, `view ${viewId} event listener ${index} has invalid preventDefault`);
38
+ assertBoolean(eventListener.stopPropagation, `view ${viewId} event listener ${index} has invalid stopPropagation`);
39
+ };
40
+ const assertEventListeners = (view) => {
41
+ if (view.eventListeners === undefined) {
42
+ return;
43
+ }
44
+ if (!Array.isArray(view.eventListeners)) {
45
+ throw new ExtensionApiError(`view ${view.id} eventListeners must be an array`);
46
+ }
47
+ for (const [index, listener] of view.eventListeners.entries()) {
48
+ assertEventListener(view.id, listener, index);
49
+ }
50
+ };
7
51
  const assertView = (view) => {
8
52
  if (!view) {
9
53
  throw new ExtensionApiError('view is not defined');
@@ -17,11 +61,13 @@ const assertView = (view) => {
17
61
  if (view.id in views) {
18
62
  throw new ExtensionApiError(`view ${view.id} is already registered`);
19
63
  }
64
+ assertEventListeners(view);
20
65
  };
21
66
  const toRegisteredView = (view) => {
22
67
  const displayName = view.displayName || view.name || view.title;
23
68
  const registeredView = {
24
69
  displayName,
70
+ ...(view.eventListeners && { eventListeners: view.eventListeners }),
25
71
  icon: view.icon,
26
72
  id: view.id,
27
73
  name: view.name,
@@ -73,6 +119,34 @@ const renderDom = async (instance) => {
73
119
  }
74
120
  return dom;
75
121
  };
122
+ const normalizeMenuEntry = (entry, index) => {
123
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
124
+ throw new ExtensionApiError(`menu entry ${index} must be an object`);
125
+ }
126
+ const menuEntry = entry;
127
+ assertString(menuEntry.id, `menu entry ${index} is missing id`);
128
+ assertString(menuEntry.label, `menu entry ${index} is missing label`);
129
+ assertString(menuEntry.command, `menu entry ${index} is missing command`);
130
+ if (menuEntry.flags !== undefined && typeof menuEntry.flags !== 'number') {
131
+ throw new ExtensionApiError(`menu entry ${index} has invalid flags`);
132
+ }
133
+ if (menuEntry.args !== undefined && !Array.isArray(menuEntry.args)) {
134
+ throw new ExtensionApiError(`menu entry ${index} has invalid args`);
135
+ }
136
+ return {
137
+ command: menuEntry.command,
138
+ flags: menuEntry.flags ?? 0,
139
+ id: menuEntry.id,
140
+ label: menuEntry.label,
141
+ ...(menuEntry.args && { args: menuEntry.args }),
142
+ };
143
+ };
144
+ const normalizeMenuEntries = (entries) => {
145
+ if (!Array.isArray(entries)) {
146
+ throw new ExtensionApiError('view menu entries must be an array');
147
+ }
148
+ return entries.map(normalizeMenuEntry);
149
+ };
76
150
  const renderPatches = async (uid, instance) => {
77
151
  const oldDom = renderedDoms[uid] || [];
78
152
  const newDom = await renderDom(instance);
@@ -83,6 +157,89 @@ const renderPatches = async (uid, instance) => {
83
157
  type: 'setPatches',
84
158
  };
85
159
  };
160
+ const normalizeContext = (context) => {
161
+ if (!context || typeof context !== 'object' || Array.isArray(context)) {
162
+ return {};
163
+ }
164
+ const normalized = {};
165
+ for (const [key, value] of Object.entries(context)) {
166
+ if (typeof value === 'boolean') {
167
+ normalized[key] = value;
168
+ }
169
+ }
170
+ return normalized;
171
+ };
172
+ const isSameContext = (oldContext, newContext) => {
173
+ const oldKeys = Object.keys(oldContext);
174
+ const newKeys = Object.keys(newContext);
175
+ if (oldKeys.length !== newKeys.length) {
176
+ return false;
177
+ }
178
+ for (const key of oldKeys) {
179
+ if (oldContext[key] !== newContext[key]) {
180
+ return false;
181
+ }
182
+ }
183
+ return true;
184
+ };
185
+ const unchangedContext = {
186
+ changed: false,
187
+ newContext: {},
188
+ oldContext: {},
189
+ };
190
+ const maybeNotifyContextChanged = async (uid, viewId, instance) => {
191
+ if (typeof instance.getContext !== 'function') {
192
+ return unchangedContext;
193
+ }
194
+ const oldContext = contexts[uid] || {};
195
+ const newContext = normalizeContext(instance.getContext());
196
+ if (isSameContext(oldContext, newContext)) {
197
+ return {
198
+ changed: false,
199
+ newContext,
200
+ oldContext,
201
+ };
202
+ }
203
+ if (Object.keys(newContext).length === 0) {
204
+ delete contexts[uid];
205
+ }
206
+ else {
207
+ contexts[uid] = newContext;
208
+ }
209
+ await ExtensionManagementWorker.invoke('Extensions.handleViewContextChange', uid, viewId, newContext);
210
+ return {
211
+ changed: true,
212
+ newContext,
213
+ oldContext,
214
+ };
215
+ };
216
+ const renderFocus = async (instance, contextChange) => {
217
+ if (!contextChange.changed || typeof instance.renderFocus !== 'function') {
218
+ return '';
219
+ }
220
+ const focusSelector = await instance.renderFocus(contextChange.oldContext, contextChange.newContext);
221
+ if (typeof focusSelector !== 'string') {
222
+ throw new ExtensionApiError('view renderFocus result must be a string');
223
+ }
224
+ return focusSelector;
225
+ };
226
+ const withFocusSelector = async (result, instance, contextChange) => {
227
+ const focusSelector = await renderFocus(instance, contextChange);
228
+ if (!focusSelector) {
229
+ return result;
230
+ }
231
+ return {
232
+ ...result,
233
+ focusSelector,
234
+ };
235
+ };
236
+ const maybeClearContext = async (uid, viewId) => {
237
+ if (!contexts[uid]) {
238
+ return;
239
+ }
240
+ delete contexts[uid];
241
+ await ExtensionManagementWorker.invoke('Extensions.handleViewContextChange', uid, viewId, {});
242
+ };
86
243
  export const createViewInstance = async (viewId, uid, context) => {
87
244
  const view = views[viewId];
88
245
  if (!view) {
@@ -96,36 +253,51 @@ export const createViewInstance = async (viewId, uid, context) => {
96
253
  requestRerender() {
97
254
  return ExtensionManagementWorker.invoke('Extensions.requestViewRerender', uid);
98
255
  },
256
+ showContextMenu(menuId, x, y) {
257
+ assertString(menuId, 'menuId must be a string');
258
+ assertNumber(x, 'x must be a number');
259
+ assertNumber(y, 'y must be a number');
260
+ return ExtensionManagementWorker.invoke('Extensions.showViewContextMenu', uid, viewId, menuId, x, y);
261
+ },
99
262
  uid,
100
263
  viewId,
101
264
  });
102
265
  assertVirtualDomViewInstance(viewId, instance);
103
266
  instances[uid] = instance;
267
+ contextViewIds[uid] = viewId;
104
268
  const dom = await renderDom(instance);
105
269
  renderedDoms[uid] = dom;
106
- return {
270
+ const result = {
107
271
  dom,
108
272
  type: 'setDom',
109
273
  };
274
+ const contextChange = await maybeNotifyContextChanged(uid, viewId, instance);
275
+ return withFocusSelector(result, instance, contextChange);
110
276
  };
111
277
  export const dispatchViewEvent = async (uid, event) => {
112
278
  const instance = getVirtualDomInstance(uid);
113
279
  if (typeof instance.handleEvent === 'function') {
114
280
  await instance.handleEvent(event);
115
281
  }
116
- return renderPatches(uid, instance);
282
+ const result = await renderPatches(uid, instance);
283
+ const contextChange = await maybeNotifyContextChanged(uid, contextViewIds[uid], instance);
284
+ return withFocusSelector(result, instance, contextChange);
117
285
  };
118
286
  export const renderViewInstance = async (uid) => {
119
287
  const instance = getVirtualDomInstance(uid);
120
- return renderPatches(uid, instance);
288
+ const result = await renderPatches(uid, instance);
289
+ const contextChange = await maybeNotifyContextChanged(uid, contextViewIds[uid], instance);
290
+ return withFocusSelector(result, instance, contextChange);
121
291
  };
122
292
  export const disposeViewInstance = async (uid) => {
123
293
  const instance = instances[uid];
124
294
  if (instance && typeof instance.dispose === 'function') {
125
295
  await instance.dispose();
126
296
  }
297
+ await maybeClearContext(uid, contextViewIds[uid]);
127
298
  delete instances[uid];
128
299
  delete renderedDoms[uid];
300
+ delete contextViewIds[uid];
129
301
  };
130
302
  export const saveViewInstanceState = async (uid) => {
131
303
  const instance = getVirtualDomInstance(uid);
@@ -134,6 +306,14 @@ export const saveViewInstanceState = async (uid) => {
134
306
  }
135
307
  return instance.saveState();
136
308
  };
309
+ export const getViewMenuEntries = async (uid, menuId) => {
310
+ assertString(menuId, 'menuId must be a string');
311
+ const instance = getVirtualDomInstance(uid);
312
+ if (typeof instance.getMenuEntries !== 'function') {
313
+ return [];
314
+ }
315
+ return normalizeMenuEntries(await instance.getMenuEntries(menuId));
316
+ };
137
317
  export const getViewRegistrySnapshot = () => {
138
318
  return {
139
319
  views: Object.values(views).map(toRegisteredView),
@@ -149,4 +329,10 @@ export const resetViewRegistry = () => {
149
329
  for (const uid of Object.keys(renderedDoms)) {
150
330
  delete renderedDoms[Number(uid)];
151
331
  }
332
+ for (const uid of Object.keys(contexts)) {
333
+ delete contexts[Number(uid)];
334
+ }
335
+ for (const uid of Object.keys(contextViewIds)) {
336
+ delete contextViewIds[Number(uid)];
337
+ }
152
338
  };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lvce-editor/api",
3
- "version": "8.34.0",
3
+ "version": "8.36.0",
4
4
  "description": "Tree-shakeable extension API for Lvce Editor extensions.",
5
5
  "keywords": [
6
6
  "lvce-editor",