@dxos/plugin-deck 0.6.8-main.046e6cf

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 (106) hide show
  1. package/LICENSE +8 -0
  2. package/README.md +15 -0
  3. package/dist/lib/browser/chunk-YVHGFQQR.mjs +12 -0
  4. package/dist/lib/browser/chunk-YVHGFQQR.mjs.map +7 -0
  5. package/dist/lib/browser/index.mjs +1657 -0
  6. package/dist/lib/browser/index.mjs.map +7 -0
  7. package/dist/lib/browser/meta.json +1 -0
  8. package/dist/lib/browser/meta.mjs +9 -0
  9. package/dist/lib/browser/meta.mjs.map +7 -0
  10. package/dist/types/src/DeckPlugin.d.ts +15 -0
  11. package/dist/types/src/DeckPlugin.d.ts.map +1 -0
  12. package/dist/types/src/components/DeckContext.d.ts +8 -0
  13. package/dist/types/src/components/DeckContext.d.ts.map +1 -0
  14. package/dist/types/src/components/DeckLayout/ActiveNode.d.ts +5 -0
  15. package/dist/types/src/components/DeckLayout/ActiveNode.d.ts.map +1 -0
  16. package/dist/types/src/components/DeckLayout/ComplementarySidebar.d.ts +9 -0
  17. package/dist/types/src/components/DeckLayout/ComplementarySidebar.d.ts.map +1 -0
  18. package/dist/types/src/components/DeckLayout/ContentEmpty.d.ts +3 -0
  19. package/dist/types/src/components/DeckLayout/ContentEmpty.d.ts.map +1 -0
  20. package/dist/types/src/components/DeckLayout/DeckLayout.d.ts +25 -0
  21. package/dist/types/src/components/DeckLayout/DeckLayout.d.ts.map +1 -0
  22. package/dist/types/src/components/DeckLayout/Fallback.d.ts +3 -0
  23. package/dist/types/src/components/DeckLayout/Fallback.d.ts.map +1 -0
  24. package/dist/types/src/components/DeckLayout/Fullscreen.d.ts +5 -0
  25. package/dist/types/src/components/DeckLayout/Fullscreen.d.ts.map +1 -0
  26. package/dist/types/src/components/DeckLayout/NodePlankHeading.d.ts +14 -0
  27. package/dist/types/src/components/DeckLayout/NodePlankHeading.d.ts.map +1 -0
  28. package/dist/types/src/components/DeckLayout/Plank.d.ts +14 -0
  29. package/dist/types/src/components/DeckLayout/Plank.d.ts.map +1 -0
  30. package/dist/types/src/components/DeckLayout/PlankError.d.ts +14 -0
  31. package/dist/types/src/components/DeckLayout/PlankError.d.ts.map +1 -0
  32. package/dist/types/src/components/DeckLayout/PlankLoading.d.ts +3 -0
  33. package/dist/types/src/components/DeckLayout/PlankLoading.d.ts.map +1 -0
  34. package/dist/types/src/components/DeckLayout/Sidebar.d.ts +8 -0
  35. package/dist/types/src/components/DeckLayout/Sidebar.d.ts.map +1 -0
  36. package/dist/types/src/components/DeckLayout/Toast.d.ts +5 -0
  37. package/dist/types/src/components/DeckLayout/Toast.d.ts.map +1 -0
  38. package/dist/types/src/components/DeckLayout/constants.d.ts +3 -0
  39. package/dist/types/src/components/DeckLayout/constants.d.ts.map +1 -0
  40. package/dist/types/src/components/DeckLayout/index.d.ts +3 -0
  41. package/dist/types/src/components/DeckLayout/index.d.ts.map +1 -0
  42. package/dist/types/src/components/LayoutContext.d.ts +5 -0
  43. package/dist/types/src/components/LayoutContext.d.ts.map +1 -0
  44. package/dist/types/src/components/LayoutSettings.d.ts +6 -0
  45. package/dist/types/src/components/LayoutSettings.d.ts.map +1 -0
  46. package/dist/types/src/components/index.d.ts +5 -0
  47. package/dist/types/src/components/index.d.ts.map +1 -0
  48. package/dist/types/src/hooks/index.d.ts +3 -0
  49. package/dist/types/src/hooks/index.d.ts.map +1 -0
  50. package/dist/types/src/hooks/useNode.d.ts +11 -0
  51. package/dist/types/src/hooks/useNode.d.ts.map +1 -0
  52. package/dist/types/src/hooks/useNodeActionExpander.d.ts +3 -0
  53. package/dist/types/src/hooks/useNodeActionExpander.d.ts.map +1 -0
  54. package/dist/types/src/index.d.ts +4 -0
  55. package/dist/types/src/index.d.ts.map +1 -0
  56. package/dist/types/src/layout.d.ts +25 -0
  57. package/dist/types/src/layout.d.ts.map +1 -0
  58. package/dist/types/src/layout.test.d.ts +2 -0
  59. package/dist/types/src/layout.test.d.ts.map +1 -0
  60. package/dist/types/src/meta.d.ts +7 -0
  61. package/dist/types/src/meta.d.ts.map +1 -0
  62. package/dist/types/src/translations.d.ts +41 -0
  63. package/dist/types/src/translations.d.ts.map +1 -0
  64. package/dist/types/src/types.d.ts +16 -0
  65. package/dist/types/src/types.d.ts.map +1 -0
  66. package/dist/types/src/util/check-app-scheme.d.ts +2 -0
  67. package/dist/types/src/util/check-app-scheme.d.ts.map +1 -0
  68. package/dist/types/src/util/index.d.ts +4 -0
  69. package/dist/types/src/util/index.d.ts.map +1 -0
  70. package/dist/types/src/util/layout-parts.d.ts +7 -0
  71. package/dist/types/src/util/layout-parts.d.ts.map +1 -0
  72. package/dist/types/src/util/overscroll.d.ts +7 -0
  73. package/dist/types/src/util/overscroll.d.ts.map +1 -0
  74. package/package.json +76 -0
  75. package/src/DeckPlugin.tsx +629 -0
  76. package/src/components/DeckContext.ts +14 -0
  77. package/src/components/DeckLayout/ActiveNode.tsx +24 -0
  78. package/src/components/DeckLayout/ComplementarySidebar.tsx +58 -0
  79. package/src/components/DeckLayout/ContentEmpty.tsx +21 -0
  80. package/src/components/DeckLayout/DeckLayout.tsx +270 -0
  81. package/src/components/DeckLayout/Fallback.tsx +28 -0
  82. package/src/components/DeckLayout/Fullscreen.tsx +32 -0
  83. package/src/components/DeckLayout/NodePlankHeading.tsx +160 -0
  84. package/src/components/DeckLayout/Plank.tsx +142 -0
  85. package/src/components/DeckLayout/PlankError.tsx +64 -0
  86. package/src/components/DeckLayout/PlankLoading.tsx +15 -0
  87. package/src/components/DeckLayout/Sidebar.tsx +43 -0
  88. package/src/components/DeckLayout/Toast.tsx +48 -0
  89. package/src/components/DeckLayout/constants.ts +6 -0
  90. package/src/components/DeckLayout/index.ts +6 -0
  91. package/src/components/LayoutContext.ts +12 -0
  92. package/src/components/LayoutSettings.tsx +86 -0
  93. package/src/components/index.ts +8 -0
  94. package/src/hooks/index.ts +6 -0
  95. package/src/hooks/useNode.ts +40 -0
  96. package/src/hooks/useNodeActionExpander.ts +24 -0
  97. package/src/index.ts +9 -0
  98. package/src/layout.test.ts +380 -0
  99. package/src/layout.ts +245 -0
  100. package/src/meta.ts +10 -0
  101. package/src/translations.ts +47 -0
  102. package/src/types.ts +38 -0
  103. package/src/util/check-app-scheme.ts +21 -0
  104. package/src/util/index.ts +7 -0
  105. package/src/util/layout-parts.ts +12 -0
  106. package/src/util/overscroll.ts +97 -0
