import { WuCore } from 'wu-framework'; import { registerReactEditor, EDITOR, DRAFT_PATH, NOTES_PATH } from './react-editor.js'; import { registerVueBoard, BOARD } from './vue-board.js'; // App code is fixed and trusted. A private core owns the two renderers and state. export async function createInteractionLab({ editorHost, boardHost, copy, onChange, signal }) { const runtime = new WuCore(); const lifetime = new AbortController(); const cleanups = []; const popupName = `wu-interaction-${crypto.randomUUID()}`; let disposed = false; let disposal; let busy = false; let transition; let releasePlaceholder; let panelRoot; let lastEditorFocus; let eventSequence = 0; let state = Object.freeze({ ready: false, hidden: false, detached: false, windowKind: null, layout: 'split', received: Object.freeze([]), events: Object.freeze([]), error: null }); const abortError = () => lifetime.signal.reason || new DOMException('Interaction laboratory disposed', 'AbortError'); const ensure = () => { if (disposed || lifetime.signal.aborted || !editorHost.isConnected || !boardHost.isConnected) throw abortError(); }; function publish(patch) { state = Object.freeze({ ...state, ...patch }); const pending = Promise.resolve(onChange?.(state)); // Keep event-originated notifications handled; awaited operations still reject. pending.catch(error => { state = Object.freeze({ ...state, error: Object.freeze({ code: error.code || 'INTERACTION_UI_ERROR', message: error.message || copy.errorGeneric }) }); }); return pending; } function record(code, api, patch = {}) { if (disposed) return Promise.resolve(); const event = Object.freeze({ id: ++eventSequence, code, api, label: copy.events?.[code] || api }); return publish({ ...patch, events: Object.freeze([...state.events, event].slice(-8)) }); } function reportError(error) { if (disposed || lifetime.signal.aborted) return; return record('error', error.code || 'WU', { error: Object.freeze({ code: error.code || 'INTERACTION_ERROR', message: error.message || copy.errorGeneric }) }); } function startAction(action) { let acquired = false; try { ensure(); if (!state.ready || busy) { const error = new Error(copy.errorGeneric); error.code = 'INTERACTION_BUSY'; throw error; } busy = true; acquired = true; // No await here: window creation must still run in the original click. publish({ error: null }); return Promise.resolve(action()).catch(error => { reportError(error); throw error; }).finally(() => { busy = false; }); } catch (error) { if (acquired) busy = false; reportError(error); return Promise.reject(error); } } const onExternalAbort = () => { lifetime.abort(signal.reason); controller.dispose().catch(() => {}); }; const controller = { runtime, get state() { return state; }, hideEditor() { return startAction(async () => { if (runtime.isDetached(EDITOR)) runtime.attach(EDITOR); // Clicking the shell's Hide button moved focus outside the editor. // Restore the actual last editor target before WU remembers it. if (lastEditorFocus?.isConnected) lastEditorFocus.focus({ preventScroll: true }); await runtime.unmount(EDITOR, { keepAlive: true }); ensure(); await record('hidden', 'wu.unmount({ keepAlive: true })', { hidden: true, detached: false, windowKind: null }); }); }, showEditor() { return startAction(async () => { await runtime.show(EDITOR); ensure(); await record('shown', 'wu.show / wu.focus.restore', { hidden: false }); }); }, detachEditor(kind) { return startAction(() => { const placeholder = editorHost.ownerDocument.createElement('wu-interaction-placeholder'); placeholder.copy = copy; const back = () => { controller.attachEditor().catch(() => {}); }; placeholder.addEventListener('return-editor', back); releasePlaceholder?.(); const release = () => { placeholder.removeEventListener('return-editor', back); if (releasePlaceholder === release) releasePlaceholder = null; }; releasePlaceholder = release; // windowsReady completed during initialization. This call opens the // requested window synchronously before its first awaited operation. const detached = runtime.detach(EDITOR, { window: kind, width: 520, height: 540, title: copy.windowTitle, name: popupName, placeholder, }); return detached.then(win => { ensure(); if (lastEditorFocus?.isConnected) lastEditorFocus.focus({ preventScroll: true }); return win; }).finally(() => { if (!runtime.isDetached(EDITOR)) release(); }); }); }, attachEditor() { return startAction(() => { const attached = runtime.attach(EDITOR); if (!attached) return publish({ detached: false, windowKind: null }); return Promise.resolve(attached); }); }, transitionLayout() { return startAction(async () => { const layout = state.layout === 'split' ? 'stacked' : 'split'; const active = transition = runtime.transition(() => publish({ layout }), { root: panelRoot, select: '[data-wu-vt]', mode: 'flip', duration: 260, }); let completed = false; try { // Mutation errors reject updateCallbackDone; finished can settle // normally even when the layout callback failed. await active.updateCallbackDone; await active.finished; completed = true; } finally { if (!completed) active.skipTransition(); if (transition === active) transition = null; } ensure(); await record('transition', 'wu.transition', { layout }); }); }, dispose() { if (disposal) return disposal; disposed = true; lifetime.abort(); releasePlaceholder?.(); lastEditorFocus = null; transition?.skipTransition(); signal?.removeEventListener('abort', onExternalAbort); disposal = (async () => { const errors = []; for (const cleanup of cleanups.reverse()) { try { await cleanup(); } catch (error) { errors.push(error); } } try { await runtime.destroy(); } catch (error) { errors.push(error); } state = Object.freeze({ ...state, ready: false, hidden: false, detached: false, windowKind: null }); if (errors.length) throw new AggregateError(errors, copy.errorGeneric); })(); return disposal; }, }; try { if (signal?.aborted) throw signal.reason || abortError(); ensure(); signal?.addEventListener('abort', onExternalAbort, { once: true }); panelRoot = editorHost.parentElement; while (panelRoot && !panelRoot.contains(boardHost)) panelRoot = panelRoot.parentElement; if (!panelRoot) throw new Error(copy.errorGeneric); const panelOf = host => { let panel = host; while (panel.parentElement && panel.parentElement !== panelRoot) panel = panel.parentElement; return panel; }; const editorPanel = panelOf(editorHost); const boardPanel = panelOf(boardHost); // Animate whole panels so their own overflow clipping cannot hide a // translated inner mount while the layout changes. editorPanel.setAttribute('data-wu-vt', 'interaction-editor'); boardPanel.setAttribute('data-wu-vt', 'interaction-board'); cleanups.push(() => { editorPanel.removeAttribute('data-wu-vt'); boardPanel.removeAttribute('data-wu-vt'); }); runtime.store.set(DRAFT_PATH, copy.initialDraft); runtime.store.set(NOTES_PATH, []); const base = new URL('/microfrontends/interaction-lab/fixtures/', editorHost.ownerDocument.baseURI); await runtime.init({ sandbox: 'module', debug: false, overrides: { enabled: false }, apps: [ { name: EDITOR, url: new URL('interaction-editor/', base).href, framework: 'react', sandbox: 'module', grants: ['store.read:interaction.*', 'store.write:interaction.draft', 'drag.offer:docs.note'] }, { name: BOARD, url: new URL('interaction-board/', base).href, framework: 'vue', sandbox: 'module', grants: ['store.read:interaction.notes', 'store.write:interaction.notes', 'drag.accept:docs.note'] }, ] }); ensure(); await runtime.windowsReady(); ensure(); cleanups.push(runtime.eventBus.on('app:detached', ({ data }) => { if (data.appName === EDITOR && !disposed) record('detached', 'wu.detach', { detached: true, hidden: false, windowKind: data.kind, error: null }); })); cleanups.push(runtime.eventBus.on('app:attached', ({ data }) => { if (data.appName === EDITOR && !disposed) { releasePlaceholder?.(); if (lastEditorFocus?.isConnected) lastEditorFocus.focus({ preventScroll: true }); record('attached', 'wu.attach', { detached: false, windowKind: null, error: null }); } })); const editor = registerReactEditor(runtime, { copy, signal: lifetime.signal, onReady(element) { lastEditorFocus = element.focusTarget; }, onFocus(element) { lastEditorFocus = element; }, onDraft() { if (!disposed) record('draft', 'context.wu.store.set', { error: null }); }, onEvent(code, api) { if (!disposed) record(code, api, { error: null }); }, onError: reportError, }); cleanups.push(() => editor.dispose()); const board = registerVueBoard(runtime, { copy, signal: lifetime.signal, onError: reportError, onReceived(notes, info) { if (!disposed) return record('received', info.event ? 'context.wu.drop.accept · drop' : 'context.wu.drop.accept · deliver', { received: Object.freeze(notes.map(note => Object.freeze({ ...note }))), error: null, }); }, }); cleanups.push(() => board.dispose()); // Both promises resolve from actual React/Lit and Vue/Lit commits plus // successful drag/drop registration. No timer is treated as readiness. await Promise.all([runtime.mount(EDITOR, editorHost), runtime.mount(BOARD, boardHost)]); ensure(); await record('ready', 'wu.init / wu.mount', { ready: true }); return controller; } catch (error) { reportError(error); try { await controller.dispose(); } catch (cleanupError) { throw new AggregateError([error, cleanupError], copy.errorGeneric); } throw error; } }