401 lines
11 KiB
TypeScript
401 lines
11 KiB
TypeScript
import LinkedList from '../../collection/linked-list.js';
|
|
import ParchmentError from '../../error.js';
|
|
import Scope from '../../scope.js';
|
|
import type { Blot, BlotConstructor, Parent, Root } from './blot.js';
|
|
import ShadowBlot from './shadow.js';
|
|
|
|
function makeAttachedBlot(node: Node, scroll: Root): Blot {
|
|
const found = scroll.find(node);
|
|
if (found) return found;
|
|
try {
|
|
return scroll.create(node);
|
|
} catch (e) {
|
|
const blot = scroll.create(Scope.INLINE);
|
|
Array.from(node.childNodes).forEach((child: Node) => {
|
|
blot.domNode.appendChild(child);
|
|
});
|
|
if (node.parentNode) {
|
|
node.parentNode.replaceChild(blot.domNode, node);
|
|
}
|
|
blot.attach();
|
|
return blot;
|
|
}
|
|
}
|
|
|
|
class ParentBlot extends ShadowBlot implements Parent {
|
|
/**
|
|
* Whitelist array of Blots that can be direct children.
|
|
*/
|
|
public static allowedChildren?: BlotConstructor[];
|
|
|
|
/**
|
|
* Default child blot to be inserted if this blot becomes empty.
|
|
*/
|
|
public static defaultChild?: BlotConstructor;
|
|
public static uiClass = '';
|
|
|
|
public children!: LinkedList<Blot>;
|
|
public domNode!: HTMLElement;
|
|
public uiNode: HTMLElement | null = null;
|
|
|
|
constructor(scroll: Root, domNode: Node) {
|
|
super(scroll, domNode);
|
|
this.build();
|
|
}
|
|
|
|
public appendChild(other: Blot): void {
|
|
this.insertBefore(other);
|
|
}
|
|
|
|
public attach(): void {
|
|
super.attach();
|
|
this.children.forEach((child) => {
|
|
child.attach();
|
|
});
|
|
}
|
|
|
|
public attachUI(node: HTMLElement): void {
|
|
if (this.uiNode != null) {
|
|
this.uiNode.remove();
|
|
}
|
|
this.uiNode = node;
|
|
if (ParentBlot.uiClass) {
|
|
this.uiNode.classList.add(ParentBlot.uiClass);
|
|
}
|
|
this.uiNode.setAttribute('contenteditable', 'false');
|
|
this.domNode.insertBefore(this.uiNode, this.domNode.firstChild);
|
|
}
|
|
|
|
/**
|
|
* Called during construction, should fill its own children LinkedList.
|
|
*/
|
|
public build(): void {
|
|
this.children = new LinkedList<Blot>();
|
|
// Need to be reversed for if DOM nodes already in order
|
|
Array.from(this.domNode.childNodes)
|
|
.filter((node: Node) => node !== this.uiNode)
|
|
.reverse()
|
|
.forEach((node: Node) => {
|
|
try {
|
|
const child = makeAttachedBlot(node, this.scroll);
|
|
this.insertBefore(child, this.children.head || undefined);
|
|
} catch (err) {
|
|
if (err instanceof ParchmentError) {
|
|
return;
|
|
} else {
|
|
throw err;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
public deleteAt(index: number, length: number): void {
|
|
if (index === 0 && length === this.length()) {
|
|
return this.remove();
|
|
}
|
|
this.children.forEachAt(index, length, (child, offset, childLength) => {
|
|
child.deleteAt(offset, childLength);
|
|
});
|
|
}
|
|
|
|
public descendant<T extends Blot>(
|
|
criteria: new (...args: any[]) => T,
|
|
index: number,
|
|
): [T | null, number];
|
|
public descendant(
|
|
criteria: (blot: Blot) => boolean,
|
|
index: number,
|
|
): [Blot | null, number];
|
|
public descendant(criteria: any, index = 0): [Blot | null, number] {
|
|
const [child, offset] = this.children.find(index);
|
|
if (
|
|
(criteria.blotName == null && criteria(child)) ||
|
|
(criteria.blotName != null && child instanceof criteria)
|
|
) {
|
|
return [child as any, offset];
|
|
} else if (child instanceof ParentBlot) {
|
|
return child.descendant(criteria, offset);
|
|
} else {
|
|
return [null, -1];
|
|
}
|
|
}
|
|
|
|
public descendants<T extends Blot>(
|
|
criteria: new (...args: any[]) => T,
|
|
index?: number,
|
|
length?: number,
|
|
): T[];
|
|
public descendants(
|
|
criteria: (blot: Blot) => boolean,
|
|
index?: number,
|
|
length?: number,
|
|
): Blot[];
|
|
public descendants(
|
|
criteria: any,
|
|
index = 0,
|
|
length: number = Number.MAX_VALUE,
|
|
): Blot[] {
|
|
let descendants: Blot[] = [];
|
|
let lengthLeft = length;
|
|
this.children.forEachAt(
|
|
index,
|
|
length,
|
|
(child: Blot, childIndex: number, childLength: number) => {
|
|
if (
|
|
(criteria.blotName == null && criteria(child)) ||
|
|
(criteria.blotName != null && child instanceof criteria)
|
|
) {
|
|
descendants.push(child);
|
|
}
|
|
if (child instanceof ParentBlot) {
|
|
descendants = descendants.concat(
|
|
child.descendants(criteria, childIndex, lengthLeft),
|
|
);
|
|
}
|
|
lengthLeft -= childLength;
|
|
},
|
|
);
|
|
return descendants;
|
|
}
|
|
|
|
public detach(): void {
|
|
this.children.forEach((child) => {
|
|
child.detach();
|
|
});
|
|
super.detach();
|
|
}
|
|
|
|
public enforceAllowedChildren(): void {
|
|
let done = false;
|
|
this.children.forEach((child: Blot) => {
|
|
if (done) {
|
|
return;
|
|
}
|
|
const allowed = this.statics.allowedChildren.some(
|
|
(def: BlotConstructor) => child instanceof def,
|
|
);
|
|
if (allowed) {
|
|
return;
|
|
}
|
|
if (child.statics.scope === Scope.BLOCK_BLOT) {
|
|
if (child.next != null) {
|
|
this.splitAfter(child);
|
|
}
|
|
if (child.prev != null) {
|
|
this.splitAfter(child.prev);
|
|
}
|
|
child.parent.unwrap();
|
|
done = true;
|
|
} else if (child instanceof ParentBlot) {
|
|
child.unwrap();
|
|
} else {
|
|
child.remove();
|
|
}
|
|
});
|
|
}
|
|
|
|
public formatAt(
|
|
index: number,
|
|
length: number,
|
|
name: string,
|
|
value: any,
|
|
): void {
|
|
this.children.forEachAt(index, length, (child, offset, childLength) => {
|
|
child.formatAt(offset, childLength, name, value);
|
|
});
|
|
}
|
|
|
|
public insertAt(index: number, value: string, def?: any): void {
|
|
const [child, offset] = this.children.find(index);
|
|
if (child) {
|
|
child.insertAt(offset, value, def);
|
|
} else {
|
|
const blot =
|
|
def == null
|
|
? this.scroll.create('text', value)
|
|
: this.scroll.create(value, def);
|
|
this.appendChild(blot);
|
|
}
|
|
}
|
|
|
|
public insertBefore(childBlot: Blot, refBlot?: Blot | null): void {
|
|
if (childBlot.parent != null) {
|
|
childBlot.parent.children.remove(childBlot);
|
|
}
|
|
let refDomNode: Node | null = null;
|
|
this.children.insertBefore(childBlot, refBlot || null);
|
|
childBlot.parent = this;
|
|
if (refBlot != null) {
|
|
refDomNode = refBlot.domNode;
|
|
}
|
|
if (
|
|
this.domNode.parentNode !== childBlot.domNode ||
|
|
this.domNode.nextSibling !== refDomNode
|
|
) {
|
|
this.domNode.insertBefore(childBlot.domNode, refDomNode);
|
|
}
|
|
childBlot.attach();
|
|
}
|
|
|
|
public length(): number {
|
|
return this.children.reduce((memo, child) => {
|
|
return memo + child.length();
|
|
}, 0);
|
|
}
|
|
|
|
public moveChildren(targetParent: Parent, refNode?: Blot | null): void {
|
|
this.children.forEach((child) => {
|
|
targetParent.insertBefore(child, refNode);
|
|
});
|
|
}
|
|
|
|
public optimize(context?: { [key: string]: any }): void {
|
|
super.optimize(context);
|
|
this.enforceAllowedChildren();
|
|
if (this.uiNode != null && this.uiNode !== this.domNode.firstChild) {
|
|
this.domNode.insertBefore(this.uiNode, this.domNode.firstChild);
|
|
}
|
|
if (this.children.length === 0) {
|
|
if (this.statics.defaultChild != null) {
|
|
const child = this.scroll.create(this.statics.defaultChild.blotName);
|
|
this.appendChild(child);
|
|
// TODO double check if necessary
|
|
// child.optimize(context);
|
|
} else {
|
|
this.remove();
|
|
}
|
|
}
|
|
}
|
|
|
|
public path(index: number, inclusive = false): [Blot, number][] {
|
|
const [child, offset] = this.children.find(index, inclusive);
|
|
const position: [Blot, number][] = [[this, index]];
|
|
if (child instanceof ParentBlot) {
|
|
return position.concat(child.path(offset, inclusive));
|
|
} else if (child != null) {
|
|
position.push([child, offset]);
|
|
}
|
|
return position;
|
|
}
|
|
|
|
public removeChild(child: Blot): void {
|
|
this.children.remove(child);
|
|
}
|
|
|
|
public replaceWith(name: string | Blot, value?: any): Blot {
|
|
const replacement =
|
|
typeof name === 'string' ? this.scroll.create(name, value) : name;
|
|
if (replacement instanceof ParentBlot) {
|
|
this.moveChildren(replacement);
|
|
}
|
|
return super.replaceWith(replacement);
|
|
}
|
|
|
|
public split(index: number, force = false): Blot | null {
|
|
if (!force) {
|
|
if (index === 0) {
|
|
return this;
|
|
}
|
|
if (index === this.length()) {
|
|
return this.next;
|
|
}
|
|
}
|
|
const after = this.clone() as ParentBlot;
|
|
if (this.parent) {
|
|
this.parent.insertBefore(after, this.next || undefined);
|
|
}
|
|
this.children.forEachAt(index, this.length(), (child, offset, _length) => {
|
|
const split = child.split(offset, force);
|
|
if (split != null) {
|
|
after.appendChild(split);
|
|
}
|
|
});
|
|
return after;
|
|
}
|
|
|
|
public splitAfter(child: Blot): Parent {
|
|
const after = this.clone() as ParentBlot;
|
|
while (child.next != null) {
|
|
after.appendChild(child.next);
|
|
}
|
|
if (this.parent) {
|
|
this.parent.insertBefore(after, this.next || undefined);
|
|
}
|
|
return after;
|
|
}
|
|
|
|
public unwrap(): void {
|
|
if (this.parent) {
|
|
this.moveChildren(this.parent, this.next || undefined);
|
|
}
|
|
this.remove();
|
|
}
|
|
|
|
public update(
|
|
mutations: MutationRecord[],
|
|
_context: { [key: string]: any },
|
|
): void {
|
|
const addedNodes: Node[] = [];
|
|
const removedNodes: Node[] = [];
|
|
mutations.forEach((mutation) => {
|
|
if (mutation.target === this.domNode && mutation.type === 'childList') {
|
|
addedNodes.push(...mutation.addedNodes);
|
|
removedNodes.push(...mutation.removedNodes);
|
|
}
|
|
});
|
|
removedNodes.forEach((node: Node) => {
|
|
// Check node has actually been removed
|
|
// One exception is Chrome does not immediately remove IFRAMEs
|
|
// from DOM but MutationRecord is correct in its reported removal
|
|
if (
|
|
node.parentNode != null &&
|
|
// @ts-expect-error Fix me later
|
|
node.tagName !== 'IFRAME' &&
|
|
document.body.compareDocumentPosition(node) &
|
|
Node.DOCUMENT_POSITION_CONTAINED_BY
|
|
) {
|
|
return;
|
|
}
|
|
const blot = this.scroll.find(node);
|
|
if (blot == null) {
|
|
return;
|
|
}
|
|
if (
|
|
blot.domNode.parentNode == null ||
|
|
blot.domNode.parentNode === this.domNode
|
|
) {
|
|
blot.detach();
|
|
}
|
|
});
|
|
addedNodes
|
|
.filter((node) => {
|
|
return node.parentNode === this.domNode && node !== this.uiNode;
|
|
})
|
|
.sort((a, b) => {
|
|
if (a === b) {
|
|
return 0;
|
|
}
|
|
if (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
return 1;
|
|
}
|
|
return -1;
|
|
})
|
|
.forEach((node) => {
|
|
let refBlot: Blot | null = null;
|
|
if (node.nextSibling != null) {
|
|
refBlot = this.scroll.find(node.nextSibling);
|
|
}
|
|
const blot = makeAttachedBlot(node, this.scroll);
|
|
if (blot.next !== refBlot || blot.next == null) {
|
|
if (blot.parent != null) {
|
|
blot.parent.removeChild(this);
|
|
}
|
|
this.insertBefore(blot, refBlot || undefined);
|
|
}
|
|
});
|
|
this.enforceAllowedChildren();
|
|
}
|
|
}
|
|
|
|
export default ParentBlot;
|