@kubohiroya/turbowarp-plus-editor-layout 0.1.3 → 0.1.5

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.
@@ -0,0 +1,20 @@
1
+ type StageCommands = {
2
+ open(): void;
3
+ close(): void;
4
+ };
5
+ export interface DockviewStagePane {
6
+ title: string;
7
+ attach(destination: HTMLElement | null): void;
8
+ connect(commands: StageCommands | null): void;
9
+ }
10
+ export declare function useDockviewStage(enabled: boolean, locale: string): {
11
+ surface: HTMLDivElement;
12
+ pane: DockviewStagePane;
13
+ docked: boolean;
14
+ commands: StageCommands;
15
+ inlineRef: (element: HTMLElement | null) => void;
16
+ suspend: (value: boolean) => void;
17
+ openLabel: string;
18
+ closeLabel: string;
19
+ };
20
+ export {};
@@ -0,0 +1,45 @@
1
+ import React from 'react';
2
+ export function useDockviewStage(enabled, locale) {
3
+ const [surface] = React.useState(() => {
4
+ if (!enabled)
5
+ return null;
6
+ const node = document.createElement('div');
7
+ node.className = 'tw-plus-dockview-stage-surface';
8
+ return node;
9
+ });
10
+ const inline = React.useRef(null);
11
+ const dock = React.useRef(null);
12
+ const suspended = React.useRef(false);
13
+ const [docked, setDocked] = React.useState(false);
14
+ const [commands, setCommands] = React.useState(null);
15
+ const move = React.useCallback(() => {
16
+ const destination = suspended.current ? inline.current : dock.current ?? inline.current;
17
+ if (surface && destination && surface.parentElement !== destination)
18
+ destination.appendChild(surface);
19
+ }, [surface]);
20
+ const inlineRef = React.useCallback((element) => {
21
+ inline.current = element;
22
+ if (surface && element)
23
+ surface.dir = element.closest('[dir]')?.getAttribute('dir') ?? '';
24
+ move();
25
+ }, [move, surface]);
26
+ const suspend = React.useCallback((value) => {
27
+ suspended.current = value;
28
+ move();
29
+ }, [move]);
30
+ const japanese = /^ja(?:-|$)/i.test(locale);
31
+ const pane = React.useMemo(() => ({
32
+ title: japanese ? 'ステージ' : 'Stage',
33
+ attach(destination) {
34
+ dock.current = destination;
35
+ move();
36
+ setDocked(Boolean(destination));
37
+ },
38
+ connect: setCommands
39
+ }), [japanese, move]);
40
+ return surface && {
41
+ surface, pane, docked, commands, inlineRef, suspend,
42
+ openLabel: japanese ? '作業領域で開く' : 'Open stage in workspace',
43
+ closeLabel: japanese ? '作業領域から戻す' : 'Return stage from workspace'
44
+ };
45
+ }
@@ -0,0 +1,15 @@
1
+ export interface DocumentPanelRequest {
2
+ reference: {
3
+ providerId: string;
4
+ documentId: string;
5
+ };
6
+ filename: string;
7
+ mount(element: HTMLElement, close: () => void): {
8
+ requestClose(): void;
9
+ dispose(): void;
10
+ };
11
+ }
12
+ export declare function getDocumentWorkspace(vm: object): {
13
+ open(request: DocumentPanelRequest): boolean;
14
+ connect(handler: NonNullable<(request: DocumentPanelRequest) => void>): () => void;
15
+ };
@@ -0,0 +1,25 @@
1
+ function createDocumentWorkspace() {
2
+ let open;
3
+ return {
4
+ open(request) {
5
+ if (!open)
6
+ return false;
7
+ open(request);
8
+ return true;
9
+ },
10
+ connect(handler) {
11
+ open = handler;
12
+ return () => { if (open === handler)
13
+ open = undefined; };
14
+ }
15
+ };
16
+ }
17
+ const workspaces = new WeakMap();
18
+ export function getDocumentWorkspace(vm) {
19
+ let workspace = workspaces.get(vm);
20
+ if (!workspace) {
21
+ workspace = createDocumentWorkspace();
22
+ workspaces.set(vm, workspace);
23
+ }
24
+ return workspace;
25
+ }
@@ -3,6 +3,7 @@ interface SpriteLayoutCommands {
3
3
  splitBelow(): void;
4
4
  close(): void;
5
5
  canClose(): boolean;
6
+ canSplit?(): boolean;
6
7
  }
