diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index 87d637cfed..90ed0930a2 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -53,6 +53,7 @@ "plugins/runtime" "tokens/numeric-input" "design-tokens/v1" + "text-editor/v2-html-paste" "text-editor/v2" "render-wasm/v1" "variants/v1"}) @@ -75,6 +76,7 @@ (def frontend-only-features #{"styles/v2" "plugins/runtime" + "text-editor/v2-html-paste" "text-editor/v2" "tokens/numeric-input" "render-wasm/v1"}) @@ -124,6 +126,7 @@ :feature-plugins "plugins/runtime" :feature-design-tokens "design-tokens/v1" :feature-text-editor-v2 "text-editor/v2" + :feature-text-editor-v2-html-paste "text-editor/v2-html-paste" :feature-render-wasm "render-wasm/v1" :feature-variants "variants/v1" :feature-token-input "tokens/numeric-input" diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index bbaba9517d..c850b014e5 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -42,9 +42,11 @@ [app.main.data.workspace.texts :as dwtxt] [app.main.data.workspace.undo :as dwu] [app.main.errors] + [app.main.features :as features] [app.main.refs :as refs] [app.main.repo :as rp] [app.main.router :as rt] + [app.main.store :as st] [app.main.streams :as ms] [app.util.clipboard :as clipboard] [app.util.code-gen.markup-svg :as svg] @@ -949,7 +951,7 @@ ptk/WatchEvent (watch [_ state _] (let [style (deref refs/workspace-clipboard-style) - root (dwtxt/create-root-from-html html style) + root (dwtxt/create-root-from-html html style (features/active-feature? @st/state "text-editor/v2-html-paste")) text (.-textContent root) content (tc/dom->cljs root)] (when (types.text/valid-content? content) 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 index e611409192..0d0df2fb54 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -83,7 +83,8 @@ default-font)) options - #js {:styleDefaults style-defaults} + #js {:styleDefaults style-defaults + :allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")} instance (dwt/create-editor editor-node canvas-node options) diff --git a/frontend/text-editor/jsconfig.json b/frontend/text-editor/jsconfig.json index e451b27fc6..41406a224f 100644 --- a/frontend/text-editor/jsconfig.json +++ b/frontend/text-editor/jsconfig.json @@ -3,5 +3,6 @@ "paths": { "~/*": ["./src/*"] } - } + }, + "include": ["./src/**/*.js"] } diff --git a/frontend/text-editor/src/editor/TextEditor.js b/frontend/text-editor/src/editor/TextEditor.js index 92800d21f0..7e8ec98065 100644 --- a/frontend/text-editor/src/editor/TextEditor.js +++ b/frontend/text-editor/src/editor/TextEditor.js @@ -22,6 +22,13 @@ import { createEmptyTextSpan, createTextSpan } from "./content/dom/TextSpan.js"; import { isLineBreak } from "./content/dom/LineBreak.js"; import LayoutType from "./layout/LayoutType.js"; +/** + * @typedef {Object} TextEditorOptions + * @property {CSSStyleDeclaration|Object.} [styleDefaults] + * @property {SelectionControllerDebug} [debug] + * @property {boolean} [allowHTMLPaste=false] + */ + /** * Text Editor. */ @@ -73,6 +80,8 @@ export class TextEditor extends EventTarget { * `beforeinput` and `input` have different `data` when * characters are deleted when the input type is * `insertCompositionText`. + * + * @type {boolean} */ #fixInsertCompositionText = false; @@ -88,6 +97,7 @@ export class TextEditor extends EventTarget { * * @param {HTMLElement} element * @param {HTMLCanvasElement} canvas + * @param {TextEditorOptions} [options] */ constructor(element, canvas, options) { super(); @@ -578,14 +588,26 @@ export class TextEditor extends EventTarget { } } -export function createRootFromHTML(html, style = undefined) { - const fragment = mapContentFragmentFromHTML(html, style || undefined); +/** + * + * @param {string} html + * @param {*} style + * @param {boolean} allowHTMLPaste + * @returns {Root} + */ +export function createRootFromHTML(html, style = undefined, allowHTMLPaste = undefined) { + const fragment = mapContentFragmentFromHTML(html, style || undefined, allowHTMLPaste || undefined); const root = createRoot([], style); root.replaceChildren(fragment); resetInertElement(); return root; } +/** + * + * @param {string} string + * @returns {Root} + */ export function createRootFromString(string) { const fragment = mapContentFragmentFromString(string); const root = createRoot([]); diff --git a/frontend/text-editor/src/editor/clipboard/paste.js b/frontend/text-editor/src/editor/clipboard/paste.js index e4872e61e1..1196efb80d 100644 --- a/frontend/text-editor/src/editor/clipboard/paste.js +++ b/frontend/text-editor/src/editor/clipboard/paste.js @@ -11,6 +11,48 @@ import { mapContentFragmentFromString, } from "../content/dom/Content.js"; +/** + * Returns a DocumentFragment from text/html. + * + * @param {DataTransfer} clipboardData + * @returns {DocumentFragment} + */ +function getFormattedFragmentFromClipboardData(clipboardData) { + return mapContentFragmentFromHTML( + clipboardData.getData("text/html"), + selectionController.currentStyle, + ); +} + +/** + * Returns a DocumentFragment from text/plain. + * + * @param {DataTransfer} clipboardData + * @returns {DocumentFragment} + */ +function getPlainFragmentFromClipboardData(clipboardData) { + return mapContentFragmentFromString( + clipboardData.getData("text/plain"), + selectionController.currentStyle, + ); +} + +/** + * Returns a DocumentFragment (or null) if it contains + * a compatible clipboardData type. + * + * @param {DataTransfer} clipboardData + * @returns {DocumentFragment|null} + */ +function getFragmentFromClipboardData(clipboardData) { + if (clipboardData.types.includes("text/html")) { + return getFormattedFragmentFromClipboardData(clipboardData) + } else if (clipboardData.types.includes("text/plain")) { + return getPlainFragmentFromClipboardData(clipboardData) + } + return null +} + /** * When the user pastes some HTML, what we do is generate * a new DOM based on what the user pasted and then we @@ -28,18 +70,10 @@ export 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 (editor?.options?.allowHTMLPaste) { + fragment = getFragmentFromClipboardData(event.clipboardData); + } else { + fragment = getPlainFragmentFromClipboardData(event.clipboardData); } if (!fragment) { diff --git a/frontend/text-editor/src/editor/content/dom/Content.js b/frontend/text-editor/src/editor/content/dom/Content.js index 81485164a5..3a3406e5b6 100644 --- a/frontend/text-editor/src/editor/content/dom/Content.js +++ b/frontend/text-editor/src/editor/content/dom/Content.js @@ -167,9 +167,21 @@ export function htmlToText(html) { * * @param {string} html * @param {CSSStyleDeclaration} [styleDefaults] + * @param {boolean} [allowHTMLPaste=false] * @returns {DocumentFragment} */ -export function mapContentFragmentFromHTML(html, styleDefaults) { +export function mapContentFragmentFromHTML(html, styleDefaults, allowHTMLPaste) { + if (allowHTMLPaste) { + try { + const parser = new DOMParser() + const document = parser.parseFromString(html, "text/html"); + return mapContentFragmentFromDocument(document, styleDefaults); + } catch (error) { + console.error("Couldn't parse HTML", html, error); + const plainText = htmlToText(html); + return mapContentFragmentFromString(plainText, styleDefaults); + } + } const plainText = htmlToText(html); return mapContentFragmentFromString(plainText, styleDefaults); } diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index f61924455c..2ebaac380f 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -58,7 +58,7 @@ import SafeGuard from "./SafeGuard.js"; * Supported options for the SelectionController. * * @typedef {Object} SelectionControllerOptions - * @property {Object} [debug] An object with references to DOM elements that will keep all the debugging values. + * @property {SelectionControllerDebug} [debug] An object with references to DOM elements that will keep all the debugging values. */ /** @@ -203,6 +203,11 @@ export class SelectionController extends EventTarget { */ #fixInsertCompositionText = false; + /** + * @type {TextEditorOptions} + */ + #options + /** * Constructor * @@ -219,6 +224,7 @@ export class SelectionController extends EventTarget { throw new TypeError("Invalid EventTarget"); } */ + this.#options = options; this.#debug = options?.debug; this.#styleDefaults = options?.styleDefaults; this.#selection = selection; @@ -238,6 +244,15 @@ export class SelectionController extends EventTarget { return this.#currentStyle; } + /** + * Text editor options. + * + * @type {TextEditorOptions} + */ + get options() { + return this.#options; + } + /** * Applies the default styles to the currentStyle * CSSStyleDeclaration.