@@ -0,0 +1,629 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { ArrowsOut, type IconProps } from '@phosphor-icons/react';
6
+ import { batch, effect } from '@preact/signals-core';
7
+ import { setAutoFreeze } from 'immer';
8
+ import React, { type PropsWithChildren } from 'react';
9
+
10
+ import {
11
+ type GraphProvides,
12
+ IntentAction,
13
+ type IntentPluginProvides,
14
+ type Layout,
15
+ LayoutAction,
16
+ NavigationAction,
17
+ parseGraphPlugin,
18
+ parseIntentPlugin,
19
+ type Plugin,
20
+ type PluginDefinition,
21
+ resolvePlugin,
22
+ Toast as ToastSchema,
23
+ SLUG_PATH_SEPARATOR,
24
+ type LayoutPart,
25
+ type LayoutEntry,
26
+ type LayoutParts,
27
+ isLayoutParts,
28
+ isLayoutAdjustment,
29
+ isLayoutMode,
30
+ openIds,
31
+ type LayoutMode,
32
+ type IntentData,
33
+ } from '@dxos/app-framework';
34
+ import { type UnsubscribeCallback } from '@dxos/async';
35
+ import { create, getTypename, isReactiveObject } from '@dxos/echo-schema';
36
+ import { LocalStorageStore } from '@dxos/local-storage';
37
+ import { log } from '@dxos/log';
38
+ import { parseAttentionPlugin, type AttentionPluginProvides } from '@dxos/plugin-attention';
39
+ import { parseClientPlugin, type ClientPluginProvides } from '@dxos/plugin-client';
40
+ import { createExtension, type Node } from '@dxos/plugin-graph';
41
+ import { ObservabilityAction } from '@dxos/plugin-observability/meta';
42
+ import { fullyQualifiedId } from '@dxos/react-client/echo';
43
+ import { translations as deckTranslations } from '@dxos/react-ui-deck';
44
+ import { Mosaic } from '@dxos/react-ui-mosaic';
45
+
46
+ import {
47
+ DeckLayout,
48
+ type DeckLayoutProps,
49
+ LayoutContext,
50
+ LayoutSettings,
51
+ NAV_ID,
52
+ DeckContext,
53
+ type DeckContextType,
54
+ } from './components';
55
+ import {
56
+ closeEntry,
57
+ incrementPlank,
58
+ mergeLayoutParts,
59
+ openEntry,
60
+ removePart,
61
+ soloPartToUri,
62
+ uriToSoloPart,
63
+ } from './layout';
64
+ import meta, { DECK_PLUGIN } from './meta';
65
+ import translations from './translations';
66
+ import { type NewPlankPositioning, type DeckPluginProvides, type DeckSettingsProps, type Overscroll } from './types';
67
+ import { checkAppScheme, getEffectivePart } from './util';
68
+
69
+ const isSocket = !!(globalThis as any).__args;
70
+
71
+ // TODO(mjamesderocher): Can we get this directly from Socket?
72
+ const appScheme = 'composer://';
73
+
74
+ // TODO(burdon): Evolve into customizable prefs, but pls leave for demo.
75
+ const customSlots: DeckLayoutProps['slots'] = {
76
+ wallpaper: {
77
+ classNames:
78
+ 'bg-cover bg-no-repeat dark:bg-[url(https://cdn.midjourney.com/3865ba61-f98a-4d94-b91a-1763ead01f4f/0_0.jpeg)]',
79
+ },
80
+ deck: {
81
+ classNames: 'px-96 bg-neutral-50 __dark:bg-neutral-950 dark:bg-transparent dark:opacity-95',
82
+ },
83
+ plank: {
84
+ classNames: 'mx-1 bg-neutral-25 dark:bg-neutral-900',
85
+ },
86
+ };
87
+
88
+ // NOTE(Zan): When producing values with immer, we shouldn't auto-freeze them because
89
+ // our signal implementation needs to add some hidden properties to the produced values.
90
+ // TODO(Zan): Move this to a more global location if we use immer more broadly.
91
+ setAutoFreeze(false);
92
+
93
+ //
94
+ // Intents
95
+ //
96
+ const DECK_ACTION = 'dxos.org/plugin/deck';
97
+
98
+ export enum DeckAction {
99
+ UPDATE_PLANK_SIZE = `${DECK_ACTION}/update-plank-size`,
100
+ }
101
+
102
+ export namespace DeckAction {
103
+ export type UpdatePlankSize = IntentData<{ id: string; size: number }>;
104
+ }
105
+
106
+ export const DeckPlugin = ({
107
+ observability,
108
+ }: {
109
+ observability?: boolean;
110
+ } = {}): PluginDefinition<DeckPluginProvides> => {
111
+ let graphPlugin: Plugin<GraphProvides> | undefined;
112
+ // TODO(burdon): GraphPlugin vs. IntentPluginProvides? (@wittjosiah).
113
+ let intentPlugin: Plugin<IntentPluginProvides> | undefined;
114
+ let attentionPlugin: Plugin<AttentionPluginProvides> | undefined;
115
+ let clientPlugin: Plugin<ClientPluginProvides> | undefined;
116
+ const unsubscriptionCallbacks = [] as (UnsubscribeCallback | undefined)[];
117
+ let currentUndoId: string | undefined;
118
+ let handleNavigation: () => Promise<void> | undefined;
119
+
120
+ const settings = new LocalStorageStore<DeckSettingsProps>('dxos.org/settings/layout', {
121
+ showFooter: false,
122
+ customSlots: false,
123
+ flatDeck: false,
124
+ enableNativeRedirect: false,
125
+ disableDeck: false,
126
+ newPlankPositioning: 'start',
127
+ overscroll: 'centering',
128
+ });
129
+
130
+ const layout = new LocalStorageStore<Layout>('dxos.org/settings/layout', {
131
+ layoutMode: 'solo',
132
+ sidebarOpen: true,
133
+ complementarySidebarOpen: false,
134
+ dialogContent: null,
135
+ dialogOpen: false,
136
+ dialogBlockAlign: undefined,
137
+ popoverContent: null,
138
+ popoverAnchorId: undefined,
139
+ popoverOpen: false,
140
+ toasts: [],
141
+ });
142
+
143
+ const deck = new LocalStorageStore<DeckContextType>('dxos.org/settings/deck', {
144
+ plankSizing: {},
145
+ });
146
+
147
+ const location = new LocalStorageStore<{ active: LayoutParts; closed: string[] }>('dxos.org/state/layout', {
148
+ active: { sidebar: [{ id: NAV_ID }] },
149
+ closed: [],
150
+ });
151
+
152
+ // TODO(Zan): Cap depth!
153
+ const layoutModeHistory = create({ values: [] as LayoutMode[] });
154
+
155
+ const handleSetLayout = ({
156
+ element,
157
+ state,
158
+ component,
159
+ subject,
160
+ anchorId,
161
+ dialogBlockAlign,
162
+ }: LayoutAction.SetLayout) => {
163
+ switch (element) {
164
+ case 'sidebar': {
165
+ layout.values.sidebarOpen = state ?? !layout.values.sidebarOpen;
166
+ return { data: true };
167
+ }
168
+
169
+ case 'complementary': {
170
+ layout.values.complementarySidebarOpen = !!state;
171
+ // TODO(thure): Hoist content into the c11y sidebar of Deck.
172
+ // layout.values.complementarySidebarContent = component || subject ? { component, subject } : null;
173
+ return { data: true };
174
+ }
175
+
176
+ case 'dialog': {
177
+ layout.values.dialogOpen = state ?? Boolean(component);
178
+ layout.values.dialogContent = component ? { component, subject } : null;
179
+ layout.values.dialogBlockAlign = dialogBlockAlign ?? 'center';
180
+ return { data: true };
181
+ }
182
+
183
+ case 'popover': {
184
+ layout.values.popoverOpen = state ?? Boolean(component);
185
+ layout.values.popoverContent = component ? { component, subject } : null;
186
+ layout.values.popoverAnchorId = anchorId;
187
+ return { data: true };
188
+ }
189
+
190
+ case 'toast': {
191
+ if (ToastSchema.safeParse(subject).success) {
192
+ layout.values.toasts = [...layout.values.toasts, subject];
193
+ return { data: true };
194
+ }
195
+ }
196
+ }
197
+ };
198
+
199
+ return {
200
+ meta,
201
+ ready: async (plugins) => {
202
+ intentPlugin = resolvePlugin(plugins, parseIntentPlugin);
203
+ graphPlugin = resolvePlugin(plugins, parseGraphPlugin);
204
+ attentionPlugin = resolvePlugin(plugins, parseAttentionPlugin);
205
+ clientPlugin = resolvePlugin(plugins, parseClientPlugin);
206
+
207
+ // prettier-ignore
208
+ layout
209
+ .prop({ key: 'layoutMode', storageKey: 'layout-mode', type: LocalStorageStore.enum<LayoutMode>() })
210
+ .prop({ key: 'sidebarOpen', storageKey: 'sidebar-open', type: LocalStorageStore.bool() })
211
+ .prop({ key: 'complementarySidebarOpen', storageKey: 'complementary-sidebar-open', type: LocalStorageStore.bool() });
212
+
213
+ // prettier-ignore
214
+ deck.prop({ key: 'plankSizing', storageKey: 'plank-sizing', type: LocalStorageStore.json<Record<string, number>>() });
215
+
216
+ // prettier-ignore
217
+ location
218
+ .prop({ key: 'active', storageKey: 'active', type: LocalStorageStore.json<LayoutParts>() })
219
+ .prop({ key: 'closed', storageKey: 'closed', type: LocalStorageStore.json<string[]>() });
220
+
221
+ unsubscriptionCallbacks.push(
222
+ clientPlugin?.provides.client.shell.onReset(() => {
223
+ layout.expunge();
224
+ location.expunge();
225
+ deck.expunge();
226
+ }),
227
+ );
228
+
229
+ // prettier-ignore
230
+ settings
231
+ .prop({ key: 'showFooter', storageKey: 'show-footer', type: LocalStorageStore.bool() })
232
+ .prop({ key: 'customSlots', storageKey: 'customSlots', type: LocalStorageStore.bool() })
233
+ .prop({ key: 'flatDeck', storageKey: 'flatDeck', type: LocalStorageStore.bool() })
234
+ .prop({ key: 'enableNativeRedirect', storageKey: 'enable-native-redirect', type: LocalStorageStore.bool() })
235
+ .prop({ key: 'disableDeck', storageKey: 'disable-deck', type: LocalStorageStore.bool() }) // Deprecated.
236
+ .prop({ key: 'newPlankPositioning', storageKey: 'newPlankPositioning', type: LocalStorageStore.enum<NewPlankPositioning>() })
237
+ .prop({ key: 'overscroll', storageKey: 'overscroll', type: LocalStorageStore.enum<Overscroll>() });
238
+
239
+ if (!isSocket && settings.values.enableNativeRedirect) {
240
+ checkAppScheme(appScheme);
241
+ }
242
+
243
+ handleNavigation = async () => {
244
+ const layoutFromUri = uriToSoloPart(window.location.pathname);
245
+ if (!layoutFromUri) {
246
+ return;
247
+ }
248
+
249
+ const startingLayout = removePart(location.values.active, 'solo');
250
+ location.values.active = mergeLayoutParts(layoutFromUri, startingLayout);
251
+ layout.values.layoutMode = 'solo';
252
+ };
253
+
254
+ await handleNavigation();
255
+ window.addEventListener('popstate', handleNavigation);
256
+
257
+ unsubscriptionCallbacks.push(
258
+ effect(() => {
259
+ const selectedPath = soloPartToUri(location.values.active);
260
+ // TODO(thure): In some browsers, this only preserves the most recent state change, even though this is not `history.replace`…
261
+ history.pushState(null, '', `/${selectedPath}${window.location.search}`);
262
+ }),
263
+ );
264
+
265
+ unsubscriptionCallbacks.push(
266
+ effect(() => {
267
+ const soloId = location.values.active.solo?.[0].id;
268
+ if (layout.values.layoutMode === 'solo' && soloId && layout.values.scrollIntoView !== soloId) {
269
+ void intentPlugin?.provides.intent.dispatch({
270
+ action: LayoutAction.SCROLL_INTO_VIEW,
271
+ data: { id: soloId },
272
+ });
273
+ }
274
+ }),
275
+ );
276
+
277
+ layoutModeHistory.values.push(`${layout.values.layoutMode}`);
278
+ },
279
+ unload: async () => {
280
+ layout.close();
281
+ location.close();
282
+ unsubscriptionCallbacks.forEach((unsubscribe) => unsubscribe?.());
283
+ window.removeEventListener('popstate', handleNavigation);
284
+ },
285
+ provides: {
286
+ settings: settings.values,
287
+ layout: layout.values,
288
+ location: location.values,
289
+ translations: [...translations, ...deckTranslations],
290
+ graph: {
291
+ builder: () => {
292
+ // TODO(burdon): Root menu isn't visible so nothing bound.
293
+ return createExtension({
294
+ id: DECK_PLUGIN,
295
+ filter: (node): node is Node<null> => node.id === 'root',
296
+ actions: () => [
297
+ {
298
+ id: `${LayoutAction.SET_LAYOUT_MODE}/fullscreen`,
299
+ data: async () => {
300
+ await intentPlugin?.provides.intent.dispatch({
301
+ plugin: DECK_PLUGIN,
302
+ action: LayoutAction.SET_LAYOUT_MODE,
303
+ data: { layoutMode: 'fullscreen' },
304
+ });
305
+ },
306
+ properties: {
307
+ label: ['toggle fullscreen label', { ns: DECK_PLUGIN }],
308
+ icon: (props: IconProps) => <ArrowsOut {...props} />,
309
+ iconSymbol: 'ph--arrows-out--regular',
310
+ keyBinding: {
311
+ macos: 'ctrl+meta+f',
312
+ windows: 'shift+ctrl+f',
313
+ },
314
+ },
315
+ },
316
+ ],
317
+ });
318
+ },
319
+ },
320
+ context: (props: PropsWithChildren) => (
321
+ <LayoutContext.Provider value={layout.values}>
322
+ <DeckContext.Provider value={deck.values}>{props.children}</DeckContext.Provider>
323
+ </LayoutContext.Provider>
324
+ ),
325
+ root: () => {
326
+ return (
327
+ <Mosaic.Root>
328
+ <DeckLayout
329
+ attention={attentionPlugin?.provides.attention ?? { attended: new Set() }}
330
+ layoutParts={location.values.active}
331
+ overscroll={settings.values.overscroll}
332
+ flatDeck={settings.values.flatDeck}
333
+ showHintsFooter={settings.values.showFooter}
334
+ slots={settings.values.customSlots ? customSlots : undefined}
335
+ toasts={layout.values.toasts}
336
+ onDismissToast={(id) => {
337
+ const index = layout.values.toasts.findIndex((toast) => toast.id === id);
338
+ if (index !== -1) {
339
+ // Allow time for the toast to animate out.
340
+ setTimeout(() => {
341
+ if (layout.values.toasts[index].id === currentUndoId) {
342
+ currentUndoId = undefined;
343
+ }
344
+ layout.values.toasts.splice(index, 1);
345
+ }, 1000);
346
+ }
347
+ }}
348
+ />
349
+ <Mosaic.DragOverlay />
350
+ </Mosaic.Root>
351
+ );
352
+ },
353
+ surface: {
354
+ component: ({ data, role }) => {
355
+ switch (role) {
356
+ case 'settings':
357
+ return data.plugin === meta.id ? <LayoutSettings settings={settings.values} /> : null;
358
+ }
359
+ return null;
360
+ },
361
+ },
362
+ intent: {
363
+ resolver: (intent) => {
364
+ switch (intent.action) {
365
+ case LayoutAction.SET_LAYOUT: {
366
+ return intent.data && handleSetLayout(intent.data as LayoutAction.SetLayout);
367
+ }
368
+
369
+ case LayoutAction.SET_LAYOUT_MODE: {
370
+ return batch(() => {
371
+ if (!intent.data) {
372
+ return;
373
+ }
374
+
375
+ if (intent.data?.revert) {
376
+ layout.values.layoutMode = layoutModeHistory.values.pop() ?? 'solo';
377
+ return { data: true };
378
+ }
379
+
380
+ if (isLayoutMode(intent?.data?.layoutMode)) {
381
+ layoutModeHistory.values.push(layout.values.layoutMode);
382
+ layout.values.layoutMode = intent.data.layoutMode;
383
+ } else {
384
+ log.warn('Invalid layout mode', intent?.data?.layoutMode);
385
+ }
386
+
387
+ return { data: true };
388
+ });
389
+ }
390
+
391
+ case LayoutAction.SCROLL_INTO_VIEW: {
392
+ layout.values.scrollIntoView = intent.data?.id ?? undefined;
393
+ return undefined;
394
+ }
395
+
396
+ case DeckAction.UPDATE_PLANK_SIZE: {
397
+ const { id, size } = intent.data as DeckAction.UpdatePlankSize;
398
+ deck.values.plankSizing[id] = size;
399
+ return { data: true };
400
+ }
401
+
402
+ case IntentAction.SHOW_UNDO: {
403
+ // TODO(wittjosiah): Support undoing further back than the last action.
404
+ if (currentUndoId) {
405
+ layout.values.toasts = layout.values.toasts.filter((toast) => toast.id !== currentUndoId);
406
+ }
407
+ currentUndoId = `${IntentAction.SHOW_UNDO}-${Date.now()}`;
408
+ const title =
409
+ // TODO(wittjosiah): How to handle chains better?
410
+ intent.data?.results?.[0]?.result?.undoable?.message ??
411
+ translations[0]['en-US']['dxos.org/plugin/deck']['undo available label'];
412
+ layout.values.toasts = [
413
+ ...layout.values.toasts,
414
+ {
415
+ id: currentUndoId,
416
+ title,
417
+ duration: 10_000,
418
+ actionLabel: translations[0]['en-US']['dxos.org/plugin/deck']['undo action label'],
419
+ actionAlt: translations[0]['en-US']['dxos.org/plugin/deck']['undo action alt'],
420
+ closeLabel: translations[0]['en-US']['dxos.org/plugin/deck']['undo close label'],
421
+ onAction: () => intentPlugin?.provides.intent.undo?.(),
422
+ },
423
+ ];
424
+ return { data: true };
425
+ }
426
+
427
+ case NavigationAction.OPEN: {
428
+ const previouslyOpenIds = new Set<string>(openIds(location.values.active));
429
+ const layoutMode = layout.values.layoutMode;
430
+ batch(() => {
431
+ if (!intent.data || !intent.data?.activeParts) {
432
+ return;
433
+ }
434
+
435
+ const newPlankPositioning = settings.values.newPlankPositioning;
436
+
437
+ const processLayoutEntry = (partName: string, entryString: string, currentLayout: any) => {
438
+ const [id, path] = entryString.split(SLUG_PATH_SEPARATOR);
439
+ const layoutEntry: LayoutEntry = {
440
+ id,
441
+ ...(path ? { path } : {}),
442
+ };
443
+ const effectivePart = getEffectivePart(partName as LayoutPart, layoutMode);
444
+ if (
445
+ layoutMode === 'deck' &&
446
+ effectivePart === 'main' &&
447
+ currentLayout[effectivePart]?.some((entry: LayoutEntry) => entry.id === id) &&
448
+ !intent.data?.noToggle
449
+ ) {
450
+ // If we're in deck mode and the main part is already open, toggle it closed.
451
+ return closeEntry(currentLayout, { part: effectivePart as LayoutPart, entryId: id });
452
+ } else {
453
+ return openEntry(currentLayout, effectivePart, layoutEntry, {
454
+ positioning: newPlankPositioning,
455
+ });
456
+ }
457
+ };
458
+
459
+ let newLayout = location.values.active;
460
+
461
+ Object.entries(intent.data.activeParts).forEach(([partName, layoutEntries]) => {
462
+ if (Array.isArray(layoutEntries)) {
463
+ layoutEntries.forEach((activePartEntry: string) => {
464
+ newLayout = processLayoutEntry(partName, activePartEntry, newLayout);
465
+ });
466
+ } else if (typeof layoutEntries === 'string') {
467
+ // Legacy single string entry
468
+ newLayout = processLayoutEntry(partName, layoutEntries, newLayout);
469
+ }
470
+ });
471
+
472
+ location.values.active = newLayout;
473
+ });
474
+
475
+ const ids = openIds(location.values.active);
476
+ const newlyOpen = ids.filter((i) => !previouslyOpenIds.has(i));
477
+
478
+ return {
479
+ data: { ids },
480
+ intents: [
481
+ newlyOpen.length > 0
482
+ ? [
483
+ {
484
+ action: LayoutAction.SCROLL_INTO_VIEW,
485
+ data: { id: newlyOpen[0] },
486
+ },
487
+ ]
488
+ : [],
489
+ intent.data?.object
490
+ ? [
491
+ {
492
+ action: NavigationAction.EXPOSE,
493
+ data: { id: fullyQualifiedId(intent.data.object) },
494
+ },
495
+ ]
496
+ : [],
497
+ observability
498
+ ? newlyOpen.map((id) => {
499
+ const active = graphPlugin?.provides.graph.findNode(id)?.data;
500
+ const typename = isReactiveObject(active) ? getTypename(active) : undefined;
501
+ return {
502
+ action: ObservabilityAction.SEND_EVENT,
503
+ data: {
504
+ name: 'navigation.activate',
505
+ properties: {
506
+ id,
507
+ typename,
508
+ },
509
+ },
510
+ };
511
+ })
512
+ : [],
513
+ ],
514
+ };
515
+ }
516
+
517
+ case NavigationAction.ADD_TO_ACTIVE: {
518
+ const data = intent.data as NavigationAction.AddToActive;
519
+ const layoutEntry = { id: data.id };
520
+ const effectivePart = getEffectivePart(data.part, layout.values.layoutMode);
521
+
522
+ location.values.active = openEntry(location.values.active, effectivePart, layoutEntry, {
523
+ positioning: data.positioning ?? settings.values.newPlankPositioning,
524
+ pivotId: data.pivotId,
525
+ });
526
+
527
+ const intents = [];
528
+ if (data.scrollIntoView && layout.values.layoutMode === 'deck') {
529
+ intents.push([
530
+ {
531
+ action: LayoutAction.SCROLL_INTO_VIEW,
532
+ data: { id: data.id },
533
+ },
534
+ ]);
535
+ }
536
+
537
+ return { data: true, intents };
538
+ }
539
+
540
+ case NavigationAction.CLOSE: {
541
+ return batch(() => {
542
+ if (!intent.data) {
543
+ return;
544
+ }
545
+ let newLayout = location.values.active;
546
+ const layoutMode = layout.values.layoutMode;
547
+ const intentParts = intent.data.activeParts;
548
+ Object.keys(intentParts).forEach((partName: string) => {
549
+ const effectivePart = getEffectivePart(partName as LayoutPart, layoutMode);
550
+ const ids = intentParts[partName];
551
+ if (Array.isArray(ids)) {
552
+ ids.forEach((id: string) => {
553
+ newLayout = closeEntry(newLayout, { part: effectivePart, entryId: id });
554
+ });
555
+ } else {
556
+ // Legacy single string entry
557
+ newLayout = closeEntry(newLayout, { part: effectivePart, entryId: ids });
558
+ }
559
+ });
560
+
561
+ location.values.active = newLayout;
562
+
563
+ return { data: true };
564
+ });
565
+ }
566
+
567
+ // TODO(wittjosiah): Factor out.
568
+ case NavigationAction.SET: {
569
+ return batch(() => {
570
+ if (isLayoutParts(intent.data?.activeParts)) {
571
+ location.values.active = intent.data!.activeParts;
572
+ }
573
+ return { data: true };
574
+ });
575
+ }
576
+
577
+ case NavigationAction.ADJUST: {
578
+ return batch(() => {
579
+ if (isLayoutAdjustment(intent.data)) {
580
+ const adjustment = intent.data;
581
+
582
+ if (adjustment.type === 'increment-end' || adjustment.type === 'increment-start') {
583
+ const nextActive = incrementPlank(location.values.active, {
584
+ type: adjustment.type,
585
+ layoutCoordinate: adjustment.layoutCoordinate,
586
+ });
587
+ location.values.active = nextActive;
588
+ }
589
+
590
+ if (adjustment.type === 'solo') {
591
+ const entryId = adjustment.layoutCoordinate.entryId;
592
+ if (layout.values.layoutMode !== 'solo') {
593
+ // Solo the entry.
594
+ return {
595
+ data: true,
596
+ intents: [
597
+ [
598
+ { action: LayoutAction.SET_LAYOUT_MODE, data: { layoutMode: 'solo' } },
599
+ { action: NavigationAction.OPEN, data: { activeParts: { solo: [entryId] } } },
600
+ ],
601
+ ],
602
+ };
603
+ } else {
604
+ // Un-solo the current entry.
605
+ return {
606
+ data: true,
607
+ intents: [
608
+ [
609
+ { action: LayoutAction.SET_LAYOUT_MODE, data: { layoutMode: 'deck' } },
610
+ { action: NavigationAction.CLOSE, data: { activeParts: { solo: [entryId] } } },
611
+ {
612
+ action: NavigationAction.OPEN,
613
+ data: { noToggle: true, activeParts: { main: [entryId] } },
614
+ },
615
+ { action: LayoutAction.SCROLL_INTO_VIEW, data: { id: entryId } },
616
+ ],
617
+ ],
618
+ };
619
+ }
620
+ }
621
+ }
622
+ });
623
+ }
624
+ }
625
+ },
626
+ },
627
+ },
628
+ };
629
+ };
@@ -0,0 +1,14 @@
1
+ //
2
+ // Copyright 2023 DXOS.org
3
+ //
4
+
5
+ import { type Context, createContext, useContext } from 'react';
6
+
7
+ import { raise } from '@dxos/debug';
8
+
9
+ export type PlankSizing = Record<string, number>;
10
+ export type DeckContextType = { plankSizing: PlankSizing };
11
+
12
+ export const DeckContext: Context<DeckContextType | null> = createContext<DeckContextType | null>(null);
13
+
14
+ export const useDeckContext = (): DeckContextType => useContext(DeckContext) ?? raise(new Error('Missing DeckContext'));
@@ -0,0 +1,24 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { Surface } from '@dxos/app-framework';
8
+ import { useGraph } from '@dxos/plugin-graph';
9
+
10
+ import { useNode, useNodeActionExpander } from '../../hooks';
11
+
12
+ export const ActiveNode = ({ id }: { id?: string }) => {
13
+ const { graph } = useGraph();
14
+ const activeNode = useNode(graph, id);
15
+ useNodeActionExpander(activeNode);
16
+
17
+ return (
18
+ <div role='none' className='sr-only'>
19
+ {/* TODO(wittjosiah): Weird that this is a surface, feel like it's not really render logic.
20
+ Probably this lives in React-land currently in order to access translations? */}
21
+ <Surface role='document-title' data={{ activeNode }} limit={1} />
22
+ </div>
23
+ );
24
+ };