296 lines
13 KiB
TypeScript
296 lines
13 KiB
TypeScript
/*
|
|
A LogicalElement plays the same role as an Element instance from the point of view of the
|
|
API consumer. Inserting and removing logical elements updates the browser DOM just the same.
|
|
|
|
The difference is that, unlike regular DOM mutation APIs, the LogicalElement APIs don't use
|
|
the underlying DOM structure as the data storage for the element hierarchy. Instead, the
|
|
LogicalElement APIs take care of tracking hierarchical relationships separately. The point
|
|
of this is to permit a logical tree structure in which parent/child relationships don't
|
|
have to be materialized in terms of DOM element parent/child relationships. And the reason
|
|
why we want that is so that hierarchies of Blazor components can be tracked even when those
|
|
components' render output need not be a single literal DOM element.
|
|
|
|
Consumers of the API don't need to know about the implementation, but how it's done is:
|
|
- Each LogicalElement is materialized in the DOM as either:
|
|
- A Node instance, for actual Node instances inserted using 'insertLogicalChild' or
|
|
for Element instances promoted to LogicalElement via 'toLogicalElement'
|
|
- A Comment instance, for 'logical container' instances inserted using 'createAndInsertLogicalContainer'
|
|
- Then, on that instance (i.e., the Node or Comment), we store an array of 'logical children'
|
|
instances, e.g.,
|
|
[firstChild, secondChild, thirdChild, ...]
|
|
... plus we store a reference to the 'logical parent' (if any)
|
|
- The 'logical children' array means we can look up in O(1):
|
|
- The number of logical children (not currently implemented because not required, but trivial)
|
|
- The logical child at any given index
|
|
- Whenever a logical child is added or removed, we update the parent's array of logical children
|
|
*/
|
|
|
|
const logicalChildrenPropname = createSymbolOrFallback('_blazorLogicalChildren');
|
|
const logicalParentPropname = createSymbolOrFallback('_blazorLogicalParent');
|
|
const logicalEndSiblingPropname = createSymbolOrFallback('_blazorLogicalEnd');
|
|
|
|
export function toLogicalRootCommentElement(start: Comment, end: Comment): LogicalElement {
|
|
// Now that we support start/end comments as component delimiters we are going to be setting up
|
|
// adding the components rendered output as siblings of the start/end tags (between).
|
|
// For that to work, we need to appropriately configure the parent element to be a logical element
|
|
// with all their children being the child elements.
|
|
// For example, imagine you have
|
|
// <app>
|
|
// <div><p>Static content</p></div>
|
|
// <!-- start component
|
|
// <!-- end component
|
|
// <footer>Some other content</footer>
|
|
// <app>
|
|
// We want the parent element to be something like
|
|
// *app
|
|
// |- *div
|
|
// |- *component
|
|
// |- *footer
|
|
if (!start.parentNode) {
|
|
throw new Error(`Comment not connected to the DOM ${start.textContent}`);
|
|
}
|
|
|
|
const parent = start.parentNode;
|
|
const parentLogicalElement = toLogicalElement(parent, /* allow existing contents */ true);
|
|
const children = getLogicalChildrenArray(parentLogicalElement);
|
|
Array.from(parent.childNodes).forEach(n => children.push(n as unknown as LogicalElement));
|
|
|
|
start[logicalParentPropname] = parentLogicalElement;
|
|
// We might not have an end comment in the case of non-prerendered components.
|
|
if (end) {
|
|
start[logicalEndSiblingPropname] = end;
|
|
toLogicalElement(end);
|
|
}
|
|
return toLogicalElement(start);
|
|
}
|
|
|
|
export function toLogicalElement(element: Node, allowExistingContents?: boolean): LogicalElement {
|
|
// Normally it's good to assert that the element has started empty, because that's the usual
|
|
// situation and we probably have a bug if it's not. But for the element that contain prerendered
|
|
// root components, we want to let them keep their content until we replace it.
|
|
if (element.childNodes.length > 0 && !allowExistingContents) {
|
|
throw new Error('New logical elements must start empty, or allowExistingContents must be true');
|
|
}
|
|
|
|
if (!(logicalChildrenPropname in element)) { // If it's already a logical element, leave it alone
|
|
element[logicalChildrenPropname] = [];
|
|
}
|
|
|
|
return element as unknown as LogicalElement;
|
|
}
|
|
|
|
export function createAndInsertLogicalContainer(parent: LogicalElement, childIndex: number): LogicalElement {
|
|
const containerElement = document.createComment('!');
|
|
insertLogicalChild(containerElement, parent, childIndex);
|
|
return containerElement as any as LogicalElement;
|
|
}
|
|
|
|
export function insertLogicalChild(child: Node, parent: LogicalElement, childIndex: number) {
|
|
const childAsLogicalElement = child as any as LogicalElement;
|
|
if (child instanceof Comment) {
|
|
const existingGrandchildren = getLogicalChildrenArray(childAsLogicalElement);
|
|
if (existingGrandchildren && getLogicalChildrenArray(childAsLogicalElement).length > 0) {
|
|
// There's nothing to stop us implementing support for this scenario, and it's not difficult
|
|
// (after inserting 'child' itself, also iterate through its logical children and physically
|
|
// put them as following-siblings in the DOM). However there's no scenario that requires it
|
|
// presently, so if we did implement it there'd be no good way to have tests for it.
|
|
throw new Error('Not implemented: inserting non-empty logical container');
|
|
}
|
|
}
|
|
|
|
if (getLogicalParent(childAsLogicalElement)) {
|
|
// Likewise, we could easily support this scenario too (in this 'if' block, just splice
|
|
// out 'child' from the logical children array of its previous logical parent by using
|
|
// Array.prototype.indexOf to determine its previous sibling index).
|
|
// But again, since there's not currently any scenario that would use it, we would not
|
|
// have any test coverage for such an implementation.
|
|
throw new Error('Not implemented: moving existing logical children');
|
|
}
|
|
|
|
const newSiblings = getLogicalChildrenArray(parent);
|
|
if (childIndex < newSiblings.length) {
|
|
// Insert
|
|
const nextSibling = newSiblings[childIndex] as any as Node;
|
|
nextSibling.parentNode!.insertBefore(child, nextSibling);
|
|
newSiblings.splice(childIndex, 0, childAsLogicalElement);
|
|
} else {
|
|
// Append
|
|
appendDomNode(child, parent);
|
|
newSiblings.push(childAsLogicalElement);
|
|
}
|
|
|
|
childAsLogicalElement[logicalParentPropname] = parent;
|
|
if (!(logicalChildrenPropname in childAsLogicalElement)) {
|
|
childAsLogicalElement[logicalChildrenPropname] = [];
|
|
}
|
|
}
|
|
|
|
export function removeLogicalChild(parent: LogicalElement, childIndex: number) {
|
|
const childrenArray = getLogicalChildrenArray(parent);
|
|
const childToRemove = childrenArray.splice(childIndex, 1)[0];
|
|
|
|
// If it's a logical container, also remove its descendants
|
|
if (childToRemove instanceof Comment) {
|
|
const grandchildrenArray = getLogicalChildrenArray(childToRemove);
|
|
if (grandchildrenArray) {
|
|
while (grandchildrenArray.length > 0) {
|
|
removeLogicalChild(childToRemove, 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finally, remove the node itself
|
|
const domNodeToRemove = childToRemove as any as Node;
|
|
domNodeToRemove.parentNode!.removeChild(domNodeToRemove);
|
|
}
|
|
|
|
export function getLogicalParent(element: LogicalElement): LogicalElement | null {
|
|
return (element[logicalParentPropname] as LogicalElement) || null;
|
|
}
|
|
|
|
export function getLogicalSiblingEnd(element: LogicalElement): LogicalElement | null {
|
|
return (element[logicalEndSiblingPropname] as LogicalElement) || null;
|
|
}
|
|
|
|
export function getLogicalChild(parent: LogicalElement, childIndex: number): LogicalElement {
|
|
return getLogicalChildrenArray(parent)[childIndex];
|
|
}
|
|
|
|
export function isSvgElement(element: LogicalElement) {
|
|
return getClosestDomElement(element).namespaceURI === 'http://www.w3.org/2000/svg';
|
|
}
|
|
|
|
export function getLogicalChildrenArray(element: LogicalElement) {
|
|
return element[logicalChildrenPropname] as LogicalElement[];
|
|
}
|
|
|
|
export function permuteLogicalChildren(parent: LogicalElement, permutationList: PermutationListEntry[]) {
|
|
// The permutationList must represent a valid permutation, i.e., the list of 'from' indices
|
|
// is distinct, and the list of 'to' indices is a permutation of it. The algorithm here
|
|
// relies on that assumption.
|
|
|
|
// Each of the phases here has to happen separately, because each one is designed not to
|
|
// interfere with the indices or DOM entries used by subsequent phases.
|
|
|
|
// Phase 1: track which nodes we will move
|
|
const siblings = getLogicalChildrenArray(parent);
|
|
permutationList.forEach((listEntry: PermutationListEntryWithTrackingData) => {
|
|
listEntry.moveRangeStart = siblings[listEntry.fromSiblingIndex];
|
|
listEntry.moveRangeEnd = findLastDomNodeInRange(listEntry.moveRangeStart);
|
|
});
|
|
|
|
// Phase 2: insert markers
|
|
permutationList.forEach((listEntry: PermutationListEntryWithTrackingData) => {
|
|
const marker = listEntry.moveToBeforeMarker = document.createComment('marker');
|
|
const insertBeforeNode = siblings[listEntry.toSiblingIndex + 1] as any as Node;
|
|
if (insertBeforeNode) {
|
|
insertBeforeNode.parentNode!.insertBefore(marker, insertBeforeNode);
|
|
} else {
|
|
appendDomNode(marker, parent);
|
|
}
|
|
});
|
|
|
|
// Phase 3: move descendants & remove markers
|
|
permutationList.forEach((listEntry: PermutationListEntryWithTrackingData) => {
|
|
const insertBefore = listEntry.moveToBeforeMarker!;
|
|
const parentDomNode = insertBefore.parentNode!;
|
|
const elementToMove = listEntry.moveRangeStart!;
|
|
const moveEndNode = listEntry.moveRangeEnd!;
|
|
let nextToMove = elementToMove as any as Node | null;
|
|
while (nextToMove) {
|
|
const nextNext = nextToMove.nextSibling;
|
|
parentDomNode.insertBefore(nextToMove, insertBefore);
|
|
|
|
if (nextToMove === moveEndNode) {
|
|
break;
|
|
} else {
|
|
nextToMove = nextNext;
|
|
}
|
|
}
|
|
|
|
parentDomNode.removeChild(insertBefore);
|
|
});
|
|
|
|
// Phase 4: update siblings index
|
|
permutationList.forEach((listEntry: PermutationListEntryWithTrackingData) => {
|
|
siblings[listEntry.toSiblingIndex] = listEntry.moveRangeStart!;
|
|
});
|
|
}
|
|
|
|
export function getClosestDomElement(logicalElement: LogicalElement) {
|
|
if (logicalElement instanceof Element) {
|
|
return logicalElement;
|
|
} else if (logicalElement instanceof Comment) {
|
|
return logicalElement.parentNode! as Element;
|
|
} else {
|
|
throw new Error('Not a valid logical element');
|
|
}
|
|
}
|
|
|
|
export interface PermutationListEntry {
|
|
fromSiblingIndex: number,
|
|
toSiblingIndex: number,
|
|
}
|
|
|
|
interface PermutationListEntryWithTrackingData extends PermutationListEntry {
|
|
// These extra properties are used internally when processing the permutation list
|
|
moveRangeStart?: LogicalElement,
|
|
moveRangeEnd?: Node,
|
|
moveToBeforeMarker?: Node,
|
|
}
|
|
|
|
function getLogicalNextSibling(element: LogicalElement): LogicalElement | null {
|
|
const siblings = getLogicalChildrenArray(getLogicalParent(element)!);
|
|
const siblingIndex = Array.prototype.indexOf.call(siblings, element);
|
|
return siblings[siblingIndex + 1] || null;
|
|
}
|
|
|
|
function appendDomNode(child: Node, parent: LogicalElement) {
|
|
// This function only puts 'child' into the DOM in the right place relative to 'parent'
|
|
// It does not update the logical children array of anything
|
|
if (parent instanceof Element) {
|
|
parent.appendChild(child);
|
|
} else if (parent instanceof Comment) {
|
|
const parentLogicalNextSibling = getLogicalNextSibling(parent) as any as Node;
|
|
if (parentLogicalNextSibling) {
|
|
// Since the parent has a logical next-sibling, its appended child goes right before that
|
|
parentLogicalNextSibling.parentNode!.insertBefore(child, parentLogicalNextSibling);
|
|
} else {
|
|
// Since the parent has no logical next-sibling, keep recursing upwards until we find
|
|
// a logical ancestor that does have a next-sibling or is a physical element.
|
|
appendDomNode(child, getLogicalParent(parent)!);
|
|
}
|
|
} else {
|
|
// Should never happen
|
|
throw new Error(`Cannot append node because the parent is not a valid logical element. Parent: ${parent}`);
|
|
}
|
|
}
|
|
|
|
// Returns the final node (in depth-first evaluation order) that is a descendant of the logical element.
|
|
// As such, the entire subtree is between 'element' and 'findLastDomNodeInRange(element)' inclusive.
|
|
function findLastDomNodeInRange(element: LogicalElement) {
|
|
if (element instanceof Element) {
|
|
return element;
|
|
}
|
|
|
|
const nextSibling = getLogicalNextSibling(element);
|
|
if (nextSibling) {
|
|
// Simple case: not the last logical sibling, so take the node before the next sibling
|
|
return (nextSibling as any as Node).previousSibling;
|
|
} else {
|
|
// Harder case: there's no logical next-sibling, so recurse upwards until we find
|
|
// a logical ancestor that does have one, or a physical element
|
|
const logicalParent = getLogicalParent(element)!;
|
|
return logicalParent instanceof Element
|
|
? logicalParent.lastChild
|
|
: findLastDomNodeInRange(logicalParent);
|
|
}
|
|
}
|
|
|
|
function createSymbolOrFallback(fallback: string): symbol | string {
|
|
return typeof Symbol === 'function' ? Symbol() : fallback;
|
|
}
|
|
|
|
// Nominal type to represent a logical element without needing to allocate any object for instances
|
|
export interface LogicalElement { LogicalElement__DO_NOT_IMPLEMENT: any }
|