55c64e5b54
* Fix frame element being deleted, when a frame handle is deleted from while selected * Fix mixing up preceding/following
272 lines
7.7 KiB
TypeScript
272 lines
7.7 KiB
TypeScript
// Copyright: Ankitects Pty Ltd and contributors
|
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
import { moveChildOutOfElement } from "../domlib/move-nodes";
|
|
import { placeCaretAfter, placeCaretBefore } from "../domlib/place-caret";
|
|
import { getSelection, isSelectionCollapsed } from "../lib/cross-browser";
|
|
import {
|
|
elementIsBlock,
|
|
hasBlockAttribute,
|
|
nodeIsElement,
|
|
nodeIsText,
|
|
} from "../lib/dom";
|
|
import { on } from "../lib/events";
|
|
import type { FrameHandle } from "./frame-handle";
|
|
import {
|
|
checkHandles,
|
|
frameElementTagName,
|
|
FrameEnd,
|
|
FrameStart,
|
|
isFrameHandle,
|
|
} from "./frame-handle";
|
|
|
|
function restoreFrameHandles(mutations: MutationRecord[]): void {
|
|
let referenceNode: Node | null = null;
|
|
|
|
for (const mutation of mutations) {
|
|
const frameElement = mutation.target as FrameElement;
|
|
const framed = frameElement.querySelector(frameElement.frames!) as HTMLElement;
|
|
|
|
for (const node of mutation.addedNodes) {
|
|
if (node === framed || isFrameHandle(node)) {
|
|
continue;
|
|
}
|
|
|
|
// In some rare cases, nodes might be inserted into the frame itself.
|
|
// For example after using execCommand.
|
|
const placement = framed.compareDocumentPosition(node);
|
|
|
|
if (placement & Node.DOCUMENT_POSITION_PRECEDING) {
|
|
referenceNode = moveChildOutOfElement(
|
|
frameElement,
|
|
node,
|
|
"beforebegin",
|
|
);
|
|
} else if (placement & Node.DOCUMENT_POSITION_FOLLOWING) {
|
|
referenceNode = moveChildOutOfElement(frameElement, node, "afterend");
|
|
}
|
|
}
|
|
|
|
for (const node of mutation.removedNodes) {
|
|
if (!isFrameHandle(node)) {
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
/* avoid triggering when (un)mounting whole frame */
|
|
mutations.length === 1 &&
|
|
!node.partiallySelected
|
|
) {
|
|
// Similar to a "movein", this could be considered a
|
|
// "deletein" event and could get some special treatment, e.g.
|
|
// first highlight the entire frame-element.
|
|
frameElement.remove();
|
|
continue;
|
|
}
|
|
|
|
if (frameElement.isConnected && !frameElement.block) {
|
|
frameElement.refreshHandles();
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (referenceNode) {
|
|
placeCaretAfter(referenceNode);
|
|
}
|
|
}
|
|
|
|
const frameObserver = new MutationObserver(restoreFrameHandles);
|
|
const frameElements = new Set<FrameElement>();
|
|
|
|
export class FrameElement extends HTMLElement {
|
|
static tagName = frameElementTagName;
|
|
|
|
static get observedAttributes(): string[] {
|
|
return ["data-frames", "block"];
|
|
}
|
|
|
|
get framedElement(): HTMLElement | null {
|
|
return this.frames ? this.querySelector(this.frames) : null;
|
|
}
|
|
|
|
frames?: string;
|
|
block: boolean;
|
|
|
|
handleStart?: FrameStart;
|
|
handleEnd?: FrameEnd;
|
|
|
|
constructor() {
|
|
super();
|
|
this.block = hasBlockAttribute(this);
|
|
frameObserver.observe(this, { childList: true });
|
|
}
|
|
|
|
attributeChangedCallback(name: string, old: string, newValue: string): void {
|
|
if (newValue === old) {
|
|
return;
|
|
}
|
|
|
|
switch (name) {
|
|
case "data-frames":
|
|
this.frames = newValue;
|
|
|
|
if (!this.framedElement) {
|
|
this.remove();
|
|
return;
|
|
}
|
|
break;
|
|
|
|
case "block":
|
|
this.block = newValue !== "false";
|
|
|
|
if (!this.block) {
|
|
this.refreshHandles();
|
|
} else {
|
|
this.removeHandles();
|
|
}
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
getHandleFrom(node: Element | null, start: boolean): FrameHandle {
|
|
const handle = isFrameHandle(node)
|
|
? node
|
|
: (document.createElement(
|
|
start ? FrameStart.tagName : FrameEnd.tagName,
|
|
) as FrameHandle);
|
|
|
|
handle.dataset.frames = this.frames;
|
|
|
|
return handle;
|
|
}
|
|
|
|
refreshHandles(): void {
|
|
customElements.upgrade(this);
|
|
|
|
this.handleStart = this.getHandleFrom(this.firstElementChild, true);
|
|
this.handleEnd = this.getHandleFrom(this.lastElementChild, false);
|
|
|
|
if (!this.handleStart.isConnected) {
|
|
this.prepend(this.handleStart);
|
|
}
|
|
|
|
if (!this.handleEnd.isConnected) {
|
|
this.append(this.handleEnd);
|
|
}
|
|
}
|
|
|
|
removeHandles(): void {
|
|
this.handleStart?.remove();
|
|
this.handleStart = undefined;
|
|
|
|
this.handleEnd?.remove();
|
|
this.handleEnd = undefined;
|
|
}
|
|
|
|
removeStart?: () => void;
|
|
removeEnd?: () => void;
|
|
|
|
addEventListeners(): void {
|
|
this.removeStart = on(this, "moveinstart" as keyof HTMLElementEventMap, () =>
|
|
this.framedElement?.dispatchEvent(new Event("moveinstart")),
|
|
);
|
|
|
|
this.removeEnd = on(this, "moveinend" as keyof HTMLElementEventMap, () =>
|
|
this.framedElement?.dispatchEvent(new Event("moveinend")),
|
|
);
|
|
}
|
|
|
|
removeEventListeners(): void {
|
|
this.removeStart?.();
|
|
this.removeStart = undefined;
|
|
|
|
this.removeEnd?.();
|
|
this.removeEnd = undefined;
|
|
}
|
|
|
|
connectedCallback(): void {
|
|
frameElements.add(this);
|
|
this.addEventListeners();
|
|
}
|
|
|
|
disconnectedCallback(): void {
|
|
frameElements.delete(this);
|
|
this.removeEventListeners();
|
|
}
|
|
|
|
insertLineBreak(offset: number): void {
|
|
const lineBreak = document.createElement("br");
|
|
|
|
if (offset === 0) {
|
|
const previous = this.previousSibling;
|
|
const focus =
|
|
previous &&
|
|
(nodeIsText(previous) ||
|
|
(nodeIsElement(previous) && !elementIsBlock(previous)))
|
|
? previous
|
|
: this.insertAdjacentElement(
|
|
"beforebegin",
|
|
document.createElement("br"),
|
|
);
|
|
|
|
placeCaretAfter(focus ?? this);
|
|
} else if (offset === 1) {
|
|
const next = this.nextSibling;
|
|
|
|
const focus =
|
|
next &&
|
|
(nodeIsText(next) || (nodeIsElement(next) && !elementIsBlock(next)))
|
|
? next
|
|
: this.insertAdjacentElement("afterend", lineBreak);
|
|
|
|
placeCaretBefore(focus ?? this);
|
|
}
|
|
}
|
|
}
|
|
|
|
function checkIfInsertingLineBreakAdjacentToBlockFrame() {
|
|
for (const frame of frameElements) {
|
|
if (!frame.block) {
|
|
continue;
|
|
}
|
|
|
|
const selection = getSelection(frame)!;
|
|
|
|
if (
|
|
selection.anchorNode === frame.framedElement &&
|
|
isSelectionCollapsed(selection)
|
|
) {
|
|
frame.insertLineBreak(selection.anchorOffset);
|
|
}
|
|
}
|
|
}
|
|
|
|
function onSelectionChange() {
|
|
checkHandles();
|
|
checkIfInsertingLineBreakAdjacentToBlockFrame();
|
|
}
|
|
|
|
document.addEventListener("selectionchange", onSelectionChange);
|
|
|
|
/**
|
|
* This function wraps an element into a "frame", which looks like this:
|
|
* <anki-frame>
|
|
* <frame-handle-start> </frame-handle-start>
|
|
* <your-element ... />
|
|
* <frame-handle-end> </frame-handle-end>
|
|
* </anki-frame>
|
|
*/
|
|
export function frameElement(element: HTMLElement, block: boolean): FrameElement {
|
|
const frame = document.createElement(FrameElement.tagName) as FrameElement;
|
|
frame.setAttribute("block", String(block));
|
|
frame.dataset.frames = element.tagName.toLowerCase();
|
|
|
|
const range = new Range();
|
|
range.selectNode(element);
|
|
range.surroundContents(frame);
|
|
|
|
return frame;
|
|
}
|