From 7398f7ce0d619ef6ab6f3003e321d9fa5847c7fb Mon Sep 17 00:00:00 2001 From: AzazelN28 Date: Tue, 27 Aug 2024 10:03:44 +0200 Subject: [PATCH] :recycle: Replace Draft.js with custom editor --- CHANGES.md | 8 + common/src/app/common/features.cljc | 10 +- common/src/app/common/text.cljc | 10 + frontend/src/app/main/data/workspace.cljs | 4 +- .../src/app/main/data/workspace/texts.cljs | 152 +- frontend/src/app/main/features.cljs | 3 +- frontend/src/app/main/refs.cljs | 6 + frontend/src/app/main/ui/context.cljs | 2 +- .../src/app/main/ui/shapes/text/styles.cljs | 3 + .../main/ui/workspace/shapes/text/editor.cljs | 2 +- .../shapes/text/new_editor/TextEditor.js | 2744 +++++++++++++++++ .../ui/workspace/shapes/text/v2_editor.cljs | 259 ++ .../ui/workspace/shapes/text/v2_editor.scss | 84 + .../workspace/shapes/text/v2_editor_impl.js | 61 + .../shapes/text/viewport_texts_html.cljs | 33 +- .../workspace/sidebar/options/menus/text.cljs | 3 +- .../sidebar/options/menus/typography.cljs | 4 +- .../sidebar/options/shapes/text.cljs | 15 +- .../src/app/main/ui/workspace/viewport.cljs | 12 +- .../main/ui/workspace/viewport/actions.cljs | 7 +- frontend/src/app/util/dom.cljs | 5 + frontend/src/app/util/text/content.cljs | 20 + .../src/app/util/text/content/from_dom.cljs | 83 + .../src/app/util/text/content/styles.cljs | 198 ++ .../src/app/util/text/content/to_dom.cljs | 124 + frontend/src/app/util/text/ui.cljs | 43 + 26 files changed, 3846 insertions(+), 49 deletions(-) create mode 100644 frontend/src/app/main/ui/workspace/shapes/text/new_editor/TextEditor.js create mode 100644 frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs create mode 100644 frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss create mode 100644 frontend/src/app/main/ui/workspace/shapes/text/v2_editor_impl.js create mode 100644 frontend/src/app/util/text/content.cljs create mode 100644 frontend/src/app/util/text/content/from_dom.cljs create mode 100644 frontend/src/app/util/text/content/styles.cljs create mode 100644 frontend/src/app/util/text/content/to_dom.cljs create mode 100644 frontend/src/app/util/text/ui.cljs diff --git a/CHANGES.md b/CHANGES.md index b7d0b9eb6..d8f76c682 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,14 @@ ### :sparkles: New features +- **Replace Draft.js completely with a custom editor** [Taiga #7706](https://tree.taiga.io/project/penpot/us/7706) + + This refactor adds better IME support, more performant text editing + experience and a better clipboard support while keeping full + retrocompatibility with previous editor. + + You can enable it with the `enable-feature-text-editor-v2` configuration flag. + ### :bug: Bugs fixed ## 2.2.0 diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index 630648858..c8fcb4b10 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -49,7 +49,8 @@ "components/v2" "styles/v2" "layout/grid" - "plugins/runtime"}) + "plugins/runtime" + "text-editor/v2"}) ;; A set of features enabled by default (def default-features @@ -64,7 +65,8 @@ ;; team feature field (def frontend-only-features #{"styles/v2" - "plugins/runtime"}) + "plugins/runtime" + "text-editor/v2"}) ;; Features that are mainly backend only or there are a proper ;; fallback when frontend reports no support for it @@ -81,7 +83,8 @@ "fdata/pointer-map" "layout/grid" "fdata/shape-data-type" - "plugins/runtime"} + "plugins/runtime" + "text-editor/v2"} (into frontend-only-features))) (sm/register! ::features @@ -101,6 +104,7 @@ :feature-fdata-objects-map "fdata/objects-map" :feature-fdata-pointer-map "fdata/pointer-map" :feature-plugins "plugins/runtime" + :feature-text-editor-v2 "text-editor/v2" nil)) (defn migrate-legacy-features diff --git a/common/src/app/common/text.cljc b/common/src/app/common/text.cljc index c5d14f549..3a7fdec93 100644 --- a/common/src/app/common/text.cljc +++ b/common/src/app/common/text.cljc @@ -78,6 +78,12 @@ (def text-all-attrs (d/concat-set shape-attrs root-attrs paragraph-attrs text-node-attrs)) +(def text-style-attrs + (d/concat-vec root-attrs paragraph-attrs text-node-attrs)) + +(def default-root-attrs + {:vertical-align "top"}) + (def default-text-attrs {:typography-ref-file nil :typography-ref-id nil @@ -92,9 +98,13 @@ :text-transform "none" :text-align "left" :text-decoration "none" + :text-direction "ltr" :fills [{:fill-color clr/black :fill-opacity 1}]}) +(def default-attrs + (merge default-root-attrs default-text-attrs)) + (def typography-fields [:font-id :font-family diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 50aea4a1e..6e8daf90e 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -85,6 +85,7 @@ [app.util.webapi :as wapi] [beicon.v2.core :as rx] [cljs.spec.alpha :as s] + [clojure.set :as set] [cuerdas.core :as str] [potok.v2.core :as ptk] [promesa.core :as p])) @@ -1543,7 +1544,8 @@ (let [objects (wsh/lookup-page-objects state) selected (->> (wsh/lookup-selected state) (cfh/clean-loops objects)) - features (features/get-team-enabled-features state) + features (-> (features/get-team-enabled-features state) + (set/difference cfeat/frontend-only-features)) file-id (:current-file-id state) frame-id (cfh/common-parent-frame objects selected) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 4d9785b67..9089965d1 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -24,14 +24,21 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.state-helpers :as wsh] [app.main.data.workspace.undo :as dwu] + [app.main.features :as features] [app.main.fonts :as fonts] [app.util.router :as rt] [app.util.text-editor :as ted] + [app.util.text.content.styles :as styles] [app.util.timers :as ts] [beicon.v2.core :as rx] [cuerdas.core :as str] [potok.v2.core :as ptk])) +;; -- V2 Editor + +(declare v2-update-text-shape-content) +(declare v2-update-text-editor-styles) + ;; -- Editor (defn update-editor @@ -186,22 +193,41 @@ [{:keys [attrs shape]}] (shape-current-values shape txt/is-root-node? attrs)) -(defn current-paragraph-values +(defn v2-current-text-values + [{:keys [editor-instance attrs]}] + (let [result (-> (.-currentStyle editor-instance) + (styles/get-styles-from-style-declaration) + (select-keys attrs)) + result (if (empty? result) txt/default-text-attrs result)] + result)) + +(defn v1-current-paragraph-values [{:keys [editor-state attrs shape]}] (if editor-state (-> (ted/get-editor-current-block-data editor-state) (select-keys attrs)) (shape-current-values shape txt/is-paragraph-node? attrs))) -(defn current-text-values - [{:keys [editor-state attrs shape]}] - (if editor-state - (let [result (-> (ted/get-editor-current-inline-styles editor-state) - (select-keys attrs)) - result (if (empty? result) txt/default-text-attrs result)] - result) - (shape-current-values shape txt/is-text-node? attrs))) +(defn current-paragraph-values + [{:keys [editor-state editor-instance attrs shape] :as options}] + (cond + (some? editor-instance) (v2-current-text-values options) + (some? editor-state) (v1-current-paragraph-values options) + :else (shape-current-values shape txt/is-paragraph-node? attrs))) +(defn v1-current-text-values + [{:keys [editor-state attrs]}] + (let [result (-> (ted/get-editor-current-inline-styles editor-state) + (select-keys attrs)) + result (if (empty? result) txt/default-text-attrs result)] + result)) + +(defn current-text-values + [{:keys [editor-state editor-instance attrs shape] :as options}] + (cond + (some? editor-instance) (v2-current-text-values options) + (some? editor-state) (v1-current-text-values options) + :else (shape-current-values shape txt/is-text-node? attrs))) ;; --- TEXT EDITION IMPL @@ -408,7 +434,9 @@ ptk/WatchEvent (watch [_ state _] - (when (nil? (get-in state [:workspace-editor-state id])) + (when (or + (and (features/active-feature? state "text-editor/v2") (nil? (:workspace-editor state))) + (and (not (features/active-feature? state "text-editor/v2")) (nil? (get-in state [:workspace-editor-state id])))) (let [objects (wsh/lookup-page-objects state) shape (get objects id) @@ -430,8 +458,18 @@ (-> shape (dissoc :fills) (d/update-when :content update-content)))] + (rx/of (dwsh/update-shapes shape-ids update-shape))))) - (rx/of (dwsh/update-shapes shape-ids update-shape))))))) + ptk/EffectEvent + (effect [_ state _] + (when (features/active-feature? state "text-editor/v2") + (let [text-editor-instance (:workspace-editor state)] + (when (some? text-editor-instance) + (let [attrs (-> (.-currentStyle text-editor-instance) + (styles/get-styles-from-style-declaration) + ((comp update-node-fn migrate-node))) + styles (styles/attrs->styles attrs)] + (.applyStylesToSelection text-editor-instance styles)))))))) ;; --- RESIZE UTILS @@ -664,22 +702,37 @@ [id attrs] (ptk/reify ::update-attrs ptk/WatchEvent - (watch [_ _ _] - (rx/concat - (let [attrs (select-keys attrs txt/root-attrs)] - (if-not (empty? attrs) - (rx/of (update-root-attrs {:id id :attrs attrs})) - (rx/empty))) + (watch [_ state _] + (let [text-editor-instance (:workspace-editor state)] + (if (and (features/active-feature? state "text-editor/v2") + (some? text-editor-instance)) + (rx/empty) + (rx/concat + (let [attrs (select-keys attrs txt/root-attrs)] + (if-not (empty? attrs) + (rx/of (update-root-attrs {:id id :attrs attrs})) + (rx/empty))) - (let [attrs (select-keys attrs txt/paragraph-attrs)] - (if-not (empty? attrs) - (rx/of (update-paragraph-attrs {:id id :attrs attrs})) - (rx/empty))) + (let [attrs (select-keys attrs txt/paragraph-attrs)] + (if-not (empty? attrs) + (rx/of (update-paragraph-attrs {:id id :attrs attrs})) + (rx/empty))) - (let [attrs (select-keys attrs txt/text-node-attrs)] - (if-not (empty? attrs) - (rx/of (update-text-attrs {:id id :attrs attrs})) - (rx/empty))))))) + (let [attrs (select-keys attrs txt/text-node-attrs)] + (if-not (empty? attrs) + (rx/of (update-text-attrs {:id id :attrs attrs})) + (rx/empty))) + + (when (features/active-feature? state "text-editor/v2") + (rx/of (v2-update-text-editor-styles id attrs))))))) + + ptk/EffectEvent + (effect [_ state _] + (when (features/active-feature? state "text-editor/v2") + (let [text-editor-instance (:workspace-editor state) + styles (styles/attrs->styles attrs)] + (when (some? text-editor-instance) + (.applyStylesToSelection text-editor-instance styles))))))) (defn update-all-attrs [ids attrs] @@ -773,3 +826,52 @@ (rx/of (update-attrs (:id shape) {:typography-ref-id typ-id :typography-ref-file file-id})))))))) + +;; -- New Editor + +(defn v2-update-text-editor-styles + [id new-styles] + (ptk/reify ::v2-update-text-editor-styles + ptk/UpdateEvent + (update [_ state] + (let [merged-styles (d/merge txt/default-text-attrs + (get-in state [:workspace-global :default-font]) + new-styles)] + (update-in state [:workspace-v2-editor-state id] (fnil merge {}) merged-styles))))) + +(defn v2-update-text-shape-position-data + [shape-id position-data] + (ptk/reify ::v2-update-text-shape-position-data + ptk/UpdateEvent + (update [_ state] + (let [] + (update-in state [:workspace-text-modifier shape-id] {:position-data position-data}))))) + +(defn v2-update-text-shape-content + ([id content] + (v2-update-text-shape-content id content false nil)) + ([id content update-name?] + (v2-update-text-shape-content id content update-name? nil)) + ([id content update-name? name] + (ptk/reify ::v2-update-text-shape-content + ptk/WatchEvent + (watch [_ state _] + (let [objects (wsh/lookup-page-objects state) + shape (get objects id) + modifiers (get-in state [:workspace-text-modifier id]) + new-shape? (nil? (:content shape))] + (rx/of + (dwsh/update-shapes + [id] + (fn [shape] + (let [{:keys [width height position-data]} modifiers] + (let [new-shape (-> shape + (assoc :content content) + (cond-> position-data + (assoc :position-data position-data)) + (cond-> (and update-name? (some? name)) + (assoc :name name)) + (cond-> (or (some? width) (some? height)) + (gsh/transform-shape (ctm/change-size shape width height))))] + new-shape))) + {:undo-group (when new-shape? id)}))))))) diff --git a/frontend/src/app/main/features.cljs b/frontend/src/app/main/features.cljs index e5a5f7c2b..477a58a75 100644 --- a/frontend/src/app/main/features.cljs +++ b/frontend/src/app/main/features.cljs @@ -109,7 +109,8 @@ (watch [_ _ _] (when *assert* (->> (rx/from cfeat/no-migration-features) - (rx/filter #(not (contains? cfeat/backend-only-features %))) + ;; text editor v2 isn't enabled by default even in devenv + (rx/filter #(not (or (contains? cfeat/backend-only-features %) (= "text-editor/v2" %)))) (rx/observe-on :async) (rx/map enable-feature)))) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 61a26b262..a755dc9fe 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -184,6 +184,9 @@ (def options-mode-global (l/derived :options-mode workspace-global)) +(def default-font + (l/derived :default-font workspace-global)) + (def inspect-expanded (l/derived :inspect-expanded workspace-local)) @@ -355,6 +358,9 @@ (def workspace-editor-state (l/derived :workspace-editor-state st/state)) +(def workspace-v2-editor-state + (l/derived :workspace-v2-editor-state st/state)) + (def workspace-modifiers (l/derived :workspace-modifiers st/state =)) diff --git a/frontend/src/app/main/ui/context.cljs b/frontend/src/app/main/ui/context.cljs index 1aa4b532b..c007896b9 100644 --- a/frontend/src/app/main/ui/context.cljs +++ b/frontend/src/app/main/ui/context.cljs @@ -30,4 +30,4 @@ (def workspace-read-only? (mf/create-context nil)) (def is-component? (mf/create-context false)) -(def sidebar (mf/create-context nil)) +(def sidebar (mf/create-context nil)) diff --git a/frontend/src/app/main/ui/shapes/text/styles.cljs b/frontend/src/app/main/ui/shapes/text/styles.cljs index 7d46c98ed..a65daafc6 100644 --- a/frontend/src/app/main/ui/shapes/text/styles.cljs +++ b/frontend/src/app/main/ui/shapes/text/styles.cljs @@ -55,6 +55,7 @@ :fontSize 0 ;;(str (:font-size data (:font-size txt/default-text-attrs)) "px") :lineHeight (:line-height data (:line-height txt/default-text-attrs)) :margin 0}] + (cond-> base (some? line-height) (obj/set! "lineHeight" line-height) (some? text-align) (obj/set! "textAlign" text-align)))) @@ -74,6 +75,7 @@ font-variant-id (:font-variant-id data) font-size (:font-size data) + fill-color (or (-> data :fills first :fill-color) (:fill-color data)) fill-opacity (or (-> data :fills first :fill-opacity) (:fill-opacity data)) fill-gradient (or (-> data :fills first :fill-color-gradient) (:fill-color-gradient data)) @@ -92,6 +94,7 @@ base #js {:textDecoration text-decoration :textTransform text-transform + :fontSize font-size :color (if (and show-text? (not gradient?)) text-color "transparent") :background (when (and show-text? gradient?) text-color) :caretColor (if (and (not gradient?) text-color) text-color "black") diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 7eace14e7..654e83653 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -200,7 +200,7 @@ (fn [editor] (st/emit! (dwt/update-editor editor)) (when editor - (dom/add-class! (dom/get-element-by-class "public-DraftEditor-content") "mousetrap") + (dom/add-class! (dom/get-element-by-class "public-DraftEditor-content") "mousetrap") (.focus ^js editor)))) handle-return diff --git a/frontend/src/app/main/ui/workspace/shapes/text/new_editor/TextEditor.js b/frontend/src/app/main/ui/workspace/shapes/text/new_editor/TextEditor.js new file mode 100644 index 000000000..91075ea42 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/new_editor/TextEditor.js @@ -0,0 +1,2744 @@ +var __typeError = (msg) => { + throw TypeError(msg); +}; +var __accessCheck = (obj, member, msg) => member.has(obj) || __typeError("Cannot " + msg); +var __privateGet = (obj, member, getter) => (__accessCheck(obj, member, "read from private field"), getter ? getter.call(obj) : member.get(obj)); +var __privateAdd = (obj, member, value) => member.has(obj) ? __typeError("Cannot add the same private member more than once") : member instanceof WeakSet ? member.add(obj) : member.set(obj, value); +var __privateSet = (obj, member, value, setter) => (__accessCheck(obj, member, "write to private field"), setter ? setter.call(obj, value) : member.set(obj, value), value); +var __privateMethod = (obj, member, method) => (__accessCheck(obj, member, "access private method"), method); +var _timeout, _time, _hasPendingChanges, _onTimeout, _rootNode, _currentNode, _added, _removed, _updated, _textEditor, _selection, _ranges, _range, _focusNode, _focusOffset, _anchorNode, _anchorOffset, _savedSelection, _textNodeIterator, _currentStyle, _inertElement, _debug, _mutations, _styleDefaults, _SelectionController_instances, applyDefaultStylesToCurrentStyle_fn, applyStylesToCurrentStyle_fn, updateCurrentStyle_fn, _onSelectionChange, notifyStyleChange_fn, setup_fn, getSavedRange_fn, applyStylesTo_fn, _element, _events, _root, _changeController, _selectionController, _selectionImposterElement, _styleDefaults2, _TextEditor_instances, setupElementProperties_fn, setupRoot_fn, _onChange, _onStyleChange, setup_fn2, createSelectionImposter_fn, _onBlur, _onFocus, _onPaste, _onCut, _onCopy, _onBeforeInput, _onInput, notifyLayout_fn; +function copy(event, editor) { +} +function cut(event, editor) { +} +let canvas = null; +let context = null; +function getContext() { + if (!canvas) { + canvas = createCanvas(1, 1); + } + if (!context) { + context = canvas.getContext("2d"); + } + return context; +} +function createCanvas(width, height) { + if ("OffscreenCanvas" in globalThis) { + return new OffscreenCanvas(width, height); + } + return document.createElement("canvas"); +} +function getByteAsHex(byte) { + return byte.toString(16).padStart(2, "0"); +} +function getColor(fillStyle) { + const context2 = getContext(); + context2.fillStyle = fillStyle; + context2.fillRect(0, 0, 1, 1); + const imageData = context2.getImageData(0, 0, 1, 1); + const [r, g, b, a] = imageData.data; + return [`#${getByteAsHex(r)}${getByteAsHex(g)}${getByteAsHex(b)}`, a / 255]; +} +function getFills(fillStyle) { + const [color, opacity] = getColor(fillStyle); + return `[["^ ","~:fill-color","${color}","~:fill-opacity",${opacity}]]`; +} +function mergeStyleDeclarations(target, source) { + for (let index = 0; index < source.length; index++) { + const styleName = source.item(index); + target.setProperty(styleName, source.getPropertyValue(styleName)); + } + return target; +} +function getComputedStyle(element) { + const inertElement = document.createElement("div"); + let currentElement = element; + while (currentElement) { + for (let index = 0; index < currentElement.style.length; index++) { + const styleName = currentElement.style.item(index); + const currentValue = inertElement.style.getPropertyValue(styleName); + if (currentValue) { + const priority = currentElement.style.getPropertyPriority(styleName); + if (priority === "important") { + const newValue = currentElement.style.getPropertyValue(styleName); + inertElement.style.setProperty(styleName, newValue); + } + } else { + inertElement.style.setProperty( + styleName, + currentElement.style.getPropertyValue(styleName) + ); + } + } + currentElement = currentElement.parentElement; + } + return inertElement.style; +} +function normalizeStyles(styleDeclaration) { + const color = styleDeclaration.getPropertyValue("color"); + if (color) { + styleDeclaration.removeProperty("color"); + styleDeclaration.setProperty("--fills", getFills(color)); + } + const fontFamily = styleDeclaration.getPropertyValue("font-family"); + const fontId = styleDeclaration.getPropertyPriority("--font-id"); + if (fontFamily && !fontId) { + styleDeclaration.removeProperty("font-family"); + } + return styleDeclaration; +} +function setStyle(element, styleName, styleValue, styleUnit) { + if (styleName.startsWith("--") && typeof styleValue !== "string" && typeof styleValue !== "number") { + if (styleName === "--fills" && styleValue === null) debugger; + element.style.setProperty(styleName, JSON.stringify(styleValue)); + } else { + element.style.setProperty(styleName, styleValue + (styleUnit ?? "")); + } + return element; +} +function getStyleFromDeclaration(style, styleName, styleUnit) { + if (styleName.startsWith("--")) { + return style.getPropertyValue(styleName); + } + const styleValue = style.getPropertyValue(styleName); + if (styleValue.endsWith(styleUnit)) { + return styleValue.slice(0, -styleUnit.length); + } + return styleValue; +} +function setStylesFromObject(element, allowedStyles, styleObject) { + for (const [styleName, styleUnit] of allowedStyles) { + if (!(styleName in styleObject)) { + continue; + } + const styleValue = styleObject[styleName]; + if (styleValue) { + setStyle(element, styleName, styleValue, styleUnit); + } + } + return element; +} +function setStylesFromDeclaration(element, allowedStyles, styleDeclaration) { + for (const [styleName, styleUnit] of allowedStyles) { + const styleValue = getStyleFromDeclaration(styleDeclaration, styleName, styleUnit); + if (styleValue) { + setStyle(element, styleName, styleValue, styleUnit); + } + } + return element; +} +function setStyles(element, allowedStyles, styleObjectOrDeclaration) { + if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) { + return setStylesFromDeclaration( + element, + allowedStyles, + styleObjectOrDeclaration + ); + } + return setStylesFromObject(element, allowedStyles, styleObjectOrDeclaration); +} +function mergeStyles(allowedStyles, styleDeclaration, newStyles) { + const mergedStyles = {}; + for (const [styleName, styleUnit] of allowedStyles) { + if (styleName in newStyles) { + mergedStyles[styleName] = newStyles[styleName]; + } else { + mergedStyles[styleName] = getStyleFromDeclaration(styleDeclaration, styleName, styleUnit); + } + } + return mergedStyles; +} +function isDisplayBlock(style) { + return style.display === "block"; +} +function createRandomId() { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36); +} +function createElement(tag, options) { + const element = document.createElement(tag); + if (options == null ? void 0 : options.attributes) { + Object.entries(options.attributes).forEach( + ([name, value]) => element.setAttribute(name, value) + ); + } + if (options == null ? void 0 : options.data) { + Object.entries(options.data).forEach( + ([name, value]) => element.dataset[name] = value + ); + } + if ((options == null ? void 0 : options.styles) && (options == null ? void 0 : options.allowedStyles)) { + setStyles(element, options.allowedStyles, options.styles); + } + if (options == null ? void 0 : options.children) { + if (Array.isArray(options.children)) { + element.append(...options.children); + } else { + element.appendChild(options.children); + } + } + return element; +} +function isElement(element, nodeName) { + return element.nodeType === Node.ELEMENT_NODE && element.nodeName === nodeName.toUpperCase(); +} +function isOffsetAtStart(node, offset) { + return offset === 0; +} +function isOffsetAtEnd(node, offset) { + if (node.nodeType === Node.TEXT_NODE) { + return node.nodeValue.length === offset; + } + return true; +} +const TAG$3 = "BR"; +function createLineBreak() { + return document.createElement(TAG$3); +} +function isLineBreak(node) { + return node.nodeType === Node.ELEMENT_NODE && node.nodeName === TAG$3; +} +const TAG$2 = "SPAN"; +const TYPE$2 = "inline"; +const QUERY$1 = `[data-itype="${TYPE$2}"]`; +const STYLES$2 = [ + ["--typography-ref-id"], + ["--typography-ref-file"], + ["--font-id"], + ["--font-variant-id"], + ["--fills"], + ["font-variant"], + ["font-family"], + ["font-size", "px"], + ["font-weight"], + ["font-style"], + ["line-height"], + ["letter-spacing", "px"], + ["text-decoration"], + ["text-transform"] +]; +function isInline(node) { + if (!node) return false; + if (!isElement(node, TAG$2)) return false; + if (node.dataset.itype !== TYPE$2) return false; + return true; +} +function isLikeInline(element) { + return element ? [ + "A", + "ABBR", + "ACRONYM", + "B", + "BDO", + "BIG", + "BR", + "BUTTON", + "CITE", + "CODE", + "DFN", + "EM", + "I", + "IMG", + "INPUT", + "KBD", + "LABEL", + "MAP", + "OBJECT", + "OUTPUT", + "Q", + "SAMP", + "SCRIPT", + "SELECT", + "SMALL", + "SPAN", + "STRONG", + "SUB", + "SUP", + "TEXTAREA", + "TIME", + "TT", + "VAR" + ].includes(element.nodeName) : false; +} +function createInline(textOrLineBreak, styles, attrs) { + if (!(textOrLineBreak instanceof HTMLBRElement) && !(textOrLineBreak instanceof Text)) { + throw new TypeError("Invalid inline child"); + } + if (textOrLineBreak instanceof Text && textOrLineBreak.nodeValue.length === 0) { + console.trace("nodeValue", textOrLineBreak.nodeValue); + throw new TypeError("Invalid inline child, cannot be an empty text"); + } + return createElement(TAG$2, { + attributes: { id: createRandomId(), ...attrs }, + data: { itype: TYPE$2 }, + styles, + allowedStyles: STYLES$2, + children: textOrLineBreak + }); +} +function createInlineFrom(inline, textOrLineBreak, styles, attrs) { + return createInline( + textOrLineBreak, + mergeStyles(STYLES$2, inline.style, styles), + attrs + ); +} +function createEmptyInline(styles) { + return createInline(createLineBreak(), styles); +} +function setInlineStyles(element, styles) { + return setStyles(element, STYLES$2, styles); +} +function getInline(node) { + if (!node) return null; + if (isInline(node)) return node; + if (node.nodeType === Node.TEXT_NODE) { + const inline = node == null ? void 0 : node.parentElement; + if (!inline) return null; + if (!isInline(inline)) return null; + return inline; + } + return node.closest(QUERY$1); +} +function isInlineStart(node, offset) { + const inline = getInline(node); + if (!inline) return false; + return isOffsetAtStart(inline, offset); +} +function isInlineEnd(node, offset) { + const inline = getInline(node); + if (!inline) return false; + return isOffsetAtEnd(inline.firstChild, offset); +} +function splitInline(inline, offset) { + const textNode = inline.firstChild; + const style = inline.style; + const newTextNode = textNode.splitText(offset); + return createInline(newTextNode, style); +} +function getInlinesFrom(startInline) { + const inlines = []; + let currentInline = startInline; + let index = 0; + while (currentInline) { + if (index > 0) inlines.push(currentInline); + currentInline = currentInline.nextElementSibling; + index++; + } + return inlines; +} +function getInlineLength(inline) { + if (!isInline(inline)) throw new Error("Invalid inline"); + if (isLineBreak(inline.firstChild)) return 0; + return inline.firstChild.nodeValue.length; +} +const TAG$1 = "DIV"; +const TYPE$1 = "root"; +const STYLES$1 = [["--vertical-align"]]; +function isRoot(node) { + if (!node) return false; + if (!isElement(node, TAG$1)) return false; + if (node.dataset.itype !== TYPE$1) return false; + return true; +} +function createRoot(paragraphs, styles, attrs) { + if (!Array.isArray(paragraphs) || !paragraphs.every(isParagraph)) + throw new TypeError("Invalid root children"); + return createElement(TAG$1, { + attributes: { id: createRandomId(), ...attrs }, + data: { itype: TYPE$1 }, + styles, + allowedStyles: STYLES$1, + children: paragraphs + }); +} +function createEmptyRoot(styles) { + return createRoot([createEmptyParagraph(styles)], styles); +} +function setRootStyles(element, styles) { + return setStyles(element, STYLES$1, styles); +} +function isTextNode(node) { + if (!node) throw new TypeError("Invalid text node"); + return node.nodeType === Node.TEXT_NODE || isLineBreak(node); +} +function getTextNodeLength(node) { + if (!node) throw new TypeError("Invalid text node"); + if (isLineBreak(node)) return 0; + return node.nodeValue.length; +} +function getClosestTextNode(node) { + if (isTextNode(node)) return node; + if (isInline(node)) return node.firstChild; + if (isParagraph(node)) return node.firstChild.firstChild; + if (isRoot(node)) return node.firstChild.firstChild.firstChild; + throw new Error("Cannot find a text node"); +} +const TAG = "DIV"; +const TYPE = "paragraph"; +const QUERY = `[data-itype="${TYPE}"]`; +const STYLES = [ + ["--typography-ref-id"], + ["--typography-ref-file"], + ["--font-id"], + ["--font-variant-id"], + ["--fills"], + ["font-variant"], + ["font-family"], + ["font-size", "px"], + ["font-weight"], + ["font-style"], + ["line-height"], + ["letter-spacing", "px"], + ["text-decoration"], + ["text-transform"], + ["text-align"], + ["direction"] +]; +function isLikeParagraph(element) { + return !isLikeInline(element); +} +function isEmptyParagraph(element) { + if (!isParagraph(element)) throw new TypeError("Invalid paragraph"); + const inline = element.firstChild; + if (!isInline(inline)) throw new TypeError("Invalid inline"); + return isLineBreak(inline.firstChild); +} +function isParagraph(node) { + if (!node) return false; + if (!isElement(node, TAG)) return false; + if (node.dataset.itype !== TYPE) return false; + return true; +} +function createParagraph(inlines, styles, attrs) { + if (inlines && (!Array.isArray(inlines) || !inlines.every(isInline))) + throw new TypeError("Invalid paragraph children"); + return createElement(TAG, { + attributes: { id: createRandomId(), ...attrs }, + data: { itype: TYPE }, + styles, + allowedStyles: STYLES, + children: inlines + }); +} +function createEmptyParagraph(styles) { + return createParagraph([ + createEmptyInline(styles) + ], styles); +} +function setParagraphStyles(element, styles) { + return setStyles(element, STYLES, styles); +} +function getParagraph(node) { + var _a; + if (!node) return null; + if (isParagraph(node)) return node; + if (node.nodeType === Node.TEXT_NODE || isLineBreak(node)) { + const paragraph = (_a = node == null ? void 0 : node.parentElement) == null ? void 0 : _a.parentElement; + if (!paragraph) { + return null; + } + if (!isParagraph(paragraph)) { + return null; + } + return paragraph; + } + return node.closest(QUERY); +} +function isParagraphStart(node, offset) { + const paragraph = getParagraph(node); + if (!paragraph) throw new Error("Can't find the paragraph"); + const inline = getInline(node); + if (!inline) throw new Error("Can't find the inline"); + return paragraph.firstElementChild === inline && isOffsetAtStart(inline.firstChild, offset); +} +function isParagraphEnd(node, offset) { + const paragraph = getParagraph(node); + if (!paragraph) throw new Error("Cannot find the paragraph"); + const inline = getInline(node); + if (!inline) throw new Error("Cannot find the inline"); + return paragraph.lastElementChild === inline && isOffsetAtEnd(inline.firstChild, offset); +} +function splitParagraph(paragraph, inline, offset) { + const style = paragraph.style; + if (isInlineEnd(inline, offset)) { + const newParagraph2 = createParagraph(getInlinesFrom(inline), style); + return newParagraph2; + } + const newInline = splitInline(inline, offset); + const newParagraph = createParagraph([newInline], style); + return newParagraph; +} +function mergeParagraphs(a, b) { + a.append(...b.children); + b.remove(); + return a; +} +function mapContentFragmentFromDocument(document2, root, styleDefaults) { + const nodeIterator = document2.createNodeIterator(root, NodeFilter.SHOW_TEXT); + const fragment = document2.createDocumentFragment(); + let currentParagraph = null; + let currentNode = nodeIterator.nextNode(); + while (currentNode) { + const parentStyle = normalizeStyles(mergeStyleDeclarations(styleDefaults, getComputedStyle(currentNode.parentElement))); + if (isDisplayBlock(currentNode.parentElement.style) || isDisplayBlock(parentStyle) || isLikeParagraph(currentNode.parentElement)) { + if (currentParagraph) { + fragment.appendChild(currentParagraph); + } + currentParagraph = createParagraph(void 0, parentStyle); + } else { + if (currentParagraph === null) { + currentParagraph = createParagraph(); + } + } + currentParagraph.appendChild( + createInline(new Text(currentNode.nodeValue), parentStyle) + ); + currentNode = nodeIterator.nextNode(); + } + fragment.appendChild(currentParagraph); + return fragment; +} +function mapContentFragmentFromHTML(html, styleDefaults) { + const parser = new DOMParser(); + const htmlDocument = parser.parseFromString(html, "text/html"); + return mapContentFragmentFromDocument( + htmlDocument, + htmlDocument.documentElement, + styleDefaults + ); +} +function mapContentFragmentFromString(string, styleDefaults) { + const lines = string.replace(/\r/g, "").split("\n"); + const fragment = document.createDocumentFragment(); + for (const line of lines) { + if (line === "") { + fragment.appendChild(createEmptyParagraph(styleDefaults)); + } else { + fragment.appendChild(createParagraph([createInline(new Text(line), styleDefaults)], styleDefaults)); + } + } + return fragment; +} +function paste(event, editor, selectionController) { + event.preventDefault(); + let fragment = null; + if (event.clipboardData.types.includes("text/html")) { + const html = event.clipboardData.getData("text/html"); + fragment = mapContentFragmentFromHTML(html, selectionController.currentStyle); + } else if (event.clipboardData.types.includes("text/plain")) { + const plain = event.clipboardData.getData("text/plain"); + fragment = mapContentFragmentFromString(plain, selectionController.currentStyle); + } + if (!fragment) { + return; + } + if (selectionController.isCollapsed) { + selectionController.insertPaste(fragment); + } else { + selectionController.replaceWithPaste(fragment); + } +} +const clipboard = { + copy, + cut, + paste +}; +function insertText(event, editor, selectionController) { + event.preventDefault(); + if (selectionController.isCollapsed) { + if (selectionController.isTextFocus) { + return selectionController.insertText(event.data); + } else if (selectionController.isLineBreakFocus) { + return selectionController.replaceLineBreak(event.data); + } + } else { + if (selectionController.isMultiParagraph) { + return selectionController.replaceParagraphs(event.data); + } else if (selectionController.isMultiInline) { + return selectionController.replaceInlines(event.data); + } else if (selectionController.isTextSame) { + return selectionController.replaceText(event.data); + } + } +} +function insertParagraph(event, editor, selectionController) { + event.preventDefault(); + if (selectionController.isCollapsed) { + return selectionController.insertParagraph(); + } + return selectionController.replaceWithParagraph(); +} +function deleteByCut(event, editor, selectionController) { + event.preventDefault(); + if (selectionController.isCollapsed) { + throw new Error("This should be impossible"); + } + return selectionController.removeSelected(); +} +function deleteContentBackward(event, editor, selectionController) { + event.preventDefault(); + if (editor.isEmpty) return; + if (!selectionController.isCollapsed) { + return selectionController.removeSelected({ direction: "backward" }); + } + if (selectionController.isTextFocus && selectionController.focusOffset > 0) { + return selectionController.removeBackwardText(); + } else if (selectionController.isTextFocus && selectionController.focusAtStart) { + return selectionController.mergeBackwardParagraph(); + } else if (selectionController.isInlineFocus || selectionController.isLineBreakFocus) { + return selectionController.removeBackwardParagraph(); + } +} +function deleteContentForward(event, editor, selectionController) { + event.preventDefault(); + if (editor.isEmpty) return; + if (!selectionController.isCollapsed) { + return selectionController.removeSelected({ direction: "forward" }); + } + if (selectionController.isTextFocus && selectionController.focusOffset >= 0) { + return selectionController.removeForwardText(); + } else if (selectionController.isTextFocus && selectionController.focusAtEnd) { + return selectionController.mergeForwardParagraph(); + } else if ((selectionController.isInlineFocus || selectionController.isLineBreakFocus) && editor.numParagraphs > 1) { + return selectionController.removeForwardParagraph(); + } +} +const commands = { + insertText, + insertParagraph, + deleteByCut, + deleteContentBackward, + deleteContentForward +}; +class ChangeController extends EventTarget { + /** + * Constructor + * + * @param {number} [time=500] + */ + constructor(time = 500) { + super(); + /** + * Keeps the timeout id. + * + * @type {number} + */ + __privateAdd(this, _timeout, null); + /** + * Keeps the time at which we're going to + * call the debounced change calls. + * + * @type {number} + */ + __privateAdd(this, _time, 1e3); + /** + * Keeps if we have some pending changes or not. + * + * @type {boolean} + */ + __privateAdd(this, _hasPendingChanges, false); + __privateAdd(this, _onTimeout, () => { + this.dispatchEvent(new Event("change")); + }); + if (typeof time === "number" && (!Number.isInteger(time) || time <= 0)) { + throw new TypeError("Invalid time"); + } + __privateSet(this, _time, time ?? 500); + } + /** + * Indicates that there are some pending changes. + * + * @type {boolean} + */ + get hasPendingChanges() { + return __privateGet(this, _hasPendingChanges); + } + /** + * Tells the ChangeController that a change has been made + * but that you need to delay the notification (and debounce) + * for sometime. + */ + notifyDebounced() { + __privateSet(this, _hasPendingChanges, true); + clearTimeout(__privateGet(this, _timeout)); + __privateSet(this, _timeout, setTimeout(__privateGet(this, _onTimeout), __privateGet(this, _time))); + } + /** + * Tells the ChangeController that a change should be notified + * immediately. + */ + notifyImmediately() { + clearTimeout(__privateGet(this, _timeout)); + __privateGet(this, _onTimeout).call(this); + } + /** + * Disposes the referenced resources. + */ + dispose() { + if (this.hasPendingChanges) { + this.notifyImmediately(); + } + clearTimeout(__privateGet(this, _timeout)); + } +} +_timeout = new WeakMap(); +_time = new WeakMap(); +_hasPendingChanges = new WeakMap(); +_onTimeout = new WeakMap(); +function tryOffset(offset) { + if (!Number.isInteger(offset) || offset < 0) + throw new TypeError("Invalid offset"); +} +function tryString(str) { + if (typeof str !== "string") throw new TypeError("Invalid string"); +} +function insertInto(str, offset, text) { + tryString(str); + tryOffset(offset); + tryString(text); + return str.slice(0, offset) + text + str.slice(offset); +} +function replaceWith(str, startOffset, endOffset, text) { + tryString(str); + tryOffset(startOffset); + tryOffset(endOffset); + tryString(text); + return str.slice(0, startOffset) + text + str.slice(endOffset); +} +function removeBackward(str, offset) { + tryString(str); + tryOffset(offset); + if (offset === 0) { + return str; + } + return str.slice(0, offset - 1) + str.slice(offset); +} +function removeForward(str, offset) { + tryString(str); + tryOffset(offset); + return str.slice(0, offset) + str.slice(offset + 1); +} +function removeSlice(str, start2, end) { + tryString(str); + tryOffset(start2); + tryOffset(end); + return str.slice(0, start2) + str.slice(end); +} +const TextNodeIteratorDirection = { + FORWARD: 1, + BACKWARD: 0 +}; +const _TextNodeIterator = class _TextNodeIterator { + /** + * Constructor + * + * @param {HTMLElement} rootNode + */ + constructor(rootNode) { + /** + * This is the root text node. + * + * @type {HTMLElement} + */ + __privateAdd(this, _rootNode, null); + /** + * This is the current text node. + * + * @type {Text|null} + */ + __privateAdd(this, _currentNode, null); + if (!(rootNode instanceof HTMLElement)) { + throw new TypeError("Invalid root node"); + } + __privateSet(this, _rootNode, rootNode); + __privateSet(this, _currentNode, _TextNodeIterator.findDown(rootNode, rootNode)); + } + /** + * Returns if a specific node is a text node. + * + * @param {Node} node + * @returns {boolean} + */ + static isTextNode(node) { + return node.nodeType === Node.TEXT_NODE || node.nodeType === Node.ELEMENT_NODE && node.nodeName === "BR"; + } + /** + * Returns if a specific node is a container node. + * + * @param {Node} node + * @returns {boolean} + */ + static isContainerNode(node) { + return node.nodeType === Node.ELEMENT_NODE && node.nodeName !== "BR"; + } + /** + * Finds a node from an initial node and down the tree. + * + * @param {Node} startNode + * @param {Node} rootNode + * @param {Set} skipNodes + * @param {number} direction + * @returns {Node} + */ + static findDown(startNode, rootNode, skipNodes = /* @__PURE__ */ new Set(), direction = TextNodeIteratorDirection.FORWARD) { + if (startNode === rootNode) { + return _TextNodeIterator.findDown( + direction === TextNodeIteratorDirection.FORWARD ? startNode.firstChild : startNode.lastChild, + rootNode, + skipNodes, + direction + ); + } + let safeGuard = Date.now(); + let currentNode = startNode; + while (currentNode) { + if (Date.now() - safeGuard >= 1e3) { + throw new Error("Iteration timeout"); + } + if (skipNodes.has(currentNode)) { + currentNode = direction === TextNodeIteratorDirection.FORWARD ? currentNode.nextSibling : currentNode.previousSibling; + continue; + } + if (_TextNodeIterator.isTextNode(currentNode)) { + return currentNode; + } else if (_TextNodeIterator.isContainerNode(currentNode)) { + return _TextNodeIterator.findDown( + direction === TextNodeIteratorDirection.FORWARD ? currentNode.firstChild : currentNode.lastChild, + rootNode, + skipNodes, + direction + ); + } + currentNode = direction === TextNodeIteratorDirection.FORWARD ? currentNode.nextSibling : currentNode.previousSibling; + } + return null; + } + /** + * Finds a node from an initial node and up the tree. + * + * @param {Node} startNode + * @param {Node} rootNode + * @param {Set} backTrack + * @param {number} direction + * @returns {Node} + */ + static findUp(startNode, rootNode, backTrack = /* @__PURE__ */ new Set(), direction = TextNodeIteratorDirection.FORWARD) { + backTrack.add(startNode); + if (_TextNodeIterator.isTextNode(startNode)) { + return _TextNodeIterator.findUp( + startNode.parentNode, + rootNode, + backTrack, + direction + ); + } else if (_TextNodeIterator.isContainerNode(startNode)) { + const found = _TextNodeIterator.findDown( + startNode, + rootNode, + backTrack, + direction + ); + if (found) { + return found; + } + if (startNode !== rootNode) { + return _TextNodeIterator.findUp( + startNode.parentNode, + rootNode, + backTrack, + direction + ); + } + } + return null; + } + /** + * Current node we're into. + * + * @type {TextNode|HTMLBRElement} + */ + get currentNode() { + return __privateGet(this, _currentNode); + } + set currentNode(newCurrentNode) { + const isContained = (newCurrentNode.compareDocumentPosition(__privateGet(this, _rootNode)) & Node.DOCUMENT_POSITION_CONTAINS) === Node.DOCUMENT_POSITION_CONTAINS; + if (!(newCurrentNode instanceof Node) || !_TextNodeIterator.isTextNode(newCurrentNode) || !isContained) { + throw new TypeError("Invalid new current node"); + } + __privateSet(this, _currentNode, newCurrentNode); + } + /** + * Returns the next Text node or
element or null if there are. + * + * @returns {Text|HTMLBRElement} + */ + nextNode() { + if (!__privateGet(this, _currentNode)) return null; + const nextNode = _TextNodeIterator.findUp( + __privateGet(this, _currentNode), + __privateGet(this, _rootNode), + /* @__PURE__ */ new Set(), + TextNodeIteratorDirection.FORWARD + ); + if (!nextNode) { + return null; + } + __privateSet(this, _currentNode, nextNode); + return __privateGet(this, _currentNode); + } + /** + * Returns the previous Text node or
element or null. + * + * @returns {Text|HTMLBRElement} + */ + previousNode() { + if (!__privateGet(this, _currentNode)) return null; + const previousNode = _TextNodeIterator.findUp( + __privateGet(this, _currentNode), + __privateGet(this, _rootNode), + /* @__PURE__ */ new Set(), + TextNodeIteratorDirection.BACKWARD + ); + if (!previousNode) { + return null; + } + __privateSet(this, _currentNode, previousNode); + return __privateGet(this, _currentNode); + } +}; +_rootNode = new WeakMap(); +_currentNode = new WeakMap(); +let TextNodeIterator = _TextNodeIterator; +class CommandMutations { + constructor(added, updated, removed) { + __privateAdd(this, _added, /* @__PURE__ */ new Set()); + __privateAdd(this, _removed, /* @__PURE__ */ new Set()); + __privateAdd(this, _updated, /* @__PURE__ */ new Set()); + if (added && Array.isArray(added)) __privateSet(this, _added, new Set(added)); + if (updated && Array.isArray(updated)) __privateSet(this, _updated, new Set(updated)); + if (removed && Array.isArray(removed)) __privateSet(this, _removed, new Set(removed)); + } + get added() { + return __privateGet(this, _added); + } + get removed() { + return __privateGet(this, _removed); + } + get updated() { + return __privateGet(this, _updated); + } + clear() { + __privateGet(this, _added).clear(); + __privateGet(this, _removed).clear(); + __privateGet(this, _updated).clear(); + } + dispose() { + __privateGet(this, _added).clear(); + __privateSet(this, _added, null); + __privateGet(this, _removed).clear(); + __privateSet(this, _removed, null); + __privateGet(this, _updated).clear(); + __privateSet(this, _updated, null); + } + add(node) { + __privateGet(this, _added).add(node); + return this; + } + remove(node) { + __privateGet(this, _removed).add(node); + return this; + } + update(node) { + __privateGet(this, _updated).add(node); + return this; + } +} +_added = new WeakMap(); +_removed = new WeakMap(); +_updated = new WeakMap(); +const SelectionDirection = { + /** The anchorNode is behind the focusNode */ + FORWARD: 1, + /** The focusNode and the anchorNode are collapsed */ + NONE: 0, + /** The focusNode is behind the anchorNode */ + BACKWARD: -1 +}; +const SAFE_GUARD_TIME = 1e3; +let startTime = Date.now(); +function start() { + startTime = Date.now(); +} +function update() { + if (Date.now - startTime >= SAFE_GUARD_TIME) { + throw new Error("Safe guard timeout"); + } +} +const SafeGuard = { + start, + update +}; +class SelectionController extends EventTarget { + /** + * Constructor + * + * @param {TextEditor} textEditor + * @param {Selection} selection + * @param {SelectionControllerOptions} [options] + */ + constructor(textEditor, selection, options) { + super(); + __privateAdd(this, _SelectionController_instances); + /** + * Reference to the text editor. + * + * @type {TextEditor} + */ + __privateAdd(this, _textEditor, null); + /** + * Selection. + * + * @type {Selection} + */ + __privateAdd(this, _selection, null); + /** + * Set of ranges (this should always have one) + * + * @type {Set} + */ + __privateAdd(this, _ranges, /* @__PURE__ */ new Set()); + /** + * Current range (.rangeAt 0) + * + * @type {Range} + */ + __privateAdd(this, _range, null); + /** + * @type {Node} + */ + __privateAdd(this, _focusNode, null); + /** + * @type {number} + */ + __privateAdd(this, _focusOffset, 0); + /** + * @type {Node} + */ + __privateAdd(this, _anchorNode, null); + /** + * @type {number} + */ + __privateAdd(this, _anchorOffset, 0); + /** + * Saved selection. + * + * @type {object} + */ + __privateAdd(this, _savedSelection, null); + /** + * TextNodeIterator that allows us to move + * around the root element but only through + *
and #text nodes. + * + * @type {TextNodeIterator} + */ + __privateAdd(this, _textNodeIterator, null); + /** + * CSSStyleDeclaration that we can mutate + * to handle style changes. + * + * @type {CSSStyleDeclaration} + */ + __privateAdd(this, _currentStyle, null); + /** + * Element used to have a custom CSSStyleDeclaration + * that we can modify to handle style changes when the + * selection is changed. + * + * @type {HTMLDivElement} + */ + __privateAdd(this, _inertElement, null); + /** + * @type {SelectionControllerDebug} + */ + __privateAdd(this, _debug, null); + /** + * Command Mutations. + * + * @type {CommandMutations} + */ + __privateAdd(this, _mutations, new CommandMutations()); + /** + * Style defaults. + * + * @type {Object.} + */ + __privateAdd(this, _styleDefaults, null); + /** + * This is called on every `selectionchange` because it is dispatched + * only by the `document` object. + * + * @param {Event} e + */ + __privateAdd(this, _onSelectionChange, (e) => { + if (!this.hasFocus) return; + let focusNodeChanges = false; + if (__privateGet(this, _focusNode) !== __privateGet(this, _selection).focusNode) { + __privateSet(this, _focusNode, __privateGet(this, _selection).focusNode); + focusNodeChanges = true; + } + __privateSet(this, _focusOffset, __privateGet(this, _selection).focusOffset); + if (__privateGet(this, _anchorNode) !== __privateGet(this, _selection).anchorNode) { + __privateSet(this, _anchorNode, __privateGet(this, _selection).anchorNode); + } + __privateSet(this, _anchorOffset, __privateGet(this, _selection).anchorOffset); + if (__privateGet(this, _selection).rangeCount > 1) { + for (let index = 0; index < __privateGet(this, _selection).rangeCount; index++) { + const range = __privateGet(this, _selection).getRangeAt(index); + if (__privateGet(this, _ranges).has(range)) { + __privateGet(this, _ranges).delete(range); + __privateGet(this, _selection).removeRange(range); + } else { + __privateGet(this, _ranges).add(range); + __privateSet(this, _range, range); + } + } + } else if (__privateGet(this, _selection).rangeCount > 0) { + const range = __privateGet(this, _selection).getRangeAt(0); + __privateSet(this, _range, range); + __privateGet(this, _ranges).clear(); + __privateGet(this, _ranges).add(range); + } else { + __privateSet(this, _range, null); + __privateGet(this, _ranges).clear(); + } + if (focusNodeChanges) { + __privateMethod(this, _SelectionController_instances, notifyStyleChange_fn).call(this); + } + if (__privateGet(this, _debug)) { + __privateGet(this, _debug).update(this); + } + }); + __privateSet(this, _debug, options == null ? void 0 : options.debug); + __privateSet(this, _styleDefaults, options == null ? void 0 : options.styleDefaults); + __privateSet(this, _selection, selection); + __privateSet(this, _textEditor, textEditor); + __privateSet(this, _textNodeIterator, new TextNodeIterator(__privateGet(this, _textEditor).element)); + __privateMethod(this, _SelectionController_instances, setup_fn).call(this); + } + /** + * Styles of the current inline. + * + * @type {CSSStyleDeclaration} + */ + get currentStyle() { + return __privateGet(this, _currentStyle); + } + /** + * Saves the current selection and returns the client rects. + * + * @returns {boolean} + */ + saveSelection() { + __privateSet(this, _savedSelection, { + isCollapsed: __privateGet(this, _selection).isCollapsed, + focusNode: __privateGet(this, _selection).focusNode, + focusOffset: __privateGet(this, _selection).focusOffset, + anchorNode: __privateGet(this, _selection).anchorNode, + anchorOffset: __privateGet(this, _selection).anchorOffset, + range: __privateMethod(this, _SelectionController_instances, getSavedRange_fn).call(this) + }); + return true; + } + /** + * Restores a saved selection if there's any. + * + * @returns {boolean} + */ + restoreSelection() { + if (!__privateGet(this, _savedSelection)) return false; + if (__privateGet(this, _savedSelection).anchorNode && __privateGet(this, _savedSelection).focusNode) { + if (__privateGet(this, _savedSelection).anchorNode === __privateGet(this, _savedSelection).focusNode) { + __privateGet(this, _selection).setPosition(__privateGet(this, _savedSelection).focusNode, __privateGet(this, _savedSelection).focusOffset); + } else { + __privateGet(this, _selection).setBaseAndExtent( + __privateGet(this, _savedSelection).anchorNode, + __privateGet(this, _savedSelection).anchorOffset, + __privateGet(this, _savedSelection).focusNode, + __privateGet(this, _savedSelection).focusOffset + ); + } + } + __privateSet(this, _savedSelection, null); + return true; + } + /** + * Marks the start of a mutation. + * + * Clears all the mutations kept in CommandMutations. + */ + startMutation() { + __privateGet(this, _mutations).clear(); + if (!__privateGet(this, _focusNode)) return false; + return true; + } + /** + * Marks the end of a mutation. + * + * @returns + */ + endMutation() { + return __privateGet(this, _mutations); + } + /** + * Selects all content. + */ + selectAll() { + __privateGet(this, _selection).selectAllChildren(__privateGet(this, _textEditor).root); + return this; + } + /** + * Moves cursor to end. + */ + cursorToEnd() { + const range = document.createRange(); + range.selectNodeContents(__privateGet(this, _textEditor).element); + range.collapse(false); + __privateGet(this, _selection).removeAllRanges(); + __privateGet(this, _selection).addRange(range); + return this; + } + /** + * Collapses a selection. + * + * @param {Node} node + * @param {number} offset + */ + collapse(node, offset) { + const nodeOffset = node.nodeType === Node.TEXT_NODE && offset >= node.nodeValue.length ? node.nodeValue.length : offset; + return this.setSelection( + node, + nodeOffset, + node, + nodeOffset + ); + } + /** + * Sets base and extent. + * + * @param {Node} anchorNode + * @param {number} anchorOffset + * @param {Node} [focusNode=anchorNode] + * @param {number} [focusOffset=anchorOffset] + */ + setSelection(anchorNode, anchorOffset, focusNode = anchorNode, focusOffset = anchorOffset) { + if (!anchorNode.isConnected) { + throw new Error("Invalid anchorNode"); + } + if (!focusNode.isConnected) { + throw new Error("Invalid focusNode"); + } + if (__privateGet(this, _savedSelection)) { + __privateGet(this, _savedSelection).isCollapsed = focusNode === anchorNode && anchorOffset === focusOffset; + __privateGet(this, _savedSelection).focusNode = focusNode; + __privateGet(this, _savedSelection).focusOffset = focusOffset; + __privateGet(this, _savedSelection).anchorNode = anchorNode; + __privateGet(this, _savedSelection).anchorOffset = anchorOffset; + __privateGet(this, _savedSelection).range.collapsed = __privateGet(this, _savedSelection).isCollapsed; + const position = focusNode.compareDocumentPosition(anchorNode); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + __privateGet(this, _savedSelection).range.startContainer = focusNode; + __privateGet(this, _savedSelection).range.startOffset = focusOffset; + __privateGet(this, _savedSelection).range.endContainer = anchorNode; + __privateGet(this, _savedSelection).range.endOffset = anchorOffset; + } else { + __privateGet(this, _savedSelection).range.startContainer = anchorNode; + __privateGet(this, _savedSelection).range.startOffset = anchorOffset; + __privateGet(this, _savedSelection).range.endContainer = focusNode; + __privateGet(this, _savedSelection).range.endOffset = focusOffset; + } + } else { + __privateSet(this, _anchorNode, anchorNode); + __privateSet(this, _anchorOffset, anchorOffset); + if (anchorNode === focusNode) { + __privateSet(this, _focusNode, __privateGet(this, _anchorNode)); + __privateSet(this, _focusOffset, __privateGet(this, _anchorOffset)); + __privateGet(this, _selection).setPosition(anchorNode, anchorOffset); + } else { + __privateSet(this, _focusNode, focusNode); + __privateSet(this, _focusOffset, focusOffset); + __privateGet(this, _selection).setBaseAndExtent( + anchorNode, + anchorOffset, + focusNode, + focusOffset + ); + } + } + } + /** + * Disposes the current resources. + */ + dispose() { + document.removeEventListener("selectionchange", __privateGet(this, _onSelectionChange)); + __privateSet(this, _textEditor, null); + __privateGet(this, _ranges).clear(); + __privateSet(this, _ranges, null); + __privateSet(this, _range, null); + __privateSet(this, _selection, null); + __privateSet(this, _focusNode, null); + __privateSet(this, _anchorNode, null); + __privateGet(this, _mutations).dispose(); + __privateSet(this, _mutations, null); + } + /** + * Returns the current selection. + * + * @type {Selection} + */ + get selection() { + return __privateGet(this, _selection); + } + /** + * Returns the current range. + * + * @type {Range} + */ + get range() { + return __privateGet(this, _range); + } + /** + * Indicates the direction of the selection + * + * @type {SelectionDirection} + */ + get direction() { + if (this.isCollapsed) { + return SelectionDirection.NONE; + } + if (this.focusNode !== this.anchorNode) { + return this.startContainer === this.focusNode ? SelectionDirection.BACKWARD : SelectionDirection.FORWARD; + } + return this.focusOffset < this.anchorOffset ? SelectionDirection.BACKWARD : SelectionDirection.FORWARD; + } + /** + * Indicates that the editor element has the + * focus. + * + * @type {boolean} + */ + get hasFocus() { + return document.activeElement === __privateGet(this, _textEditor).element; + } + /** + * Returns true if the selection is collapsed (caret) + * or false otherwise. + * + * @type {boolean} + */ + get isCollapsed() { + if (__privateGet(this, _savedSelection)) { + return __privateGet(this, _savedSelection).isCollapsed; + } + return __privateGet(this, _selection).isCollapsed; + } + /** + * Current or saved anchor node. + * + * @type {Node} + */ + get anchorNode() { + if (__privateGet(this, _savedSelection)) { + return __privateGet(this, _savedSelection).anchorNode; + } + return __privateGet(this, _anchorNode); + } + /** + * Current or saved anchor offset. + * + * @type {number} + */ + get anchorOffset() { + if (__privateGet(this, _savedSelection)) { + return __privateGet(this, _savedSelection).anchorOffset; + } + return __privateGet(this, _selection).anchorOffset; + } + /** + * Indicates that the caret is at the start of the node. + * + * @type {boolean} + */ + get anchorAtStart() { + return this.anchorOffset === 0; + } + /** + * Indicates that the caret is at the end of the node. + * + * @type {boolean} + */ + get anchorAtEnd() { + return this.anchorOffset === this.anchorNode.nodeValue.length; + } + /** + * Current or saved focus node. + * + * @type {Node} + */ + get focusNode() { + if (__privateGet(this, _savedSelection)) { + return __privateGet(this, _savedSelection).focusNode; + } + if (!__privateGet(this, _focusNode)) + console.trace("focusNode", __privateGet(this, _focusNode)); + return __privateGet(this, _focusNode); + } + /** + * Current or saved focus offset. + * + * @type {number} + */ + get focusOffset() { + if (__privateGet(this, _savedSelection)) { + return __privateGet(this, _savedSelection).focusOffset; + } + return __privateGet(this, _focusOffset); + } + /** + * Indicates that the caret is at the start of the node. + * + * @type {boolean} + */ + get focusAtStart() { + return this.focusOffset === 0; + } + /** + * Indicates that the caret is at the end of the node. + * + * @type {boolean} + */ + get focusAtEnd() { + return this.focusOffset === this.focusNode.nodeValue.length; + } + /** + * Returns the paragraph in the focus node + * of the current selection. + * + * @type {HTMLElement|null} + */ + get focusParagraph() { + return getParagraph(this.focusNode); + } + /** + * Returns the inline in the focus node + * of the current selection. + * + * @type {HTMLElement|null} + */ + get focusInline() { + return getInline(this.focusNode); + } + /** + * Returns the current paragraph in the anchor + * node of the current selection. + * + * @type {HTMLElement|null} + */ + get anchorParagraph() { + return getParagraph(this.anchorNode); + } + /** + * Returns the current inline in the anchor + * node of the current selection. + * + * @type {HTMLElement|null} + */ + get anchorInline() { + return getInline(this.anchorNode); + } + /** + * Start container of the current range. + */ + get startContainer() { + var _a, _b, _c; + if (__privateGet(this, _savedSelection)) { + return (_b = (_a = __privateGet(this, _savedSelection)) == null ? void 0 : _a.range) == null ? void 0 : _b.startContainer; + } + return (_c = __privateGet(this, _range)) == null ? void 0 : _c.startContainer; + } + /** + * `startOffset` of the current range. + * + * @type {number|null} + */ + get startOffset() { + var _a, _b, _c; + if (__privateGet(this, _savedSelection)) { + return (_b = (_a = __privateGet(this, _savedSelection)) == null ? void 0 : _a.range) == null ? void 0 : _b.startOffset; + } + return (_c = __privateGet(this, _range)) == null ? void 0 : _c.startOffset; + } + /** + * Start paragraph of the current range. + * + * @type {HTMLElement|null} + */ + get startParagraph() { + const startContainer = this.startContainer; + if (!startContainer) return null; + return getParagraph(startContainer); + } + /** + * Start inline of the current page. + * + * @type {HTMLElement|null} + */ + get startInline() { + const startContainer = this.startContainer; + if (!startContainer) return null; + return getInline(startContainer); + } + /** + * End container of the current range. + * + * @type {Node} + */ + get endContainer() { + var _a, _b, _c; + if (__privateGet(this, _savedSelection)) { + return (_b = (_a = __privateGet(this, _savedSelection)) == null ? void 0 : _a.range) == null ? void 0 : _b.endContainer; + } + return (_c = __privateGet(this, _range)) == null ? void 0 : _c.endContainer; + } + /** + * `endOffset` of the current range + * + * @type {HTMLElement|null} + */ + get endOffset() { + var _a, _b, _c; + if (__privateGet(this, _savedSelection)) { + return (_b = (_a = __privateGet(this, _savedSelection)) == null ? void 0 : _a.range) == null ? void 0 : _b.endOffset; + } + return (_c = __privateGet(this, _range)) == null ? void 0 : _c.endOffset; + } + /** + * Paragraph element of the `endContainer` of + * the current range. + * + * @type {HTMLElement|null} + */ + get endParagraph() { + const endContainer = this.endContainer; + if (!endContainer) return null; + return getParagraph(endContainer); + } + /** + * Inline element of the `endContainer` of + * the current range. + * + * @type {HTMLElement|null} + */ + get endInline() { + const endContainer = this.endContainer; + if (!endContainer) return null; + return getInline(endContainer); + } + /** + * Returns true if the anchor node and the focus + * node are the same text nodes. + * + * @type {boolean} + */ + get isTextSame() { + return this.isTextFocus === this.isTextAnchor && this.focusNode === this.anchorNode; + } + /** + * Indicates that focus node is a text node. + * + * @type {boolean} + */ + get isTextFocus() { + return this.focusNode.nodeType === Node.TEXT_NODE; + } + /** + * Indicates that anchor node is a text node. + * + * @type {boolean} + */ + get isTextAnchor() { + return this.anchorNode.nodeType === Node.TEXT_NODE; + } + /** + * Is true if the current focus node is a inline. + * + * @type {boolean} + */ + get isInlineFocus() { + return isInline(this.focusNode); + } + /** + * Is true if the current anchor node is a inline. + * + * @type {boolean} + */ + get isInlineAnchor() { + return isInline(this.anchorNode); + } + /** + * Is true if the current focus node is a paragraph. + * + * @type {boolean} + */ + get isParagraphFocus() { + return isParagraph(this.focusNode); + } + /** + * Is true if the current anchor node is a paragraph. + * + * @type {boolean} + */ + get isParagraphAnchor() { + return isParagraph(this.anchorNode); + } + /** + * Is true if the current focus node is a line break. + * + * @type {boolean} + */ + get isLineBreakFocus() { + return isLineBreak(this.focusNode) || isInline(this.focusNode) && isLineBreak(this.focusNode.firstChild); + } + /** + * Indicates that we have multiple nodes selected. + * + * @type {boolean} + */ + get isMulti() { + return this.focusNode !== this.anchorNode; + } + /** + * Indicates that we have selected multiple + * paragraph elements. + * + * @type {boolean} + */ + get isMultiParagraph() { + return this.isMulti && this.focusParagraph !== this.anchorParagraph; + } + /** + * Indicates that we have selected multiple + * inline elements. + * + * @type {boolean} + */ + get isMultiInline() { + return this.isMulti && this.focusInline !== this.anchorInline; + } + /** + * Indicates that the caret (only the caret) + * is at the start of an inline. + * + * @type {boolean} + */ + get isInlineStart() { + if (!this.isCollapsed) return false; + return isInlineStart(this.focusNode, this.focusOffset); + } + /** + * Indicates that the caret (only the caret) + * is at the end of an inline. This value doesn't + * matter when dealing with selections. + * + * @type {boolean} + */ + get isInlineEnd() { + if (!this.isCollapsed) return false; + return isInlineEnd(this.focusNode, this.focusOffset); + } + /** + * Indicates that we're in the starting position of a paragraph. + * + * @type {boolean} + */ + get isParagraphStart() { + if (!this.isCollapsed) return false; + return isParagraphStart(this.focusNode, this.focusOffset); + } + /** + * Indicates that we're in the ending position of a paragraph. + * + * @type {boolean} + */ + get isParagraphEnd() { + if (!this.isCollapsed) return false; + return isParagraphEnd(this.focusNode, this.focusOffset); + } + /** + * Insert pasted fragment. + * + * @param {DocumentFragment} fragment + */ + insertPaste(fragment) { + fragment.children.length; + if (this.isParagraphStart) { + this.focusParagraph.before(fragment); + } else if (this.isParagraphEnd) { + this.focusParagraph.after(fragment); + } else { + const newParagraph = splitParagraph( + this.focusParagraph, + this.focusInline, + this.focusOffset + ); + this.focusParagraph.after(fragment, newParagraph); + } + } + /** + * Replaces data with pasted fragment + * + * @param {DocumentFragment} fragment + */ + replaceWithPaste(fragment) { + fragment.children.length; + this.removeSelected(); + this.insertPaste(fragment); + } + /** + * Replaces the current line break with text + * + * @param {string} text + */ + replaceLineBreak(text) { + const newText = new Text(text); + this.focusInline.replaceChildren(newText); + this.collapse(newText, text.length); + } + /** + * Removes text forward from the current position. + */ + removeForwardText() { + __privateGet(this, _textNodeIterator).currentNode = this.focusNode; + const removedData = removeForward( + this.focusNode.nodeValue, + this.focusOffset + ); + if (this.focusNode.nodeValue !== removedData) { + this.focusNode.nodeValue = removedData; + } + const paragraph = this.focusParagraph; + if (!paragraph) throw new Error("Cannot find paragraph"); + const inline = this.focusInline; + if (!inline) throw new Error("Cannot find inline"); + const nextTextNode = __privateGet(this, _textNodeIterator).nextNode(); + if (this.focusNode.nodeValue === "") { + this.focusNode.remove(); + } + if (paragraph.childNodes.length === 1 && inline.childNodes.length === 0) { + const lineBreak = createLineBreak(); + inline.appendChild(lineBreak); + return this.collapse(lineBreak, 0); + } else if (paragraph.childNodes.length > 1 && inline.childNodes.length === 0) { + inline.remove(); + return this.collapse(nextTextNode, 0); + } + return this.collapse(this.focusNode, this.focusOffset); + } + /** + * Removes text backward from the current caret position. + */ + removeBackwardText() { + __privateGet(this, _textNodeIterator).currentNode = this.focusNode; + const removedData = removeBackward( + this.focusNode.nodeValue, + this.focusOffset + ); + if (this.focusNode.nodeValue !== removedData) { + this.focusNode.nodeValue = removedData; + } + if (this.focusOffset - 1 > 0) { + return this.collapse(this.focusNode, this.focusOffset - 1); + } + const paragraph = this.focusParagraph; + if (!paragraph) throw new Error("Cannot find paragraph"); + const inline = this.focusInline; + if (!inline) throw new Error("Cannot find inline"); + const previousTextNode = __privateGet(this, _textNodeIterator).previousNode(); + if (this.focusNode.nodeValue === "") { + this.focusNode.remove(); + } + if (paragraph.children.length === 1 && inline.childNodes.length === 0) { + const lineBreak = createLineBreak(); + inline.appendChild(lineBreak); + return this.collapse(lineBreak, 0); + } else if (paragraph.children.length > 1 && inline.childNodes.length === 0) { + inline.remove(); + return this.collapse(previousTextNode, getTextNodeLength(previousTextNode)); + } + return this.collapse(this.focusNode, this.focusOffset - 1); + } + /** + * Inserts some text in the caret position. + * + * @param {string} newText + */ + insertText(newText) { + this.focusNode.nodeValue = insertInto( + this.focusNode.nodeValue, + this.focusOffset, + newText + ); + __privateGet(this, _mutations).update(this.focusInline); + return this.collapse(this.focusNode, this.focusOffset + newText.length); + } + /** + * Replaces currently selected text. + * + * @param {string} newText + */ + replaceText(newText) { + const startOffset = Math.min(this.anchorOffset, this.focusOffset); + const endOffset = Math.max(this.anchorOffset, this.focusOffset); + this.focusNode.nodeValue = replaceWith( + this.focusNode.nodeValue, + startOffset, + endOffset, + newText + ); + __privateGet(this, _mutations).update(this.focusInline); + return this.collapse(this.focusNode, startOffset + newText.length); + } + /** + * Replaces the selected inlines with new text. + * + * @param {string} newText + */ + replaceInlines(newText) { + const currentParagraph = this.focusParagraph; + if (this.startInline === currentParagraph.firstChild && this.startOffset === 0 && this.endInline === currentParagraph.lastChild && this.endOffset === currentParagraph.lastChild.textContent.length) { + const newTextNode = new Text(newText); + currentParagraph.replaceChildren( + createInline(newTextNode, this.anchorInline.style) + ); + return this.collapse(newTextNode, newTextNode.nodeValue.length); + } + this.removeSelected(); + this.focusNode.nodeValue = insertInto( + this.focusNode.nodeValue, + this.focusOffset, + newText + ); + return this.collapse(this.focusNode, this.focusOffset + newText.length); + } + /** + * Replaces paragraphs with text. + * + * @param {string} newText + */ + replaceParagraphs(newText) { + const currentParagraph = this.focusParagraph; + this.removeSelected(); + this.focusNode.nodeValue = insertInto( + this.focusNode.nodeValue, + this.focusOffset, + newText + ); + for (const child of currentParagraph.children) { + if (child.textContent === "") { + child.remove(); + } + } + } + /** + * Inserts a new paragraph after the current paragraph. + */ + insertParagraphAfter() { + const currentParagraph = this.focusParagraph; + const newParagraph = createEmptyParagraph(__privateGet(this, _currentStyle)); + currentParagraph.after(newParagraph); + __privateGet(this, _mutations).update(currentParagraph); + __privateGet(this, _mutations).add(newParagraph); + return this.collapse(newParagraph.firstChild.firstChild, 0); + } + /** + * Inserts a new paragraph before the current paragraph. + */ + insertParagraphBefore() { + const currentParagraph = this.focusParagraph; + const newParagraph = createEmptyParagraph(__privateGet(this, _currentStyle)); + currentParagraph.before(newParagraph); + __privateGet(this, _mutations).update(currentParagraph); + __privateGet(this, _mutations).add(newParagraph); + return this.collapse(currentParagraph.firstChild.firstChild, 0); + } + /** + * Splits the current paragraph. + */ + splitParagraph() { + const currentParagraph = this.focusParagraph; + const newParagraph = splitParagraph( + this.focusParagraph, + this.focusInline, + __privateGet(this, _focusOffset) + ); + this.focusParagraph.after(newParagraph); + __privateGet(this, _mutations).update(currentParagraph); + __privateGet(this, _mutations).add(newParagraph); + return this.collapse(newParagraph.firstChild.firstChild, 0); + } + /** + * Inserts a new paragraph. + */ + insertParagraph() { + if (this.isParagraphEnd) { + return this.insertParagraphAfter(); + } else if (this.isParagraphStart) { + return this.insertParagraphBefore(); + } + return this.splitParagraph(); + } + /** + * Replaces the currently selected content with + * a paragraph. + */ + replaceWithParagraph() { + const currentParagraph = this.focusParagraph; + const currentInline = this.focusInline; + this.removeSelected(); + const newParagraph = splitParagraph( + currentParagraph, + currentInline, + this.focusOffset + ); + currentParagraph.after(newParagraph); + __privateGet(this, _mutations).update(currentParagraph); + __privateGet(this, _mutations).add(newParagraph); + } + /** + * Removes a paragraph in backward direction. + */ + removeBackwardParagraph() { + const previousParagraph = this.focusParagraph.previousElementSibling; + if (!previousParagraph) { + return; + } + const paragraphToBeRemoved = this.focusParagraph; + paragraphToBeRemoved.remove(); + const previousInline = previousParagraph.children.length > 1 ? previousParagraph.lastElementChild : previousParagraph.firstChild; + const previousOffset = isLineBreak(previousInline.firstChild) ? 0 : previousInline.firstChild.nodeValue.length; + __privateGet(this, _mutations).remove(paragraphToBeRemoved); + return this.collapse(previousInline.firstChild, previousOffset); + } + /** + * Merges the previous paragraph with the current paragraph. + */ + mergeBackwardParagraph() { + const currentParagraph = this.focusParagraph; + const previousParagraph = this.focusParagraph.previousElementSibling; + if (!previousParagraph) { + return; + } + let previousInline = previousParagraph.lastChild; + const previousOffset = getInlineLength(previousInline); + if (isEmptyParagraph(previousParagraph)) { + previousParagraph.replaceChildren(...currentParagraph.children); + previousInline = previousParagraph.firstChild; + currentParagraph.remove(); + } else { + mergeParagraphs(previousParagraph, currentParagraph); + } + __privateGet(this, _mutations).remove(currentParagraph); + __privateGet(this, _mutations).update(previousParagraph); + return this.collapse(previousInline.firstChild, previousOffset); + } + /** + * Merges the next paragraph with the current paragraph. + */ + mergeForwardParagraph() { + const currentParagraph = this.focusParagraph; + const nextParagraph = this.focusParagraph.nextElementSibling; + if (!nextParagraph) { + return; + } + mergeParagraphs(this.focusParagraph, nextParagraph); + __privateGet(this, _mutations).update(currentParagraph); + __privateGet(this, _mutations).remove(nextParagraph); + } + /** + * Removes the forward paragraph. + */ + removeForwardParagraph() { + const nextParagraph = this.focusParagraph.nextSibling; + if (!nextParagraph) { + return; + } + const paragraphToBeRemoved = this.focusParagraph; + paragraphToBeRemoved.remove(); + const nextInline = nextParagraph.firstChild; + const nextOffset = this.focusOffset; + __privateGet(this, _mutations).remove(paragraphToBeRemoved); + return this.collapse(nextInline.firstChild, nextOffset); + } + /** + * Cleans up all the affected paragraphs. + * + * @param {Set} affectedParagraphs + * @param {Set} affectedInlines + */ + cleanUp(affectedParagraphs, affectedInlines) { + for (const inline of affectedInlines) { + if (inline.textContent === "") { + inline.remove(); + __privateGet(this, _mutations).remove(inline); + } + } + for (const paragraph of affectedParagraphs) { + if (paragraph.children.length === 0) { + paragraph.remove(); + __privateGet(this, _mutations).remove(paragraph); + } + } + } + /** + * Removes the selected content. + * + * @param {RemoveSelectedOptions} [options] + */ + removeSelected(options) { + if (this.isCollapsed) return; + const affectedInlines = /* @__PURE__ */ new Set(); + const affectedParagraphs = /* @__PURE__ */ new Set(); + const startNode = getClosestTextNode(__privateGet(this, _range).startContainer); + const endNode = getClosestTextNode(__privateGet(this, _range).endContainer); + const startOffset = __privateGet(this, _range).startOffset; + const endOffset = __privateGet(this, _range).endOffset; + if (startNode === endNode) { + __privateGet(this, _textNodeIterator).currentNode = startNode; + __privateGet(this, _textNodeIterator).previousNode(); + __privateGet(this, _textNodeIterator).currentNode = startNode; + __privateGet(this, _textNodeIterator).nextNode(); + const inline = getInline(startNode); + const paragraph = getParagraph(startNode); + affectedInlines.add(inline); + affectedParagraphs.add(paragraph); + const newNodeValue = removeSlice( + startNode.nodeValue, + startOffset, + endOffset + ); + if (newNodeValue === "") { + const lineBreak = createLineBreak(); + inline.replaceChildren(lineBreak); + return this.collapse(lineBreak, 0); + } + startNode.nodeValue = newNodeValue; + return this.collapse(startNode, startOffset); + } + __privateGet(this, _textNodeIterator).currentNode = startNode; + const startInline = getInline(startNode); + const startParagraph = getParagraph(startNode); + const endInline = getInline(endNode); + const endParagraph = getParagraph(endNode); + SafeGuard.start(); + do { + SafeGuard.update(); + const currentNode = __privateGet(this, _textNodeIterator).currentNode; + const inline = getInline(__privateGet(this, _textNodeIterator).currentNode); + const paragraph = getParagraph(__privateGet(this, _textNodeIterator).currentNode); + let shouldRemoveNodeCompletely = false; + if (__privateGet(this, _textNodeIterator).currentNode === startNode) { + if (startOffset === 0) { + shouldRemoveNodeCompletely = true; + } else { + currentNode.nodeValue = currentNode.nodeValue.slice(0, startOffset); + } + } else if (__privateGet(this, _textNodeIterator).currentNode === endNode) { + if (isLineBreak(endNode) || isTextNode(endNode) && endOffset === endNode.nodeValue.length) { + shouldRemoveNodeCompletely = true; + } else { + currentNode.nodeValue = currentNode.nodeValue.slice(endOffset); + } + } else { + shouldRemoveNodeCompletely = true; + } + __privateGet(this, _textNodeIterator).nextNode(); + if (shouldRemoveNodeCompletely) { + currentNode.remove(); + if (currentNode === startNode) { + continue; + } + if (currentNode === endNode) { + break; + } + if (inline.childNodes.length === 0) { + inline.remove(); + } + if (paragraph !== startParagraph && paragraph.children.length === 0) { + paragraph.remove(); + } + } + if (currentNode === endNode) { + break; + } + } while (__privateGet(this, _textNodeIterator).currentNode); + if (startParagraph !== endParagraph) { + const mergedParagraph = mergeParagraphs(startParagraph, endParagraph); + if (mergedParagraph.children.length === 0) { + const newEmptyInline = createEmptyInline(__privateGet(this, _currentStyle)); + mergedParagraph.appendChild(newEmptyInline); + return this.collapse(newEmptyInline.firstChild, 0); + } + } + if (startInline.childNodes.length === 0 && endInline.childNodes.length > 0) { + startInline.remove(); + return this.collapse(endNode, 0); + } else if (startInline.childNodes.length > 0 && endInline.childNodes.length === 0) { + endInline.remove(); + return this.collapse(startNode, startOffset); + } else if (startInline.childNodes.length === 0 && endInline.childNodes.length === 0) { + const previousInline = startInline.previousElementSibling; + const nextInline = endInline.nextElementSibling; + startInline.remove(); + endInline.remove(); + if (previousInline) { + return this.collapse(previousInline.firstChild, previousInline.firstChild.nodeValue.length); + } + if (nextInline) { + return this.collapse(nextInline.firstChild, 0); + } + const newEmptyInline = createEmptyInline(__privateGet(this, _currentStyle)); + startParagraph.appendChild(newEmptyInline); + return this.collapse(newEmptyInline.firstChild, 0); + } + return this.collapse(startNode, startOffset); + } + /** + * Applies styles to selection + * + * @param {Object.} newStyles + * @returns {void} + */ + applyStyles(newStyles) { + return __privateMethod(this, _SelectionController_instances, applyStylesTo_fn).call(this, this.startContainer, this.startOffset, this.endContainer, this.endOffset, newStyles); + } +} +_textEditor = new WeakMap(); +_selection = new WeakMap(); +_ranges = new WeakMap(); +_range = new WeakMap(); +_focusNode = new WeakMap(); +_focusOffset = new WeakMap(); +_anchorNode = new WeakMap(); +_anchorOffset = new WeakMap(); +_savedSelection = new WeakMap(); +_textNodeIterator = new WeakMap(); +_currentStyle = new WeakMap(); +_inertElement = new WeakMap(); +_debug = new WeakMap(); +_mutations = new WeakMap(); +_styleDefaults = new WeakMap(); +_SelectionController_instances = new WeakSet(); +/** + * Applies the default styles to the currentStyle + * CSSStyleDeclaration. + */ +applyDefaultStylesToCurrentStyle_fn = function() { + if (__privateGet(this, _styleDefaults)) { + for (const [name, value] of Object.entries(__privateGet(this, _styleDefaults))) { + __privateGet(this, _currentStyle).setProperty( + name, + value + (name === "font-size" ? "px" : "") + ); + } + } +}; +/** + * Applies some styles to the currentStyle + * CSSStyleDeclaration + * + * @param {HTMLElement} element + */ +applyStylesToCurrentStyle_fn = function(element) { + for (let index = 0; index < element.style.length; index++) { + const styleName = element.style.item(index); + const styleValue = element.style.getPropertyValue(styleName); + __privateGet(this, _currentStyle).setProperty(styleName, styleValue); + } +}; +/** + * Updates current styles based on the currently selected inline. + * + * @param {HTMLSpanElement} inline + * @returns {SelectionController} + */ +updateCurrentStyle_fn = function(inline) { + __privateMethod(this, _SelectionController_instances, applyDefaultStylesToCurrentStyle_fn).call(this); + const root = inline.parentElement.parentElement; + __privateMethod(this, _SelectionController_instances, applyStylesToCurrentStyle_fn).call(this, root); + const paragraph = inline.parentElement; + __privateMethod(this, _SelectionController_instances, applyStylesToCurrentStyle_fn).call(this, paragraph); + __privateMethod(this, _SelectionController_instances, applyStylesToCurrentStyle_fn).call(this, inline); + return this; +}; +_onSelectionChange = new WeakMap(); +/** + * Notifies that the styles have changed. + */ +notifyStyleChange_fn = function() { + const inline = this.focusInline; + if (inline) { + __privateMethod(this, _SelectionController_instances, updateCurrentStyle_fn).call(this, inline); + this.dispatchEvent( + new CustomEvent("stylechange", { + detail: __privateGet(this, _currentStyle) + }) + ); + } +}; +/** + * Setups + */ +setup_fn = function() { + __privateSet(this, _inertElement, document.createElement("div")); + __privateSet(this, _currentStyle, __privateGet(this, _inertElement).style); + __privateMethod(this, _SelectionController_instances, applyDefaultStylesToCurrentStyle_fn).call(this); + if (__privateGet(this, _selection).rangeCount > 0) { + const range = __privateGet(this, _selection).getRangeAt(0); + __privateSet(this, _range, range); + __privateGet(this, _ranges).add(range); + } + if (__privateGet(this, _selection).rangeCount > 1) { + for (let index = 1; index < __privateGet(this, _selection).rangeCount; index++) { + __privateGet(this, _selection).removeRange(index); + } + } + document.addEventListener("selectionchange", __privateGet(this, _onSelectionChange)); +}; +/** + * Returns a Range-like object. + * + * @returns {RangeLike} + */ +getSavedRange_fn = function() { + if (!__privateGet(this, _range)) { + return { + collapsed: true, + commonAncestorContainer: null, + startContainer: null, + startOffset: 0, + endContainer: null, + endOffset: 0 + }; + } + return { + collapsed: __privateGet(this, _range).collapsed, + commonAncestorContainer: __privateGet(this, _range).commonAncestorContainer, + startContainer: __privateGet(this, _range).startContainer, + startOffset: __privateGet(this, _range).startOffset, + endContainer: __privateGet(this, _range).endContainer, + endOffset: __privateGet(this, _range).endOffset + }; +}; +/** + * Applies styles from the startNode to the endNode. + * + * @param {Node} startNode + * @param {number} startOffset + * @param {Node} endNode + * @param {number} endOffset + * @param {Object.|CSSStyleDeclaration} newStyles + * @returns {void} + */ +applyStylesTo_fn = function(startNode, startOffset, endNode, endOffset, newStyles) { + const root = __privateGet(this, _textEditor).root; + setRootStyles(root, newStyles); + if (startNode === endNode && startNode.nodeType === Node.TEXT_NODE) { + if (startOffset === 0 && endOffset === endNode.nodeValue.length) { + const paragraph = this.startParagraph; + const inline = this.startInline; + setParagraphStyles(paragraph, newStyles); + setInlineStyles(inline, newStyles); + } else if (startOffset !== endOffset) { + const paragraph = this.startParagraph; + setParagraphStyles(paragraph, newStyles); + const inline = this.startInline; + const midText = startNode.splitText(startOffset); + const endText = midText.splitText(endOffset - startOffset); + const midInline = createInlineFrom(inline, midText, newStyles); + inline.after(midInline); + if (endText.length > 0) { + const endInline = createInline(endText, inline.style); + midInline.after(endInline); + } + this.setSelection(midText, 0, midText, midText.nodeValue.length); + } else { + const paragraph = this.startParagraph; + setParagraphStyles(paragraph, newStyles); + } + return __privateMethod(this, _SelectionController_instances, notifyStyleChange_fn).call(this); + } else if (startNode !== endNode) { + SafeGuard.start(); + const expectedEndNode = getClosestTextNode(endNode); + __privateGet(this, _textNodeIterator).currentNode = getClosestTextNode(startNode); + do { + SafeGuard.update(); + const paragraph = getParagraph(__privateGet(this, _textNodeIterator).currentNode); + setParagraphStyles(paragraph, newStyles); + const inline = getInline(__privateGet(this, _textNodeIterator).currentNode); + if (__privateGet(this, _textNodeIterator).currentNode === startNode && startOffset > 0) { + const newInline = splitInline(inline, startOffset); + setInlineStyles(newInline, newStyles); + inline.after(newInline); + } else if (__privateGet(this, _textNodeIterator).currentNode === startNode && startOffset === 0 || __privateGet(this, _textNodeIterator).currentNode !== startNode && __privateGet(this, _textNodeIterator).currentNode !== endNode || __privateGet(this, _textNodeIterator).currentNode === endNode && endOffset === endNode.nodeValue.length) { + setInlineStyles(inline, newStyles); + } else if (__privateGet(this, _textNodeIterator).currentNode === endNode && endOffset < endNode.nodeValue.length) { + const newInline = splitInline(inline, endOffset); + setInlineStyles(inline, newStyles); + inline.after(newInline); + } + if (__privateGet(this, _textNodeIterator).currentNode === expectedEndNode) return; + __privateGet(this, _textNodeIterator).nextNode(); + } while (__privateGet(this, _textNodeIterator).currentNode); + } + return __privateMethod(this, _SelectionController_instances, notifyStyleChange_fn).call(this); +}; +function createSelectionImposterFromClientRects(referenceRect, clientRects) { + const fragment = document.createDocumentFragment(); + for (const rect of clientRects) { + const rectElement = document.createElement("div"); + rectElement.className = "selection-imposter-rect"; + rectElement.style.left = `${rect.x - referenceRect.x}px`; + rectElement.style.top = `${rect.y - referenceRect.y}px`; + rectElement.style.width = `${rect.width}px`; + rectElement.style.height = `${rect.height}px`; + fragment.appendChild(rectElement); + } + return fragment; +} +function addEventListeners(target, object, options) { + Object.entries(object).forEach( + ([type, listener]) => target.addEventListener(type, listener, options) + ); +} +function removeEventListeners(target, object) { + Object.entries(object).forEach( + ([type, listener]) => target.removeEventListener(type, listener) + ); +} +const LayoutType = { + FULL: "full", + PARTIAL: "partial" +}; +class TextEditor extends EventTarget { + /** + * Constructor. + * + * @param {HTMLElement} element + */ + constructor(element, options) { + super(); + __privateAdd(this, _TextEditor_instances); + /** + * Element content editable to be used by the TextEditor + * + * @type {HTMLElement} + */ + __privateAdd(this, _element, null); + /** + * Map/Dictionary of events. + * + * @type {Object.} + */ + __privateAdd(this, _events, null); + /** + * Root element that will contain the content. + * + * @type {HTMLElement} + */ + __privateAdd(this, _root, null); + /** + * Change controller controls when we should notify changes. + * + * @type {ChangeController} + */ + __privateAdd(this, _changeController, null); + /** + * Selection controller controls the current/saved selection. + * + * @type {SelectionController} + */ + __privateAdd(this, _selectionController, null); + /** + * Selection imposter keeps selection elements. + * + * @type {HTMLElement} + */ + __privateAdd(this, _selectionImposterElement, null); + /** + * Style defaults. + * + * @type {Object.} + */ + __privateAdd(this, _styleDefaults2, null); + /** + * Dispatchs a `change` event. + * + * @param {CustomEvent} e + * @returns {void} + */ + __privateAdd(this, _onChange, (e) => this.dispatchEvent(new e.constructor(e.type, e))); + /** + * Dispatchs a `stylechange` event. + * + * @param {CustomEvent} e + * @returns {void} + */ + __privateAdd(this, _onStyleChange, (e) => { + if (__privateGet(this, _selectionImposterElement).children.length > 0) { + __privateMethod(this, _TextEditor_instances, createSelectionImposter_fn).call(this); + } + this.dispatchEvent(new e.constructor(e.type, e)); + }); + /** + * On blur we create a new FakeSelection if there's any. + * + * @param {FocusEvent} e + */ + __privateAdd(this, _onBlur, (e) => { + __privateGet(this, _changeController).notifyImmediately(); + __privateGet(this, _selectionController).saveSelection(); + __privateMethod(this, _TextEditor_instances, createSelectionImposter_fn).call(this); + this.dispatchEvent(new FocusEvent(e.type, e)); + }); + /** + * On focus we should restore the FakeSelection from the current + * selection. + * + * @param {FocusEvent} e + */ + __privateAdd(this, _onFocus, (e) => { + __privateGet(this, _selectionController).restoreSelection(); + if (__privateGet(this, _selectionImposterElement)) { + __privateGet(this, _selectionImposterElement).replaceChildren(); + } + this.dispatchEvent(new FocusEvent(e.type, e)); + }); + /** + * Event called when the user pastes some text into the + * editor. + * + * @param {ClipboardEvent} e + */ + __privateAdd(this, _onPaste, (e) => clipboard.paste(e, this, __privateGet(this, _selectionController))); + /** + * Event called when the user cuts some text from the + * editor. + * + * @param {ClipboardEvent} e + */ + __privateAdd(this, _onCut, (e) => clipboard.cut(e, this, __privateGet(this, _selectionController))); + /** + * Event called when the user copies some text from the + * editor. + * + * @param {ClipboardEvent} e + */ + __privateAdd(this, _onCopy, (e) => clipboard.copy(e, this, __privateGet(this, _selectionController))); + /** + * Event called before the DOM is modified. + * + * @param {InputEvent} e + */ + __privateAdd(this, _onBeforeInput, (e) => { + if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { + return; + } + if (!(e.inputType in commands)) { + if (e.inputType !== "insertCompositionText") { + e.preventDefault(); + } + return; + } + if (e.inputType in commands) { + const command = commands[e.inputType]; + if (!__privateGet(this, _selectionController).startMutation()) + return; + command(e, this, __privateGet(this, _selectionController)); + const mutations = __privateGet(this, _selectionController).endMutation(); + __privateMethod(this, _TextEditor_instances, notifyLayout_fn).call(this, LayoutType.FULL, mutations); + } + }); + /** + * Event called after the DOM is modified. + * + * @param {InputEvent} e + */ + __privateAdd(this, _onInput, (e) => { + if (e.inputType === "historyUndo" || e.inputType === "historyRedo") { + return; + } + if (e.inputType === "insertCompositionText") { + __privateMethod(this, _TextEditor_instances, notifyLayout_fn).call(this, LayoutType.FULL, null); + } + }); + if (!(element instanceof HTMLElement)) + throw new TypeError("Invalid text editor element"); + __privateSet(this, _element, element); + __privateSet(this, _selectionImposterElement, options == null ? void 0 : options.selectionImposterElement); + __privateSet(this, _events, { + blur: __privateGet(this, _onBlur), + focus: __privateGet(this, _onFocus), + paste: __privateGet(this, _onPaste), + cut: __privateGet(this, _onCut), + copy: __privateGet(this, _onCopy), + beforeinput: __privateGet(this, _onBeforeInput), + input: __privateGet(this, _onInput) + }); + __privateSet(this, _styleDefaults2, options == null ? void 0 : options.styleDefaults); + __privateMethod(this, _TextEditor_instances, setup_fn2).call(this, options); + } + /** + * Root element that contains all the paragraphs. + * + * @type {HTMLDivElement} + */ + get root() { + return __privateGet(this, _root); + } + set root(newRoot) { + const previousRoot = __privateGet(this, _root); + __privateSet(this, _root, newRoot); + previousRoot.replaceWith(newRoot); + } + /** + * Element that contains the root and that has the + * contenteditable attribute. + * + * @type {HTMLElement} + */ + get element() { + return __privateGet(this, _element); + } + /** + * Returns true if the content is in an empty state. + * + * @type {boolean} + */ + get isEmpty() { + return __privateGet(this, _root).children.length === 1 && __privateGet(this, _root).firstElementChild.children.length === 1 && isLineBreak(__privateGet(this, _root).firstElementChild.firstElementChild.firstChild); + } + /** + * Indicates the amount of paragraphs in the current content. + * + * @type {number} + */ + get numParagraphs() { + return __privateGet(this, _root).children.length; + } + /** + * CSS Style declaration for the current inline. From here we + * can infer root, paragraph and inline declarations. + * + * @type {CSSStyleDeclaration} + */ + get currentStyle() { + return __privateGet(this, _selectionController).currentStyle; + } + /** + * Focus the element + */ + focus() { + return __privateGet(this, _element).focus(); + } + /** + * Blurs the element + */ + blur() { + return __privateGet(this, _element).blur(); + } + /** + * Creates a new root. + * + * @param {...any} args + * @returns {HTMLDivElement} + */ + createRoot(...args) { + return createRoot(...args); + } + /** + * Creates a new paragraph. + * + * @param {...any} args + * @returns {HTMLDivElement} + */ + createParagraph(...args) { + return createParagraph(...args); + } + /** + * Creates a new inline from a string. + * + * @param {string} text + * @param {Object.|CSSStyleDeclaration} styles + * @returns {HTMLSpanElement} + */ + createInlineFromString(text, styles) { + if (text === "") { + return createEmptyInline(styles); + } + return createInline(new Text(text), styles); + } + /** + * Creates a new inline. + * + * @param {...any} args + * @returns {HTMLSpanElement} + */ + createInline(...args) { + return createInline(...args); + } + /** + * Applies the current styles to the selection or + * the current DOM node at the caret. + * + * @param {*} styles + */ + applyStylesToSelection(styles) { + __privateGet(this, _selectionController).startMutation(); + __privateGet(this, _selectionController).applyStyles(styles); + const mutations = __privateGet(this, _selectionController).endMutation(); + __privateMethod(this, _TextEditor_instances, notifyLayout_fn).call(this, LayoutType.FULL, mutations); + __privateGet(this, _changeController).notifyImmediately(); + return this; + } + /** + * Selects all content. + */ + selectAll() { + __privateGet(this, _selectionController).selectAll(); + return this; + } + /** + * Moves cursor to end. + * + * @returns + */ + cursorToEnd() { + __privateGet(this, _selectionController).cursorToEnd(); + return this; + } + /** + * Disposes everything. + */ + dispose() { + __privateGet(this, _changeController).removeEventListener("change", __privateGet(this, _onChange)); + __privateGet(this, _changeController).dispose(); + __privateSet(this, _changeController, null); + __privateGet(this, _selectionController).removeEventListener( + "stylechange", + __privateGet(this, _onStyleChange) + ); + __privateGet(this, _selectionController).dispose(); + __privateSet(this, _selectionController, null); + removeEventListeners(__privateGet(this, _element), __privateGet(this, _events)); + __privateSet(this, _element, null); + __privateSet(this, _root, null); + } +} +_element = new WeakMap(); +_events = new WeakMap(); +_root = new WeakMap(); +_changeController = new WeakMap(); +_selectionController = new WeakMap(); +_selectionImposterElement = new WeakMap(); +_styleDefaults2 = new WeakMap(); +_TextEditor_instances = new WeakSet(); +/** + * Setups editor properties. + */ +setupElementProperties_fn = function() { + if (!__privateGet(this, _element).isContentEditable) { + __privateGet(this, _element).contentEditable = "true"; + if (!__privateGet(this, _element).isContentEditable) { + __privateGet(this, _element).setAttribute("contenteditable", "true"); + } + } + if (__privateGet(this, _element).spellcheck) __privateGet(this, _element).spellcheck = false; + if (__privateGet(this, _element).autocapitalize) __privateGet(this, _element).autocapitalize = false; + if (!__privateGet(this, _element).autofocus) __privateGet(this, _element).autofocus = true; + if (!__privateGet(this, _element).role || __privateGet(this, _element).role !== "textbox") + __privateGet(this, _element).role = "textbox"; + if (__privateGet(this, _element).ariaAutoComplete) __privateGet(this, _element).ariaAutoComplete = false; + if (!__privateGet(this, _element).ariaMultiLine) __privateGet(this, _element).ariaMultiLine = true; + __privateGet(this, _element).dataset.itype = "editor"; +}; +/** + * Setups the root element. + */ +setupRoot_fn = function() { + __privateSet(this, _root, createEmptyRoot(__privateGet(this, _styleDefaults2))); + __privateGet(this, _element).appendChild(__privateGet(this, _root)); +}; +_onChange = new WeakMap(); +_onStyleChange = new WeakMap(); +/** + * Setups the elements, the properties and the + * initial content. + */ +setup_fn2 = function(options) { + __privateMethod(this, _TextEditor_instances, setupElementProperties_fn).call(this); + __privateMethod(this, _TextEditor_instances, setupRoot_fn).call(this); + __privateSet(this, _changeController, new ChangeController(this)); + __privateGet(this, _changeController).addEventListener("change", __privateGet(this, _onChange)); + __privateSet(this, _selectionController, new SelectionController( + this, + document.getSelection(), + options + )); + __privateGet(this, _selectionController).addEventListener( + "stylechange", + __privateGet(this, _onStyleChange) + ); + addEventListeners(__privateGet(this, _element), __privateGet(this, _events), { + capture: true + }); +}; +/** + * Creates the selection imposter. + */ +createSelectionImposter_fn = function() { + var _a; + if (__privateGet(this, _selectionImposterElement) && !__privateGet(this, _selectionController).isCollapsed) { + const rects = (_a = __privateGet(this, _selectionController).range) == null ? void 0 : _a.getClientRects(); + if (rects) { + const rect = __privateGet(this, _selectionImposterElement).getBoundingClientRect(); + __privateGet(this, _selectionImposterElement).replaceChildren( + createSelectionImposterFromClientRects(rect, rects) + ); + } + } +}; +_onBlur = new WeakMap(); +_onFocus = new WeakMap(); +_onPaste = new WeakMap(); +_onCut = new WeakMap(); +_onCopy = new WeakMap(); +_onBeforeInput = new WeakMap(); +_onInput = new WeakMap(); +/** + * Notifies that the edited texts needs layout. + * + * @param {'full'|'partial'} type + * @param {CommandMutations} mutations + */ +notifyLayout_fn = function(type = LayoutType.FULL, mutations) { + this.dispatchEvent( + new CustomEvent("needslayout", { + detail: { + type, + mutations + } + }) + ); +}; +export default TextEditor; +//# sourceMappingURL=TextEditor.js.map diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs new file mode 100644 index 000000000..c8791d9ca --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -0,0 +1,259 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.shapes.text.v2-editor + (:require-macros [app.main.style :as stl]) + (:require + ["./v2_editor_impl.js" :as impl] + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.common.geom.shapes :as gsh] + [app.common.geom.shapes.text :as gst] + [app.common.math :as mth] + [app.common.text :as txt] + [app.config :as cf] + [app.main.data.workspace :as dw] + [app.main.data.workspace.texts :as dwt] + [app.main.refs :as refs] + [app.main.store :as st] + [app.main.ui.css-cursors :as cur] + [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [app.util.object :as obj] + [app.util.text.content :as content] + [app.util.text.content.styles :as styles] + [goog.events :as events] + [rumext.v2 :as mf])) + +(mf/defc text-editor-html + "Text editor (HTML)" + {::mf/wrap [mf/memo] + ::mf/wrap-props false} + [{:keys [shape] :as props}] + (let [content (:content shape) + shape-id (:id shape) + + ;; Gets the default font from the workspace refs. + default-font (deref refs/default-font) + + ;; This is a reference to the dom element that + ;; should contain the TextEditor. + text-editor-ref (mf/use-ref nil) + + ;; This reference is to the container + text-editor-container-ref (mf/use-ref nil) + text-editor-instance-ref (mf/use-ref nil) + text-editor-selection-ref (mf/use-ref nil) + + on-blur + (mf/use-fn + (fn [] + (let [text-editor-instance (mf/ref-val text-editor-instance-ref) + container (mf/ref-val text-editor-container-ref) + new-content (content/dom->cljs (impl/getRoot text-editor-instance))] + (when (some? new-content) + (st/emit! (dwt/v2-update-text-shape-content shape-id new-content true))) + (dom/set-style! container "opacity" 0)))) + + on-focus + (mf/use-fn + (fn [] + (let [container (mf/ref-val text-editor-container-ref)] + (dom/set-style! container "opacity" 1)))) + + on-stylechange + (mf/use-fn + (fn [e] + (let [new-styles (styles/get-styles-from-event e)] + (st/emit! (dwt/v2-update-text-editor-styles shape-id new-styles))))) + + on-needslayout + (mf/use-fn + (fn [] + (let [text-editor-instance (mf/ref-val text-editor-instance-ref) + new-content (content/dom->cljs (impl/getRoot text-editor-instance))] + (when (some? new-content) + (st/emit! (dwt/v2-update-text-shape-content shape-id new-content true))) + ;; FIXME: We need to find a better way to trigger layout changes. + #_(st/emit! + (dwt/v2-update-text-shape-position-data shape-id []))))) + + on-change + (mf/use-fn + (fn [] + (let [text-editor-instance (mf/ref-val text-editor-instance-ref) + new-content (content/dom->cljs (impl/getRoot text-editor-instance))] + (when (some? new-content) + (st/emit! (dwt/v2-update-text-shape-content shape-id new-content true)))))) + + on-key-up + (mf/use-fn + (fn [e] + (dom/stop-propagation e) + (when (kbd/esc? e) + (st/emit! :interrupt (dw/clear-edition-mode)))))] + + ;; Initialize text editor content. + (mf/use-effect + (mf/deps text-editor-ref) + (fn [] + (let [keys [(events/listen js/document "keyup" on-key-up)] + text-editor (mf/ref-val text-editor-ref) + style-defaults (styles/get-style-defaults (d/merge txt/default-attrs default-font)) + text-editor-options #js {:styleDefaults style-defaults + :selectionImposterElement (mf/ref-val text-editor-selection-ref)} + text-editor-instance (impl/createTextEditor text-editor text-editor-options)] + (mf/set-ref-val! text-editor-instance-ref text-editor-instance) + (.addEventListener text-editor-instance "blur" on-blur) + (.addEventListener text-editor-instance "focus" on-focus) + (.addEventListener text-editor-instance "needslayout" on-needslayout) + (.addEventListener text-editor-instance "stylechange" on-stylechange) + (.addEventListener text-editor-instance "change" on-change) + (st/emit! (dwt/update-editor text-editor-instance)) + (when (some? content) + (impl/setRoot text-editor-instance (content/cljs->dom content))) + (st/emit! (dwt/focus-editor)) + + ;; This function is called when the component is unmount. + (fn [] + (.removeEventListener text-editor-instance "blur" on-blur) + (.removeEventListener text-editor-instance "focus" on-focus) + (.removeEventListener text-editor-instance "needslayout" on-needslayout) + (.removeEventListener text-editor-instance "stylechange" on-stylechange) + (.removeEventListener text-editor-instance "change" on-change) + (.dispose text-editor-instance) + (st/emit! (dwt/update-editor nil)) + (doseq [key keys] + (events/unlistenByKey key)))))) + + [:div + {:class (dm/str (cur/get-dynamic "text" (:rotation shape)) + " " + (stl/css :text-editor-container)) + :ref text-editor-container-ref + :data-testid "text-editor-container" + :style {:width (:width shape) + :height (:height shape)} + ;; We hide the editor when is blurred because otherwise the selection won't let us see + ;; the underlying text. Use opacity because display or visibility won't allow to recover + ;; focus afterwards. + ;; IMPORTANT! This is now done through DOM mutations (see on-blur and on-focus) + ;; but I keep this for future references. + ;; :opacity (when @blurred 0)}} + } + [:div + {:class (stl/css :text-editor-selection-imposter) + :ref text-editor-selection-ref}] + [:div + {:class (dm/str + "mousetrap " + (stl/css-case + :text-editor-content true + :grow-type-fixed (= (:grow-type shape) :fixed) + :grow-type-auto-width (= (:grow-type shape) :auto-width) + :grow-type-auto-height (= (:grow-type shape) :auto-height) + :align-top (= (:vertical-align content "top") "top") + :align-center (= (:vertical-align content) "center") + :align-bottom (= (:vertical-align content) "bottom"))) + :ref text-editor-ref + :data-testid "text-editor-content" + :data-x (dm/get-prop shape :x) + :data-y (dm/get-prop shape :y) + :content-editable true + :role "textbox" + :aria-multiline true + :aria-autocomplete "none"}]])) + +(defn- shape->justify + [{:keys [content]}] + (case (d/nilv (:vertical-align content) "top") + "center" "center" + "top" "flex-start" + "bottom" "flex-end" + nil)) + +;; +;; Text Editor Wrapper +;; This is an SVG element that wraps the HTML editor. +;; +(mf/defc text-editor + "Text editor wrapper component" + {::mf/wrap [mf/memo] + ::mf/wrap-props false + ::mf/forward-ref true} + [{:keys [shape modifiers] :as props} _] + (let [shape-id (dm/get-prop shape :id) + modifiers (dm/get-in modifiers [shape-id :modifiers]) + + clip-id (dm/str "text-edition-clip" shape-id) + + text-modifier-ref + (mf/use-memo (mf/deps (:id shape)) #(refs/workspace-text-modifier-by-id (:id shape))) + + text-modifier + (mf/deref text-modifier-ref) + + ;; For Safari It's necesary to scale the editor with the zoom + ;; level to fix a problem with foreignObjects not scaling + ;; correctly with the viewbox + ;; + ;; NOTE: this teoretically breaks hooks rules, but in practice + ;; it is imposible to really break it + maybe-zoom + (when (cf/check-browser? :safari-16) + (mf/deref refs/selected-zoom)) + + shape (cond-> shape + (some? text-modifier) + (dwt/apply-text-modifier text-modifier) + + (some? modifiers) + (gsh/transform-shape modifiers)) + + bounds (gst/shape->rect shape) + + x (mth/min (dm/get-prop bounds :x) + (dm/get-prop shape :x)) + y (mth/min (dm/get-prop bounds :y) + (dm/get-prop shape :y)) + width (mth/max (dm/get-prop bounds :width) + (dm/get-prop shape :width)) + height (mth/max (dm/get-prop bounds :height) + (dm/get-prop shape :height)) + style + (cond-> #js {:pointerEvents "all"} + + (not (cf/check-browser? :safari)) + (obj/merge! + #js {:transform (dm/fmt "translate(%px, %px)" (- (dm/get-prop shape :x) x) (- (dm/get-prop shape :y) y))}) + + (cf/check-browser? :safari-17) + (obj/merge! + #js {:height "100%" + :display "flex" + :flexDirection "column" + :justifyContent (shape->justify shape)}) + + (cf/check-browser? :safari-16) + (obj/merge! + #js {:position "fixed" + :left 0 + :top (- (dm/get-prop shape :y) y) + :transform-origin "top left" + :transform (when (some? maybe-zoom) + (dm/fmt "scale(%)" maybe-zoom))}))] + + [:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id) + :transform (dm/str (gsh/transform-matrix shape))} + [:defs + [:clipPath {:id clip-id} + [:rect {:x x :y y :width width :height height}]]] + + [:foreignObject {:x x :y y :width width :height height} + [:div {:style style} + [:& text-editor-html {:shape shape + :key (dm/str shape-id)}]]]])) + diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss new file mode 100644 index 000000000..cc4ab2ea8 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss @@ -0,0 +1,84 @@ +:global { + .selection-imposter-rect { + position: absolute; + background-color: var(--text-editor-selection-background-color); + } +} + +.text-editor-container { + height: 100%; + position: relative; +} + +.text-editor-selection-imposter { + position: relative; +} + +.text-editor-content { + height: 100%; + font-family: sourcesanspro; + + outline: none; + user-select: text; + white-space: pre-wrap; + overflow-wrap: break-word; + + caret-color: black; + + color: transparent; + + [data-itype="paragraph"] { + line-height: inherit; + user-select: text; + margin: 0px; + font-size: 0px; + } + + [data-itype="inline"] { + line-break: auto; + line-height: inherit; + overflow-wrap: initial; + caret-color: rgb(0, 0, 0); + } + + [data-itype="root"] { + display: flex; + flex-direction: column; + height: 100%; + } +} + +// Grow type +.grow-type-fixed, +.grow-type-auto-height { + [data-itype="inline"], + [data-itype="paragraph"] { + white-space: break-spaces; + } +} + +.grow-type-auto-width { + [data-itype="inline"], + [data-itype="paragraph"] { + white-space: nowrap; + } +} + +// Vertical align. +.align-top { + [data-itype="root"] { + justify-content: start; + } +} + +.align-center { + [data-itype="root"] { + justify-content: center; + } +} + +.align-bottom { + [data-itype="root"] { + justify-content: end; + } +} diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor_impl.js b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor_impl.js new file mode 100644 index 000000000..c950d3cf9 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor_impl.js @@ -0,0 +1,61 @@ +/** + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * Copyright (c) KALEIDOS INC + */ + +import TextEditor from "./new_editor/TextEditor.js"; + +/** + * Applies styles to the current selection or the + * saved selection. + * + * @param {TextEditor} editor + * @param {*} styles + */ +export function applyStylesToSelection(editor, styles) { + return editor.applyStylesToSelection(styles); +} + +/** + * Returns the editor root. + * + * @param {TextEditor} editor + * @returns {HTMLDivElement} + */ +export function getRoot(editor) { + return editor.root; +} + +/** + * Sets the editor root. + * + * @param {TextEditor} editor + * @param {HTMLDivElement} root + * @returns {TextEditor} + */ +export function setRoot(editor, root) { + editor.root = root; + return editor; +} + +/** + * Creates a new Text Editor instance. + * + * @param {HTMLElement} element + * @param {object} options + * @returns {TextEditor} + */ +export function createTextEditor(element, options) { + return new TextEditor(element, { + ...options, + }); +} + +export default { + createTextEditor, + setRoot, + getRoot +}; diff --git a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs index 3879ba507..d6b901907 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/viewport_texts_html.cljs @@ -26,6 +26,7 @@ [app.util.object :as obj] [app.util.text-editor :as ted] [app.util.text-svg-position :as tsp] + [app.util.text.content :as content] [promesa.core :as p] [rumext.v2 :as mf])) @@ -46,6 +47,12 @@ (dissoc :modifiers))) shape)) +(defn- update-shape-with-content + [shape content editor-content] + (cond-> shape + (and (some? shape) (some? editor-content)) + (assoc :content (d/txt-merge content editor-content)))) + (defn- update-with-editor-state "Updates the shape with the current state in the editor" [shape editor-state] @@ -56,9 +63,15 @@ (ted/get-editor-current-content) (ted/export-content)))] - (cond-> shape - (and (some? shape) (some? editor-content)) - (assoc :content (d/txt-merge content editor-content))))) + (update-shape-with-content shape content editor-content))) + +(defn- update-with-editor-v2 + "Updates the shape with the current editor" + [shape editor] + (let [content (:content shape) + editor-content (content/dom->cljs (.-root editor))] + + (update-shape-with-content shape content editor-content))) (defn- update-text-shape [{:keys [grow-type id migrate] :as shape} node] @@ -219,22 +232,28 @@ {::mf/wrap-props false ::mf/wrap [mf/memo]} [props] - (let [shape (obj/get props "shape") + shape-id (:id shape) workspace-editor-state (mf/deref refs/workspace-editor-state) + workspace-v2-editor-state (mf/deref refs/workspace-v2-editor-state) + workspace-editor (mf/deref refs/workspace-editor) - editor-state (get workspace-editor-state (:id shape)) + editor-state (get workspace-editor-state shape-id) + v2-editor-state (get workspace-v2-editor-state shape-id) text-modifier-ref - (mf/use-memo (mf/deps (:id shape)) #(refs/workspace-text-modifier-by-id (:id shape))) + (mf/use-memo (mf/deps shape-id) #(refs/workspace-text-modifier-by-id shape-id)) text-modifier (mf/deref text-modifier-ref) shape (cond-> shape (some? editor-state) - (update-with-editor-state editor-state)) + (update-with-editor-state editor-state) + + (and (some? v2-editor-state) (some? workspace-editor)) + (update-with-editor-v2 workspace-editor)) ;; When we have a text with grow-type :auto-height or :auto-height we need to check the correct height ;; otherwise the center alignment will break diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 2f6f6b74c..9309b1a76 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -24,6 +24,7 @@ [app.main.ui.workspace.sidebar.options.menus.typography :refer [typography-entry text-options]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] + [app.util.text.ui :as txu] [app.util.timers :as ts] [rumext.v2 :as mf])) @@ -278,7 +279,7 @@ 100 (fn [] (when (not= "INPUT" (-> (dom/get-active) (dom/get-tag-name))) - (let [node (dom/get-element-by-class "public-DraftEditor-content")] + (let [node (txu/get-text-editor-content)] (dom/focus! node))))))}] [:div {:class (stl/css :element-set)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index e74ae2d28..ca14bbab4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -15,6 +15,7 @@ [app.main.data.fonts :as fts] [app.main.data.shortcuts :as dsc] [app.main.data.workspace :as dw] + [app.main.features :as features] [app.main.fonts :as fonts] [app.main.refs :as refs] [app.main.store :as st] @@ -399,10 +400,11 @@ {::mf/wrap-props false} [{:keys [values on-change on-blur]}] (let [text-transform (or (:text-transform values) "none") + unset-value (if (features/active-feature? @st/state "text-editor/v2") "none" "unset") handle-change (fn [type] (if (= text-transform type) - (on-change {:text-transform "unset"}) + (on-change {:text-transform unset-value}) (on-change {:text-transform type})) (when (some? on-blur) (on-blur)))] diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs index a3b9f3c4e..ae912c12b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs @@ -10,7 +10,9 @@ [app.common.text :as txt] [app.common.types.shape.layout :as ctl] [app.main.data.workspace.texts :as dwt] + [app.main.features :as features] [app.main.refs :as refs] + [app.main.store :as st] [app.main.ui.hooks :as hooks] [app.main.ui.workspace.sidebar.options.menus.blur :refer [blur-menu]] [app.main.ui.workspace.sidebar.options.menus.color-selection :refer [color-selection-menu]] @@ -47,15 +49,22 @@ parents-by-ids-ref (mf/use-memo (mf/deps ids) #(refs/parents-by-ids ids)) parents (mf/deref parents-by-ids-ref) - state-map (mf/deref refs/workspace-editor-state) + state-map (if (features/active-feature? @st/state "text-editor/v2") + (mf/deref refs/workspace-v2-editor-state) + (mf/deref refs/workspace-editor-state)) + shared-libs (mf/deref refs/workspace-libraries) - editor-state (get state-map (:id shape)) + editor-state (when (not (features/active-feature? @st/state "text-editor/v2")) + (get state-map (:id shape))) layer-values (select-keys shape layer-attrs) + editor-instance (when (features/active-feature? @st/state "text-editor/v2") + (mf/deref refs/workspace-editor)) fill-values (-> (dwt/current-text-values {:editor-state editor-state + :editor-instance editor-instance :shape shape :attrs (conj txt/text-fill-attrs :fills)}) (d/update-in-when [:fill-color-gradient :type] keyword)) @@ -75,10 +84,12 @@ :attrs txt/root-attrs}) (dwt/current-paragraph-values {:editor-state editor-state + :editor-instance editor-instance :shape shape :attrs txt/paragraph-attrs}) (dwt/current-text-values {:editor-state editor-state + :editor-instance editor-instance :shape shape :attrs txt/text-node-attrs})) layout-item-values (select-keys shape layout-item-attrs)] diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index e68bd71cf..052f84c8f 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -15,15 +15,18 @@ [app.common.types.shape-tree :as ctt] [app.common.types.shape.layout :as ctl] [app.main.data.workspace.modifiers :as dwm] + [app.main.features :as features] [app.main.refs :as refs] + [app.main.store :as st] [app.main.ui.context :as ctx] [app.main.ui.flex-controls :as mfc] [app.main.ui.hooks :as ui-hooks] [app.main.ui.measurements :as msr] [app.main.ui.shapes.export :as use] [app.main.ui.workspace.shapes :as shapes] - [app.main.ui.workspace.shapes.text.editor :as editor] + [app.main.ui.workspace.shapes.text.editor :as editor-v1] [app.main.ui.workspace.shapes.text.text-edition-outline :refer [text-edition-outline]] + [app.main.ui.workspace.shapes.text.v2-editor :as editor-v2] [app.main.ui.workspace.shapes.text.viewport-texts-html :as stvh] [app.main.ui.workspace.viewport.actions :as actions] [app.main.ui.workspace.viewport.comments :as comments] @@ -383,8 +386,11 @@ [:g {:style {:pointer-events (if disable-events? "none" "auto")}} (when show-text-editor? - [:& editor/text-editor-svg {:shape editing-shape - :modifiers modifiers}]) + (if (features/active-feature? @st/state "text-editor/v2") + [:& editor-v2/text-editor {:shape editing-shape + :modifiers modifiers}] + [:& editor-v1/text-editor-svg {:shape editing-shape + :modifiers modifiers}])) (when show-frame-outline? (let [outlined-frame-id diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index 013a157dc..8aaef6be1 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -30,6 +30,7 @@ [app.util.mouse :as mse] [app.util.object :as obj] [app.util.rxops :refer [throttle-fn]] + [app.util.text.ui :as txu] [app.util.timers :as ts] [app.util.webapi :as wapi] [beicon.v2.core :as rx] @@ -49,7 +50,7 @@ ;; We need to handle editor related stuff here because ;; handling on editor dom node does not works properly. (let [target (dom/get-target bevent) - editor (.closest ^js target ".public-DraftEditor-content")] + editor (txu/closest-text-editor-content target)] ;; Capture mouse pointer to detect the movements even if cursor ;; leaves the viewport or the browser itself ;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture @@ -319,7 +320,7 @@ mod? (kbd/mod? event) target (dom/get-target event) - editing? (or (some? (.closest ^js target ".public-DraftEditor-content")) + editing? (or (txu/some-text-editor-content? target) (= "rich-text" (obj/get target "className")) (= "INPUT" (obj/get target "tagName")) (= "TEXTAREA" (obj/get target "tagName")))] @@ -338,7 +339,7 @@ mod? (kbd/mod? event) target (dom/get-target event) - editing? (or (some? (.closest ^js target ".public-DraftEditor-content")) + editing? (or (txu/some-text-editor-content? target) (= "rich-text" (obj/get target "className")) (= "INPUT" (obj/get target "tagName")) (= "TEXTAREA" (obj/get target "tagName")))] diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 488a0d728..e3563fd9d 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -632,6 +632,11 @@ (when (some? node) (.setAttribute node attr value))) +(defn set-style! + [^js node ^string style value] + (when (some? node) + (.setProperty (.-style node) style value))) + (defn remove-attribute! [^js node ^string attr] (when (some? node) (.removeAttribute node attr))) diff --git a/frontend/src/app/util/text/content.cljs b/frontend/src/app/util/text/content.cljs new file mode 100644 index 000000000..279b8f07c --- /dev/null +++ b/frontend/src/app/util/text/content.cljs @@ -0,0 +1,20 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.util.text.content + (:require + [app.util.text.content.from-dom :as fd] + [app.util.text.content.to-dom :as td])) + +(defn dom->cljs + "Gets the editor content from a DOM structure" + [root] + (fd/create-root root)) + +(defn cljs->dom + "Sets the editor content from a CLJS structure" + [root] + (td/create-root root)) diff --git a/frontend/src/app/util/text/content/from_dom.cljs b/frontend/src/app/util/text/content/from_dom.cljs new file mode 100644 index 000000000..32d03a4d1 --- /dev/null +++ b/frontend/src/app/util/text/content/from_dom.cljs @@ -0,0 +1,83 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.util.text.content.from-dom + (:require + [app.common.data :as d] + [app.common.text :as txt] + [app.util.text.content.styles :as styles])) + +(defn is-text-node + [node] + (= (.-nodeType node) js/Node.TEXT_NODE)) + +(defn is-element + [node tag] + (and (= (.-nodeType node) js/Node.ELEMENT_NODE) + (= (.-nodeName node) (.toUpperCase tag)))) + +(defn is-line-break + [node] + (is-element node "br")) + +(defn is-inline-child + [node] + (or (is-line-break node) + (is-text-node node))) + +(defn get-inline-text + [element] + (when-not (is-inline-child (.-firstChild element)) + (throw (js/TypeError. "Invalid inline child"))) + (if (is-line-break (.-firstChild element)) + "" + (.-textContent element))) + +(defn get-attrs-from-styles + [element attrs] + (reduce (fn [acc key] + (let [style (.-style element)] + (if (contains? styles/mapping key) + (let [style-name (styles/get-style-name-as-css-variable key) + [_ style-decode] (get styles/mapping key) + value (style-decode (.getPropertyValue style style-name))] + (assoc acc key value)) + (let [style-name (styles/get-style-name key)] + (assoc acc key (styles/normalize-attr-value key (.getPropertyValue style style-name))))))) {} attrs)) + +(defn get-inline-styles + [element] + (get-attrs-from-styles element txt/text-node-attrs)) + +(defn get-paragraph-styles + [element] + (get-attrs-from-styles element (d/concat-set txt/paragraph-attrs txt/text-node-attrs))) + +(defn get-root-styles + [element] + (get-attrs-from-styles element txt/root-attrs)) + +(defn create-inline + [element] + (d/merge {:text (get-inline-text element) + :key (.-id element)} + (get-inline-styles element))) + +(defn create-paragraph + [element] + (d/merge {:type "paragraph" + :key (.-id element) + :children (mapv create-inline (.-children element))} + (get-paragraph-styles element))) + +(defn create-root + [element] + (let [root-styles (get-root-styles element)] + (d/merge {:type "root", + :key (.-id element) + :children [{:type "paragraph-set" + :children (mapv create-paragraph (.-children element))}]} + root-styles))) diff --git a/frontend/src/app/util/text/content/styles.cljs b/frontend/src/app/util/text/content/styles.cljs new file mode 100644 index 000000000..9a99e8aa0 --- /dev/null +++ b/frontend/src/app/util/text/content/styles.cljs @@ -0,0 +1,198 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.util.text.content.styles + (:require + [app.common.text :as txt] + [app.common.transit :as transit] + [cuerdas.core :as str])) + +(defn encode + [value] + (transit/encode-str value)) + +(defn decode + [value] + (if (= value "") + nil + (transit/decode-str value))) + +(def mapping + {:fills [encode decode] + :typography-ref-id [encode decode] + :typography-ref-file [encode decode] + :font-id [identity identity] + :font-variant-id [identity identity] + :vertical-align [identity identity]}) + +(defn normalize-style-value + "This function adds units to style values" + [k v] + (cond + (and (or (= k :font-size) + (= k :letter-spacing)) + (not= (str/slice v -2) "px")) + (str v "px") + + :else + v)) + +(defn normalize-attr-value + "This function strips units from attr values" + [k v] + (cond + (and (or (= k :font-size) + (= k :letter-spacing)) + (= (str/slice v -2) "px")) + (str/slice v 0 -2) + + :else + v)) + +(defn get-style-name-as-css-variable + [key] + (str/concat "--" (name key))) + +(defn get-style-name + [key] + (cond + (= key :text-direction) + "direction" + + :else + (name key))) + +(defn get-style-keyword + [key] + (keyword (get-style-name-as-css-variable key))) + +(defn get-attr-keyword-from-css-variable + [style-name] + (keyword (str/slice style-name 2))) + +(defn get-attr-keyword + [style-name] + (cond + (= style-name "direction") + :text-direction + + :else + (keyword style-name))) + +(defn attr-needs-mapping? + [key] + (let [contained? (contains? mapping key)] + contained?)) + +(defn attr->style-key + [key] + (if (attr-needs-mapping? key) + (let [name (get-style-name-as-css-variable key)] + (keyword name)) + (cond + (= key :text-direction) + (keyword "direction") + + :else + key))) + +(defn attr->style-value + ([key value] + (attr->style-value key value false)) + ([key value normalize?] + (if (attr-needs-mapping? key) + (let [[encoder] (get mapping key)] + (if normalize? + (normalize-style-value key (encoder value)) + (encoder value))) + (if normalize? + (normalize-style-value key value) + value)))) + +(defn attr->style + [[key value]] + [(attr->style-key key) + (attr->style-value key value)]) + +(defn attrs->styles + "Maps attrs to styles" + [styles] + (let [mapped-styles + (into {} (map attr->style styles))] + (clj->js mapped-styles))) + +(defn style-needs-mapping? + [name] + (str/starts-with? name "--")) + +(defn style->attr-key + [key] + (if (style-needs-mapping? key) + (keyword (str/slice key 2)) + (keyword key))) + +(defn style->attr-value + ([name value] + (style->attr-value name value false)) + ([name value normalize?] + (if (style-needs-mapping? name) + (let [key (get-attr-keyword-from-css-variable name) + [_ decoder] (get mapping key)] + (if normalize? + (normalize-attr-value key (decoder value)) + (decoder value))) + (let [key (get-attr-keyword name)] + (if normalize? + (normalize-attr-value key value) + value))))) + +(defn style->attr + "Maps style to attr" + [[key value]] + [(style->attr-key key) + (style->attr-value key value)]) + +(defn styles->attrs + "Maps styles to attrs" + [styles] + (let [mapped-attrs + (into {} (map style->attr styles))] + mapped-attrs)) + +(defn get-style-defaults + "Returns a Javascript object compatible with the TextEditor default styles" + [style-defaults] + (clj->js + (reduce + (fn [acc [k v]] + (if (contains? mapping k) + (let [[style-encode] (get mapping k) + style-name (get-style-name-as-css-variable k) + style-value (normalize-style-value style-name (style-encode v))] + (assoc acc style-name style-value)) + (let [style-name (get-style-name k) + style-value (normalize-style-value style-name v)] + (assoc acc style-name style-value)))) {} style-defaults))) + +(defn get-styles-from-style-declaration + "Returns a ClojureScript object compatible with text nodes" + [style-declaration] + (reduce + (fn [acc k] + (if (contains? mapping k) + (let [style-name (get-style-name-as-css-variable k) + [_ style-decode] (get mapping k) + style-value (.getPropertyValue style-declaration style-name)] + (assoc acc k (style-decode style-value))) + (let [style-name (get-style-name k) + style-value (normalize-attr-value k (.getPropertyValue style-declaration style-name))] + (assoc acc k style-value)))) {} txt/text-style-attrs)) + +(defn get-styles-from-event + "Returns a ClojureScript object compatible with text nodes" + [e] + (let [style-declaration (.-detail e)] + (get-styles-from-style-declaration style-declaration))) diff --git a/frontend/src/app/util/text/content/to_dom.cljs b/frontend/src/app/util/text/content/to_dom.cljs new file mode 100644 index 000000000..fbf089018 --- /dev/null +++ b/frontend/src/app/util/text/content/to_dom.cljs @@ -0,0 +1,124 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.util.text.content.to-dom + (:require + [app.common.data :as d] + [app.common.text :as txt] + [app.util.dom :as dom] + [app.util.text.content.styles :as styles])) + +(defn set-dataset + [element data] + (doseq [[data-name data-value] data] + (dom/set-data! element (name data-name) data-value))) + +(defn set-styles + [element styles] + (doseq [[style-name style-value] styles] + (if (contains? styles/mapping style-name) + (let [[style-encode] (get styles/mapping style-name) + style-encoded-value (style-encode style-value)] + (dom/set-style! element (styles/get-style-name-as-css-variable style-name) style-encoded-value)) + (dom/set-style! element (styles/get-style-name style-name) (styles/normalize-style-value style-name style-value))))) + +(defn create-element + ([tag] + (create-element tag nil nil)) + ([tag attrs] + (create-element tag attrs nil)) + ([tag attrs children] + (let [element (dom/create-element tag)] + ;; set attributes to the element if necessary. + (doseq [[attr-name attr-value] attrs] + (case attr-name + :data (set-dataset element attr-value) + :style (set-styles element attr-value) + (dom/set-attribute! element (name attr-name) attr-value))) + + ;; add childs to the element if necessary. + (doseq [child children] + (dom/append-child! element child)) + + ;; we need to return the DOM element + element))) + +(defn get-styles-from-attrs + [node attrs] + (let [styles (reduce (fn [acc key] (assoc acc key (get node key))) {} attrs) + fills + (cond + ;; DEPRECATED: still here for backward compatibility with + ;; old penpot files that still has a single color. + (or (some? (:fill-color node)) + (some? (:fill-opacity node)) + (some? (:fill-color-gradient node))) + [(d/without-nils (select-keys node [:fill-color :fill-opacity :fill-color-gradient + :fill-color-ref-id :fill-color-ref-file]))] + + (nil? (:fills node)) + [{:fill-color "#000000" :fill-opacity 1}] + + :else + (:fills node))] + (assoc styles :fills fills))) + +(defn get-paragraph-styles + [paragraph] + (let [styles (get-styles-from-attrs paragraph (d/concat-set txt/paragraph-attrs txt/text-node-attrs)) + ;; If the text is not empty we must the paragraph font size to 0, + ;; it affects to the height calculation the browser does + font-size (if (some #(not= "" (:text %)) (:children paragraph)) + "0" + (:font-size styles (:font-size txt/default-text-attrs)))] + (cond-> styles + ;; Every paragraph must have line-height to be correctly rendered + (nil? (:line-height styles)) (assoc :line-height (:line-height txt/default-text-attrs)) + true (assoc :font-size font-size)))) + +(defn get-root-styles + [root] + (get-styles-from-attrs root txt/root-attrs)) + +(defn get-inline-styles + [inline paragraph] + (let [node (if (= "" (:text inline)) paragraph inline) + styles (get-styles-from-attrs node txt/text-node-attrs)] + (dissoc styles :line-height))) + +(defn get-inline-children + [inline] + [(if (= "" (:text inline)) + (dom/create-element "br") + (dom/create-text (:text inline)))]) + +(defn create-inline + [inline paragraph] + (create-element + "span" + {:id (:key inline) + :data {:itype "inline"} + :style (get-inline-styles inline paragraph)} + (get-inline-children inline))) + +(defn create-paragraph + [paragraph] + (create-element + "div" + {:id (:key paragraph) + :data {:itype "paragraph"} + :style (get-paragraph-styles paragraph)} + (mapv #(create-inline % paragraph) (:children paragraph)))) + +(defn create-root + [root] + (let [root-styles (get-root-styles root)] + (create-element + "div" + {:id (:key root) + :data {:itype "root"} + :style root-styles} + (mapv create-paragraph (get-in root [:children 0 :children]))))) diff --git a/frontend/src/app/util/text/ui.cljs b/frontend/src/app/util/text/ui.cljs new file mode 100644 index 000000000..71bb704ee --- /dev/null +++ b/frontend/src/app/util/text/ui.cljs @@ -0,0 +1,43 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.util.text.ui + (:require + [app.main.features :as features] + [app.main.store :as st] + [app.util.dom :as dom])) + +(defn v1-closest-text-editor-content + [target] + (.closest ^js target ".public-DraftEditor-content")) + +(defn v2-closest-text-editor-content + [target] + (.closest ^js target ".text-editor-content")) + +(defn closest-text-editor-content + [target] + (if (features/active-feature? @st/state "text-editor/v2") + (v2-closest-text-editor-content target) + (v1-closest-text-editor-content target))) + +(defn some-text-editor-content? + [target] + (some? (closest-text-editor-content target))) + +(defn v1-get-text-editor-content + [] + (dom/get-element-by-class "public-DraftEditor-content")) + +(defn v2-get-text-editor-content + [] + (dom/get-element-by-class "text-editor-content")) + +(defn get-text-editor-content + [] + (if (features/active-feature? @st/state "text-editor/v2") + (v2-get-text-editor-content) + (v1-get-text-editor-content)))