7
8
  export declare function createSpriteLayoutMenu(document: Document, locale: string, commands: SpriteLayoutCommands): {
8
9
  element: HTMLDivElement;
@@ -64,6 +64,7 @@ export function createSpriteLayoutMenu(document, locale, commands) {
64
64
  if (openedIn)
65
65
  return;
66
66
  openedIn = trigger.ownerDocument;
67
+ buttons[0].disabled = buttons[1].disabled = commands.canSplit?.() === false;
67
68
  buttons[2].disabled = !commands.canClose();
68
69
  topLayer = typeof popup.showPopover === 'function';
69
70
  if (topLayer) {
@@ -1,6 +1,9 @@
1
1
  import React from 'react';
2
2
  import type { SpriteVariable } from './sprite-asset-pane.tsx';
3
3
  import type { SpriteSearchResult } from './sprite-editor-toolbar.tsx';
4
+ import type { DockviewStagePane } from './dockview-stage.ts';
5
+ export { getDocumentWorkspace } from './document-workspace.ts';
6
+ export { useDockviewStage } from './dockview-stage.ts';
4
7
  interface CostumeAsset {
5
8
  encodeDataURI(): string;
6
9
  }
@@ -71,6 +74,7 @@ interface TabOptions<Snapshot = unknown> {
71
74
  resize(): void;
72
75
  reopenLabel: string;
73
76
  multiView?: boolean;
77
+ stagePane?: DockviewStagePane;
74
78
  variables?: boolean;
75
79
  locale?: string;
76
80
  viewAdapter?: SpriteTabAdapter<Snapshot>;
@@ -114,6 +118,8 @@ declare const renameLabels: {
114
118
  type RenameLabels = typeof renameLabels.en;
115
119
  export declare function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElement, options: TabOptions<Snapshot>): {
116
120
  select: (id: string) => void;
121
+ openStage: () => void;
122
+ closeStage: () => void;
117
123
  split: (direction: SpriteSplitDirection, id?: string) => string;
118
124
  closeSplit: (id?: string) => boolean;
119
125
  activateView: (id: string) => boolean;
@@ -150,6 +156,8 @@ export declare function useSpriteTabs<Snapshot>({ available, enabled, locale, ..
150
156
  }): {
151
157
  controller: React.MutableRefObject<{
152
158
  select: (id: string) => void;
159
+ openStage: () => void;
160
+ closeStage: () => void;
153
161
  split: (direction: SpriteSplitDirection, id?: string) => string;
154
162
  closeSplit: (id?: string) => boolean;
155
163
  activateView: (id: string) => boolean;
@@ -171,4 +179,3 @@ export declare function useSpriteTabs<Snapshot>({ available, enabled, locale, ..
171
179
  renderEditorTabRow: (children: React.ReactNode) => React.FunctionComponentElement<SpriteTabsProps<Snapshot>>;
172
180
  renderEditorPanel: (index: number, children: React.ReactNode) => React.ReactPortal;
173
181
  };
174
- export {};
@@ -4,6 +4,9 @@ import { createDockview, DefaultTab, themeLight } from 'dockview';
4
4
  import { createSpriteLayoutMenu } from "./sprite-layout-menu.js";
5
5
  import { SpriteAssetPane } from "./sprite-asset-pane.js";
6
6
  import { SpriteEditorToolbar, spriteEditorSections } from "./sprite-editor-toolbar.js";
7
+ import { getDocumentWorkspace } from "./document-workspace.js";
8
+ export { getDocumentWorkspace } from "./document-workspace.js";
9
+ export { useDockviewStage } from "./dockview-stage.js";
7
10
  const variableSection = spriteEditorSections.indexOf('variables');
8
11
  function createAssetSelections(target, source) {
9
12
  const selected = new Map(source?.selected);
@@ -39,6 +42,8 @@ function createAssetSelections(target, source) {
39
42
  const ports = { costumes: forKind('costumes'), sounds: forKind('sounds') };
40
43
  return { selected, ports, layouts, selectors, scroll };
41
44
  }
45
+ const stagePanelId = 'tw-plus-stage-display';
46
+ const documentComponent = 'tw-plus-document';
42
47
  const renameLabels = {
43
48
  en: { title: 'Rename sprite', name: 'Sprite name', cancel: 'Cancel', save: 'Rename' },
44
49
  ja: { title: 'スプライト名の変更', name: 'スプライト名', cancel: 'キャンセル', save: '変更' }
@@ -68,11 +73,13 @@ class SpriteTab extends DefaultTab {
68
73
  export function mountSpriteTabs(root, content, options) {
69
74
  const { vm, multiView = false, locale = 'en' } = options;
70
75
  const viewAdapter = multiView ? options.viewAdapter : undefined;
76
+ const stagePane = multiView ? options.stagePane : undefined;
71
77
  const usesNativeSection = (section) => section !== variableSection || !options.variables;
72
78
  const document = root.ownerDocument;
73
79
  const editLabel = /^ja(?:-|$)/i.test(locale) ? 'クリックしてこのビューで編集' : 'Activate this view to edit';
74
80
  const slots = new Map();
75
81
  const views = new Map();
82
+ const documents = new Map();
76
83
  const costumeIcons = new WeakMap();
77
84
  const remembered = new WeakMap();
78
85
  const lastUsed = new WeakMap();
@@ -95,8 +102,21 @@ export function mountSpriteTabs(root, content, options) {
95
102
  return [];
96
103
  return [{ label: text.title, action: () => openRenameDialog(target) }];
97
104
  },
98
- createComponent: () => {
105
+ createComponent: ({ name, id: componentId }) => {
99
106
  const element = document.createElement('div');
107
+ if (name === documentComponent) {
108
+ element.className = 'tw-plus-document-slot';
109
+ return { element, init({ api }) {
110
+ const item = documents.get(componentId);
111
+ element.dataset.providerId = item.request.reference.providerId;
112
+ element.dataset.documentId = item.request.reference.documentId;
113
+ item.session = item.request.mount(element, () => api.close());
114
+ } };
115
+ }
116
+ if (name === stagePanelId) {
117
+ element.className = 'tw-plus-dockview-stage-slot';
118
+ return { element, init() { stagePane.attach(element); } };
119
+ }
100
120
  element.className = 'tw-plus-sprite-tab-slot';
101
121
  const body = multiView ? document.createElement('div') : element;
102
122
  const toolbar = multiView && options.onToolbarSlots ? document.createElement('div') : undefined;
@@ -182,7 +202,8 @@ export function mountSpriteTabs(root, content, options) {
182
202
  split('below', group.activePanel.id); },
183
203
  close: () => { if (group.activePanel)
184
204
  closeSplit(group.activePanel.id); },
185
- canClose: () => dockview.groups.length > 1
205
+ canClose: () => canCloseGroup(group),
206
+ canSplit: () => views.has(group.activePanel?.id)
186
207
  });
187
208
  return { ...menu, init() { } };
188
209
  }
@@ -320,6 +341,11 @@ export function mountSpriteTabs(root, content, options) {
320
341
  view.snapshots.set(view.section, { snapshot, view: descriptor });
321
342
  }
322
343
  function show(panel) {
344
+ if (panel?.id === stagePanelId || documents.has(panel?.id)) {
345
+ // Utility panels do not change the VM editing target.
346
+ refreshSurfaces();
347
+ return;
348
+ }
323
349
  const next = views.get(panel?.id);
324
350
  if (active === next) {
325
351
  refreshSurfaces();
@@ -355,7 +381,7 @@ export function mountSpriteTabs(root, content, options) {
355
381
  }
356
382
  function add(target, splitPosition, section) {
357
383
  let id = target.id;
358
- if (splitPosition || views.has(id)) {
384
+ if (splitPosition || views.has(id) || id === stagePanelId || documents.has(id)) {
359
385
  do {
360
386
  id = 'sprite-view-' + ++nextView;
361
387
  } while (views.has(id) || vm.runtime.targets.some(item => item.id === id));
@@ -394,6 +420,61 @@ export function mountSpriteTabs(root, content, options) {
394
420
  [...views.values()].find(item => item.target === target);
395
421
  activateView(view?.id ?? add(target).id);
396
422
  }
423
+ function openDocument(request) {
424
+ const { providerId, documentId } = request.reference;
425
+ for (const [id, { request: opened }] of documents) {
426
+ if (opened.reference.providerId === providerId && opened.reference.documentId === documentId) {
427
+ dockview.getPanel(id)?.api.setActive();
428
+ return;
429
+ }
430
+ }
431
+ let id;
432
+ do {
433
+ id = 'document-view-' + ++nextView;
434
+ } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(target => target.id === id));
435
+ const title = request.filename + ' (' + providerId + ' / ' + documentId + ')';
436
+ documents.set(id, { request });
437
+ try {
438
+ dockview.addPanel({ id, title, component: documentComponent, renderer: 'always' });
439
+ }
440
+ catch (error) {
441
+ documents.get(id)?.session?.dispose();
442
+ documents.delete(id);
443
+ throw error;
444
+ }
445
+ }
446
+ function guardDocumentClose(event) {
447
+ const element = event.target;
448
+ const tab = element.closest('[data-tab-panel-id]');
449
+ const item = documents.get(tab?.dataset.tabPanelId);
450
+ if (!item)
451
+ return;
452
+ const close = event.type === 'click' ? Boolean(element.closest('.dv-default-tab-action')) :
453
+ event.target === tab && ['Delete', 'Backspace'].includes(event.key);
454
+ if (!close)
455
+ return;
456
+ event.preventDefault();
457
+ event.stopPropagation();
458
+ item.session?.requestClose();
459
+ }
460
+ function canCloseGroup(group) {
461
+ return dockview.groups.length > 1 && group.panels.every(panel => !documents.has(panel.id));
462
+ }
463
+ function openStage() {
464
+ if (!stagePane)
465
+ return;
466
+ const existing = dockview.getPanel(stagePanelId);
467
+ if (existing) {
468
+ existing.api.setActive();
469
+ return;
470
+ }
471
+ const reference = dockview.activePanel;
472
+ dockview.addPanel({ id: stagePanelId, title: stagePane.title, component: stagePanelId, renderer: 'always',
473
+ position: reference ? { referencePanel: reference.id, direction: 'right' } : undefined });
474
+ }
475
+ function closeStage() {
476
+ dockview.getPanel(stagePanelId)?.api.close();
477
+ }
397
478
  function split(direction, id = active?.id) {
398
479
  const reference = views.get(id);
399
480
  if (!multiView || !reference)
@@ -405,7 +486,7 @@ export function mountSpriteTabs(root, content, options) {
405
486
  }
406
487
  function closeSplit(id = active?.id) {
407
488
  const panel = dockview.getPanel(id);
408
- if (!multiView || !panel || dockview.groups.length <= 1)
489
+ if (!multiView || !panel || !canCloseGroup(panel.api.group))
409
490
  return false;
410
491
  syncing = true;
411
492
  try {
@@ -498,6 +579,16 @@ export function mountSpriteTabs(root, content, options) {
498
579
  }
499
580
  });
500
581
  const removal = dockview.onDidRemovePanel(panel => {
582
+ const item = documents.get(panel.id);
583
+ if (item) {
584
+ documents.delete(panel.id);
585
+ item.session?.dispose();
586
+ return;
587
+ }
588
+ if (panel.id === stagePanelId) {
589
+ stagePane?.attach(null);
590
+ return;
591
+ }
501
592
  const view = views.get(panel.id);
502
593
  if (!view)
503
594
  return;
@@ -508,10 +599,13 @@ export function mountSpriteTabs(root, content, options) {
508
599
  closed.add(view.target);
509
600
  });
510
601
  const layout = multiView && dockview.onDidLayoutChange(refreshSurfaces);
602
+ const disconnectDocuments = multiView && getDocumentWorkspace(vm).connect(openDocument);
603
+ root.addEventListener('click', guardDocumentClose, true);
604
+ root.addEventListener('keydown', guardDocumentClose, true);
511
605
  vm.on('targetsUpdate', refresh);
512
606
  refresh();
513
607
  return {
514
- select, split, closeSplit, activateView, setViewSection, syncActiveSection, refreshSurfaces,
608
+ select, openStage, closeStage, split, closeSplit, activateView, setViewSection, syncActiveSection, refreshSurfaces,
515
609
  selectAsset(id, kind, index) {
516
610
  if (active?.id === id && active.section === (kind === 'costumes' ? 1 : 2)) {
517
611
  active.assets.selectors.get(kind)?.(index);
@@ -520,6 +614,14 @@ export function mountSpriteTabs(root, content, options) {
520
614
  currentView: () => active && describe(active),
521
615
  views: () => [...views.values()].map(view => describe(view)),
522
616
  dispose() {
617
+ if (disconnectDocuments)
618
+ disconnectDocuments();
619
+ root.removeEventListener('click', guardDocumentClose, true);
620
+ root.removeEventListener('keydown', guardDocumentClose, true);
621
+ for (const item of documents.values())
622
+ item.session?.dispose();
623
+ documents.clear();
624
+ stagePane?.attach(null);
523
625
  renameDialog?.remove();
524
626
  renameDialog = undefined;
525
627
  vm.removeListener('targetsUpdate', refresh);
@@ -628,6 +730,7 @@ function SpriteTabs(props) {
628
730
  resize: () => latest.current.resize(),
629
731
  reopenLabel: props.reopenLabel,
630
732
  multiView: props.multiView,
733
+ stagePane: props.stagePane,
631
734
  variables: hasVariables,
632
735
  locale: props.locale,
633
736
  viewAdapter: props.viewAdapter,
@@ -636,14 +739,17 @@ function SpriteTabs(props) {
636
739
  });
637
740
  controller.current = instance;
638
741
  latest.current.connect(instance);
742
+ if (props.multiView)
743
+ props.stagePane?.connect({ open: instance.openStage, close: instance.closeStage });
639
744
  return () => {
640
745
  frame.cancelAnimationFrame(restoreFrame);
641
746
  controller.current = null;
642
747
  latest.current.connect(null);
748
+ props.stagePane?.connect(null);
643
749
  instance.dispose();
644
750
  };
645
751
  }, [content, props.enabled, props.vm, props.reopenLabel, props.multiView, props.locale,
646
- props.viewAdapter, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
752
+ props.viewAdapter, props.stagePane, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
647
753
  React.useLayoutEffect(() => {
648
754
  controller.current?.syncActiveSection(props.activeSection);
649
755
  }, [props.activeSection]);
@@ -1,4 +1,5 @@
1
1
  import type { Context, ElementType } from 'react';
2
+ import type { useDockviewStage } from './dockview-stage.ts';
2
3
  interface StageWindowConnection<Target> {
3
4
  ready: Promise<Target>;
4
5
  disconnected: AbortSignal;
@@ -15,6 +16,7 @@ export declare function stageDockActionLabel(locale: string, { drawer, detached,
15
16
  detached: boolean;
16
17
  external?: boolean;
17
18
  }): "ドックに戻す" | "Dock stage" | "別窓で開く" | "別ウィンドウで開く" | "Open in a separate window" | "アンドック" | "Undock stage";
19
+ type StagePlacement = Pick<NonNullable<ReturnType<typeof useDockviewStage>>, 'surface' | 'inlineRef' | 'docked'>;
18
20
  export declare function createStageSurface(React: typeof import('react'), { context, EdgeDrawer, Panel }: {
19
21
  context: Context<{
20
22
  locale: string;
@@ -23,9 +25,8 @@ export declare function createStageSurface(React: typeof import('react'), { cont
23
25
  }>;
24
26
  EdgeDrawer: ElementType;
25
27
  Panel: ElementType;
26
- }): ({ externalHost, onDockActionChange, ...props }: {
27
- [x: string]: any;
28
- externalHost: any;
29
- onDockActionChange: any;
28
+ }): ({ externalHost, onDockActionChange, placement, ...props }: {
29
+ placement?: StagePlacement;
30
+ [key: string]: any;
30
31
  }) => import("react").ReactElement<any, string | import("react").JSXElementConstructor<any>>;
31
32
  export {};
@@ -1,3 +1,4 @@
1
+ import { createPortal } from 'react-dom';
1
2
  import { createDockview, themeLight } from 'dockview';
2
3
  export function createStagePopoutHost(host, title) {
3
4
  return {
@@ -58,7 +59,7 @@ export function stageDockActionLabel(locale, { drawer, detached, external }) {
58
59
  return japanese ? 'アンドック' : 'Undock stage';
59
60
  }
60
61
  export function createStageSurface(React, { context, EdgeDrawer, Panel }) {
61
- return function StageSurface({ externalHost, onDockActionChange, ...props }) {
62
+ return function StageSurface({ externalHost, onDockActionChange, placement, ...props }) {
62
63
  const { locale, stageDrawer, setStageDrawerOpen } = React.useContext(context);
63
64
  const japanese = isJapanese(locale);
64
65
  const title = japanese ? 'ステージ' : 'Stage';
@@ -72,10 +73,16 @@ export function createStageSurface(React, { context, EdgeDrawer, Panel }) {
72
73
  externalOnly: stageDrawer,
73
74
  onDockActionChange: stageDrawer && !host ? undefined : onDockActionChange
74
75
  });
75
- return stageDrawer ? React.createElement(EdgeDrawer, {
76
+ const inline = placement ? React.createElement('div', {
77
+ ref: placement.inlineRef,
78
+ 'data-stage-in-workspace': placement.docked || undefined
79
+ }) : panel;
80
+ const dock = stageDrawer ? React.createElement(EdgeDrawer, {
76
81
  edge: 'right',
77
82
  label: japanese ? 'ステージを展開' : 'Reveal stage',
78
- onOpenChange: setStageDrawerOpen
79
- }, panel) : panel;
83
+ onOpenChange: setStageDrawerOpen,
84
+ portalContent: Boolean(placement)
85
+ }, inline) : inline;
86
+ return placement ? React.createElement(React.Fragment, null, dock, createPortal(panel, placement.surface)) : dock;
80
87
  };
81
88
  }
@@ -16,11 +16,12 @@ export declare function createStandardLayout(React: any, { styles, targetStyles,
16
16
  Container: any;
17
17
  render: any;
18
18
  }) => any;
19
- EdgeDrawer: ({ edge, label, pinned, onOpenChange, children }: {
19
+ EdgeDrawer: ({ edge, label, pinned, onOpenChange, portalContent, children }: {
20
20
  edge: any;
21
21
  label: any;
22
22
  pinned?: boolean;
23
23
  onOpenChange: any;
24
+ portalContent?: boolean;
24
25
  children: any;
25
26
  }) => any;
26
27
  TargetLayout: ({ compact, render }: {
@@ -136,30 +136,49 @@ export function createStandardLayout(React, { styles, targetStyles, context, Lay
136
136
  stageDrawer: plusLayout && stageDrawer, setStageDrawerOpen, locale: props.locale
137
137
  } }, Container ? element(Container, { locale: props.locale }, layout) : layout);
138
138
  }
139
- function EdgeDrawer({ edge, label, pinned = false, onOpenChange, children }) {
139
+ function EdgeDrawer({ edge, label, pinned = false, onOpenChange, portalContent = false, children }) {
140
140
  const [hovered, setHovered] = useState(false);
141
141
  const [engaged, setEngaged] = useState(false);
142
+ const drawer = useRef(null);
142
143
  const trigger = useRef(null);
143
144
  const panel = useRef(null);
144
145
  const open = pinned || hovered || engaged;
145
146
  useLayoutEffect(() => { onOpenChange?.(open); }, [open, onOpenChange]);
147
+ const enter = (event) => {
148
+ if (event.pointerType === 'mouse')
149
+ setHovered(true);
150
+ };
151
+ const leave = () => setHovered(false);
152
+ const focus = (event) => {
153
+ if (panel.current.contains(event.target))
154
+ setEngaged(true);
155
+ };
156
+ const blur = (event) => {
157
+ if (!drawer.current.contains(event.relatedTarget))
158
+ setEngaged(false);
159
+ };
160
+ useLayoutEffect(() => {
161
+ if (!portalContent)
162
+ return;
163
+ const node = drawer.current;
164
+ // Use the DOM ancestry for a surface rendered through a portal.
165
+ node.addEventListener('pointerenter', enter);
166
+ node.addEventListener('pointerleave', leave);
167
+ node.addEventListener('focusin', focus);
168
+ node.addEventListener('focusout', blur);
169
+ return () => {
170
+ node.removeEventListener('pointerenter', enter);
171
+ node.removeEventListener('pointerleave', leave);
172
+ node.removeEventListener('focusin', focus);
173
+ node.removeEventListener('focusout', blur);
174
+ };
175
+ }, [portalContent]);
146
176
  return element('div', {
147
177
  className: styles.edgeDrawer,
178
+ ref: drawer,
148
179
  'data-edge': edge,
149
180
  'data-pinned': pinned || undefined,
150
- onPointerEnter: event => {
151
- if (event.pointerType === 'mouse')
152
- setHovered(true);
153
- },
154
- onPointerLeave: () => setHovered(false),
155
- onFocus: event => {
156
- if (panel.current.contains(event.target))
157
- setEngaged(true);
158
- },
159
- onBlur: event => {
160
- if (!event.currentTarget.contains(event.relatedTarget))
161
- setEngaged(false);
162
- },
181
+ ...(!portalContent && { onPointerEnter: enter, onPointerLeave: leave, onFocus: focus, onBlur: blur }),
163
182
  onKeyDown: event => {
164
183
  if (event.key !== 'Escape' || pinned || !open || event.defaultPrevented)
165
184
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kubohiroya/turbowarp-plus-editor-layout",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Resizable panes and layout for TurboWarp Plus Editor",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -0,0 +1,53 @@
1
+ import React from 'react';
2
+
3
+ type StageCommands = {open(): void; close(): void};
4
+
5
+ export interface DockviewStagePane {
6
+ title: string;
7
+ attach(destination: HTMLElement | null): void;
8
+ connect(commands: StageCommands | null): void;
9
+ }
10
+
11
+ export function useDockviewStage(enabled: boolean, locale: string) {
12
+ const [surface] = React.useState(() => {
13
+ if (!enabled) return null;
14
+ const node = document.createElement('div');
15
+ node.className = 'tw-plus-dockview-stage-surface';
16
+ return node;
17
+ });
18
+ const inline = React.useRef<HTMLElement>(null);
19
+ const dock = React.useRef<HTMLElement>(null);
20
+ const suspended = React.useRef(false);
21
+ const [docked, setDocked] = React.useState(false);
22
+ const [commands, setCommands] = React.useState<StageCommands>(null);
23
+
24
+ const move = React.useCallback(() => {
25
+ const destination = suspended.current ? inline.current : dock.current ?? inline.current;
26
+ if (surface && destination && surface.parentElement !== destination) destination.appendChild(surface);
27
+ }, [surface]);
28
+ const inlineRef = React.useCallback((element: HTMLElement | null) => {
29
+ inline.current = element;
30
+ if (surface && element) surface.dir = element.closest('[dir]')?.getAttribute('dir') ?? '';
31
+ move();
32
+ }, [move, surface]);
33
+ const suspend = React.useCallback((value: boolean) => {
34
+ suspended.current = value;
35
+ move();
36
+ }, [move]);
37
+
38
+ const japanese = /^ja(?:-|$)/i.test(locale);
39
+ const pane = React.useMemo<DockviewStagePane>(() => ({
40
+ title: japanese ? 'ステージ' : 'Stage',
41
+ attach(destination) {
42
+ dock.current = destination;
43
+ move();
44
+ setDocked(Boolean(destination));
45
+ },
46
+ connect: setCommands
47
+ }), [japanese, move]);
48
+ return surface && {
49
+ surface, pane, docked, commands, inlineRef, suspend,
50
+ openLabel: japanese ? '作業領域で開く' : 'Open stage in workspace',
51
+ closeLabel: japanese ? '作業領域から戻す' : 'Return stage from workspace'
52
+ };
53
+ }
@@ -0,0 +1,31 @@
1
+ export interface DocumentPanelRequest {
2
+ reference: {providerId: string; documentId: string};
3
+ filename: string;
4
+ mount(element: HTMLElement, close: () => void): {requestClose(): void; dispose(): void};
5
+ }
6
+
7
+ function createDocumentWorkspace() {
8
+ let open: ((request: DocumentPanelRequest) => void) | undefined;
9
+ return {
10
+ open(request: DocumentPanelRequest) {
11
+ if (!open) return false;
12
+ open(request);
13
+ return true;
14
+ },
15
+ connect(handler: NonNullable<typeof open>) {
16
+ open = handler;
17
+ return () => { if (open === handler) open = undefined; };
18
+ }
19
+ };
20
+ }
21
+
22
+ const workspaces = new WeakMap<object, ReturnType<typeof createDocumentWorkspace>>();
23
+
24
+ export function getDocumentWorkspace(vm: object) {
25
+ let workspace = workspaces.get(vm);
26
+ if (!workspace) {
27
+ workspace = createDocumentWorkspace();
28
+ workspaces.set(vm, workspace);
29
+ }
30
+ return workspace;
31
+ }
package/src/editor.css CHANGED
@@ -649,6 +649,10 @@ details.layout-menu button {
649
649
  height: fit-content;
650
650
  }
651
651
 
652
+ .standard-body:has(.sprite-tabs[data-multi-view]) .stage-drawer {
653
+ z-index: var(--plus-docked-utility-layer);
654
+ }
655
+
652
656
  .standard-body:has(.sprite-tabs[data-multi-view]):has(.retracted-targets) {
653
657
  padding-left: 0.625rem;
654
658
  }
@@ -754,3 +758,26 @@ details.layout-menu button {
754
758
  min-height: 0;
755
759
  min-width: 0;
756
760
  }
761
+
762
+ :global(.tw-plus-dockview-stage-slot),
763
+ :global(.tw-plus-dockview-stage-slot) > :global(.tw-plus-dockview-stage-surface) {
764
+ inline-size: 100%;
765
+ block-size: 100%;
766
+ min-inline-size: 0;
767
+ min-block-size: 0;
768
+ }
769
+
770
+ :global(.tw-plus-dockview-stage-slot) > :global(.tw-plus-dockview-stage-surface) > .basic-panel {
771
+ block-size: 100%;
772
+ }
773
+
774
+ .floating-stage:has([data-stage-in-workspace]:empty) {
775
+ pointer-events: none;
776
+ }
777
+
778
+ :global(.tw-plus-document-slot) {
779
+ inline-size: 100%;
780
+ block-size: 100%;
781
+ min-inline-size: 0;
782
+ min-block-size: 0;
783
+ }
@@ -3,6 +3,7 @@ interface SpriteLayoutCommands {
3
3
  splitBelow(): void;
4
4
  close(): void;
5
5
  canClose(): boolean;
6
+ canSplit?(): boolean;
6
7
  }
7
8
 
8
9
  const labels = {
@@ -72,6 +73,7 @@ export function createSpriteLayoutMenu(document: Document, locale: string, comma
72
73
  function open(last = false) {
73
74
  if (openedIn) return;
74
75
  openedIn = trigger.ownerDocument;
76
+ buttons[0].disabled = buttons[1].disabled = commands.canSplit?.() === false;
75
77
  buttons[2].disabled = !commands.canClose();
76
78
  topLayer = typeof popup.showPopover === 'function';
77
79
  if (topLayer) {
@@ -7,6 +7,13 @@ import {SpriteAssetPane} from './sprite-asset-pane.tsx';
7
7
  import type {SpriteVariable} from './sprite-asset-pane.tsx';
8
8
  import {SpriteEditorToolbar, spriteEditorSections} from './sprite-editor-toolbar.tsx';
9
9
  import type {SpriteSearchResult} from './sprite-editor-toolbar.tsx';
10
+ import type {DockviewStagePane} from './dockview-stage.ts';
11
+ import {getDocumentWorkspace} from './document-workspace.ts';
12
+ import type {DocumentPanelRequest} from './document-workspace.ts';
13
+
14
+ export {getDocumentWorkspace} from './document-workspace.ts';
15
+
16
+ export {useDockviewStage} from './dockview-stage.ts';
10
17
 
11
18
  interface CostumeAsset {
12
19
  encodeDataURI(): string;
@@ -111,6 +118,9 @@ function createAssetSelections(target: Target, source?: {
111
118
 
112
119
  export type SpriteSplitDirection = 'right' | 'below';
113
120
 
121
+ const stagePanelId = 'tw-plus-stage-display';
122
+ const documentComponent = 'tw-plus-document';
123
+
114
124
  interface TabOptions<Snapshot = unknown> {
115
125
  vm: Project;
116
126
  getSection(): number;
@@ -119,6 +129,7 @@ interface TabOptions<Snapshot = unknown> {
119
129
  resize(): void;
120
130
  reopenLabel: string;
121
131
  multiView?: boolean;
132
+ stagePane?: DockviewStagePane;
122
133
  variables?: boolean;
123
134
  locale?: string;
124
135
  viewAdapter?: SpriteTabAdapter<Snapshot>;
@@ -198,11 +209,13 @@ class SpriteTab extends DefaultTab {
198
209
  export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElement, options: TabOptions<Snapshot>) {
199
210
  const {vm, multiView = false, locale = 'en'} = options;
200
211
  const viewAdapter = multiView ? options.viewAdapter : undefined;
212
+ const stagePane = multiView ? options.stagePane : undefined;
201
213
  const usesNativeSection = (section: number) => section !== variableSection || !options.variables;
202
214
  const document = root.ownerDocument;
203
215
  const editLabel = /^ja(?:-|$)/i.test(locale) ? 'クリックしてこのビューで編集' : 'Activate this view to edit';
204
216
  const slots = new Map<string, TabSlot>();
205
217
  const views = new Map<string, View<Snapshot>>();
218
+ const documents = new Map<string, {request: DocumentPanelRequest; session?: ReturnType<DocumentPanelRequest['mount']>}>();
206
219
  const costumeIcons = new WeakMap<CostumeAsset, string>();
207
220
  const remembered = new WeakMap<Target, number>();
208
221
  const lastUsed = new WeakMap<Target, View<Snapshot>>();
@@ -225,8 +238,21 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
225
238
  if (!target || target.isStage || !vm.renameSprite) return [];
226
239
  return [{label: text.title, action: () => openRenameDialog(target)}];
227
240
  },
228
- createComponent: () => {
241
+ createComponent: ({name, id: componentId}) => {
229
242
  const element = document.createElement('div');
243
+ if (name === documentComponent) {
244
+ element.className = 'tw-plus-document-slot';
245
+ return {element, init({api}) {
246
+ const item = documents.get(componentId);
247
+ element.dataset.providerId = item.request.reference.providerId;
248
+ element.dataset.documentId = item.request.reference.documentId;
249
+ item.session = item.request.mount(element, () => api.close());
250
+ }};
251
+ }
252
+ if (name === stagePanelId) {
253
+ element.className = 'tw-plus-dockview-stage-slot';
254
+ return {element, init() { stagePane.attach(element); }};
255
+ }
230
256
  element.className = 'tw-plus-sprite-tab-slot';
231
257
  const body = multiView ? document.createElement('div') : element;
232
258
  const toolbar = multiView && options.onToolbarSlots ? document.createElement('div') : undefined;
@@ -301,7 +327,8 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
301
327
  splitRight: () => { if (group.activePanel) split('right', group.activePanel.id); },
302
328
  splitBelow: () => { if (group.activePanel) split('below', group.activePanel.id); },
303
329
  close: () => { if (group.activePanel) closeSplit(group.activePanel.id); },
304
- canClose: () => dockview.groups.length > 1
330
+ canClose: () => canCloseGroup(group),
331
+ canSplit: () => views.has(group.activePanel?.id)
305
332
  });
306
333
  return {...menu, init() {}};
307
334
  }
@@ -432,6 +459,11 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
432
459
  }
433
460
 
434
461
  function show(panel: IDockviewPanel | undefined) {
462
+ if (panel?.id === stagePanelId || documents.has(panel?.id)) {
463
+ // Utility panels do not change the VM editing target.
464
+ refreshSurfaces();
465
+ return;
466
+ }
435
467
  const next = views.get(panel?.id);
436
468
  if (active === next) {
437
469
  refreshSurfaces();
@@ -461,7 +493,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
461
493
 
462
494
  function add(target: Target, splitPosition?: {referencePanel: string; direction: SpriteSplitDirection}, section?: number) {
463
495
  let id = target.id;
464
- if (splitPosition || views.has(id)) {
496
+ if (splitPosition || views.has(id) || id === stagePanelId || documents.has(id)) {
465
497
  do {
466
498
  id = 'sprite-view-' + ++nextView;
467
499
  } while (views.has(id) || vm.runtime.targets.some(item => item.id === id));
@@ -500,6 +532,62 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
500
532
  activateView(view?.id ?? add(target).id);
501
533
  }
502
534
 
535
+ function openDocument(request: DocumentPanelRequest) {
536
+ const {providerId, documentId} = request.reference;
537
+ for (const [id, {request: opened}] of documents) {
538
+ if (opened.reference.providerId === providerId && opened.reference.documentId === documentId) {
539
+ dockview.getPanel(id)?.api.setActive();
540
+ return;
541
+ }
542
+ }
543
+ let id: string;
544
+ do {
545
+ id = 'document-view-' + ++nextView;
546
+ } while (views.has(id) || documents.has(id) || vm.runtime.targets.some(target => target.id === id));
547
+ const title = request.filename + ' (' + providerId + ' / ' + documentId + ')';
548
+ documents.set(id, {request});
549
+ try {
550
+ dockview.addPanel({id, title, component: documentComponent, renderer: 'always'});
551
+ } catch (error) {
552
+ documents.get(id)?.session?.dispose();
553
+ documents.delete(id);
554
+ throw error;
555
+ }
556
+ }
557
+
558
+ function guardDocumentClose(event: MouseEvent | KeyboardEvent) {
559
+ const element = event.target as HTMLElement;
560
+ const tab = element.closest<HTMLElement>('[data-tab-panel-id]');
561
+ const item = documents.get(tab?.dataset.tabPanelId);
562
+ if (!item) return;
563
+ const close = event.type === 'click' ? Boolean(element.closest('.dv-default-tab-action')) :
564
+ event.target === tab && ['Delete', 'Backspace'].includes((event as KeyboardEvent).key);
565
+ if (!close) return;
566
+ event.preventDefault();
567
+ event.stopPropagation();
568
+ item.session?.requestClose();
569
+ }
570
+
571
+ function canCloseGroup(group: DockviewGroupPanel) {
572
+ return dockview.groups.length > 1 && group.panels.every(panel => !documents.has(panel.id));
573
+ }
574
+
575
+ function openStage() {
576
+ if (!stagePane) return;
577
+ const existing = dockview.getPanel(stagePanelId);
578
+ if (existing) {
579
+ existing.api.setActive();
580
+ return;
581
+ }
582
+ const reference = dockview.activePanel;
583
+ dockview.addPanel({id: stagePanelId, title: stagePane.title, component: stagePanelId, renderer: 'always',
584
+ position: reference ? {referencePanel: reference.id, direction: 'right'} : undefined});
585
+ }
586
+
587
+ function closeStage() {
588
+ dockview.getPanel(stagePanelId)?.api.close();
589
+ }
590
+
503
591
  function split(direction: SpriteSplitDirection, id = active?.id) {
504
592
  const reference = views.get(id);
505
593
  if (!multiView || !reference) return;
@@ -511,7 +599,7 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
511
599
 
512
600
  function closeSplit(id = active?.id) {
513
601
  const panel = dockview.getPanel(id);
514
- if (!multiView || !panel || dockview.groups.length <= 1) return false;
602
+ if (!multiView || !panel || !canCloseGroup(panel.api.group)) return false;
515
603
  syncing = true;
516
604
  try {
517
605
  dockview.removeGroup(panel.api.group);
@@ -595,6 +683,16 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
595
683
  }
596
684
  });
597
685
  const removal = dockview.onDidRemovePanel(panel => {
686
+ const item = documents.get(panel.id);
687
+ if (item) {
688
+ documents.delete(panel.id);
689
+ item.session?.dispose();
690
+ return;
691
+ }
692
+ if (panel.id === stagePanelId) {
693
+ stagePane?.attach(null);
694
+ return;
695
+ }
598
696
  const view = views.get(panel.id);
599
697
  if (!view) return;
600
698
  if (view === active) remembered.set(view.target, usesNativeSection(view.section) ? options.getSection() : view.section);
@@ -602,10 +700,13 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
602
700
  if (![...views.values()].some(item => item.target === view.target)) closed.add(view.target);
603
701
  });
604
702
  const layout = multiView && dockview.onDidLayoutChange(refreshSurfaces);
703
+ const disconnectDocuments = multiView && getDocumentWorkspace(vm).connect(openDocument);
704
+ root.addEventListener('click', guardDocumentClose, true);
705
+ root.addEventListener('keydown', guardDocumentClose, true);
605
706
  vm.on('targetsUpdate', refresh);
606
707
  refresh();
607
708
  return {
608
- select, split, closeSplit, activateView, setViewSection, syncActiveSection, refreshSurfaces,
709
+ select, openStage, closeStage, split, closeSplit, activateView, setViewSection, syncActiveSection, refreshSurfaces,
609
710
  selectAsset(id: string, kind: AssetKind, index: number) {
610
711
  if (active?.id === id && active.section === (kind === 'costumes' ? 1 : 2)) {
611
712
  active.assets.selectors.get(kind)?.(index);
@@ -614,6 +715,12 @@ export function mountSpriteTabs<Snapshot>(root: HTMLElement, content: HTMLElemen
614
715
  currentView: () => active && describe(active),
615
716
  views: () => [...views.values()].map(view => describe(view)),
616
717
  dispose() {
718
+ if (disconnectDocuments) disconnectDocuments();
719
+ root.removeEventListener('click', guardDocumentClose, true);
720
+ root.removeEventListener('keydown', guardDocumentClose, true);
721
+ for (const item of documents.values()) item.session?.dispose();
722
+ documents.clear();
723
+ stagePane?.attach(null);
617
724
  renameDialog?.remove();
618
725
  renameDialog = undefined;
619
726
  vm.removeListener('targetsUpdate', refresh);
@@ -746,6 +853,7 @@ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
746
853
  resize: () => latest.current.resize(),
747
854
  reopenLabel: props.reopenLabel,
748
855
  multiView: props.multiView,
856
+ stagePane: props.stagePane,
749
857
  variables: hasVariables,
750
858
  locale: props.locale,
751
859
  viewAdapter: props.viewAdapter,
@@ -754,14 +862,16 @@ function SpriteTabs<Snapshot>(props: SpriteTabsProps<Snapshot>) {
754
862
  });
755
863
  controller.current = instance;
756
864
  latest.current.connect(instance);
865
+ if (props.multiView) props.stagePane?.connect({open: instance.openStage, close: instance.closeStage});
757
866
  return () => {
758
867
  frame.cancelAnimationFrame(restoreFrame);
759
868
  controller.current = null;
760
869
  latest.current.connect(null);
870
+ props.stagePane?.connect(null);
761
871
  instance.dispose();
762
872
  };
763
873
  }, [content, props.enabled, props.vm, props.reopenLabel, props.multiView, props.locale,
764
- props.viewAdapter, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
874
+ props.viewAdapter, props.stagePane, props.renameLabels, hasVariables, hasToolbar, hasAssetPreview]);
765
875
  React.useLayoutEffect(() => {
766
876
  controller.current?.syncActiveSection(props.activeSection);
767
877
  }, [props.activeSection]);
@@ -1,4 +1,6 @@
1
1
  import type {Context, ElementType} from 'react';
2
+ import {createPortal} from 'react-dom';
3
+ import type {useDockviewStage} from './dockview-stage.ts';
2
4
  import {createDockview, themeLight} from 'dockview';
3
5
 
4
6
  interface StageWindowConnection<Target> {
@@ -74,6 +76,8 @@ export function stageDockActionLabel(locale: string, {drawer, detached, external
74
76
  return japanese ? 'アンドック' : 'Undock stage';
75
77
  }
76
78
 
79
+ type StagePlacement = Pick<NonNullable<ReturnType<typeof useDockviewStage>>, 'surface' | 'inlineRef' | 'docked'>;
80
+
77
81
  export function createStageSurface(React: typeof import('react'), {
78
82
  context, EdgeDrawer, Panel
79
83
  }: {
@@ -81,7 +85,7 @@ export function createStageSurface(React: typeof import('react'), {
81
85
  EdgeDrawer: ElementType;
82
86
  Panel: ElementType;
83
87
  }) {
84
- return function StageSurface({externalHost, onDockActionChange, ...props}) {
88
+ return function StageSurface({externalHost, onDockActionChange, placement, ...props}: {placement?: StagePlacement; [key: string]: any}) {
85
89
  const {locale, stageDrawer, setStageDrawerOpen} = React.useContext(context);
86
90
  const japanese = isJapanese(locale);
87
91
  const title = japanese ? 'ステージ' : 'Stage';
@@ -95,10 +99,16 @@ export function createStageSurface(React: typeof import('react'), {
95
99
  externalOnly: stageDrawer,
96
100
  onDockActionChange: stageDrawer && !host ? undefined : onDockActionChange
97
101
  });
98
- return stageDrawer ? React.createElement(EdgeDrawer, {
102
+ const inline = placement ? React.createElement('div', {
103
+ ref: placement.inlineRef,
104
+ 'data-stage-in-workspace': placement.docked || undefined
105
+ }) : panel;
106
+ const dock = stageDrawer ? React.createElement(EdgeDrawer, {
99
107
  edge: 'right',
100
108
  label: japanese ? 'ステージを展開' : 'Reveal stage',
101
- onOpenChange: setStageDrawerOpen
102
- }, panel) : panel;
109
+ onOpenChange: setStageDrawerOpen,
110
+ portalContent: Boolean(placement)
111
+ }, inline) : inline;
112
+ return placement ? React.createElement(React.Fragment, null, dock, createPortal(panel, placement.surface)) : dock;
103
113
  };
104
114
  }
@@ -151,29 +151,49 @@ export function createStandardLayout(React, {styles, targetStyles, context, Layo
151
151
  Container ? element(Container, {locale: props.locale}, layout) : layout);
152
152
  }
153
153
 
154
- function EdgeDrawer({edge, label, pinned = false, onOpenChange, children}) {
154
+ function EdgeDrawer({edge, label, pinned = false, onOpenChange, portalContent = false, children}) {
155
155
  const [hovered, setHovered] = useState(false);
156
156
  const [engaged, setEngaged] = useState(false);
157
+ const drawer = useRef(null);
157
158
  const trigger = useRef(null);
158
159
  const panel = useRef(null);
159
160
  const open = pinned || hovered || engaged;
160
161
 
161
162
  useLayoutEffect(() => {onOpenChange?.(open);}, [open, onOpenChange]);
162
163
 
164
+ const enter = (event: PointerEvent) => {
165
+ if (event.pointerType === 'mouse') setHovered(true);
166
+ };
167
+ const leave = () => setHovered(false);
168
+ const focus = (event: FocusEvent) => {
169
+ if (panel.current.contains(event.target as Node)) setEngaged(true);
170
+ };
171
+ const blur = (event: FocusEvent) => {
172
+ if (!drawer.current.contains(event.relatedTarget as Node)) setEngaged(false);
173
+ };
174
+
175
+ useLayoutEffect(() => {
176
+ if (!portalContent) return;
177
+ const node = drawer.current as HTMLElement;
178
+ // Use the DOM ancestry for a surface rendered through a portal.
179
+ node.addEventListener('pointerenter', enter);
180
+ node.addEventListener('pointerleave', leave);
181
+ node.addEventListener('focusin', focus);
182
+ node.addEventListener('focusout', blur);
183
+ return () => {
184
+ node.removeEventListener('pointerenter', enter);
185
+ node.removeEventListener('pointerleave', leave);
186
+ node.removeEventListener('focusin', focus);
187
+ node.removeEventListener('focusout', blur);
188
+ };
189
+ }, [portalContent]);
190
+
163
191
  return element('div', {
164
192
  className: styles.edgeDrawer,
193
+ ref: drawer,
165
194
  'data-edge': edge,
166
195
  'data-pinned': pinned || undefined,
167
- onPointerEnter: event => {
168
- if (event.pointerType === 'mouse') setHovered(true);
169
- },
170
- onPointerLeave: () => setHovered(false),
171
- onFocus: event => {
172
- if (panel.current.contains(event.target)) setEngaged(true);
173
- },
174
- onBlur: event => {
175
- if (!event.currentTarget.contains(event.relatedTarget)) setEngaged(false);
176
- },
196
+ ...(!portalContent && {onPointerEnter: enter, onPointerLeave: leave, onFocus: focus, onBlur: blur}),
177
197
  onKeyDown: event => {
178
198
  if (event.key !== 'Escape' || pinned || !open || event.defaultPrevented) return;
179
199
  setHovered(false);