0
Fork 0
mirror of https://github.com/penpot/penpot.git synced 2025-04-05 11:31:35 -05:00

Merge pull request #5029 from penpot/azazeln28-refactor-text-editor

♻️ Refactor text editor
This commit is contained in:
Andrey Antukh 2024-10-02 11:05:26 +02:00 committed by GitHub
commit dcc49dafd3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 3846 additions and 49 deletions

View file

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

View file

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

View file

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

View file

@ -86,6 +86,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]))
@ -1545,7 +1546,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)

View file

@ -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)})))))))

View file

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

View file

@ -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 =))

View file

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

View file

@ -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")

View file

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

File diff suppressed because it is too large Load diff

View file

@ -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)}]]]]))

View file

@ -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;
}
}

View file

@ -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
};

View file

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

View file

@ -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)}

View file

@ -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)))]

View file

@ -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)]

View file

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

View file

@ -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")))]

View file

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

View file

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

View file

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

View file

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

View file

@ -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])))))

View file

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