import { Arr, Id, Obj, Singleton, Unicode } from '@ephox/katamari'; import { Attribute, Class, Classes, Html, Insert, Replication, SugarElement, SugarNode, Traverse } from '@ephox/sugar'; import Editor from '../api/Editor'; import * as ExpandRange from '../fmt/ExpandRange'; import * as RangeWalk from '../selection/RangeWalk'; import * as SelectionUtils from '../selection/SelectionUtils'; import * as TableCellSelection from '../selection/TableCellSelection'; import { ChildContext, context } from './AnnotationContext'; import { AnnotatorSettings } from './AnnotationsRegistry'; import * as Markings from './Markings'; export type DecoratorData = Record; export type Decorator = ( uid: string, data: DecoratorData ) => { attributes?: {}; classes?: string[]; }; const applyWordGrab = (editor: Editor, rng: Range): void => { const r = ExpandRange.expandRng(editor.dom, rng, [{ inline: 'span' }]); rng.setStart(r.startContainer, r.startOffset); rng.setEnd(r.endContainer, r.endOffset); editor.selection.setRng(rng); }; const applyAnnotation = ( elem: SugarElement, masterUId: string, data: DecoratorData, annotationName: string, decorate: Decorator, directAnnotation: boolean ): void => { const { uid = masterUId, ...otherData } = data; Class.add(elem, Markings.annotation()); Attribute.set(elem, `${Markings.dataAnnotationId()}`, uid); Attribute.set(elem, `${Markings.dataAnnotation()}`, annotationName); const { attributes = {}, classes = [] } = decorate(uid, otherData); Attribute.setAll(elem, attributes); Classes.add(elem, classes); if (directAnnotation) { if (classes.length > 0) { Attribute.set(elem, `${Markings.dataAnnotationClasses()}`, classes.join(',')); } const attributeNames = Obj.keys(attributes); if (attributeNames.length > 0) { Attribute.set(elem, `${Markings.dataAnnotationAttributes()}`, attributeNames.join(',')); } } }; const removeDirectAnnotation = (elem: SugarElement): void => { Class.remove(elem, Markings.annotation()); Attribute.remove(elem, `${Markings.dataAnnotationId()}`); Attribute.remove(elem, `${Markings.dataAnnotation()}`); Attribute.remove(elem, `${Markings.dataAnnotationActive()}`); const customAttrNames = Attribute.getOpt(elem, `${Markings.dataAnnotationAttributes()}`).map((names) => names.split(',')).getOr([]); const customClasses = Attribute.getOpt(elem, `${Markings.dataAnnotationClasses()}`).map((names) => names.split(',')).getOr([]); Arr.each(customAttrNames, (name) => Attribute.remove(elem, name)); Classes.remove(elem, customClasses); Attribute.remove(elem, `${Markings.dataAnnotationClasses()}`); Attribute.remove(elem, `${Markings.dataAnnotationAttributes()}`); }; const makeAnnotation = (eDoc: Document, uid: string, data: DecoratorData, annotationName: string, decorate: Decorator): SugarElement => { const master = SugarElement.fromTag('span', eDoc); applyAnnotation(master, uid, data, annotationName, decorate, false); return master; }; const annotate = (editor: Editor, rng: Range, uid: string, annotationName: string, decorate: Decorator, data: DecoratorData): SugarElement[] => { // Setup all the wrappers that are going to be used. const newWrappers: SugarElement[] = []; // Setup the spans for the comments const master = makeAnnotation(editor.getDoc(), uid, data, annotationName, decorate); // Set the current wrapping element const wrapper = Singleton.value>(); // Clear the current wrapping element, so that subsequent calls to // getOrOpenWrapper spawns a new one. const finishWrapper = () => { wrapper.clear(); }; // Get the existing wrapper, or spawn a new one. const getOrOpenWrapper = (): SugarElement => wrapper.get().getOrThunk(() => { const nu = Replication.shallow(master); newWrappers.push(nu); wrapper.set(nu); return nu; }); const processElements = (elems: SugarElement[]) => { Arr.each(elems, processElement); }; const processElement = (elem: SugarElement) => { const ctx = context(editor, elem, 'span', SugarNode.name(elem)); switch (ctx) { case ChildContext.InvalidChild: { finishWrapper(); const children = Traverse.children(elem); processElements(children); finishWrapper(); break; } case ChildContext.ValidBlock: { finishWrapper(); applyAnnotation(elem as SugarElement, uid, data, annotationName, decorate, true); break; } case ChildContext.Valid: { const w = getOrOpenWrapper(); Insert.wrap(elem, w); break; } // INVESTIGATE: Are these sensible things to do? case ChildContext.Skipping: case ChildContext.Existing: case ChildContext.Caret: { // Do nothing. } } }; const processNodes = (nodes: Node[]) => { const elems = Arr.map(nodes, SugarElement.fromDom); processElements(elems); }; RangeWalk.walk(editor.dom, rng, (nodes) => { finishWrapper(); processNodes(nodes); }); return newWrappers; }; const annotateWithBookmark = (editor: Editor, name: string, settings: AnnotatorSettings, data: {}): void => { editor.undoManager.transact(() => { const selection = editor.selection; const initialRng = selection.getRng(); const hasFakeSelection = TableCellSelection.getCellsFromEditor(editor).length > 0; const masterUid = Id.generate('mce-annotation'); if (initialRng.collapsed && !hasFakeSelection) { applyWordGrab(editor, initialRng); } // Even after applying word grab, we could not find a selection. Therefore, // just make a wrapper and insert it at the current cursor if (selection.getRng().collapsed && !hasFakeSelection) { const wrapper = makeAnnotation(editor.getDoc(), masterUid, data, name, settings.decorate); // Put something visible in the marker Html.set(wrapper, Unicode.nbsp); selection.getRng().insertNode(wrapper.dom); selection.select(wrapper.dom); } else { // The bookmark is responsible for splitting the nodes beforehand at the selection points // The "false" here means a zero width cursor is NOT put in the bookmark. It seems to be required // to stop an empty paragraph splitting into two paragraphs. Probably a better way exists. SelectionUtils.preserve(selection, false, () => { SelectionUtils.runOnRanges(editor, (selectionRng) => { annotate(editor, selectionRng, masterUid, name, settings.decorate, data); }); }); } }); }; export { removeDirectAnnotation, annotateWithBookmark };