diff --git a/packages/dom-adapters/src/InlineToolAdapter/index.ts b/packages/dom-adapters/src/InlineToolAdapter/index.ts index 0d6fd1ad..3062bbe7 100644 --- a/packages/dom-adapters/src/InlineToolAdapter/index.ts +++ b/packages/dom-adapters/src/InlineToolAdapter/index.ts @@ -1,7 +1,26 @@ -import type { DataKey, EditorJSModel, InlineToolData, InlineToolName, ModelEvents } from '@editorjs/model'; +import type { + DataKey, + EditorJSModel, + InlineFragment, + InlineToolData, + InlineToolName, + ModelEvents, + TextRange +} from '@editorjs/model'; import { EventType, TextFormattedEvent, TextUnformattedEvent } from '@editorjs/model'; -import { getAbsoluteOffset, getRange, normalize, unwrapByToolType } from '../utils/helpers.js'; +import { unwrapByToolType, createRange, normalizeNode } from '../utils/index.js'; +import type { CaretAdapter } from '../caret/CaretAdapter.js'; +export interface InlineTool { + name: InlineToolName; + create(data?: InlineToolData): HTMLElement; + getAction(index: TextRange, fragments: InlineFragment[], data?: InlineToolData): { action: FormattingAction; range: TextRange }; +} + +export enum FormattingAction { + Format = 'format', + Unformat = 'unformat', +} /** * InlineToolAdapter class is used to connect InlineTools with the EditorJS model and apply changes into the DOM @@ -14,7 +33,7 @@ export class InlineToolAdapter { * * @todo Replace any with InlineTool type */ - #tools: Map = new Map(); + #tools: Map = new Map(); /** * EditorJS model @@ -44,6 +63,11 @@ export class InlineToolAdapter { */ #input: HTMLElement; + /** + * Caret adapter instance for the input + */ + #caretAdapter: CaretAdapter; + /** * InlineToolAdapter constructor * @@ -52,11 +76,12 @@ export class InlineToolAdapter { * @param dataKey - key of the data in the BlockNode inline tool is related to * @param input - input element inline tool is related to */ - constructor(model: EditorJSModel, blockIndex: number, dataKey: DataKey, input: HTMLElement) { + constructor(model: EditorJSModel, blockIndex: number, dataKey: DataKey, input: HTMLElement, caretAdapter: CaretAdapter) { this.#model = model; this.#blockIndex = blockIndex; this.#dataKey = dataKey; this.#input = input; + this.#caretAdapter = caretAdapter; this.#subscribe(); } @@ -69,7 +94,7 @@ export class InlineToolAdapter { * * @todo Replace any with InlineTool type */ - public attachTool(tool: any): void { + public attachTool(tool: InlineTool): void { this.#tools.set(tool.name, tool); } @@ -78,114 +103,41 @@ export class InlineToolAdapter { * * @param tool - tool to detach */ - public detachTool(tool: any): void { + public detachTool(tool: InlineTool): void { this.#tools.delete(tool.name); } - /** - * Applies formatting to the input using current selection - * - * @param tool - name of the tool to apply - * @param [data] - inline tool data if applicable - */ - public format(tool: InlineToolName, data?: InlineToolData): void; - /** - * Applies formatting to the input using specified range - * - * @param tool - name of the tool to apply - * @param start - char start index of the range - * @param end - char end index of the range - * @param [data] - inline tool data if applicable - */ - public format(tool: InlineToolName, start: number, end: number, data?: InlineToolData): void; - /** - * General declaration - * - * @param args - arguments - */ - public format(...args: [InlineToolName, number | InlineToolData | undefined, number?, InlineToolData?]): void { - let tool: InlineToolName, start: number, end: number, data: InlineToolData | undefined; + public applyFormat(toolName: InlineToolName, data: InlineToolData): void { + const index = this.#caretAdapter.getIndex(); - if (args.length < 3) { - tool = args[0]; - data = args[1] as InlineToolData | undefined; - - const selection = window.getSelection(); - - if (!selection || selection.rangeCount === 0) { - return; - } + if (index === null) { + console.warn('InlineToolAdapter: caret index is outside of the input'); - const range = selection?.getRangeAt(0); - - const comparison = range.compareBoundaryPoints(Range.START_TO_END, range); - - if (comparison >= 0) { - start = getAbsoluteOffset(this.#input, range.startContainer, range.startOffset); - end = getAbsoluteOffset(this.#input, range.endContainer, range.endOffset); - } else { - start = getAbsoluteOffset(this.#input, range.endContainer, range.endOffset); - end = getAbsoluteOffset(this.#input, range.startContainer, range.startOffset); - } - } else { - tool = args[0]; - start = args[1] as number; - end = args[2] as number; - data = args[3] as InlineToolData | undefined; + return; } + const tool = this.#tools.get(toolName); - this.#model.format(this.#blockIndex, this.#dataKey, tool, start, end, data); - } - - /** - * Removes formatting from the input using current selection - * - * @param tool - name of the tool to remove formatting for - */ - public unformat(tool: InlineToolName): void; - /** - * Removes formatting from the input using specified range - * - * @param tool - name of the tool to remove formatting for - * @param start - char start index of the range - * @param end - char end index of the range - */ - public unformat(tool: InlineToolName, start: number, end: number): void; - /** - * General declaration - * @param args - arguments - */ - public unformat(...args: [InlineToolName, number?, number?]): void { - let tool: InlineToolName, start: number, end: number; + if (!tool) { + console.warn(`InlineToolAdapter: tool ${toolName} is not attached`); - if (args.length === 1) { - tool = args[0]; + return; + } - const selection = window.getSelection(); + const fragments = this.#model.getFragments(this.#blockIndex, this.#dataKey, ...index, toolName); - if (!selection || selection.rangeCount === 0) { - return; - } + const { action, range } = tool.getAction(index, fragments, data); - const range = selection?.getRangeAt(0); + switch (action) { + case FormattingAction.Format: + this.#model.format(this.#blockIndex, this.#dataKey, toolName, ...range, data); - const comparison = range.compareBoundaryPoints(Range.START_TO_END, range); + break; + case FormattingAction.Unformat: + this.#model.unformat(this.#blockIndex, this.#dataKey, toolName, ...range); - if (comparison >= 0) { - start = getAbsoluteOffset(this.#input, range.startContainer, range.startOffset); - end = getAbsoluteOffset(this.#input, range.endContainer, range.endOffset); - } else { - start = getAbsoluteOffset(this.#input, range.endContainer, range.endOffset); - end = getAbsoluteOffset(this.#input, range.startContainer, range.startOffset); - } - } else { - tool = args[0]; - start = args[1] as number; - end = args[2] as number; + break; } - - this.#model.unformat(this.#blockIndex, this.#dataKey, tool, start, end); } /** @@ -206,14 +158,14 @@ export class InlineToolAdapter { return; } - const tool = this.#tools.get(data.tool); + const tool = this.#tools.get(data.tool)!; const wrappedTool = tool.create(data.data); wrappedTool.dataset.tool = data.tool; const [start, end] = index[0]; - const range = getRange(this.#input, start, end); + const range = createRange(this.#input, start, end); /** * Apply formatting to the input @@ -233,7 +185,7 @@ export class InlineToolAdapter { unwrapByToolType(range, data.tool); } - normalize(this.#input); + normalizeNode(this.#input); this.#input.normalize(); }); } diff --git a/packages/dom-adapters/src/caret/CaretAdapter.ts b/packages/dom-adapters/src/caret/CaretAdapter.ts index ea1a2252..886a010a 100644 --- a/packages/dom-adapters/src/caret/CaretAdapter.ts +++ b/packages/dom-adapters/src/caret/CaretAdapter.ts @@ -1,7 +1,5 @@ import type { EditorJSModel, TextRange } from '@editorjs/model'; -import { useSelectionChange, type Subscriber, type InputWithCaret } from './utils/useSelectionChange.js'; -import { getAbsoluteRangeOffset } from './utils/absoluteOffset.js'; - +import { useSelectionChange, type Subscriber, type InputWithCaret, getAbsoluteRangeOffset } from '../utils/index.js'; /** * Caret adapter watches input caret change and passes it to the model * @@ -87,6 +85,13 @@ export class CaretAdapter extends EventTarget { this.#input = null; } + /** + * Returns caret index + */ + public getIndex(): TextRange | null { + return this.#index; + } + /** * Callback that will be called on document selection change * @@ -102,9 +107,21 @@ export class CaretAdapter extends EventTarget { * @param selection - changed document selection */ #updateIndex(selection: Selection | null): void { - const range = selection?.getRangeAt(0); + if (selection === null) { + this.#index = null; + + this.dispatchEvent(new CustomEvent('change', { + detail: { + index: this.#index, + }, + })); - if (!range || !this.#input) { + return; + } + + const range = selection.getRangeAt(0); + + if (!this.#input) { return; } diff --git a/packages/dom-adapters/src/index.ts b/packages/dom-adapters/src/index.ts index c944ac35..5b7342fd 100644 --- a/packages/dom-adapters/src/index.ts +++ b/packages/dom-adapters/src/index.ts @@ -1,7 +1,3 @@ export * from './caret/CaretAdapter.js'; export * from './InlineToolAdapter/index.js'; -export { normalize } from './utils/helpers.js'; -export { unwrapByToolType } from './utils/helpers.js'; -export { getRange } from './utils/helpers.js'; -export { getLength } from './utils/helpers.js'; -export { getAbsoluteOffset } from './utils/helpers.js'; +export * from './utils/index.js'; diff --git a/packages/dom-adapters/src/utils/createRange.ts b/packages/dom-adapters/src/utils/createRange.ts new file mode 100644 index 00000000..45c6bffa --- /dev/null +++ b/packages/dom-adapters/src/utils/createRange.ts @@ -0,0 +1,97 @@ +import { getNodeTextLength } from './getNodeTextLength.js'; +import { findChildByCharIndex } from './findChildByCharIndex.js'; + +/** + * Returns DOM Range by char range (relative to input) + * + * @param input - input element char indexes are related to + * @param start - start char index + * @param end - end char index + */ +export function createRange(input: HTMLElement, start: number, end: number): Range { + const length = getNodeTextLength(input); + + if (start < 0 || start > length || end < 0 || end > length) { + throw new Error('InlineToolAdapter: range is out of bounds'); + } + + let startContainer: Node = input; + let startOffset = start; + let endContainer: Node = input; + let endOffset = end; + + /** + * Find start node and offset for the range + */ + while (!(startContainer instanceof Text)) { + const [child, offset] = findChildByCharIndex(startContainer, startOffset); + + startContainer = child; + startOffset = startOffset - offset; + } + + /** + * Find end node and offset for the range + */ + while (!(endContainer instanceof Text)) { + const [child, offset] = findChildByCharIndex(endContainer, endOffset); + + endContainer = child; + endOffset = endOffset - offset; + } + + const range = new Range(); + + /** + * If startOffset equals to the length of the startContainer, we need to set start after the startContainer + * + * However, we also need to consider parent nodes siblings + */ + if (startOffset === getNodeTextLength(startContainer)) { + let nextSibling = startContainer.nextSibling; + let parent = startContainer.parentNode!; + + while (nextSibling === null && parent !== input && parent !== null) { + nextSibling = parent.nextSibling; + parent = parent.parentNode!; + } + + if (nextSibling !== null) { + range.setStartBefore(nextSibling); + } else { + range.setStartAfter(startContainer); + } + } else { + range.setStart(startContainer, startOffset); + } + + /** + * If endOffset equals to 0, we need to set end before the endContainer + */ + if (endOffset === 0) { + range.setEndBefore(endContainer); + /** + * If endOffset equals to the length of the endContainer, we need to set end after the endContainer + * + * We need to consider parent nodes siblings as well + */ + } else if (endOffset === getNodeTextLength(endContainer)) { + let nextSibling = endContainer.nextSibling; + let parent = endContainer.parentNode!; + + while (nextSibling === null && parent !== input) { + nextSibling = parent.nextSibling; + parent = parent.parentNode!; + } + + if (nextSibling !== null) { + range.setEndBefore(nextSibling); + } else { + range.setEndAfter(endContainer); + } + } else { + range.setEnd(endContainer, endOffset); + } + + return range; +} diff --git a/packages/dom-adapters/src/utils/findChildByCharIndex.ts b/packages/dom-adapters/src/utils/findChildByCharIndex.ts new file mode 100644 index 00000000..2fcd7f3e --- /dev/null +++ b/packages/dom-adapters/src/utils/findChildByCharIndex.ts @@ -0,0 +1,30 @@ +import { getNodeTextLength } from './getNodeTextLength.js'; + +/** + * Gets child node by char index (relative to the parent node) + * + * @param node - parent node + * @param index - char index + * + * @returns [child, offset] - child node and offset relative to the child node + */ +export function findChildByCharIndex(node: Node, index: number): [child: Node, offset: number] { + const children = Array.from(node.childNodes); + let totalLength = 0; + + for (const child of children) { + if (index <= getNodeTextLength(child) + totalLength) { + return [child, totalLength]; + } + + totalLength += getNodeTextLength(child); + } + + + /** + * This is unreachable code in normal operation, but we need it to have consistent types + */ + /* Stryker disable next-line StringLiteral */ + /* istanbul ignore next */ + throw new Error(`Child is not found by ${index} index`); +} diff --git a/packages/dom-adapters/src/caret/utils/absoluteOffset.ts b/packages/dom-adapters/src/utils/getAbsoluteRangeOffset.ts similarity index 84% rename from packages/dom-adapters/src/caret/utils/absoluteOffset.ts rename to packages/dom-adapters/src/utils/getAbsoluteRangeOffset.ts index 18d83a09..4e68f8ef 100644 --- a/packages/dom-adapters/src/caret/utils/absoluteOffset.ts +++ b/packages/dom-adapters/src/utils/getAbsoluteRangeOffset.ts @@ -1,3 +1,5 @@ +import { getNodeTextLength } from './getNodeTextLength.js'; + /** * Returns true if node is a line break * @@ -28,7 +30,10 @@ export function getAbsoluteRangeOffset(parent: Node, initialNode: Node, initialO throw new Error('Range is not contained by the parent node'); } - while (node !== parent) { + /** + * Iterate over all parents and compute offset + */ + do { const childNodes = Array.from(node.parentNode!.childNodes); const index = childNodes.indexOf(node as ChildNode); @@ -47,11 +52,11 @@ export function getAbsoluteRangeOffset(parent: Node, initialNode: Node, initialO /** * Compute offset with text length of left siblings */ - return acc + child.textContent!.length; - }, offset); + return acc + getNodeTextLength(child); + }, initialNode instanceof Text ? offset : 0); node = node.parentNode!; - } + } while (node !== parent); return offset; } diff --git a/packages/dom-adapters/src/utils/getNodeTextLength.ts b/packages/dom-adapters/src/utils/getNodeTextLength.ts new file mode 100644 index 00000000..e86167af --- /dev/null +++ b/packages/dom-adapters/src/utils/getNodeTextLength.ts @@ -0,0 +1,8 @@ +/** + * Returns length of the node text contents + * + * @param node + */ +export function getNodeTextLength(node: Node): number { + return node.textContent?.length ?? 0; +} diff --git a/packages/dom-adapters/src/utils/helpers.ts b/packages/dom-adapters/src/utils/helpers.ts deleted file mode 100644 index 2a650b79..00000000 --- a/packages/dom-adapters/src/utils/helpers.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * Gets offset relatative to the parent node - * - * @param parent - parent node - * @param initialNode - initial node initialOffset is related to - * @param initialOffset - initial offset - */ -export function getAbsoluteOffset(parent: Node, initialNode: Node, initialOffset: number): number { - let node = initialNode; - let offset = initialOffset; - - if (!parent.contains(node)) { - throw new Error('BlockToolAdapter: range is not contained in the parent node'); - } - - while (!Array.from(node.childNodes) - .includes(parent as ChildNode)) { - const childNodes = Array.from(node.parentNode!.childNodes); - const index = childNodes.indexOf(node as ChildNode); - - offset = childNodes.slice(0, index) - .reduce((acc, child) => acc + getLength(child), initialNode instanceof Text ? offset : 0); - - node = node.parentNode!; - } - - return offset; -} - -/** - * Returns length of the node text contents - * - * @param node - */ -export function getLength(node: Node): number { - return node.textContent?.length ?? 0; -} - -/** - * Gets child node by char index (relative to the parent node) - * - * @param node - parent node - * @param index - char index - * - * @returns [child, offset] - child node and offset relative to the child node - */ -function findChildByIndex(node: Node, index: number): [child: Node, offset: number] { - const children = Array.from(node.childNodes); - let totalLength = 0; - - for (const child of children) { - if (index <= getLength(child) + totalLength) { - return [child, totalLength]; - } - - totalLength += getLength(child); - } - - - /** - * This is unreachable code in normal operation, but we need it to have consistent types - */ - /* Stryker disable next-line StringLiteral */ - /* istanbul ignore next */ - throw new Error(`Child is not found by ${index} index`); -} - -/** - * Returns DOM Range by char range (relative to input) - * - * @param input - input element char indexes are related to - * @param start - start char index - * @param end - end char index - */ -export function getRange(input: HTMLElement, start: number, end: number): Range { - const length = getLength(input); - - if (start < 0 || start > length || end < 0 || end > length) { - throw new Error('InlineToolAdapter: range is out of bounds'); - } - - let startContainer: Node = input; - let startOffset = start; - let endContainer: Node = input; - let endOffset = end; - - /** - * Find start node and offset for the range - */ - while (!(startContainer instanceof Text)) { - const [child, offset] = findChildByIndex(startContainer, startOffset); - - startContainer = child; - startOffset = startOffset - offset; - } - - /** - * Find end node and offset for the range - */ - while (!(endContainer instanceof Text)) { - const [child, offset] = findChildByIndex(endContainer, endOffset); - - endContainer = child; - endOffset = endOffset - offset; - } - - const range = new Range(); - - /** - * If startOffset equals to the length of the startContainer, we need to set start after the startContainer - * - * However, we also need to consider parent nodes siblings - */ - if (startOffset === getLength(startContainer)) { - let nextSibling = startContainer.nextSibling; - let parent = startContainer.parentNode!; - - while (nextSibling === null && parent !== input) { - nextSibling = parent.nextSibling; - parent = parent.parentNode!; - } - - if (nextSibling !== null) { - range.setStartBefore(nextSibling); - } else { - range.setStartAfter(startContainer); - } - } else { - range.setStart(startContainer, startOffset); - } - - /** - * If endOffset equals to 0, we need to set end before the endContainer - */ - if (endOffset === 0) { - range.setEndBefore(endContainer); - /** - * If endOffset equals to the length of the endContainer, we need to set end after the endContainer - * - * We need to consider parent nodes siblings as well - */ - } else if (endOffset === getLength(endContainer)) { - let nextSibling = endContainer.nextSibling; - let parent = endContainer.parentNode!; - - while (nextSibling === null && parent !== input) { - nextSibling = parent.nextSibling; - parent = parent.parentNode!; - } - - if (nextSibling !== null) { - range.setEndBefore(nextSibling); - } else { - range.setEndAfter(endContainer); - } - } else { - range.setEnd(endContainer, endOffset); - } - - return range; -} - -/** - * Splits element into two by the offset - * - * Function modifies passed node to include only left side of the split and returns right side as a new node (cloned) - * - * Function takes into account any subtree of the node - * - * @param node - node to split - * @param offset - offset to split by - */ -function splitElement(node: HTMLElement, offset: number): null | HTMLElement { - if (offset === 0 || offset === getLength(node)) { - return null; - } - - const newNode = node.cloneNode() as HTMLElement; - - const range = getRange(node, offset, getLength(node)); - - /** - * We need to ensure we are including the whole subtree and the node itself - */ - range.setEndAfter(node.lastChild!); - - const extracted = range.extractContents(); - - newNode.append(extracted); - - return newNode; -} - -/** - * Unwraps elements of the specified tool type from the passed range - * - * In order to work correctly, function requires that there are no nested elements of the same tool type - * - * There are three cases we need to consider: - * 1. Range is contained in the element of the specified tool type - * 2. Range contains element or elements of the specified tool type - * 3. Range intersects with the element of the specified tool type - * - * The second and the third cases could appear at the same time - * - * @param range - range to unwrap - * @param targetTool - tool to unwrap - */ -export function unwrapByToolType(range: Range, targetTool: string): void { - const commonAncestorElement = range.commonAncestorContainer instanceof Text ? range.commonAncestorContainer.parentElement! : range.commonAncestorContainer as HTMLElement; - - /** - * To cover the first case, we need to check if there are any closest elements with targetTool relative to common ancestor - */ - const targetToolAbove = commonAncestorElement.closest(`[data-tool="${targetTool}"]`) as HTMLElement | null; - - /** - * To cover the second and the third cases, we need to check if there are any elements with targetTool contained in the common ancestor - */ - const targetToolsBelow = Array.from(commonAncestorElement.querySelectorAll(`[data-tool="${targetTool}"]`)) as HTMLElement[]; - - /** - * If no elements are found, there is nothing to unwrap - */ - if (targetToolAbove === null && targetToolsBelow.length === 0) { - return; - } - - /** - * If range is contained in the element of the specified tool type, we need: - * 1. to split the element into three - * 2. to unwrap the middle element (or the start one if middle one is not created by split) - */ - if (targetToolAbove) { - const startNode = targetToolAbove; - const endNode = splitElement(targetToolAbove, getAbsoluteOffset(startNode, range.endContainer, range.endOffset)); - const midNode = splitElement(targetToolAbove, getAbsoluteOffset(startNode, range.startContainer, range.startOffset)) ?? targetToolAbove; - - const newChildren = [ ...midNode.childNodes ]; - - if (endNode) { - newChildren.push(endNode); - } - - /** - * To unwrap the element we just replace with its children - */ - startNode.after(...newChildren); - - return; - } - - /** - * If common container contains elements of the specified tool type, for each element we need: - * 1. check if target range intersects with the element - * 2. if it does, we need to check if the element is fully contained in the range - * 3. if it is, we just unwrap the element by replacing it with its children - * 4. if element is partially intersected, we need to split it into two and unwrap the one inside the range - */ - if (targetToolsBelow.length > 0) { - targetToolsBelow.forEach(node => { - if (!range.intersectsNode(node)) { - return; - } - - const isStartInRange = range.isPointInRange(node, 0); - const isEndInRange = range.isPointInRange(node, node.childNodes.length); - - /** - * If element starts inside the range, but ends outside, we need to split it at the end of the range - */ - if (isStartInRange && !isEndInRange) { - const newNode = splitElement(node, getAbsoluteOffset(node, range.endContainer, range.endOffset)); - - if (newNode) { - node.after(newNode); - } - - /** - * If element ends inside the range, but starts outside, we need to split it at the start of the range - */ - } else if (!isStartInRange && isEndInRange) { - const newNode = splitElement(node, getAbsoluteOffset(node, range.startContainer, range.startOffset)); - - if (newNode) { - node.before(newNode); - } - } - - /** - * To unwrap the element we just replace with its children - */ - node.replaceWith(...node.childNodes); - }); - } -} - -/** - * Normalizes node's subtree to remove nested elements of the same type - * - * @param node - node to normalize - */ -export function normalize(node: Node): void { - const children = Array.from(node.childNodes); - - if (children.length === 0) { - return; - } - - children.forEach(child => { - if (child instanceof Text) { - return; - } - - const element = child as HTMLElement; - - if (element.dataset.tool !== undefined) { - const sameToolNodes = element.querySelectorAll(`[data-tool="${element.dataset.tool}"]`); - - sameToolNodes.forEach(n => n.replaceWith(...n.childNodes)); - } - }); - - children.forEach(normalize); -} diff --git a/packages/dom-adapters/src/utils/index.ts b/packages/dom-adapters/src/utils/index.ts new file mode 100644 index 00000000..34f164c7 --- /dev/null +++ b/packages/dom-adapters/src/utils/index.ts @@ -0,0 +1,9 @@ +export * from './createRange.js'; +export * from './findChildByCharIndex.js'; +export * from './getAbsoluteRangeOffset.js'; +export * from './getNodeTextLength.js'; +export * from './normalizeNode.js'; +export * from './singleton.js'; +export * from './splitElement.js'; +export * from './unwrapByToolType.js'; +export * from './useSelectionChange.js'; diff --git a/packages/dom-adapters/src/utils/normalizeNode.ts b/packages/dom-adapters/src/utils/normalizeNode.ts new file mode 100644 index 00000000..be521fcc --- /dev/null +++ b/packages/dom-adapters/src/utils/normalizeNode.ts @@ -0,0 +1,28 @@ +/** + * Normalizes node's subtree to remove nested elements of the same type + * + * @param node - node to normalize + */ +export function normalizeNode(node: Node): void { + const children = Array.from(node.childNodes); + + if (children.length === 0) { + return; + } + + children.forEach(child => { + if (child instanceof Text) { + return; + } + + const element = child as HTMLElement; + + if (element.dataset.tool !== undefined) { + const sameToolNodes = element.querySelectorAll(`[data-tool="${element.dataset.tool}"]`); + + sameToolNodes.forEach(n => n.replaceWith(...n.childNodes)); + } + }); + + children.forEach(normalizeNode); +} diff --git a/packages/dom-adapters/src/caret/utils/singleton.ts b/packages/dom-adapters/src/utils/singleton.ts similarity index 100% rename from packages/dom-adapters/src/caret/utils/singleton.ts rename to packages/dom-adapters/src/utils/singleton.ts diff --git a/packages/dom-adapters/src/utils/splitElement.ts b/packages/dom-adapters/src/utils/splitElement.ts new file mode 100644 index 00000000..766b7828 --- /dev/null +++ b/packages/dom-adapters/src/utils/splitElement.ts @@ -0,0 +1,33 @@ +import { getNodeTextLength } from './getNodeTextLength.js'; +import { createRange } from './createRange.js'; + +/** + * Splits element into two by the offset + * + * Function modifies passed node to include only left side of the split and returns right side as a new node (cloned) + * + * Function takes into account any subtree of the node + * + * @param node - node to split + * @param offset - offset to split by + */ +export function splitElement(node: HTMLElement, offset: number): null | HTMLElement { + if (offset === 0 || offset === getNodeTextLength(node)) { + return null; + } + + const newNode = node.cloneNode() as HTMLElement; + + const range = createRange(node, offset, getNodeTextLength(node)); + + /** + * We need to ensure we are including the whole subtree and the node itself + */ + range.setEndAfter(node.lastChild!); + + const extracted = range.extractContents(); + + newNode.append(extracted); + + return newNode; +} diff --git a/packages/dom-adapters/src/utils/unwrapByToolType.ts b/packages/dom-adapters/src/utils/unwrapByToolType.ts new file mode 100644 index 00000000..3760d70a --- /dev/null +++ b/packages/dom-adapters/src/utils/unwrapByToolType.ts @@ -0,0 +1,116 @@ +import { getAbsoluteRangeOffset } from './getAbsoluteRangeOffset.js'; +import { splitElement } from './splitElement.js'; + +/** + * Unwraps elements of the specified tool type from the passed range + * + * In order to work correctly, function requires that there are no nested elements of the same tool type + * + * There are three cases we need to consider: + * 1. Range is contained in the element of the specified tool type + * 2. Range contains element or elements of the specified tool type + * 3. Range intersects with the element of the specified tool type + * + * The second and the third cases could appear at the same time + * + * @param range - range to unwrap + * @param targetTool - tool to unwrap + */ +export function unwrapByToolType(range: Range, targetTool: string): void { + const commonAncestorElement = range.commonAncestorContainer instanceof Text ? range.commonAncestorContainer.parentElement! : range.commonAncestorContainer as HTMLElement; + + /** + * To cover the first case, we need to check if there are any closest elements with targetTool relative to common ancestor + */ + const targetToolAbove = commonAncestorElement.closest(`[data-tool="${targetTool}"]`) as HTMLElement | null; + + /** + * To cover the second and the third cases, we need to check if there are any elements with targetTool contained in the common ancestor + */ + const targetToolsBelow = Array.from(commonAncestorElement.querySelectorAll(`[data-tool="${targetTool}"]`)) as HTMLElement[]; + + /** + * If no elements are found, there is nothing to unwrap + */ + if (targetToolAbove === null && targetToolsBelow.length === 0) { + return; + } + + /** + * If range is contained in the element of the specified tool type, we need: + * 1. to split the element into three + * 2. to unwrap the middle element (or the start one if middle one is not created by split) + */ + if (targetToolAbove) { + const startNode = targetToolAbove; + const endNode = splitElement(targetToolAbove, getAbsoluteRangeOffset(startNode, range.endContainer, range.endOffset)); + const midNode = splitElement(targetToolAbove, getAbsoluteRangeOffset(startNode, range.startContainer, range.startOffset)) ?? targetToolAbove; + + const newChildren = [ ...midNode.childNodes ]; + + if (endNode) { + newChildren.push(endNode); + } + + /** + * To unwrap the element we just replace with its children + */ + startNode.after(...newChildren); + + return; + } + + /** + * If common container contains elements of the specified tool type, for each element we need: + * 1. check if target range intersects with the element + * 2. if it does, we need to check if the element is fully contained in the range + * 3. if it is, we just unwrap the element by replacing it with its children + * 4. if element is partially intersected, we need to split it into two and unwrap the one inside the range + */ + if (targetToolsBelow.length > 0) { + targetToolsBelow.forEach(node => { + if (!range.intersectsNode(node)) { + return; + } + + const isStartInRange = range.isPointInRange(node, 0); + const isEndInRange = range.isPointInRange(node, node.childNodes.length); + + /** + * If element starts inside the range, but ends outside, we need to split it at the end of the range + */ + if (isStartInRange && !isEndInRange) { + const newNode = splitElement(node, getAbsoluteRangeOffset(node, range.endContainer, range.endOffset)); + + if (newNode) { + node.after(newNode); + } + + /** + * To unwrap the element we just replace with its children + */ + node.replaceWith(...node.childNodes); + /** + * If element ends inside the range, but starts outside, we need to split it at the start of the range + */ + } else if (!isStartInRange && isEndInRange) { + const newNode = splitElement(node, getAbsoluteRangeOffset(node, range.startContainer, range.startOffset)); + + if (newNode) { + node.after(newNode); + + /** + * To unwrap the element we just replace with its children + */ + newNode.replaceWith(...newNode.childNodes); + } + } else { + /** + * If element is fully contained in the range, we just replace it with its children + */ + node.replaceWith(...node.childNodes); + } + }); + } +} + diff --git a/packages/dom-adapters/src/caret/utils/useSelectionChange.ts b/packages/dom-adapters/src/utils/useSelectionChange.ts similarity index 93% rename from packages/dom-adapters/src/caret/utils/useSelectionChange.ts rename to packages/dom-adapters/src/utils/useSelectionChange.ts index bb4e801f..71081c98 100644 --- a/packages/dom-adapters/src/caret/utils/useSelectionChange.ts +++ b/packages/dom-adapters/src/utils/useSelectionChange.ts @@ -72,8 +72,12 @@ export const useSelectionChange = createSingleton(() => { inputsWatched.forEach((input) => { const subscriber = subscribers.get(input); - if (subscriber && isSelectionRelatedToInput(selection, input)) { - subscriber.callback.call(subscriber.context, selection); + if (subscriber) { + if (isSelectionRelatedToInput(selection, input)) { + subscriber.callback.call(subscriber.context, selection); + } else { + subscriber.callback.call(subscriber.context, null); + } } }); } diff --git a/packages/model/src/utils/EventBus/index.ts b/packages/model/src/utils/EventBus/index.ts index 2b26395b..af640a96 100644 --- a/packages/model/src/utils/EventBus/index.ts +++ b/packages/model/src/utils/EventBus/index.ts @@ -4,5 +4,5 @@ export type * from './types/EventAction.js'; export type * from './types/EventMap.js'; export type * from './types/EventPayloadBase.js'; export type * from './types/EventTarget.js'; -export type * from './types/EventType.js'; +export * from './types/EventType.js'; export type * from './types/indexing.js'; diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 3c1c48fc..6968527e 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,10 +1,16 @@ @@ -24,7 +30,7 @@ const model = new EditorJSModel(data); -
{{ data }}
+
{{ document.serialized }}
import { onMounted, ref } from 'vue'; -import { CaretAdapter } from '@editorjs/dom-adapters'; -import { type EditorJSModel, TextRange } from '@editorjs/model'; +import { CaretAdapter, FormattingAction, InlineTool, InlineToolAdapter } from '@editorjs/dom-adapters'; +import { createDataKey, createInlineToolName, type EditorJSModel, InlineFragment, TextRange } from '@editorjs/model'; const input = ref(null); const index = ref(null); @@ -13,13 +13,61 @@ const props = defineProps<{ model: EditorJSModel; }>(); +const boldTool = { + name: createInlineToolName('bold'), + create() { + return document.createElement('b'); + }, + getAction(range: TextRange, fragments: InlineFragment[]) { + const action = fragments.length === 0 ? FormattingAction.Format : FormattingAction.Unformat; + + return { + action, + range, + }; + }, +} satisfies InlineTool; + +const italicTool = { + name: createInlineToolName('italic'), + create() { + return document.createElement('i'); + }, + getAction(range: TextRange, fragments: InlineFragment[]) { + const action = fragments.length === 0 ? FormattingAction.Format : FormattingAction.Unformat; + + return { + action, + range, + }; + }, +} satisfies InlineTool; + onMounted(() => { - const adapter = new CaretAdapter(props.model, 0); + console.log('mounted'); + props.model.addBlock({ + name: 'paragraph', + data: { + text: { + $t: 't', + value: 'Some words inside the input' + }, + }, + }); + + const caretAdapter = new CaretAdapter(props.model, 0); if (input.value !== null) { - adapter.attachInput(input.value, 'text'); + caretAdapter.attachInput(input.value, 'text'); + + const inlineToolAdapter = new InlineToolAdapter(props.model, 0, createDataKey('text'), input.value, caretAdapter); + + inlineToolAdapter.attachTool(boldTool); + inlineToolAdapter.attachTool(italicTool); + + window.inlineToolAdapter = inlineToolAdapter; - adapter.addEventListener('change', (event) => { + caretAdapter.addEventListener('change', (event) => { index.value = (event as CustomEvent<{ index: TextRange }>).detail.index; }); } @@ -33,7 +81,7 @@ onMounted(() => { contenteditable type="text" :class="$style.input" - v-html="`Some words inside the input`" + v-html="`Some words inside the input`" />