import { Controller } from '@hotwired/stimulus'; import 'trix'; class Item { /** * Sort items by their index. * * @param a {Item} * @param b {Item} * @returns {number} */ static comparator(a, b) { return a.index - b.index; } /** * @param node {Element} li[data-content-index] */ constructor(node) { this.node = node; } /** * @returns {String} id of the node's item (from data attributes) */ get itemId() { return this.node.dataset[`contentItemId`]; } get #itemIdInput() { return this.node.querySelector(`input[name$="[id]"]`); } /** * @param itemId {String} id */ set itemId(id) { if (this.itemId === id) return; this.node.dataset[`contentItemId`] = `${id}`; this.#itemIdInput.value = `${id}`; } /** * @returns {number} logical nesting depth of node in container */ get depth() { return parseInt(this.node.dataset[`contentDepth`]) || 0; } get #depthInput() { return this.node.querySelector(`input[name$="[depth]"]`); } /** * @param depth {number} depth >= 0 */ set depth(depth) { if (this.depth === depth) return; this.node.dataset[`contentDepth`] = `${depth}`; this.#depthInput.value = `${depth}`; } /** * @returns {number} logical index of node in container (pre-order traversal) */ get index() { return parseInt(this.node.dataset[`contentIndex`]); } get #indexInput() { return this.node.querySelector(`input[name$="[index]"]`); } /** * @param index {number} index >= 0 */ set index(index) { if (this.index === index) return; this.node.dataset[`contentIndex`] = `${index}`; this.#indexInput.value = `${index}`; } /** * @returns {boolean} true if this item can have children */ get isLayout() { return this.node.hasAttribute("data-content-layout"); } /** * @returns {Item} nearest neighbour (index - 1) */ get previousItem() { let sibling = this.node.previousElementSibling; if (sibling) return new Item(sibling); } /** * @returns {Item} nearest neighbour (index + 1) */ get nextItem() { let sibling = this.node.nextElementSibling; if (sibling) return new Item(sibling); } /** * @returns {boolean} true if this item has any collapsed children */ hasCollapsedDescendants() { let childrenList = this.#childrenListElement; return !!childrenList && childrenList.children.length > 0; } /** * @returns {boolean} true if this item has any expanded children */ hasExpandedDescendants() { let sibling = this.nextItem; return !!sibling && sibling.depth > this.depth; } /** * Recursively traverse the node and its descendants. * * @callback {Item} */ traverse(callback) { // capture descendants before traversal in case of side-effects // specifically, setting depth affects calculation const expanded = this.#expandedDescendants; callback(this); this.#traverseCollapsed(callback); expanded.forEach((item) => item.#traverseCollapsed(callback)); } /** * Recursively traverse the node's collapsed descendants, if any. * * @callback {Item} */ #traverseCollapsed(callback) { if (!this.hasCollapsedDescendants()) return; this.#collapsedDescendants.forEach((item) => { callback(item); item.#traverseCollapsed(callback); }); } /** * Move the given item into this element's hidden children list. * Assumes the list already exists. * * @param item {Item} */ collapseChild(item) { this.#childrenListElement.appendChild(item.node); } /** * Collapses visible (logical) children into this element's hidden children * list, creating it if it doesn't already exist. */ collapse() { let listElement = this.#childrenListElement; if (!listElement) listElement = createChildrenList(this.node); this.#expandedDescendants.forEach((child) => listElement.appendChild(child.node), ); } /** * Moves any collapsed children back into the parent container. */ expand() { if (!this.hasCollapsedDescendants()) return; Array.from(this.#childrenListElement.children) .reverse() .forEach((node) => { this.node.insertAdjacentElement("afterend", node); }); } /** * Sets the state of a given rule on the target node. * * @param rule {String} * @param deny {boolean} */ toggleRule(rule, deny = false) { if (this.node.dataset.hasOwnProperty(rule) && !deny) { delete this.node.dataset[rule]; } if (!this.node.dataset.hasOwnProperty(rule) && deny) { this.node.dataset[rule] = ""; } if (rule === "denyDrag") { if (!this.node.hasAttribute("draggable") && !deny) { this.node.setAttribute("draggable", "true"); } if (this.node.hasAttribute("draggable") && deny) { this.node.removeAttribute("draggable"); } } } /** * Detects turbo item changes by comparing the dataset id with the input */ hasItemIdChanged() { return !(this.#itemIdInput.value === this.itemId); } /** * Updates inputs, in case they don't match the data values, e.g., when the * nested inputs have been hot-swapped by turbo with data from the server. * * Updates itemId from input as that is the canonical source. */ updateAfterChange() { this.itemId = this.#itemIdInput.value; this.#indexInput.value = this.index; this.#depthInput.value = this.depth; } /** * Finds the dom container for storing collapsed (hidden) children, if present. * * @returns {Element} ol[data-content-children] */ get #childrenListElement() { return this.node.querySelector(`:scope > [data-content-children]`); } /** * @returns {Item[]} all items that follow this element that have a greater depth. */ get #expandedDescendants() { const descendants = []; let sibling = this.nextItem; while (sibling && sibling.depth > this.depth) { descendants.push(sibling); sibling = sibling.nextItem; } return descendants; } /** * @returns {Item[]} all items directly contained inside this element's hidden children element. */ get #collapsedDescendants() { if (!this.hasCollapsedDescendants()) return []; return Array.from(this.#childrenListElement.children).map( (node) => new Item(node), ); } } /** * Finds or creates a dom container for storing collapsed (hidden) children. * * @param node {Element} li[data-content-index] * @returns {Element} ol[data-content-children] */ function createChildrenList(node) { const childrenList = document.createElement("ol"); childrenList.setAttribute("class", "hidden"); // if objectType is "rich-content" set richContentChildren as a data attribute childrenList.dataset[`contentChildren`] = ""; node.appendChild(childrenList); return childrenList; } /** * @param nodes {NodeList} * @returns {Item[]} */ function createItemList(nodes) { return Array.from(nodes).map((node) => new Item(node)); } class Container { /** * @param node {Element} content editor list */ constructor(node) { this.node = node; } /** * @return {Item[]} an ordered list of all items in the container */ get items() { return createItemList(this.node.querySelectorAll("[data-content-index]")); } /** * @return {String} a serialized description of the structure of the container */ get state() { const inputs = this.node.querySelectorAll("li input[type=hidden]"); return Array.from(inputs) .map((e) => e.value) .join("/"); } /** * Set the index of items based on their current position. */ reindex() { this.items.map((item, index) => (item.index = index)); } /** * Resets the order of items to their defined index. * Useful after an aborted drag. */ reset() { this.items.sort(Item.comparator).forEach((item) => { this.node.appendChild(item.node); }); } } class RulesEngine { static rules = [ "denyDeNest", "denyNest", "denyCollapse", "denyExpand", "denyRemove", "denyDrag", "denyEdit", ]; constructor(debug = false) { if (debug) { this.debug = (...args) => console.log(...args); } else { this.debug = () => {}; } } /** * Enforce structural rules to ensure that the given item is currently in a * valid state. * * @param {Item} item */ normalize(item) { // structural rules enforce a valid tree structure this.firstItemDepthZero(item); this.depthMustBeSet(item); this.itemCannotHaveInvalidDepth(item); this.parentMustBeLayout(item); this.parentCannotHaveExpandedAndCollapsedChildren(item); } /** * Apply rules to the given item to determine what operations are permitted. * * @param {Item} item */ update(item) { this.rules = {}; // behavioural rules define what the user is allowed to do this.parentsCannotDeNest(item); this.rootsCannotDeNest(item); this.onlyLastItemCanDeNest(item); this.nestingNeedsParent(item); this.leavesCannotCollapse(item); this.needHiddenItemsToExpand(item); this.parentsCannotBeDeleted(item); this.parentsCannotBeDragged(item); RulesEngine.rules.forEach((rule) => { item.toggleRule(rule, !!this.rules[rule]); }); } /** * First item can't have a parent, so its depth should always be 0 */ firstItemDepthZero(item) { if (item.index === 0 && item.depth !== 0) { this.debug(`enforce depth on item ${item.index}: ${item.depth} => 0`); item.depth = 0; } } /** * Every item should have a non-negative depth set. * * @param {Item} item */ depthMustBeSet(item) { if (isNaN(item.depth) || item.depth < 0) { this.debug(`unset depth on item ${item.index}: => 0`); item.depth = 0; } } /** * Depth must increase stepwise. * * @param {Item} item */ itemCannotHaveInvalidDepth(item) { const previous = item.previousItem; if (previous && previous.depth < item.depth - 1) { this.debug( `invalid depth on item ${item.index}: ${item.depth} => ${ previous.depth + 1 }`, ); item.depth = previous.depth + 1; } } /** * Parent item, if any, must be a layout. * * @param {Item} item */ parentMustBeLayout(item) { // if we're the first child, make sure our parent is a layout // if we're a sibling, we know the previous item is valid so we must be too const previous = item.previousItem; if (previous && previous.depth < item.depth && !previous.isLayout) { this.debug( `invalid parent for item ${item.index}: ${item.depth} => ${previous.depth}`, ); item.depth = previous.depth; } } /** * If a parent has expanded and collapsed children, expand. * * @param {Item} item */ parentCannotHaveExpandedAndCollapsedChildren(item) { if (item.hasCollapsedDescendants() && item.hasExpandedDescendants()) { this.debug(`expanding collapsed children of item ${item.index}`); item.expand(); } } /** * De-nesting an item would create a gap of 2 between itself and its children * * @param {Item} item */ parentsCannotDeNest(item) { if (item.hasExpandedDescendants()) this.#deny("denyDeNest"); } /** * Item depth can't go below 0. * * @param {Item} item */ rootsCannotDeNest(item) { if (item.depth === 0) this.#deny("denyDeNest"); } /** * De-nesting an item that has siblings would make it a container. * * @param {Item} item */ onlyLastItemCanDeNest(item) { const next = item.nextItem; if (next && next.depth === item.depth && !item.isLayout) this.#deny("denyDeNest"); } /** * If an item doesn't have children it can't be collapsed. * * @param {Item} item */ leavesCannotCollapse(item) { if (!item.hasExpandedDescendants()) this.#deny("denyCollapse"); } /** * If an item doesn't have any hidden descendants then it can't be expanded. * * @param {Item} item */ needHiddenItemsToExpand(item) { if (!item.hasCollapsedDescendants()) this.#deny("denyExpand"); } /** * An item can't be nested (indented) if it doesn't have a valid parent. * * @param {Item} item */ nestingNeedsParent(item) { const previous = item.previousItem; // no previous, so cannot nest if (!previous) this.#deny("denyNest"); // previous is too shallow, nesting would increase depth too much else if (previous.depth < item.depth) this.#deny("denyNest"); // new parent is not a layout else if (previous.depth === item.depth && !previous.isLayout) this.#deny("denyNest"); } /** * An item can't be deleted if it has visible children. * * @param {Item} item */ parentsCannotBeDeleted(item) { if (!item.itemId || item.hasExpandedDescendants()) this.#deny("denyRemove"); } /** * Items cannot be dragged if they have visible children. * * @param {Item} item */ parentsCannotBeDragged(item) { if (item.hasExpandedDescendants()) this.#deny("denyDrag"); } /** * Record a deny. * * @param rule {String} */ #deny(rule) { this.rules[rule] = true; } } class ContainerController extends Controller { static targets = ["container"]; connect() { this.state = this.container.state; this.reindex(); } get container() { return new Container(this.containerTarget); } reindex() { this.container.reindex(); this.#update(); } reset() { this.container.reset(); } drop(event) { this.container.reindex(); // set indexes before calculating previous const item = getEventItem(event); const previous = item.previousItem; let delta = 0; if (previous === undefined) { // if previous does not exist, set depth to 0 delta = -item.depth; } else if ( previous.isLayout && item.nextItem && item.nextItem.depth > previous.depth ) { // if previous is a layout and next is a child of previous, make item a child of previous delta = previous.depth - item.depth + 1; } else { // otherwise, make item a sibling of previous delta = previous.depth - item.depth; } item.traverse((child) => { child.depth += delta; }); this.#update(); event.preventDefault(); } remove(event) { const item = getEventItem(event); item.node.remove(); this.#update(); event.preventDefault(); } nest(event) { const item = getEventItem(event); item.traverse((child) => { child.depth += 1; }); this.#update(); event.preventDefault(); } deNest(event) { const item = getEventItem(event); item.traverse((child) => { child.depth -= 1; }); this.#update(); event.preventDefault(); } collapse(event) { const item = getEventItem(event); item.collapse(); this.#update(); event.preventDefault(); } expand(event) { const item = getEventItem(event); item.expand(); this.#update(); event.preventDefault(); } /** * Re-apply rules to items to enable/disable appropriate actions. */ #update() { // debounce requests to ensure that we only update once per tick this.updateRequested = true; setTimeout(() => { if (!this.updateRequested) return; this.updateRequested = false; const engine = new RulesEngine(); this.container.items.forEach((item) => engine.normalize(item)); this.container.items.forEach((item) => engine.update(item)); this.#notifyChange(); }, 0); } #notifyChange() { this.dispatch("change", { bubbles: true, prefix: "content", detail: { dirty: this.#isDirty() }, }); } #isDirty() { return this.container.state !== this.state; } } function getEventItem(event) { return new Item(event.target.closest("[data-content-item]")); } class ItemController extends Controller { get item() { return new Item(this.li); } get ol() { return this.element.closest("ol"); } get li() { return this.element.closest("li"); } connect() { if (this.element.dataset.hasOwnProperty("delete")) { this.remove(); } // if index is not already set, re-index will set it else if (!(this.item.index >= 0)) { this.reindex(); } // if item has been replaced via turbo, re-index will run the rules engine // update our depth and index with values from the li's data attributes else if (this.item.hasItemIdChanged()) { this.item.updateAfterChange(); this.reindex(); } } remove() { // capture ol this.ol; // remove self from dom this.li.remove(); // reindex ol this.reindex(); } reindex() { this.dispatch("reindex", { bubbles: true, prefix: "content" }); } } class ListController extends Controller { connect() { this.enterCount = 0; } /** * When the user starts a drag within the list, set the item's dataTransfer * properties to indicate that it's being dragged and update its style. * * We delay setting the dataset property until the next animation frame * so that the style updates can be computed before the drag begins. * * @param event {DragEvent} */ dragstart(event) { if (this.element !== event.target.parentElement) return; const target = event.target; event.dataTransfer.effectAllowed = "move"; // update element style after drag has begun requestAnimationFrame(() => (target.dataset.dragging = "")); } /** * When the user drags an item over another item in the last, swap the * dragging item with the item under the cursor. * * As a special case, if the item is dragged over placeholder space at the end * of the list, move the item to the bottom of the list instead. This allows * users to hit the list element more easily when adding new items to an empty * list. * * @param event {DragEvent} */ dragover(event) { const item = this.dragItem; if (!item) return; swap(dropTarget(event.target), item); event.preventDefault(); return true; } /** * When the user drags an item into the list, create a placeholder item to * represent the new item. Note that we can't access the drag data * until drop, so we assume that this is our template item for now. * * Users can cancel the drag by dragging the item out of the list or by * pressing escape. Both are handled by `cancelDrag`. * * @param event {DragEvent} */ dragenter(event) { event.preventDefault(); // Safari doesn't support relatedTarget, so we count enter/leave pairs this.enterCount++; if (copyAllowed(event) && !this.dragItem) { const item = document.createElement("li"); item.dataset.dragging = ""; item.dataset.newItem = ""; this.element.appendChild(item); } } /** * When the user drags the item out of the list, remove the placeholder. * This allows users to cancel the drag by dragging the item out of the list. * * @param event {DragEvent} */ dragleave(event) { // Safari doesn't support relatedTarget, so we count enter/leave pairs // https://bugs.webkit.org/show_bug.cgi?id=66547 this.enterCount--; if ( this.enterCount <= 0 && this.dragItem.dataset.hasOwnProperty("newItem") ) { this.dragItem.remove(); this.reset(); } } /** * When the user drops an item into the list, end the drag and reindex the list. * * If the item is a new item, we replace the placeholder with the template * item data from the dataTransfer API. * * @param event {DragEvent} */ drop(event) { let item = this.dragItem; if (!item) return; event.preventDefault(); delete item.dataset.dragging; swap(dropTarget(event.target), item); if (item.dataset.hasOwnProperty("newItem")) { const placeholder = item; const template = document.createElement("template"); template.innerHTML = event.dataTransfer.getData("text/html"); item = template.content.querySelector("li"); this.element.replaceChild(item, placeholder); requestAnimationFrame(() => item.querySelector("[role='button'][value='edit']").click(), ); } this.dispatch("drop", { target: item, bubbles: true, prefix: "content" }); } /** * End an in-progress drag. If the item is a new item, remove it, otherwise * reset the item's style and restore its original position in the list. */ dragend() { const item = this.dragItem; if (!item) ; else if (item.dataset.hasOwnProperty("newItem")) { item.remove(); } else { delete item.dataset.dragging; this.reset(); } } get isDragging() { return !!this.dragItem; } get dragItem() { return this.element.querySelector("[data-dragging]"); } reindex() { this.dispatch("reindex", { bubbles: true, prefix: "content" }); } reset() { this.dispatch("reset", { bubbles: true, prefix: "content" }); } } /** * Swaps two list items. If target is a list, the item is appended. * * @param target the target element to swap with * @param item the item the user is dragging */ function swap(target, item) { if (!target) return; if (target === item) return; if (target.nodeName === "LI") { const positionComparison = target.compareDocumentPosition(item); if (positionComparison & Node.DOCUMENT_POSITION_FOLLOWING) { target.insertAdjacentElement("beforebegin", item); } else if (positionComparison & Node.DOCUMENT_POSITION_PRECEDING) { target.insertAdjacentElement("afterend", item); } } if (target.nodeName === "OL") { target.appendChild(item); } } /** * Returns true if the event supports copy or copy move. * * Chrome and Firefox use copy, but Safari only supports copyMove. */ function copyAllowed(event) { return ( event.dataTransfer.effectAllowed === "copy" || event.dataTransfer.effectAllowed === "copyMove" ); } /** * Given an event target, return the closest drop target, if any. */ function dropTarget(e) { return ( e && (e.closest("[data-controller='content--editor--list'] > *") || e.closest("[data-controller='content--editor--list']")) ); } class NewItemController extends Controller { static targets = ["template"]; dragstart(event) { if (this.element !== event.target) return; event.dataTransfer.setData("text/html", this.templateTarget.innerHTML); event.dataTransfer.effectAllowed = "copy"; } } class StatusBarController extends Controller { connect() { // cache the version's state in the controller on connect this.versionState = this.element.dataset.state; } change(e) { if (e.detail && e.detail.hasOwnProperty("dirty")) { this.update(e.detail); } } update({ dirty }) { if (dirty) { this.element.dataset.state = "dirty"; } else { this.element.dataset.state = this.versionState; } } } const EDITOR = `
`; class TableController extends Controller { static targets = ["input", "update"]; constructor(config) { super(config); this.observer = new MutationObserver(this.change); } connect() { const template = document.createElement("TEMPLATE"); template.innerHTML = EDITOR; this.content = template.content.firstElementChild; this.content.innerHTML = this.inputTarget.value; this.content.className += ` ${this.inputTarget.className}`; this.inputTarget.insertAdjacentElement("beforebegin", this.content); this.inputTarget.hidden = true; this.observer.observe(this.content, { attributes: true, childList: true, characterData: true, subtree: true, }); } disconnect() { this.observer.disconnect(); this.content.remove(); delete this.content; } change = (mutations) => { this.inputTarget.value = this.table?.outerHTML; }; update = () => { this.updateTarget.click(); }; paste = (e) => { if (e.clipboardData.getData("text/html").indexOf(" { const { lang } = Trix.config; return `
`; }; /** * If the element is in the HTML when Trix loads, then Trix will have already injected the toolbar content * before our code gets a chance to run. Fix that now. * * Note: in Trix 2 this is likely to no longer be necessary. */ document.querySelectorAll("trix-toolbar").forEach((e) => { e.innerHTML = Trix.config.toolbar.getDefaultHTML(); }); const Definitions = [ { identifier: "content--editor--container", controllerConstructor: ContainerController, }, { identifier: "content--editor--item", controllerConstructor: ItemController, }, { identifier: "content--editor--list", controllerConstructor: ListController, }, { identifier: "content--editor--new-item", controllerConstructor: NewItemController, }, { identifier: "content--editor--status-bar", controllerConstructor: StatusBarController, }, { identifier: "content--editor--table", controllerConstructor: TableController, }, { identifier: "content--editor--trix", controllerConstructor: TrixController, }, ]; export { Definitions as default };