mirror of
https://github.com/penpot/penpot.git
synced 2025-12-12 06:24:17 +01:00
🎉 Add an option to enable and disable HTML paste
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"paths": {
|
"paths": {
|
||||||
"~/*": ["./src/*"]
|
"~/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"include": ["./src/**/*.js"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user