import { FieldSchema, StructureSchema } from '@ephox/boulder'; import { Arr, Obj, type Optional } from '@ephox/katamari'; import { DomEvent, type EventArgs, Insert, type SugarElement } from '@ephox/sugar'; import type { UncurriedHandler } from '../../events/EventRegistry'; import * as SimulatedEvent from '../../events/SimulatedEvent'; import ForeignCache from '../../foreign/ForeignCache'; import * as Tagger from '../../registry/Tagger'; import type { AlloyBehaviourRecord } from '../behaviour/Behaviour'; import type { AlloyComponent } from '../component/ComponentApi'; import * as GuiFactory from '../component/GuiFactory'; import type { AlloyEventRecord } from '../events/AlloyEvents'; import * as Gui from './Gui'; export interface ForeignGuiSpec { readonly root: SugarElement; readonly dispatchers: Dispatcher[]; readonly insertion?: (root: SugarElement, system: Gui.GuiSystem) => void; } export interface DispatchedAlloyConfig { readonly events?: AlloyEventRecord; readonly behaviours: AlloyBehaviourRecord; readonly eventOrder?: Record; } export interface Dispatcher { readonly getTarget: (elem: SugarElement) => Optional>; readonly alloyConfig: DispatchedAlloyConfig; } export interface ForeignGuiDetail { readonly root: SugarElement; readonly dispatchers: Dispatcher[]; readonly insertion: (root: SugarElement, system: Gui.GuiSystem) => void; } interface DispatcherMission { readonly target: SugarElement; readonly dispatcher: Dispatcher; } export interface ForeignGuiConnection { readonly dispatchTo: (type: string, event: EventArgs) => void; readonly unproxy: (component: AlloyComponent) => void; readonly disengage: () => void; } const schema = StructureSchema.objOfOnly([ FieldSchema.required('root'), FieldSchema.requiredArrayOfObj('dispatchers', [ // Given the initial target, what is the target of this particular behaviour? FieldSchema.required('getTarget'), // The configuration for the behaviours FieldSchema.required('alloyConfig') ]), FieldSchema.defaulted('insertion', (root: SugarElement, system: AlloyComponent) => { Insert.append(root, system.element); }) ]); /* * ForeignGui is an experimental concept * * Essentially, the basic idea is that we want to be able to use alloy behaviours and events * on elements that were not created by alloy. The common example of this is the contents inside * an iframe of an editor. We did not create the content (it is also volatile), but we still * want to be able to pinch it, drag it, toggle it etc. However, we don't have Component * instances for any of these things inside the alloy root ... they are completely separate. * * Most behaviours (probably not all) require configuration and a connection to an alloy * root to exist for a small amount of time to execute their actions. For this reason, we * create an internal alloy root for this foreign object (and insert it using insertion, so * for iframes, it would often be inserted at the document level so that it wasn't in the content), * and connect and disconnect the external elements to that. The elements themselves store their * configuration so that we get garbage collecting benefits (a previous version stored them in * a separate map), and that configuration can be retrieved from the element. If it isn't there, * it is recreated (and any state with it). The configuration is maintained on the DOM object * itself (through DomState), and the connection to the internal alloy root (/system) is removed after * execution of the action. * * The connection to the internal alloy root can be required if the behaviour needs to create additional * components (e.g. blockers for dragging). However, because these components are already within * the internal alloy root, they don't need to be handled in this way (they are just maintained * like normal alloy components having been built by alloy). Therefore, we exit dispatching if the * target is within our internal system. * * A ForeignGui is setup with a list of dispatchers. Dispatchers tell alloy which things should have * alloy like behaviour. Note, that each dispatcher has a getTarget, which returns an option of element. * The purpose of this is it means that you may choose to add the behaviour to an *ancestor* of the target * element (e.g. table) rather than the target element itself (td). The alloyConfig lists the behaviours and * events to proxy for this dispatcher. */ // Not all events are supported at the moment const supportedEvents = [ 'click', 'mousedown', 'mousemove', 'touchstart', 'touchend', 'gesturestart', 'touchmove' ]; // Find the dispatcher information for the target if available. Note, the // dispatcher may also change the target. const findDispatcher = (dispatchers: Dispatcher[], target: SugarElement): Optional => Arr.findMap(dispatchers, (dispatcher: Dispatcher) => dispatcher.getTarget(target).map((newTarget) => ({ target: newTarget, dispatcher }))); const getProxy = (event: T, target: SugarElement) => { // Setup the component wrapping for the target element const component = GuiFactory.build( GuiFactory.external({ element: target }) ); const simulatedEvent = SimulatedEvent.fromTarget(event, target); return { component, simulatedEvent }; }; const engage = (spec: ForeignGuiSpec): ForeignGuiConnection => { const detail: ForeignGuiDetail = StructureSchema.asRawOrDie('ForeignGui', schema, spec); // Creates an inner GUI and inserts it appropriately. This will be used // as the system for all behaviours const gui = Gui.create(); detail.insertion(detail.root, gui); const cache = ForeignCache(); const domEvents = Arr.map(supportedEvents, (type) => DomEvent.bind(detail.root, type, (event) => { dispatchTo(type, event); })); const proxyFor = (event: T, target: SugarElement, descHandler: UncurriedHandler) => { // create a simple alloy wrapping around the element, and add it to the world const proxy = getProxy(event, target); const component = proxy.component; gui.addToWorld(component); // fire the event const handler = descHandler.handler; handler(component, proxy.simulatedEvent); // now remove from the world and revoke any alloy ids unproxy(component); }; const dispatchTo = (type: string, event: EventArgs): void => { /* * 1. Check that the event did not originate in our internal alloy root. If it did, * we don't need to handle it here. The alloy root will handle it as usual. * 2. Find the dispatcher based on the target element. It will find the first dispatcher * that matches * 3. Retrieve the configuration for this external element from its DOM state or create * it if it doesn't already exist * 4. If the event is supported: * a) create a thin proxy wrapping for the DOM element to have component like APIs * b) add it to the internal system * c) execute the event handler * d) remove it from the internal system and clear any DOM markers (alloy-ids etc) */ if (gui.element.dom.contains(event.target.dom)) { return; } // Find if the target has an assigned dispatcher findDispatcher(detail.dispatchers, event.target).each((mission) => { // get any info for this current element, creating it if necessary const data = cache.getEvents(mission.target, mission.dispatcher.alloyConfig); const events = data.evts; // if this dispatcher defines this event, proxy it and fire the handler if (Obj.hasNonNullableKey(events, type)) { proxyFor(event, mission.target, events[type]); } }); }; // Remove any traces of the foreign component from the internal alloy system. const unproxy = (component: AlloyComponent): void => { gui.removeFromWorld(component); Tagger.revoke(component.element); }; // Disconnect the foreign GUI const disengage = (): void => { Arr.each(domEvents, (e) => { e.unbind(); }); }; return { dispatchTo, unproxy, disengage }; }; export { engage };