import {
closestTag,
currentRanges,
elementAt,
markedInSelection,
placeCaret,
selectionFullyMarked,
} from "./selection.js";
import { checkUrl } from "./urls.js";
import { bindToolbar } from "./toolbar.js";
const WRAPPERS = {
bold: "strong",
italic: "em",
underline: "u",
strike: "s",
code: "code",
};
const BLOCK_LEVEL = new Set([
"H1", "H2", "H3", "H4", "H5", "H6",
"PRE", "BLOCKQUOTE", "UL", "OL", "DIV", "P", "SECTION",
]);
export class FutureCssEditor {
root ;
content ;
toolbar ;
options ;
selectionAnchor =
null;
mutating = false;
pendingMark = null;
cleanups = [];
constructor(options ) {
this.options = options;
this.root =
typeof options.root === "string" ? document.querySelector(options.root) : options.root;
if (!this.root) throw new Error("future-css: editor root not found");
this.content = this.root.querySelector (".fc-editor__content") ;
if (!this.content) {
throw new Error("future-css: editor has no .fc-editor__content region");
}
this.toolbar = this.root.querySelector (".fc-toolbar");
if (this.toolbar) this.cleanups.push(bindToolbar(this.toolbar));
this.wireToolbar();
this.wireSelectionTracking();
}
destroy() {
for (const cleanup of this.cleanups.splice(0)) cleanup();
}
get value() {
return this.content.innerHTML;
}
set value(html ) {
this.mutating = true;
try {
this.content.innerHTML = html;
} finally {
this.mutating = false;
}
this.options.onChange?.(html);
this.syncToolbar();
}
isMarkActive(mark ) {
if (mark === "link") return selectionFullyMarked("A", this.content);
const tagName = WRAPPERS[mark];
if (!tagName) return false;
return selectionFullyMarked(tagName, this.content);
}
applyMark(mark ) {
if (mark === "link") {
this.promptForLink();
return;
}
const tagName = WRAPPERS[mark];
if (!tagName) return;
this.preserveSelection();
if (this.isMarkActive(mark)) this.removeMark(mark);
else this.wrapSelection(tagName);
this.afterMutation();
}
removeMark(mark ) {
const tagName = WRAPPERS[mark];
if (!tagName) return;
for (const el of markedInSelection(tagName, this.content)) {
if (BLOCK_LEVEL.has(el.tagName)) continue;
this.unwrap(el);
}
this.afterMutation();
}
toggleBlock(tag ) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
this.preserveSelection();
const already = this.blocksOfType(tag).length > 0;
for (const el of this.blocksOfType(tag)) {
this.unwrap(el);
}
if (!already) {
const range = this.ownRanges()[0];
if (range) {
const container = elementAt(range.commonAncestorContainer)?.closest(
"p,div,li,h1,h2,h3,h4,h5,h6,pre,blockquote",
);
if (container && container !== this.content) {
const block = document.createElement(tag);
block.append(...Array.from(container.childNodes));
container.replaceWith(block);
}
}
}
this.afterMutation();
}
wrapSelection(tagName ) {
this.mutating = true;
try {
const selection = window.getSelection();
if (!selection || selection.isCollapsed) {
this.insertEmptyElement(tagName);
return;
}
for (const range of this.ownRanges()) {
const wrapper = document.createElement(tagName);
try {
range.surroundContents(wrapper);
} catch {
wrapper.appendChild(range.extractContents());
range.insertNode(wrapper);
}
}
} finally {
this.mutating = false;
}
}
insertEmptyElement(tagName ) {
this.mutating = true;
try {
const el = document.createElement(tagName);
const selection = window.getSelection();
const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
if (!range || !this.content.contains(range.startContainer)) return;
range.insertNode(el);
placeCaret(el, 0);
this.selectionAnchor = null;
this.pendingMark = el;
} finally {
this.mutating = false;
}
}
promptForLink() {
const href = window.prompt("Link URL");
if (href === null) return;
const verdict = checkUrl(href);
if (!verdict.safe) {
console.warn(
`future-css: refused link with ${verdict.reason} "${verdict.scheme}:"`,
);
return;
}
const selection = window.getSelection();
if (!selection || selection.isCollapsed) return;
this.preserveSelection();
this.mutating = true;
try {
for (const range of this.ownRanges()) {
const anchor = document.createElement("a");
anchor.href = href;
anchor.rel = "noopener noreferrer";
try {
range.surroundContents(anchor);
} catch {
anchor.appendChild(range.extractContents());
range.insertNode(anchor);
}
}
} finally {
this.mutating = false;
}
this.afterMutation();
}
ownRanges() {
return currentRanges().filter((range) => this.content.contains(range.commonAncestorContainer));
}
blocksOfType(tag ) {
return markedInSelection(tag.toUpperCase(), this.content);
}
unwrap(el ) {
const parent = el.parentNode;
if (!parent) return;
while (el.firstChild) parent.insertBefore(el.firstChild, el);
parent.removeChild(el);
if (parent instanceof Element) parent.normalize();
}
afterMutation() {
this.restoreSelection();
this.options.onChange?.(this.value);
this.syncToolbar();
}
preserveSelection() {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (!this.content.contains(range.endContainer)) {
this.selectionAnchor = null;
return;
}
const endContainer = range.endContainer;
const endOffset = range.endOffset;
this.selectionAnchor = {
node: endContainer,
offset: endOffset,
offsetInSelection: characterOffsetIn(endContainer, endOffset, this.content),
};
}
restoreSelection() {
const anchor = this.selectionAnchor;
if (!anchor) return;
const stillHasText =
anchor.node.isConnected &&
(anchor.node.textContent?.length ?? 0) > 0 &&
anchor.offset <= (anchor.node.textContent?.length ?? 0);
if (stillHasText) {
placeCaret(anchor.node, anchor.offset);
return;
}
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const target = this.textNodeAtCharacterOffset(anchor.offsetInSelection);
if (!target) return;
const range = document.createRange();
range.setStart(target.node, target.offset);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
textNodeAtCharacterOffset(offset ) {
let remaining = Math.max(0, offset);
const walker = document.createTreeWalker(this.content, NodeFilter.SHOW_TEXT, null);
let node = walker.nextNode();
while (node) {
const length = node.textContent?.length ?? 0;
if (remaining <= length) return { node, offset: remaining };
remaining -= length;
node = walker.nextNode();
}
const last = this.content.lastChild;
return last ? { node: last, offset: last.textContent?.length ?? 0 } : null;
}
wireToolbar() {
if (!this.toolbar) return;
const onClick = (event ) => {
const button = (event.target ).closest (
"[data-fc-mark], [data-fc-block]",
);
if (!button) return;
event.preventDefault();
this.preserveSelection();
const mark = button.dataset.fcMark ;
const block = button.dataset.fcBlock ;
if (mark) this.applyMark(mark);
else if (block) this.toggleBlock(block);
this.content.focus();
};
const toolbar = this.toolbar;
toolbar.addEventListener("click", onClick);
this.cleanups.push(() => toolbar.removeEventListener("click", onClick));
}
wireSelectionTracking() {
const onSelectionChange = () => this.syncToolbar();
document.addEventListener("selectionchange", onSelectionChange);
this.cleanups.push(() => document.removeEventListener("selectionchange", onSelectionChange));
const onInput = () => {
if (this.mutating) return;
this.options.onChange?.(this.value);
this.syncToolbar();
};
this.content.addEventListener("input", onInput);
this.cleanups.push(() => this.content.removeEventListener("input", onInput));
const onBeforeInput = (event ) => {
const mark = this.pendingMark;
if (!mark) return;
if (event.isComposing || COMPOSITION_INPUT.has(event.inputType)) return;
this.pendingMark = null;
if (event.inputType !== "insertText" || !event.data) return;
const selection = window.getSelection();
if (!mark.isConnected || mark.firstChild || !selection?.isCollapsed) return;
if (selection.anchorNode !== mark) return;
event.preventDefault();
this.fillPendingMark(mark, event.data);
};
this.content.addEventListener("beforeinput", onBeforeInput);
this.cleanups.push(() => this.content.removeEventListener("beforeinput", onBeforeInput));
const onCompositionEnd = (event ) => {
const mark = this.pendingMark;
this.pendingMark = null;
const data = event.data;
if (!mark || !data || !mark.isConnected || mark.firstChild) return;
const selection = window.getSelection();
const node = selection?.anchorNode;
if (!selection?.isCollapsed || !(node instanceof Text)) return;
const end = selection.anchorOffset;
const start = end - data.length;
if (start < 0 || node.data.slice(start, end) !== data) return;
const before = node.nextSibling === mark && end === node.length;
const after = node.previousSibling === mark && start === 0;
if (!before && !after) return;
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, end);
selection.removeAllRanges();
selection.addRange(range);
this.fillPendingMark(mark, data);
};
this.content.addEventListener("compositionend", onCompositionEnd);
this.cleanups.push(() => this.content.removeEventListener("compositionend", onCompositionEnd));
}
fillPendingMark(mark , text ) {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
if (range.collapsed && range.startContainer === mark) {
range.setStartBefore(mark);
range.collapse(true);
}
const shell = mark.cloneNode(false) ;
shell.textContent = text;
const parent = mark.parentNode;
const next = mark.nextSibling;
mark.remove();
selection.removeAllRanges();
selection.addRange(range);
this.mutating = true;
let inserted = false;
try {
inserted = document.execCommand("insertHTML", false, shell.outerHTML);
} catch {
inserted = false;
} finally {
this.mutating = false;
}
if (!inserted) {
if (!range.collapsed) range.deleteContents();
parent?.insertBefore(mark, next);
mark.textContent = text;
}
const filled = inserted ? this.markBeforeCaret(shell.tagName) : mark;
if (inserted && filled && !mark.hasAttribute("style")) filled.removeAttribute("style");
if (filled?.lastChild) placeCaret(filled.lastChild, filled.lastChild.textContent?.length ?? 0);
this.options.onChange?.(this.value);
this.syncToolbar();
}
markBeforeCaret(tagName ) {
const selection = window.getSelection();
const node = selection?.anchorNode;
if (!node || !this.content.contains(node)) return null;
const inside = closestTag(node instanceof Element ? node : node.parentElement, tagName, this.content);
if (inside) return inside;
const offset = selection .anchorOffset;
const previous =
node instanceof Text ? (offset === 0 ? node.previousSibling : null) : node.childNodes[offset - 1];
return previous instanceof Element && previous.tagName === tagName ? previous : null;
}
syncToolbar() {
if (!this.toolbar) return;
const active = {} ;
for (const button of this.toolbar.querySelectorAll ("[data-fc-mark]")) {
const mark = button.dataset.fcMark ;
const on = this.isMarkActive(mark);
active[mark] = on;
button.setAttribute("aria-pressed", String(on));
}
this.options.onSelectionChange?.(active);
}
}
export { closestTag };
const COMPOSITION_INPUT = new Set(["insertCompositionText", "deleteCompositionText", "insertFromComposition"]);
function characterOffsetIn(node , offset , root ) {
if (!root.contains(node)) return 0;
try {
const range = document.createRange();
range.setStart(root, 0);
range.setEnd(node, offset);
return range.toString().length;
} catch {
return 0;
}
}