@bendyline/squisq-editor-react 1.6.2 → 2.0.1

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 (218) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1275 -183
  3. package/dist/index.js +20345 -12777
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1607 -241
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +39 -33
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +349 -261
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +362 -89
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/RecorderEntry.tsx +2 -0
  23. package/src/StatusBar.tsx +5 -2
  24. package/src/TemplateAnnotation.ts +12 -3
  25. package/src/TemplatePicker.tsx +75 -21
  26. package/src/ThemeCustomizerPanel.tsx +8 -0
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +357 -51
  30. package/src/TransitionPicker.tsx +36 -4
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/WysiwygEditor.tsx +124 -91
  33. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  34. package/src/__tests__/blockTagActivity.test.ts +183 -0
  35. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  36. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  37. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  38. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  39. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +51 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +123 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +0 -12
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  54. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +382 -5
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/recorderTheme.test.tsx +42 -0
  66. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  67. package/src/__tests__/selectionConversions.test.ts +80 -0
  68. package/src/__tests__/statusBar.test.tsx +27 -0
  69. package/src/__tests__/teleprompter.test.tsx +190 -0
  70. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  71. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  72. package/src/__tests__/tiptapBridge.test.ts +71 -7
  73. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  74. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  75. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  76. package/src/__tests__/transformStyleId.test.ts +13 -0
  77. package/src/__tests__/useImageEditor.test.tsx +67 -0
  78. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  79. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  80. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  81. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  82. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  83. package/src/asciiDiagram/AsciiDiagramWidget.tsx +254 -0
  84. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  85. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  86. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  87. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +284 -0
  88. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  89. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  90. package/src/asciiDiagram/asciiDiagramCommands.ts +174 -0
  91. package/src/asciiDiagram/asciiDiagramData.ts +149 -0
  92. package/src/asciiDiagram/asciiDiagramOps.ts +238 -0
  93. package/src/asciiDiagram/asciiPaste.ts +19 -0
  94. package/src/blockTagActivity.ts +233 -0
  95. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  96. package/src/buildPreviewDoc.ts +19 -12
  97. package/src/codeContext/types.ts +1 -1
  98. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  99. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  100. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  101. package/src/customTemplates/__tests__/library.test.ts +8 -0
  102. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  103. package/src/customTemplates/designer.css +158 -99
  104. package/src/customTemplates/library.ts +16 -3
  105. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  106. package/src/customThemes/CustomThemeContext.tsx +6 -0
  107. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  108. package/src/customThemes/ImportThemeSection.tsx +178 -0
  109. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  110. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  111. package/src/customThemes/customThemeLibrary.ts +7 -4
  112. package/src/customThemes/index.ts +7 -1
  113. package/src/customThemes/themeDraft.ts +23 -7
  114. package/src/diagram/DiagramCanvas.tsx +25 -6
  115. package/src/diagram/types.ts +35 -0
  116. package/src/editorScrollSync.ts +73 -0
  117. package/src/emojiData.ts +3 -23
  118. package/src/frontmatterSettings.ts +23 -0
  119. package/src/headingTransition.ts +0 -15
  120. package/src/imageEditor/CanvasSurface.tsx +29 -23
  121. package/src/imageEditor/LayersPanel.tsx +78 -3
  122. package/src/imageEditor/Toolbar.tsx +5 -1
  123. package/src/imageEditor/icons.tsx +4 -0
  124. package/src/imageEditor/image-editor.css +75 -0
  125. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  126. package/src/imageEditor/useImageEditor.ts +71 -14
  127. package/src/index.ts +198 -20
  128. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  129. package/src/jsonEditor/RenderNode.tsx +24 -3
  130. package/src/jsonEditor/editors.tsx +86 -19
  131. package/src/mediaEntries.ts +12 -0
  132. package/src/monaco.ts +35 -0
  133. package/src/monacoWorkers.ts +89 -0
  134. package/src/outlineSource.ts +171 -0
  135. package/src/presentation/PresentationMode.tsx +596 -0
  136. package/src/rawEditorIsolation.ts +18 -0
  137. package/src/recorder/RecorderButton.tsx +9 -1
  138. package/src/recorder/RecorderModal.tsx +84 -41
  139. package/src/recorder/RecorderPanel.tsx +9 -1
  140. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  141. package/src/resolveBlockVisual.ts +10 -4
  142. package/src/scene/Scene.tsx +48 -18
  143. package/src/scene/SceneBlockExtension.ts +17 -10
  144. package/src/scene/SceneBlockWidget.tsx +5 -1
  145. package/src/scene/SceneViewport.tsx +7 -3
  146. package/src/scene/ShapePalette.tsx +17 -2
  147. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  148. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  149. package/src/scene/__tests__/sceneIsolation.test.tsx +119 -0
  150. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  151. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  152. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  153. package/src/scene/commands/SceneCommand.ts +17 -3
  154. package/src/scene/index.ts +1 -6
  155. package/src/scene/layers/DiagramEdges.tsx +21 -7
  156. package/src/scene/layers/edgeGeometry.ts +9 -1
  157. package/src/scene/layers/nodeCard.tsx +27 -8
  158. package/src/scene/scene.css +5 -2
  159. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  160. package/src/scene/text/sceneTextChannel.ts +26 -20
  161. package/src/scene/text/sceneTextConfig.ts +4 -0
  162. package/src/scene/tools/ConnectTool.ts +13 -4
  163. package/src/scene/tools/SceneTool.ts +8 -0
  164. package/src/scene/tools/SelectTool.ts +39 -23
  165. package/src/scene/tools/ShapeTool.ts +2 -3
  166. package/src/selectionConversions.ts +155 -0
  167. package/src/styles/ascii-diagram.css +79 -0
  168. package/src/styles/ascii-timeline.css +596 -0
  169. package/src/styles/editor.css +736 -120
  170. package/src/styles/index.css +3 -0
  171. package/src/styles/tree-view.css +189 -0
  172. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  173. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  174. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  175. package/src/teleprompter/TeleprompterView.tsx +338 -0
  176. package/src/teleprompter/canvasRenderer.ts +161 -0
  177. package/src/teleprompter/floatingWindow.ts +352 -0
  178. package/src/teleprompter/index.ts +61 -0
  179. package/src/teleprompter/pcmWorklet.ts +62 -0
  180. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  181. package/src/teleprompter/recording/narrationSave.ts +134 -0
  182. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  183. package/src/teleprompter/scrollModel.ts +85 -0
  184. package/src/teleprompter/teleprompterTheme.ts +303 -0
  185. package/src/teleprompter/types.ts +38 -0
  186. package/src/teleprompter/useFloatingWindow.ts +74 -0
  187. package/src/teleprompter/useMicAnalysis.ts +211 -0
  188. package/src/teleprompter/useTeleprompter.ts +421 -0
  189. package/src/templateContentPreviewResolver.ts +12 -5
  190. package/src/timeline/TimelineEditorWidget.tsx +861 -0
  191. package/src/timeline/TimelineViewExtension.ts +252 -0
  192. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +414 -0
  193. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  194. package/src/timeline/__tests__/timelineCommands.test.ts +359 -0
  195. package/src/timeline/__tests__/timelineOps.test.ts +277 -0
  196. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  197. package/src/timeline/timelineCommands.ts +421 -0
  198. package/src/timeline/timelineData.ts +54 -0
  199. package/src/timeline/timelineOps.ts +381 -0
  200. package/src/timeline/timelinePaste.ts +8 -0
  201. package/src/tiptapBridge.ts +47 -21
  202. package/src/transformStyleId.ts +17 -0
  203. package/src/treeview/TreeOutlineWidget.tsx +390 -0
  204. package/src/treeview/TreeViewExtension.ts +250 -0
  205. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  206. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  207. package/src/treeview/__tests__/treeOps.test.ts +177 -0
  208. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  209. package/src/treeview/__tests__/treeViewCommands.test.ts +179 -0
  210. package/src/treeview/treeOps.ts +231 -0
  211. package/src/treeview/treePaste.ts +13 -0
  212. package/src/treeview/treeViewCommands.ts +99 -0
  213. package/src/treeview/treeViewData.ts +41 -0
  214. package/src/useMonacoLoader.ts +11 -34
  215. package/src/wysiwygImageUpload.ts +113 -0
  216. package/src/diagram/DiagramExtension.ts +0 -209
  217. package/src/diagram/DiagramWidget.tsx +0 -272
  218. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,290 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ */
