UNPKG

@lrnwebcomponents/hax-body

Version:

A full on Headless authoring experience as a single tag. The ultimate authoring solution across platforms to win the future.

1,416 lines (1,391 loc) 162 kB
import { html, css, render, unsafeCSS } from "lit"; import { SimpleColors } from "@lrnwebcomponents/simple-colors/simple-colors.js"; import { UndoManagerBehaviors } from "@lrnwebcomponents/undo-manager/undo-manager.js"; import { HAXStore } from "./lib/hax-store.js"; import { autorun, toJS } from "mobx"; import "./lib/hax-text-editor-toolbar.js"; import { encapScript, wipeSlot, generateResourceID, nodeToHaxElement, haxElementToNode, camelToDash, wrap, unwrap, ReplaceWithPolyfill, normalizeEventPath, } from "@lrnwebcomponents/utils/utils.js"; import { HaxUiBaseStyles } from "./lib/hax-ui-styles.js"; import { I18NMixin } from "@lrnwebcomponents/i18n-manager/lib/I18NMixin.js"; import "@lrnwebcomponents/absolute-position-behavior/absolute-position-behavior.js"; import "@lrnwebcomponents/simple-icon/lib/simple-icons.js"; import { SimpleIconsetStore } from "@lrnwebcomponents/simple-icon/lib/simple-iconset.js"; import "./lib/hax-context-behaviors.js"; import "./lib/hax-plate-context.js"; // our default way of handing grids import "@lrnwebcomponents/grid-plate/grid-plate.js"; // our default image in core import "@lrnwebcomponents/media-image/media-image.js"; import { SuperDaemonInstance } from "@lrnwebcomponents/super-daemon/super-daemon.js"; // BURN A THOUSAND FIREY DEATHS SAFARI if (!Element.prototype.replaceWith) { Element.prototype.replaceWith = ReplaceWithPolyfill; } if (!CharacterData.prototype.replaceWith) { CharacterData.prototype.replaceWith = ReplaceWithPolyfill; } if (!DocumentType.prototype.replaceWith) { DocumentType.prototype.replaceWith = ReplaceWithPolyfill; } // polyfill for replaceAll, I hate you Safari / really old stuff if (!String.prototype.replaceAll) { String.prototype.replaceAll = function (find, replace) { return this.split(find).join(replace); }; } // END OF 1000 DEATHS // variables required as part of the gravity drag and scroll var gravityScrollTimer = null; const maxStep = 25; const edgeSize = 200; /** * `hax-body` * Manager of the body area that can be modified * ### Styling `<hax-bodys>` provides following custom properties for styling: Custom property | Description | Default ----------------|-------------|-------- --hax-ui-headings | | #d4ff77; --hax-color-text | default text color | #000 --hax-contextual-action-text-color | | --simple-colors-default-theme-grey-1 --hax-contextual-action-color | | --simple-colors-default-theme-cyan-7 --hax-contextual-action-hover-color | | --hax-body-target-background-color: --simple-colors-default-theme-cyan-2 --hax-body-possible-target-background-color: --simple-colors-default-theme-grey-2 ####Outlines Custom property | Description | Default ----------------|-------------|-------- --hax-body-editable-outline | | 1px solid --simple-colors-default-theme-deep-orange --hax-body-active-outline-hover: 1px solid --hax-contextual-action-color --hax-body-active-outline: 3px solid --hax-contextual-action-color * * @microcopy - the mental model for this element * - body is effectively a body of content that can be manipulated in the browser. This is for other HAX elements ultimately to interface with and reside in. It is the controller of input and output for all of HAX as it exists in a document. body is not the <body> tag but we need a similar mental model container for all our other elements. * - text-context - the context menu that shows up when an item is active so it can have text based operations performed to it. * - plate/grid plate - a plate or grid plate is a container that we can operate on in HAX. it can also have layout / "global" type of body operations performed on it such as delete, duplicate and higher level format styling. * * @demo demo/index.html * @LitElement * @element hax-body */ class HaxBody extends I18NMixin(UndoManagerBehaviors(SimpleColors)) { static get tag() { return "hax-body"; } /** * LitElement constructable styles enhancement */ static get styles() { return [ super.styles, css` :host([edit-mode]), :host([edit-mode]) * ::slotted(*) { caret-color: var(--hax-ui-caret-color, auto); } hax-text-editor-toolbar { background-color: var(--hax-ui-background-color); --simple-toolbar-button-bg: var(--hax-ui-background-color); --simple-picker-options-background-color: var( --hax-ui-background-color ); --simple-picker-option-active-background-color: var( --hax-ui-color-accent ); --simple-picker-option-active-color: var(--hax-tray-text-color); --simple-picker-color-active: var(--hax-tray-text-color); --simple-picker-color: var(--hax-tray-text-color); } :host([edit-mode][tray-status="full-panel"]) { opacity: 0.2; pointer-events: none; } :host { display: block; position: relative; min-height: 32px; min-width: 32px; outline: none; --hax-contextual-action-text-color: var(--hax-ui-background-color); --hax-contextual-action-hover-color: var(--hax-ui-color-accent); --hax-contextual-action-color: var(--hax-ui-color-accent-secondary); --hax-body-editable-outline: 1px solid var(--hax-ui-disabled-color, #ddd); --hax-body-active-outline-hover: 2px solid var(--hax-ui-color-faded, #444); --hax-body-active-outline: 2px solid var(--hax-ui-color-focus, #000); --hax-body-active-drag-outline: 1px solid var(--hax-ui-color-accent, #009dc7); --hax-body-target-background-color: var( --hax-ui-background-color-accent ); --hax-body-possible-target-background-color: inherit; } #topcontext { z-index: calc(var(--hax-ui-focus-z-index) - 2); min-width: 280px; } #topcontextmenu { width: auto; max-width: 100%; position: absolute; bottom: 0; margin-bottom: 10px; margin-left: -10px; } .hax-context-menu { visibility: hidden; opacity: 0; z-index: -1; pointer-events: none; transition: 0.3s all ease-in-out; } .hax-context-menu:hover { z-index: calc(var(--hax-ui-focus-z-index) + 1); } .hax-context-visible, .hax-context-menu-active { display: flex; pointer-events: auto; visibility: visible; z-index: 1; opacity: 1; } /* this helps ensure editable-table doesn't try internal text editor; all others should */ :host([edit-mode]) #bodycontainer ::slotted(*[contenteditable][data-hax-ray]:not(editable-table)) { -webkit-appearance: textfield; cursor: text; -moz-user-select: text; -khtml-user-select: text; -webkit-user-select: text; -o-user-select: text; } :host([edit-mode]) #bodycontainer ::slotted(*[data-hax-ray]:hover) { cursor: pointer; outline: 2px solid var(--hax-ui-color-hover, #0001); transition: 0.2s outline-width ease-in-out; outline-offset: 8px; } :host([edit-mode]) #bodycontainer ::slotted( [contenteditable][data-hax-ray]:empty:not( [data-instructional-action] ) )::before { content: attr(data-hax-ray); opacity: 0.2; transition: 0.6s all ease-in-out; } :host([edit-mode]) #bodycontainer ::slotted( [contenteditable][data-hax-ray][data-hax-active]:empty:not( [data-instructional-action] ) )::before { content: "Type '/' for Merlin"; opacity: 0.4; } :host([edit-mode]) #bodycontainer ::slotted( [contenteditable][data-hax-ray]:hover:empty:not( [data-instructional-action] ) )::before { opacity: 0.4; cursor: text; } :host([edit-mode]) #bodycontainer ::slotted( [contenteditable][data-hax-ray]:empty:focus:not( [data-instructional-action] ) )::before { content: ""; } :host([edit-mode]) #bodycontainer ::slotted([data-hax-active]), :host([edit-mode]) #bodycontainer ::slotted(*.hax-hovered) { outline-offset: 8px; } :host([edit-mode]) #bodycontainer ::slotted(img[contenteditable]) { max-width: 100%; } :host([edit-mode]) #bodycontainer ::slotted(*[contenteditable]) { caret-color: var(--hax-ui-caret-color, auto); } :host([edit-mode]) #bodycontainer ::slotted(*.blinkfocus) { outline: 2px solid var(--hax-contextual-action-hover-color); } :host([edit-mode]) #bodycontainer ::slotted(*[data-hax-lock]) { opacity: 0.5; transition: 0.3s all ease-in-out; } :host([edit-mode]) #bodycontainer ::slotted(*[data-hax-lock]:hover) { opacity: 0.9; } :host([edit-mode]) #bodycontainer ::slotted(*[data-hax-lock])::after { width: 28px; height: 28px; content: ""; display: flex; float: right; z-index: 1; position: relative; background-position: center; background-repeat: no-repeat; background-color: #fffafa; } :host([edit-mode]) #bodycontainer ::slotted(*:not([data-hax-layout]):hover) { outline: var(--hax-body-active-outline-hover); caret-color: var(--hax-ui-caret-color, auto); } :host(.hax-add-content-visible[edit-mode]) #bodycontainer ::slotted([data-hax-active]) { margin-bottom: 30px; } :host([edit-mode]) #bodycontainer ::slotted([data-hax-active]:hover) { cursor: text !important; caret-color: var(--hax-ui-caret-color, auto); outline: var(--hax-body-active-outline-hover); } :host([edit-mode]) #bodycontainer ::slotted(*:not([data-hax-layout]) [data-hax-active]:hover) { cursor: text !important; caret-color: var(--hax-ui-caret-color, auto); outline: var(--hax-body-active-outline-hover); } :host([edit-mode]) #bodycontainer ::slotted([data-hax-active][contenteditable]) { outline: var(--hax-body-active-outline) !important; caret-color: var(--hax-ui-caret-color, auto); } :host([edit-mode]) #bodycontainer ::slotted(hr[contenteditable]) { height: 2px; background-color: #eeeeee; padding-top: 4px; padding-bottom: 4px; } /** Fix to support safari as it defaults to none */ :host([edit-mode]) #bodycontainer ::slotted(*[contenteditable]) { -webkit-user-select: text; cursor: pointer; } :host([edit-mode]) #bodycontainer ::slotted(*[contenteditable]::-moz-selection), :host([edit-mode]) #bodycontainer ::slotted(*[contenteditable] *::-moz-selection) { background-color: var(--hax-body-highlight, #ffffac); color: black; } :host([edit-mode]) #bodycontainer ::slotted(*[contenteditable]::selection), :host([edit-mode]) #bodycontainer ::slotted(*[contenteditable] *::selection) { background-color: var(--hax-body-highlight, #ffffac); color: black; } #bodycontainer { -webkit-user-select: text; user-select: text; } absolute-position-behavior:not(:defined), .hax-context-menu:not(:defined) { display: none; } /* drag and drop */ :host([edit-mode][hax-mover]) #bodycontainer ::slotted(*)::before { background-color: var(--hax-body-possible-target-background-color); content: " "; width: 100%; display: block; position: relative; margin: -12px 0 0 0; z-index: 2; height: 12px; transition: 0.3s all ease-in-out; } :host([edit-mode][hax-mover]) #bodycontainer ::slotted(img) { outline: var(--hax-body-editable-outline); } :host([edit-mode]) #bodycontainer ::slotted(img.hax-hovered), :host([edit-mode]) #bodycontainer ::slotted(*.hax-hovered)::before { background-color: var(--hax-body-target-background-color) !important; } :host([edit-mode]) #bodycontainer ::slotted(img.hax-hovered) { border-top: 8px var(--hax-contextual-action-hover-color, var(--hax-ui-color-accent)); margin-top: -8px; } [hidden], :host([hidden]), #textcontextmenu.not-text { display: none !important; } /** This is mobile layout for controls */ @media screen and (max-width: 800px) { .hax-context-menu { height: 0px; } .hax-context-visible { height: auto; } :host([edit-mode]) #bodycontainer, :host([edit-mode]) #bodycontainer[element-align="left"], :host([edit-mode]) #bodycontainer[element-align="right"] { margin: calc(100px + var(--hax-tray-menubar-min-height)) 0 0 0; } } @media screen and (min-color-index: 0) and(-webkit-min-device-pixel-ratio:0) { /* Define here the CSS styles applied only to Safari browsers (any version and any device) via https://solvit.io/bcf61b6 */ :host([edit-mode][hax-mover]) #bodycontainer ::slotted(*) { outline: var(--hax-body-editable-outline); background-color: var(--hax-body-possible-target-background-color); } :host([edit-mode]) #bodycontainer ::slotted(*.hax-hovered) { background-color: var( --hax-body-target-background-color ) !important; outline: var(--hax-body-active-outline); } } `, ]; } /** * HTMLElement */ constructor() { super(); // lock to ensure we don't flood events on hitting the up / down arrows // as we use a mutation observer to manage draggable bindings this._useristyping = false; this.__ignoreActive = false; this.__dragMoving = false; this.___moveLock = false; this.viewSourceToggle = false; this.editMode = false; this.haxMover = false; this.activeNode = null; this.__lockIconPath = SimpleIconsetStore.getIcon("icons:lock"); this.part = "hax-body"; this.t = { addContent: "Add Content", }; // double key press counter this.timesClickedArrowDown = 0; this.timesClickedArrowUp = 0; // primary registration for the namespace so all tags under hax // can leverage this data this.registerLocalization({ context: this, namespace: "hax", }); if (!window.HaxUiStyles) { globalThis.HaxUiStyles = document.createElement("div"); let s = document.createElement("style"), css = HaxUiBaseStyles.map((st) => st.cssText).join(""); s.setAttribute("data-hax", true); s.setAttribute("type", "text/css"); if (s.styleSheet) { // IE s.styleSheet.cssText = css; } else { // the world s.appendChild(document.createTextNode(css)); } globalThis.document.body.appendChild(s); } this.polyfillSafe = HAXStore.computePolyfillSafe(); this.addEventListener( "place-holder-replace", this.replacePlaceholder.bind(this), ); this.addEventListener("focusin", this._focusIn.bind(this)); this.addEventListener("mousemove", this._mouseMove.bind(this)); this.addEventListener("mouseleave", this._mouseLeave.bind(this)); this.addEventListener("touchstart", this._mouseMove.bind(this), { passive: true, }); this.addEventListener("mousedown", this._mouseDown.bind(this)); this.addEventListener("mouseup", this._mouseUp.bind(this)); this.addEventListener("dragenter", this.dragEnterBody.bind(this)); this.addEventListener("dragend", this.dragEndBody.bind(this)); this.addEventListener("drop", this.dropEvent.bind(this)); autorun(() => { this.editMode = toJS(HAXStore.editMode); }); autorun(() => { this.elementAlign = toJS(HAXStore.elementAlign); }); autorun(() => { this.trayStatus = toJS(HAXStore.trayStatus); this.trayDetail = toJS(HAXStore.trayDetail); }); autorun(() => { this.activeNode = toJS(HAXStore.activeNode); if (this.activeNode && this.activeNode.setAttribute) { this.activeNode.setAttribute("data-hax-active", "data-hax-active"); } }); autorun(() => { const activeEditingElement = toJS(HAXStore.activeEditingElement); }); } get isGridActive() { return HAXStore.isGridPlateElement(activeNode); } /** * When we end dragging ensure we remove the mover class. */ dragEndBody(e) { this.__manageFakeEndCap(false); HAXStore._lockContextPosition = false; this.querySelectorAll(".hax-hovered").forEach((el) => { el.classList.remove("hax-hovered"); }); } _mouseLeave(e) { if (this.editMode && HAXStore.ready) { clearTimeout(this.__mouseQuickTimer); clearTimeout(this.__mouseTimer); this.__activeHover = null; } } _mouseMove(e) { if (this.editMode && HAXStore.ready) { var eventPath = normalizeEventPath(e); clearTimeout(this.__mouseQuickTimer); this.__mouseQuickTimer = setTimeout(() => { if ( this.__activeHover && this.__activeHover != eventPath[0].closest("[data-hax-ray]:not(li)") ) { this.__activeHover = null; } }, 300); clearTimeout(this.__mouseTimer); this.__mouseTimer = setTimeout(() => { let target = eventPath[0].closest("[data-hax-ray]:not(li)"); if (target) { this.__activeHover = target; } else if ( eventPath[0].closest("[data-move-order]") && eventPath[3] && eventPath[3].closest("[data-hax-layout]") ) { // weird but we need the structure of grid plate here unfortunately // if it has nodes in the column we are active on then we need // to defer to the grid level because you could always force a node if (!eventPath[0].closest("[data-move-order]:not(.has-nodes")) { // way out of a column to the host of the template this.__activeHover = eventPath[0].closest( "[data-move-order]", ).parentNode.parentNode.host; } else { // to avoid a later loop, we force this to "false" this.__addAbove = false; // this is a grid column so get it's ID to understand it's slot // this leverages our internal __slot hack that gets picked up // by our MO in order to automatically set __slot on a node anywhere // it's inserted in the body area leveraging alternative logic to // figure out which it should place where this.__slot = eventPath[0] .closest("[data-move-order]") .getAttribute("id") .replace("col", "col-"); // based on what we learned we don't have nodes in the path column // but we KNOW there MUST be an element somewhere in this if ( eventPath[0].closest("[data-move-order]").parentNode.parentNode .host.children.length == 0 ) { let p = document.createElement("p"); eventPath[0] .closest("[data-move-order]") .parentNode.parentNode.host.appendChild(p); } this.__activeHover = eventPath[0].closest( "[data-move-order]", ).parentNode.parentNode.host.children[0]; } } else if (eventPath[0].closest("#bodycontainer")) { this.__activeHover = null; } }, 400); } } _mouseDown(e) { if (this.editMode) { this.__mouseDown = true; let target = e.target; // resolve to the closest ediable element if possible // otherwise keep the target we had // @todo need to test more situations for this.. if (target.closest("[draggable]")) { target = target.closest("[draggable]"); } else if (target.closest("[slot]")) { target = target.closest("[slot]"); } else if (target.closest("[data-hax-ray]")) { target = target.closest("[data-hax-ray]"); } else if (target.closest("[contenteditable]")) { target = target.closest("[contenteditable]"); } else if (HAXStore.validTagList.includes(target.tagName.toLowerCase())) { // tagName is in the valid tag list so just let it get selected } else if (target.tagName !== "HAX-BODY" && !target.haxUIElement) { // this is a usecase we didn't think of... console.warn(target); } // block haxUIElements, except for editable-table as it's a unique tag // bc it's repairing that table is not natively editable if (!target.haxUIElement && this.__focusLogic(target)) { HAXStore.haxTray.trayDetail = "content-edit"; e.stopPropagation(); e.stopImmediatePropagation(); } } } /** * On mouse release, dump any scroller and the end cap element */ _mouseUp(e) { // this helps w/ ensuring that the "focusin" event doesn't // fire when a mousedown is executed setTimeout(() => { this.__mouseDown = false; }, 0); this._useristyping = false; // failsafe to clear to the gravity scrolling clearTimeout(gravityScrollTimer); this.__manageFakeEndCap(false); } scrollerFixclickEvent(e) { this._useristyping = false; this.positionContextMenus(); // failsafe to clear to the gravity scrolling clearTimeout(gravityScrollTimer); } blurEvent(e) { if (this.editMode) { // specialized element / item interaction that generated a blur // event which could imply we clicked on an iframe and "left" the // scope of the current browsing document. Example of // what can cause this is monaco-editor // @todo implement a possible hook here if (HAXStore.activeEditingElement) { } } } /** * Make a fake end cap element so we can drop in the last position * @note This is much easier logic than the alternatives to account for. */ __manageFakeEndCap(create = true) { if (create && !this.__fakeEndCap) { let fake = document.createElement("fake-hax-body-end"); fake.style.width = "100%"; fake.style.height = "20px"; fake.style.zIndex = "2"; fake.style.display = "block"; this.__fakeEndCap = fake; this.haxMover = true; this.appendChild(this.__fakeEndCap); this.__applyNodeEditableState(this.__fakeEndCap, true); } else if (!create && this.__fakeEndCap) { this.__fakeEndCap.remove(); this.haxMover = false; this.__fakeEndCap = null; } } /** * Activation allowed from outside this grid as far as drop areas */ dragEnterBody(e) { this.hideContextMenus(); this._useristyping = false; // insert a fake child at the end this.__manageFakeEndCap(true); } revealMenuIfHidden(e) { this._useristyping = false; this.positionContextMenus(); } /** * LitElement render */ render() { return html` <style id="hax-body-style-element"></style> <div id="bodycontainer" class="ignore-activation" element-align="${this.elementAlign || "left"}" > <slot id="body"></slot> </div> <absolute-position-behavior id="topcontext" fit-to-visible-bounds justify position="top" allow-overlap auto sticky data-node-type="${!this.activeNode ? "" : this.viewSourceToggle ? this.activeNode.parentNode.tagName : this.activeNode.tagName}" .target="${!this.activeNode ? document.body : this.viewSourceToggle ? this.activeNode.parentNode : this.activeNode}" .trayStatus="${this.trayStatus}" ?hidden="${!this.activeNode}" > <div id="topcontextmenu" @mouseenter="${this.revealMenuIfHidden}"> <hax-plate-context always-expanded id="platecontextmenu" class="hax-context-menu ignore-activation" .activeNode="${this.activeNode}" .trayDetail="${this.trayDetail}" .trayStatus="${this.trayStatus}" ?viewSource="${this.viewSourceToggle}" ?canMoveElement="${this.canMoveElement}" ></hax-plate-context> <hax-text-editor-toolbar id="textcontextmenu" class="hax-context-menu ignore-activation ${this.calcClasses( this.activeNode, )}" .activeNode="${this.activeNode}" show="always" > </hax-text-editor-toolbar> </div> </absolute-position-behavior> `; } calcClasses(activeNode) { let txt = "not-text"; if ( activeNode && activeNode.getAttribute && !activeNode.getAttribute("data-hax-lock") && activeNode.parentNode && activeNode.parentNode.getAttribute && !activeNode.parentNode.getAttribute("data-hax-lock") && HAXStore.isTextElement(activeNode) && !HAXStore.isSingleSlotElement(activeNode) ) { txt = "is-text"; } return txt; } /** * LitElement / popular convention */ static get properties() { return { ...super.properties, _useristyping: { type: Boolean, }, haxMover: { type: Boolean, attribute: "hax-mover", reflect: true, }, /** * State of if we are editing or not. */ editMode: { type: Boolean, reflect: true, attribute: "edit-mode", }, /** * element align */ elementAlign: { type: String, reflect: true, attribute: "element-align", }, /** * is hax tray collapsed, side-panel, or full-panel */ trayDetail: { type: String, reflect: true, attribute: "tray-detail", }, /** * is hax tray collapsed, side-panel, or full-panel */ trayStatus: { type: String, reflect: true, attribute: "tray-status", }, /** * A reference to the active node in the slot. */ activeNode: { type: Object, }, /** * activeNode can be moved */ canMoveElement: { type: Boolean, }, /** *Is active node in view source mode? */ viewSourceToggle: { type: Boolean, reflect: true, }, }; } HAXBODYStyleSheetContent() { let styles = []; styles.push(css` :host([edit-mode]) #bodycontainer ::slotted(*[data-hax-lock])::after { background-image: url("${unsafeCSS(this.__lockIconPath)}"); } `); return styles; } /** * LitElement life cycle - ready */ firstUpdated(changedProperties) { render( this.HAXBODYStyleSheetContent(), this.shadowRoot.querySelector("#hax-body-style-element"), ); this.dispatchEvent( new CustomEvent("hax-register-body", { bubbles: true, cancelable: true, composed: true, detail: this, }), ); // try to normalize paragraph insert on enter try { document.execCommand("enableObjectResizing", false, false); document.execCommand("defaultParagraphSeparator", false, "p"); } catch (e) { console.warn(e); } this.contextMenus = { text: this.shadowRoot.querySelector("#textcontextmenu"), plate: this.shadowRoot.querySelector("#platecontextmenu"), parent: this.shadowRoot.querySelector("#topcontext"), }; // track and store range on mouse up. this helps w/ Safari focus selection // issues as well as any "tap" event from a phone knowing what text // WAS selected prior to an operation that might lose focus / selection // during the workflow like replacing an element in context / inline this.shadowRoot.querySelector("slot").addEventListener("mouseup", (e) => { if (this.editMode) { setTimeout(() => { const tmp = HAXStore.getSelection(); HAXStore._tmpSelection = tmp; HAXStore.haxSelectedText = tmp.toString(); try { const range = HAXStore.getRange(); if (range.cloneRange) { HAXStore._tmpRange = range.cloneRange(); } } catch (e) { console.warn(e); } }, 10); } }); // in case we miss this on the initial setup. possible in auto opening environments. this.editMode = HAXStore.editMode; // ensure this resets every append this.__tabTrap = false; this.ready = true; if (super.firstUpdated) { super.firstUpdated(changedProperties); } } /** * LitElement life cycle - properties changed callback */ async updated(changedProperties) { if (super.updated) { super.updated(changedProperties); } changedProperties.forEach(async (oldValue, propName) => { if (propName == "editMode" && oldValue !== undefined) { // microtask delay to allow store to establish child nodes appropriately setTimeout(async () => { this.__ignoreActive = true; await this._editModeChanged(this[propName], oldValue); // ensure we don't process all mutations happening in tee-up setTimeout(() => { this.__ignoreActive = false; }, 100); }, 0); } if (propName == "_useristyping" && this[propName]) { this.hideContextMenus(); } if (propName == "activeNode" && this.ready && oldValue !== undefined) { await this._activeNodeChanged(this[propName], oldValue); } }); } // we were told node was locked or unlocked, toggle to ensure we rerender // since it's an attribute setting _toggleNodeLocking(e) { if (!e.detail.lock) { this.contextMenus.plate.disableDuplicate = false; this.contextMenus.plate.disableOps = false; this.contextMenus.plate.disableItemOps = false; this.contextMenus.plate.canMoveElement = this.canMoveElement; e.detail.node.setAttribute("contenteditable", true); this.setAttribute("contenteditable", true); } else { this.contextMenus.plate.disableDuplicate = true; this.contextMenus.plate.disableOps = true; this.contextMenus.plate.disableItemOps = true; this.contextMenus.plate.canMoveElement = false; e.detail.node.removeAttribute("contenteditable"); this.removeAttribute("contenteditable"); } this.requestUpdate(); } /** * Keep the context menu visible if needed */ _keepContextVisible(e = null) { if (this.editMode) { clearTimeout(this.__contextVisibleLock); this.__contextVisibleLock = setTimeout(() => { // see if the text context menu is visible let el = false; if (this.contextMenus.plate.classList.contains("hax-context-visible")) { el = this.contextMenus.plate; } // if we see it, ensure we don't have the pin if (el) { this.positionContextMenus(); } }, 100); } } _onKeyUp(e) { if ( ["ArrowUp", "ArrowDown"].includes(e.key) && this.activeNode && HAXStore.isTextElement(this.activeNode) && !SuperDaemonInstance.opened ) { let key = e.key; this[`timesClicked${key}`]++; if ( this[`timesClicked${key}`] >= 2 && this.activeNode === this.prevKeyActiveNode ) { if (key === "ArrowUp") { // implies we're at the top of the body if ( this.activeNode.previousElementSibling && this.activeNode.previousElementSibling.tagName === "PAGE-BREAK" ) { this.haxInsert("p", "", {}, this.activeNode.previousElementSibling); } else if ( this.activeNode.parentNode !== this && this.activeNode.parentNode.previousElementSibling && this.activeNode.parentNode.previousElementSibling.tagName === "PAGE-BREAK" ) { this.haxInsert( "p", "", {}, this.activeNode.parentNode.previousElementSibling, ); // would imply top of document, shouldn't be possible } else if ( !this.activeNode.previousElementSibling && this.activeNode.parentNode === this ) { let p = document.createElement("p"); this.insertBefore(p, this.activeNode); } } else { if ( !this.activeNode.nextElementSibling && this.children[this.children.length - 1] === this.activeNode ) { this.haxInsert("p", "", {}); } else if ( this.activeNode.parentNode && this.activeNode.parentNode !== this && !this.activeNode.parentNode.nextElementSibling && this.children[this.children.length - 1] === this.activeNode.parentNode ) { this.haxInsert("p", "", {}, this.activeNode.parentNode); } this[`timesClicked${key}`] = 0; this.prevKeyActiveNode = null; } } else { // store previous reference to ensure we stay in same context between key presses this.prevKeyActiveNode = this.activeNode; } setTimeout(() => { this[`timesClicked${key}`] = 0; this.prevKeyActiveNode = null; }, 200); } } _onKeyDown(e) { // make sure we don't have an open drawer, and editing, and we are not focused on tray if ( this.editMode && document.activeElement.tagName !== "HAX-TRAY" && document.activeElement.tagName !== "BODY" && document.activeElement.tagName !== "SIMPLE-MODAL" ) { if (this.getAttribute("contenteditable")) { this.__dropActiveVisible(); this.__manageFakeEndCap(false); let sel = HAXStore.getSelection(); if (sel.anchorNode != null) { switch (e.key) { case "Z": case "z": // trab for undo / redo if (e.ctrlKey) { if (e.shiftKey) { this.redo(); } else { this.undo(); } if (e.detail.keyboardEvent) { e.detail.keyboardEvent.preventDefault(); e.detail.keyboardEvent.stopPropagation(); e.detail.keyboardEvent.stopImmediatePropagation(); } e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } break; case "Tab": this._useristyping = true; if (HAXStore.isTextElement(this.activeNode)) { if (e.detail.keyboardEvent) { e.detail.keyboardEvent.preventDefault(); e.detail.keyboardEvent.stopPropagation(); e.detail.keyboardEvent.stopImmediatePropagation(); } e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); if (e.shiftKey) { this._tabBackKeyPressed(); } else { this._tabKeyPressed(); } } break; case "Enter": this._useristyping = true; if (this.activeNode) { this.__slot = this.activeNode.getAttribute("slot"); } if ( this.activeNode && this.activeNode.tagName === "P" && ["1", "#", "`", ">", "-"].includes( this.activeNode.textContent[0], ) ) { // ensure the "whitespace character" has been replaced w/ a normal space const guess = this.activeNode.textContent.replaceAll(/ /g, " "); // ensures that the user has done a matching action and a " " spacebar to ensure they // are ready to commit the action this.keyboardShortCutProcess(guess); } break; // extra trap set for this in case we care that we are in the act of deleting case "Backspace": case "Delete": // trap for NOTHING existing and so the contenteditable process // could accidentally delete the entire element as well as the 1 before it // which is page break and makes us much sadness // there's also edge cases w/ contenteditable where hitting delete on // a container about to be made empty will then delete table or iframe before it if ( this.activeNode && this.activeNode.textContent == "" && this.activeNode.previousElementSibling && this.activeNode.previousElementSibling.tagName && ([ "TABLE", "EDITABLE-TABLE", "IFRAME-LOADER", "IFRAME", "WEBVIEW", ].includes(this.activeNode.previousElementSibling.tagName) || (this.activeNode.previousElementSibling.tagName === "PAGE-BREAK" && this.shadowRoot .querySelector("#body") .assignedNodes({ flatten: true }).length === 2 && this.shadowRoot .querySelector("#body") .assignedNodes({ flatten: true })[1] === this.activeNode)) ) { e.preventDefault(); } this._useristyping = true; this.__delHit = true; this.querySelectorAll("[data-hax-active]").forEach( (el) => el.classList.remove, ); setTimeout(() => { const tmp = HAXStore.getSelection(); HAXStore._tmpSelection = tmp; HAXStore.haxSelectedText = tmp.toString(); const rng = HAXStore.getRange(); if ( rng.commonAncestorContainer && this.activeNode !== rng.commonAncestorContainer && typeof rng.commonAncestorContainer.focus === "function" ) { if (rng.commonAncestorContainer.tagName !== "HAX-BODY") { this.__focusLogic(rng.commonAncestorContainer, false); } } // need to check on the parent too if this was a text node else if ( rng.commonAncestorContainer && rng.commonAncestorContainer.parentNode && this.activeNode !== rng.commonAncestorContainer.parentNode && typeof rng.commonAncestorContainer.parentNode.focus === "function" ) { if ( rng.commonAncestorContainer.parentNode.tagName !== "HAX-BODY" ) { this.__focusLogic( rng.commonAncestorContainer.parentNode, false, ); } else { this.__focusLogic(rng.commonAncestorContainer, false); } } }, 100); break; case "Escape": this._useristyping = true; break; case "/": const rng = HAXStore.getRange(); if ( this.activeNode && HAXStore.isTextElement(this.activeNode) && rng.commonAncestorContainer.textContent.trim() == "" ) { e.preventDefault(); SuperDaemonInstance.mini = true; SuperDaemonInstance.activeRange = rng; SuperDaemonInstance.activeSelection = HAXStore.getSelection(); SuperDaemonInstance.activeNode = rng.commonAncestorContainer; SuperDaemonInstance.runProgram( rng.commonAncestorContainer.textContent.trim(), "*", ); SuperDaemonInstance.open(); } break; case "ArrowUp": case "ArrowDown": case "ArrowLeft": case "ArrowRight": this._useristyping = true; this.querySelectorAll("[data-hax-active]").forEach( (el) => el.classList.remove, ); setTimeout(() => { const tmp = HAXStore.getSelection(); HAXStore._tmpSelection = tmp; HAXStore.haxSelectedText = tmp.toString(); const rng = HAXStore.getRange(); if ( rng.commonAncestorContainer && this.activeNode !== rng.commonAncestorContainer && typeof rng.commonAncestorContainer.focus === "function" ) { if (rng.commonAncestorContainer.tagName !== "HAX-BODY") { this.__focusLogic(rng.commonAncestorContainer, false); } } // need to check on the parent too if this was a text node else if ( rng.commonAncestorContainer && rng.commonAncestorContainer.parentNode && this.activeNode !== rng.commonAncestorContainer.parentNode && typeof rng.commonAncestorContainer.parentNode.focus === "function" ) { if ( rng.commonAncestorContainer.parentNode.tagName !== "HAX-BODY" ) { this.__focusLogic( rng.commonAncestorContainer.parentNode, false, ); } else { this.__focusLogic(rng.commonAncestorContainer, false); } } }, 0); break; default: this._useristyping = true; // we only care about contextual ops in a paragraph // delay a micro-task to ensure activenode's innerText is set setTimeout(() => { if ( this.activeNode && this.activeNode.tagName === "P" && ["1", "#", "`", ">", "-"].includes( this.activeNode.textContent[0], ) ) { // ensure the "whitespace character" has been replaced w/ a normal space const guess = this.activeNode.textContent.replaceAll( / /g, " ", ); // ensures that the user has done a matching action and a " " spacebar to ensure they // are ready to commit the action if (guess[guess.length - 1] === " ") { this.keyboardShortCutProcess(guess); } } }, 0); break; } } } } } /** * Process input to see if it matches any defined keyboard shortcuts */ keyboardShortCutProcess(guess) { // see if our map matches if (HAXStore.keyboardShortcuts[guess.replace(" ", "")]) { let el = haxElementToNode( HAXStore.keyboardShortcuts[guess.replace(" ", "")], ); this.haxReplaceNode(this.activeNode, el); this.__focusLogic(el); // breaks should jump just PAST the break // and add a p since it's a divider really if (el.tagName === "HR") { // then insert a P which will assume active status this.haxInsert("p", "", {}); } } } /** * sets active node * * @param {*} node * @memberof HaxBody */ setActiveNode(node, force = false) { if ( node && this.editMode && this.activeNode && (HAXStore.isTextElement(this.activeNode) || force) ) { HAXStore.activeNode = node; // If the user has paused for awhile, show the menu clearTimeout(this.__positionContextTimer); this.__positionContextTimer = setTimeout(() => { // always on active if we were just typing this.__addActiveVisible(); this.positionContextMenus(); }, 2000); } } /** * Only true if we are scrolling and part way through an element */ elementMidViewport() { const y = this.activeNode.getBoundingClientRect().y; return y < 0 && y > -1 * this.activeNode.offsetHeight + 140; } /** * Replace place holder after an event has called for it in the element itself */ replacePlaceholder(e) { // generate a paragraph of text here on click if (e.detail === "text") { // make sure text just escalates to a paragraph tag let p = document.createElement("p"); this.haxReplaceNode(this.activeNode, p); this.__focusLogic(p); if (this.activeNode.parentNode) { this.activeNode.parentNode.setAttribute("contenteditable", true); } } else { this.replaceElementWorkflow(); } } async canTansformNode(node = null) { return (await this.replaceElementWorkflow(node, true).length) > 0 ? true : false; } /** * Whole workflow of replacing something in place contextually. * This can fire for things like events needing this workflow to * invoke whether it's a "convert" event or a "replace placeholder" event */ async insertElementWorkflow(activeNode = null, testOnly = false) {} /** * Whole workflow of replacing something in place contextually. * This can fire for things like events needing this workflow to * invoke whether it's a "convert" event or a "replace placeholder" event */ get primitiveTextBlocks() { return ["p", "div", "pre", "h1", "h2", "h3", "h4", "h5", "h6"]; } /** * * gets configuration for all of given grid's slots * * @param {object} grid * @returns {array} */ getAllSlotConfig(node) { if (!node) return; let grid = this.getParentGrid(node); return !!grid && !!grid.tag ? this.getSlotConfig(HAXStore.elementList[grid.tag], slot) : undefined; } /** * * gets parent grid if given node is slotted content * * @param {object} node * @returns {object} */ getParentGrid(node) { node = node || this.activeNode; let slot = !!node ? node.slot : undefined; return !!slot ? nodeToHaxElement(node.parentNode) : undefined; } /** * * gets slot configuration for a given slot from haxProperties given * * @param {string} slotId * @param {object} props * @returns {object} */ getSlotConfig(slotId = "", props = {}) { let settings = props.settings, matchingSlots = !!settings ? Object.keys(settings || {}) .map((group) => settings[group].filter( (setting) => !!setting.slot && (!slotId || setting.slot === slotId), ), ) .flat() : undefined; return matchingSlots && matchingSlots.length > 0 ? matchingSlots[0] : undefined; } async replaceElementWorkflow(activeNode = null, testOnly = false) { // support for tests with things other than activeNode if (activeNode == null) { activeNode = this.activeNode; } let element = await nodeToHaxElement(activeNode, null); if (!element) return; let type = "*"; let skipPropMatch = false; let slot = (activeNode || {}).slot; let grid = this.getParentGrid(activeNode); // special support for place holder which defines exactly // what the user wants this replaced with if ( element.tag === "place-holder" && typeof element.properties["type"] !== typeof undefined ) { type = element.properties["type"]; skipPropMatch = true; } else if (this.primitiveTextBlocks.includes(element.tag)) { skipPropMatch = true; } var props = !!element.content ? { innerHTML: element.content } : {}; // see if we have a gizmo as it's not a requirement to registration // as well as having handlers since mapping is not required either if ( typeof HAXStore.elementList[element.tag] !== typeof undefined && HAXStore.elementList[element.tag].gizmo !== false && typeof HAXStore.elementList[element.tag].gizmo.handles !== typeof undefined && HAXStore.elementList[element.tag].gizmo.handles.length > 0 ) { // get the haxProper