import React, { useLayoutEffect, useRef, useState } from 'react'; import { createRoot } from 'react-dom/client'; export const EDITOR = 'interaction-editor'; export const NOTE_TYPE = 'docs.note'; export const DRAFT_PATH = 'interaction.draft'; export const NOTES_PATH = 'interaction.notes'; // The registration receives the owned core explicitly; it never reads window.wu. export function registerReactEditor(runtime, { copy, signal, onReady, onDraft, onEvent, onError, onFocus }) { let root; let view; let mounting; let sequence = 0; const aborted = () => signal?.reason || new DOMException('Interaction editor disposed', 'AbortError'); const ensure = () => { if (signal?.aborted) throw aborted(); }; const dispose = () => { mounting?.reject(aborted()); root?.unmount(); root = null; view = null; mounting = null; }; runtime.define(EDITOR, { async mount(container, { wu: scoped }) { ensure(); mounting = {}; const ready = new Promise((resolve, reject) => Object.assign(mounting, { resolve, reject })); // React is the state owner; the Lit element is its visual component. function Editor() { const element = useRef(null); const [draft, setDraft] = useState(() => scoped.store.get(DRAFT_PATH) ?? copy.initialDraft); const [received, setReceived] = useState(() => (scoped.store.get(NOTES_PATH) || []).length); useLayoutEffect(() => { view = element.current; view.copy = copy; let live = true; let releaseOffer; const payload = () => ({ id: `note-${++sequence}`, text: String(scoped.store.get(DRAFT_PATH) ?? '') }); const change = event => { if (!live || signal?.aborted) return; const value = String(event.detail?.value ?? ''); scoped.store.set(DRAFT_PATH, value); onDraft?.(value); }; const send = async () => { if (!live || signal?.aborted) return; try { await scoped.drag.deliver(NOTE_TYPE, payload(), { to: 'interaction-board' }); if (live && !signal?.aborted) onEvent?.('sent', 'context.wu.drag.deliver'); } catch (error) { if (live && !signal?.aborted) onError?.(error); } }; const focus = event => { if (live) onFocus?.(event.detail?.element || view.focusTarget); }; view.addEventListener('draft-change', change); view.addEventListener('send-note', send); view.addEventListener('editor-focus', focus); const stopDraft = scoped.store.on(DRAFT_PATH, value => setDraft(String(value ?? ''))); const stopNotes = scoped.store.on(NOTES_PATH, notes => setReceived((notes || []).length)); const abort = () => mounting?.reject(aborted()); signal?.addEventListener('abort', abort, { once: true }); (async () => { await view.updateComplete; ensure(); if (!live) throw aborted(); releaseOffer = await scoped.drag.offer(view.dragElement, { type: NOTE_TYPE, payload, effect: 'copy', onEnd(info) { if (live && info.dropped) onEvent?.('sent', 'context.wu.drag.offer'); }, }); if (!live || signal?.aborted) { releaseOffer(); throw aborted(); } onReady?.(view); mounting?.resolve(view); })().catch(error => mounting?.reject(error)); return () => { live = false; releaseOffer?.(); stopDraft(); stopNotes(); signal?.removeEventListener('abort', abort); view.removeEventListener('draft-change', change); view.removeEventListener('send-note', send); view.removeEventListener('editor-focus', focus); mounting?.reject(aborted()); }; }, []); useLayoutEffect(() => { element.current.copy = copy; element.current.draft = draft; element.current.received = received; }, [draft, received]); return React.createElement('wu-interaction-editor', { ref: element, 'data-renderer': 'react', 'data-renderer-version': React.version, }); } root = createRoot(container); root.render(React.createElement(Editor)); await ready; }, unmount: dispose, }); return { get element() { return view; }, dispose }; }