🎉 Add an option to enable and disable HTML paste

This commit is contained in:
Aitor Moreno
2025-11-03 10:23:58 +01:00
parent 0a3fe9836a
commit d73be5832b
8 changed files with 109 additions and 19 deletions

View File

@@ -53,6 +53,7 @@
"plugins/runtime" "plugins/runtime"
"tokens/numeric-input" "tokens/numeric-input"
"design-tokens/v1" "design-tokens/v1"
"text-editor/v2-html-paste"
"text-editor/v2" "text-editor/v2"
"render-wasm/v1" "render-wasm/v1"
"variants/v1"}) "variants/v1"})
@@ -75,6 +76,7 @@
(def frontend-only-features (def frontend-only-features
#{"styles/v2" #{"styles/v2"
"plugins/runtime" "plugins/runtime"
"text-editor/v2-html-paste"
"text-editor/v2" "text-editor/v2"
"tokens/numeric-input" "tokens/numeric-input"
"render-wasm/v1"}) "render-wasm/v1"})
@@ -124,6 +126,7 @@
:feature-plugins "plugins/runtime" :feature-plugins "plugins/runtime"
:feature-design-tokens "design-tokens/v1" :feature-design-tokens "design-tokens/v1"
:feature-text-editor-v2 "text-editor/v2" :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-render-wasm "render-wasm/v1"
:feature-variants "variants/v1" :feature-variants "variants/v1"
:feature-token-input "tokens/numeric-input" :feature-token-input "tokens/numeric-input"

View File

@@ -42,9 +42,11 @@
[app.main.data.workspace.texts :as dwtxt] [app.main.data.workspace.texts :as dwtxt]
[app.main.data.workspace.undo :as dwu] [app.main.data.workspace.undo :as dwu]
[app.main.errors] [app.main.errors]
[app.main.features :as features]
[app.main.refs :as refs] [app.main.refs :as refs]
[app.main.repo :as rp] [app.main.repo :as rp]
[app.main.router :as rt] [app.main.router :as rt]
[app.main.store :as st]
[app.main.streams :as ms] [app.main.streams :as ms]
[app.util.clipboard :as clipboard] [app.util.clipboard :as clipboard]
[app.util.code-gen.markup-svg :as svg] [app.util.code-gen.markup-svg :as svg]
@@ -949,7 +951,7 @@
ptk/WatchEvent ptk/WatchEvent
(watch [_ state _] (watch [_ state _]
(let [style (deref refs/workspace-clipboard-style) (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) text (.-textContent root)
content (tc/dom->cljs root)] content (tc/dom->cljs root)]
(when (types.text/valid-content? content) (when (types.text/valid-content? content)

View File

@@ -83,7 +83,8 @@
default-font)) default-font))
options options
#js {:styleDefaults style-defaults} #js {:styleDefaults style-defaults
:allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")}
instance instance
(dwt/create-editor editor-node canvas-node options) (dwt/create-editor editor-node canvas-node options)

View File

@@ -3,5 +3,6 @@
"paths": { "paths": {
"~/*": ["./src/*"] "~/*": ["./src/*"]
} }
} },
"include": ["./src/**/*.js"]
} }

View File

