@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
JavaScript
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