4
+
5
+ import { useRef } from 'react';
6
+ import { afterEach, describe, expect, it, vi } from 'vitest';
7
+ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
8
+ import { EditorProvider } from '../EditorContext';
9
+ import {
10
+ PresentationModeControl,
11
+ PresentationModeProvider,
12
+ } from '../presentation/PresentationMode';
13
+
14
+ function Harness() {
15
+ const rootRef = useRef<HTMLDivElement>(null);
16
+ return (
17
+ <div ref={rootRef} data-testid="shell">
18
+ <PresentationModeProvider rootRef={rootRef}>
19
+ <PresentationModeControl />
20
+ <div data-testid="content">Preview</div>
21
+ </PresentationModeProvider>
22
+ </div>
23
+ );
24
+ }
25
+
26
+ function renderHarness() {
27
+ return render(
28
+ <EditorProvider initialMarkdown="# Presentation" initialView="preview">
29
+ <Harness />
30
+ </EditorProvider>,
31
+ );
32
+ }
33
+
34
+ afterEach(() => {
35
+ cleanup();
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ describe('PresentationModeControl', () => {
40
+ it('chooses the launch behavior from an accessible radio menu', () => {
41
+ renderHarness();
42
+
43
+ expect(screen.getByRole('button', { name: 'Present: Fill Squisq' })).toBeTruthy();
44
+ fireEvent.click(screen.getByRole('button', { name: 'Presentation options' }));
45
+
46
+ const menu = screen.getByRole('menu', { name: 'Presentation options' });
47
+ const items = within(menu).getAllByRole('menuitemradio');
48
+ expect(items.map((item) => item.textContent)).toEqual([
49
+ 'Fill SquisqUse the entire Squisq control.',
50
+ 'New windowOpen an audience view synced to this one.',
51
+ 'Browser full screenBrowser full screen is unavailable.',
52
+ ]);
53
+ expect(items[0].getAttribute('aria-checked')).toBe('true');
54
+ expect(items.map((item) => item.tabIndex)).toEqual([0, -1, -1]);
55
+
56
+ fireEvent.keyDown(items[0], { key: 'ArrowDown' });
57
+ expect(document.activeElement).toBe(items[1]);
58
+ const tabEvent = new KeyboardEvent('keydown', {
59
+ key: 'Tab',
60
+ bubbles: true,
61
+ cancelable: true,
62
+ });
63
+ fireEvent(items[1], tabEvent);
64
+ expect(tabEvent.defaultPrevented).toBe(true);
65
+ expect(screen.queryByRole('menu', { name: 'Presentation options' })).toBeNull();
66
+ expect(document.activeElement).toBe(
67
+ screen.getByRole('button', { name: 'Presentation options' }),
68
+ );
69
+
70
+ fireEvent.click(screen.getByRole('button', { name: 'Presentation options' }));
71
+ const reopenedMenu = screen.getByRole('menu', { name: 'Presentation options' });
72
+
73
+ fireEvent.click(within(reopenedMenu).getByRole('menuitemradio', { name: /New window/ }));
74
+ expect(screen.getByRole('button', { name: 'Present: New window' })).toBeTruthy();
75
+ expect(screen.queryByRole('menu', { name: 'Presentation options' })).toBeNull();
76
+ });
77
+
78
+ it('fills only the Squisq shell and exits on Escape', async () => {
79
+ renderHarness();
80
+ const shell = screen.getByTestId('shell');
81
+ const launchButton = screen.getByRole('button', { name: 'Present: Fill Squisq' });
82
+
83
+ launchButton.focus();
84
+ fireEvent.click(launchButton);
85
+ expect(shell.getAttribute('data-presentation-mode')).toBe('control');
86
+ const exitButton = screen.getByRole('button', { name: 'Exit presentation mode' });
87
+ expect(document.activeElement).toBe(exitButton);
88
+
89
+ fireEvent.keyDown(document, { key: 'Escape' });
90
+ await waitFor(() => expect(shell.hasAttribute('data-presentation-mode')).toBe(false));
91
+ expect(screen.getByRole('button', { name: 'Present: Fill Squisq' })).toBeTruthy();
92
+ expect(document.activeElement).toBe(launchButton);
93
+ });
94
+
95
+ it('tracks native fullscreen exit from the browser', async () => {
96
+ const originalRequest = Object.getOwnPropertyDescriptor(
97
+ HTMLElement.prototype,
98
+ 'requestFullscreen',
99
+ );
100
+ const originalExit = Object.getOwnPropertyDescriptor(document, 'exitFullscreen');
101
+ const originalElement = Object.getOwnPropertyDescriptor(document, 'fullscreenElement');
102
+ let fullscreenElement: Element | null = null;
103
+ const requestFullscreen = vi.fn(() => Promise.resolve());
104
+ Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', {
105
+ configurable: true,
106
+ value: requestFullscreen,
107
+ });
108
+ Object.defineProperty(document, 'exitFullscreen', {
109
+ configurable: true,
110
+ value: vi.fn(async () => {
111
+ fullscreenElement = null;
112
+ }),
113
+ });
114
+ Object.defineProperty(document, 'fullscreenElement', {
115
+ configurable: true,
116
+ get: () => fullscreenElement,
117
+ });
118
+
119
+ try {
120
+ renderHarness();
121
+ fireEvent.click(screen.getByRole('button', { name: 'Presentation options' }));
122
+ fireEvent.click(
123
+ screen.getByRole('menuitemradio', { name: /Browser full screen.*Use the browser/ }),
124
+ );
125
+ fireEvent.click(screen.getByRole('button', { name: 'Present: Browser full screen' }));
126
+
127
+ const shell = screen.getByTestId('shell');
128
+ await waitFor(() => expect(requestFullscreen).toHaveBeenCalledWith());
129
+ await waitFor(() => expect(shell.getAttribute('data-presentation-mode')).toBe('fullscreen'));
130
+
131
+ fullscreenElement = null;
132
+ fireEvent(document, new Event('fullscreenchange'));
133
+ await waitFor(() => expect(shell.hasAttribute('data-presentation-mode')).toBe(false));
134
+ } finally {
135
+ if (originalRequest) {
136
+ Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', originalRequest);
137
+ } else {
138
+ Reflect.deleteProperty(HTMLElement.prototype, 'requestFullscreen');
139
+ }
140
+ if (originalExit) Object.defineProperty(document, 'exitFullscreen', originalExit);
141
+ else Reflect.deleteProperty(document, 'exitFullscreen');
142
+ if (originalElement) Object.defineProperty(document, 'fullscreenElement', originalElement);
143
+ else Reflect.deleteProperty(document, 'fullscreenElement');
144
+ }
145
+ });
146
+
147
+ it('keeps fullscreen presentation active when browser exit is rejected', async () => {
148
+ const originalRequest = Object.getOwnPropertyDescriptor(
149
+ HTMLElement.prototype,
150
+ 'requestFullscreen',
151
+ );
152
+ const originalExit = Object.getOwnPropertyDescriptor(document, 'exitFullscreen');
153
+ const originalElement = Object.getOwnPropertyDescriptor(document, 'fullscreenElement');
154
+ let fullscreenElement: Element | null = null;
155
+ let requestedRoot: Element | null = null;
156
+ Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', {
157
+ configurable: true,
158
+ value: vi.fn(async () => {
159
+ fullscreenElement = requestedRoot;
160
+ }),
161
+ });
162
+ Object.defineProperty(document, 'exitFullscreen', {
163
+ configurable: true,
164
+ value: vi.fn(async () => {
165
+ throw new Error('exit denied');
166
+ }),
167
+ });
168
+ Object.defineProperty(document, 'fullscreenElement', {
169
+ configurable: true,
170
+ get: () => fullscreenElement,
171
+ });
172
+
173
+ try {
174
+ renderHarness();
175
+ requestedRoot = screen.getByTestId('shell');
176
+ fireEvent.click(screen.getByRole('button', { name: 'Presentation options' }));
177
+ fireEvent.click(screen.getByRole('menuitemradio', { name: /Browser full screen/ }));
178
+ fireEvent.click(screen.getByRole('button', { name: 'Present: Browser full screen' }));
179
+ await waitFor(() =>
180
+ expect(screen.getByTestId('shell').getAttribute('data-presentation-mode')).toBe(
181
+ 'fullscreen',
182
+ ),
183
+ );
184
+
185
+ fireEvent.click(screen.getByRole('button', { name: 'Stop presentation' }));
186
+ await waitFor(() =>
187
+ expect(screen.getByRole('alert').textContent).toContain('could not exit full screen'),
188
+ );
189
+ expect(screen.getByTestId('shell').getAttribute('data-presentation-mode')).toBe('fullscreen');
190
+
191
+ fullscreenElement = null;
192
+ fireEvent(document, new Event('fullscreenchange'));
193
+ } finally {
194
+ if (originalRequest) {
195
+ Object.defineProperty(HTMLElement.prototype, 'requestFullscreen', originalRequest);
196
+ } else {
197
+ Reflect.deleteProperty(HTMLElement.prototype, 'requestFullscreen');
198
+ }
199
+ if (originalExit) Object.defineProperty(document, 'exitFullscreen', originalExit);
200
+ else Reflect.deleteProperty(document, 'exitFullscreen');
201
+ if (originalElement) Object.defineProperty(document, 'fullscreenElement', originalElement);
202
+ else Reflect.deleteProperty(document, 'fullscreenElement');
203
+ }
204
+ });
205
+
206
+ it('opens a same-origin audience window and cleans up when it closes', async () => {
207
+ const popupDocument = document.implementation.createHTMLDocument('');
208
+ const popupEvents = new EventTarget();
209
+ let closed = false;
210
+ const popup = {
211
+ document: popupDocument,
212
+ get closed() {
213
+ return closed;
214
+ },
215
+ close: vi.fn(() => {
216
+ closed = true;
217
+ }),
218
+ focus: vi.fn(),
219
+ addEventListener: popupEvents.addEventListener.bind(popupEvents),
220
+ removeEventListener: popupEvents.removeEventListener.bind(popupEvents),
221
+ } as unknown as Window;
222
+ vi.spyOn(window, 'open').mockReturnValue(popup);
223
+
224
+ renderHarness();
225
+ fireEvent.click(screen.getByRole('button', { name: 'Presentation options' }));
226
+ fireEvent.click(screen.getByRole('menuitemradio', { name: /New window/ }));
227
+ fireEvent.click(screen.getByRole('button', { name: 'Present: New window' }));
228
+
229
+ const shell = screen.getByTestId('shell');
230
+ await waitFor(() => expect(shell.getAttribute('data-presentation-mode')).toBe('window'));
231
+ expect(popupDocument.querySelector('#squisq-presentation-root')).toBeTruthy();
232
+ expect(popupDocument.querySelector('[aria-label="Exit presentation mode"]')).toBeTruthy();
233
+
234
+ // Re-selecting the checked destination only closes the menu; it must not
235
+ // tear down the active audience window.
236
+ fireEvent.click(screen.getByRole('button', { name: 'Presentation options' }));
237
+ fireEvent.click(screen.getByRole('menuitemradio', { name: /New window/ }));
238
+ expect(shell.getAttribute('data-presentation-mode')).toBe('window');
239
+ expect(popup.close).not.toHaveBeenCalled();
240
+
241
+ popupEvents.dispatchEvent(new Event('pagehide'));
242
+ await waitFor(() => expect(shell.hasAttribute('data-presentation-mode')).toBe(false));
243
+ });
244
+
245
+ it('reports a blocked audience window without entering presentation', async () => {
246
+ vi.spyOn(window, 'open').mockReturnValue(null);
247
+ renderHarness();
248
+ fireEvent.click(screen.getByRole('button', { name: 'Presentation options' }));
249
+ fireEvent.click(screen.getByRole('menuitemradio', { name: /New window/ }));
250
+ fireEvent.click(screen.getByRole('button', { name: 'Present: New window' }));
251
+
252
+ await waitFor(() =>
253
+ expect(screen.getByRole('alert').textContent).toContain('presentation window was blocked'),
254
+ );
255
+ expect(screen.getByTestId('shell').hasAttribute('data-presentation-mode')).toBe(false);
256
+ });
257
+
258
+ it('cleans up when popup initialization fails after the portal is prepared', async () => {
259
+ const popupDocument = document.implementation.createHTMLDocument('');
260
+ const popupEvents = new EventTarget();
261
+ let closed = false;
262
+ const popup = {
263
+ document: popupDocument,
264
+ get closed() {
265
+ return closed;
266
+ },
267
+ close: vi.fn(() => {
268
+ closed = true;
269
+ }),
270
+ focus: vi.fn(() => {
271
+ throw new Error('focus failed');
272
+ }),
273
+ addEventListener: popupEvents.addEventListener.bind(popupEvents),
274
+ removeEventListener: popupEvents.removeEventListener.bind(popupEvents),
275
+ } as unknown as Window;
276
+ vi.spyOn(window, 'open').mockReturnValue(popup);
277
+
278
+ renderHarness();
279
+ fireEvent.click(screen.getByRole('button', { name: 'Presentation options' }));
280
+ fireEvent.click(screen.getByRole('menuitemradio', { name: /New window/ }));
281
+ fireEvent.click(screen.getByRole('button', { name: 'Present: New window' }));
282
+
283
+ await waitFor(() =>
284
+ expect(screen.getByRole('alert').textContent).toContain('presentation window was blocked'),
285
+ );
286
+ expect(screen.getByTestId('shell').hasAttribute('data-presentation-mode')).toBe(false);
287
+ expect(popup.close).toHaveBeenCalledOnce();
288
+ expect(popupDocument.querySelector('[aria-label="Exit presentation mode"]')).toBeNull();
289
+ });
290
+ });