@@ -22,6 +22,13 @@ import { createEmptyTextSpan, createTextSpan } from "./content/dom/TextSpan.js";
import { isLineBreak } from "./content/dom/LineBreak.js"; import { isLineBreak } from "./content/dom/LineBreak.js";
import LayoutType from "./layout/LayoutType.js"; import LayoutType from "./layout/LayoutType.js";
/**
* @typedef {Object} TextEditorOptions
* @property {CSSStyleDeclaration|Object.<string,*>} [styleDefaults]
* @property {SelectionControllerDebug} [debug]
* @property {boolean} [allowHTMLPaste=false]
*/
/** /**
* Text Editor. * Text Editor.
*/ */
@@ -73,6 +80,8 @@ export class TextEditor extends EventTarget {
* `beforeinput` and `input` have different `data` when * `beforeinput` and `input` have different `data` when
* characters are deleted when the input type is * characters are deleted when the input type is
* `insertCompositionText`. * `insertCompositionText`.
*
* @type {boolean}
*/ */
#fixInsertCompositionText = false; #fixInsertCompositionText = false;
@@ -88,6 +97,7 @@ export class TextEditor extends EventTarget {
* *
* @param {HTMLElement} element * @param {HTMLElement} element
* @param {HTMLCanvasElement} canvas * @param {HTMLCanvasElement} canvas
* @param {TextEditorOptions} [options]
*/ */
constructor(element, canvas, options) { constructor(element, canvas, options) {
super(); 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); const root = createRoot([], style);
root.replaceChildren(fragment); root.replaceChildren(fragment);
resetInertElement(); resetInertElement();
return root; return root;
} }
/**
*
* @param {string} string
* @returns {Root}
*/
export function createRootFromString(string) { export function createRootFromString(string) {
const fragment = mapContentFragmentFromString(string); const fragment = mapContentFragmentFromString(string);
const root = createRoot([]); const root = createRoot([]);

View File

@@ -11,6 +11,48 @@ import {
mapContentFragmentFromString, mapContentFragmentFromString,
} from "../content/dom/Content.js"; } 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 * When the user pastes some HTML, what we do is generate
* a new DOM based on what the user pasted and then we * a new DOM based on what the user pasted and then we
@@ -28,18 +70,10 @@ export function paste(event, editor, selectionController) {
event.preventDefault(); event.preventDefault();
let fragment = null; let fragment = null;
if (event.clipboardData.types.includes("text/html")) { if (editor?.options?.allowHTMLPaste) {
const html = event.clipboardData.getData("text/html"); fragment = getFragmentFromClipboardData(event.clipboardData);
fragment = mapContentFragmentFromHTML( } else {
html, fragment = getPlainFragmentFromClipboardData(event.clipboardData);
selectionController.currentStyle,
);
} else if (event.clipboardData.types.includes("text/plain")) {
const plain = event.clipboardData.getData("text/plain");
fragment = mapContentFragmentFromString(
plain,
selectionController.currentStyle,
);
} }
if (!fragment) { if (!fragment) {

View File

@@ -167,9 +167,21 @@ export function htmlToText(html) {
* *
* @param {string} html * @param {string} html
* @param {CSSStyleDeclaration} [styleDefaults] * @param {CSSStyleDeclaration} [styleDefaults]
* @param {boolean} [allowHTMLPaste=false]
* @returns {DocumentFragment} * @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); const plainText = htmlToText(html);
return mapContentFragmentFromString(plainText, styleDefaults); return mapContentFragmentFromString(plainText, styleDefaults);
} }

View File

@@ -58,7 +58,7 @@ import SafeGuard from "./SafeGuard.js";
* Supported options for the SelectionController. * Supported options for the SelectionController.
* *
* @typedef {Object} SelectionControllerOptions * @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; #fixInsertCompositionText = false;
/**
* @type {TextEditorOptions}
*/
#options
/** /**
* Constructor * Constructor
* *
@@ -219,6 +224,7 @@ export class SelectionController extends EventTarget {
throw new TypeError("Invalid EventTarget"); throw new TypeError("Invalid EventTarget");
} }
*/ */
this.#options = options;
this.#debug = options?.debug; this.#debug = options?.debug;
this.#styleDefaults = options?.styleDefaults; this.#styleDefaults = options?.styleDefaults;
this.#selection = selection; this.#selection = selection;
@@ -238,6 +244,15 @@ export class SelectionController extends EventTarget {
return this.#currentStyle; return this.#currentStyle;
} }
/**
* Text editor options.
*
* @type {TextEditorOptions}
*/
get options() {
return this.#options;
}
/** /**
* Applies the default styles to the currentStyle * Applies the default styles to the currentStyle
* CSSStyleDeclaration. * CSSStyleDeclaration.