Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-07-28 11:00:47 +02:00
commit 535ccfb930
18 changed files with 502 additions and 75 deletions

View File

@ -29,6 +29,7 @@ function isDefined(v) {
} }
function mergeBlockData(block, newData) { function mergeBlockData(block, newData) {
if (!block) return undefined;
let data = block.getData(); let data = block.getData();
for (let key of Object.keys(newData)) { for (let key of Object.keys(newData)) {
@ -176,10 +177,12 @@ export function splitBlockPreservingData(state) {
content = Modifier.splitBlock(content, selection); content = Modifier.splitBlock(content, selection);
const blockData = content.blockMap.get(content.selectionBefore.getStartKey()).getData(); const startKey = content.selectionBefore.getStartKey();
const block = content.blockMap.get(startKey);
const blockData = (block && block.getData()) || new Map();
const blockKey = content.selectionAfter.getStartKey(); const blockKey = content.selectionAfter.getStartKey();
const blockMap = content.blockMap.update(blockKey, (block) => { const blockMap = content.blockMap.update(blockKey, (b) => {
return block.set("data", blockData); return b.set("data", blockData);
}); });
content = content.set("blockMap", blockMap); content = content.set("blockMap", blockMap);
@ -325,6 +328,7 @@ export function updateBlockData(state, blockKey, data) {
const content = state.getCurrentContent(); const content = state.getCurrentContent();
const block = content.getBlockForKey(blockKey); const block = content.getBlockForKey(blockKey);
const newBlock = mergeBlockData(block, data); const newBlock = mergeBlockData(block, data);
if (!newBlock) return state;
const blockData = newBlock.getData(); const blockData = newBlock.getData();

View File

@ -275,7 +275,11 @@
;; the event loop ;; the event loop
expand-s (->> (rx/of (dwc/expand-all-parents ids objects)) expand-s (->> (rx/of (dwc/expand-all-parents ids objects))
(rx/observe-on :async)) (rx/observe-on :async))
interrupt-s (rx/of :interrupt ::dwsp/interrupt)] ;; :interrupt aborts drag-stopper; only emit it when clearing edition
;; (unconditional emit broke marquee selection after #10798).
interrupt-s (if (some? (dm/get-in state [:workspace-local :edition]))
(rx/of :interrupt ::dwsp/interrupt)
(rx/of ::dwsp/interrupt))]
(rx/merge expand-s interrupt-s))))) (rx/merge expand-s interrupt-s)))))
(defn select-all (defn select-all

View File

@ -107,8 +107,8 @@
(defn format-last-events (defn format-last-events
"Render the `last-events` buffer as a multi-line string with the "Render the `last-events` buffer as a multi-line string with the
wall-clock time of each event and the delta (ms) since the previous wall-clock time of each event and the delta (ms) since the previous
entry. The first entry has no delta. Useful for embedding in error entry. The delta column is right-padded to 10 chars so the event
reports." names align. Useful for embedding in error reports."
([] (format-last-events @last-events)) ([] (format-last-events @last-events))
([events] ([events]
(let [lines (let [lines
@ -117,13 +117,14 @@
out (transient [])] out (transient [])]
(if xs (if xs
(let [{:keys [name t]} (first xs) (let [{:keys [name t]} (first xs)
iso (ct/format-inst t :iso) iso (ct/format-inst t :iso)
tail (if prev-t delta (if prev-t
(str " (+" (ct/diff-ms prev-t t) "ms)") (str "(+" (ct/diff-ms prev-t t) "ms)")
"")] "(+0ms)")
delta-pad (str/pad delta {:length 10 :type :right})]
(recur t (recur t
(next xs) (next xs)
(conj! out (str iso tail " " name)))) (conj! out (str iso " " delta-pad " " name))))
(persistent! out)))] (persistent! out)))]
(str/join "\n" lines)))) (str/join "\n" lines))))

View File

@ -449,3 +449,190 @@
(on-submit form event))))] (on-submit form event))))]
[:> (mf/provider form-ctx) {:value form} [:> (mf/provider form-ctx) {:value form}
[:form {:class class :on-submit on-submit'} children]])) [:form {:class class :on-submit on-submit'} children]]))
(defn- conj-dedup
"A helper that adds item into a vector and removes possible
duplicates. This is not very efficient implementation but is ok for
handling form input that will have a small number of items."
[coll item]
(into [] (distinct) (conj coll item)))
(mf/defc multi-input
[{:keys [form label class trim valid-item-fn caution-item-fn on-submit] :as props}]
(let [form (or form (mf/use-ctx form-ctx))
input-name (get props :name)
touched? (get-in @form [:touched input-name])
error (get-in @form [:errors input-name])
focus? (mf/use-state false)
auto-focus? (get props :auto-focus? false)
items (mf/use-state
(fn []
(let [initial (get-in @form [:data input-name])]
(if (or (vector? initial)
(set? initial))
(mapv (fn [val]
{:text val
:valid (valid-item-fn val)
:caution (caution-item-fn val)})
initial)
[]))))
value (mf/use-state "")
result (hooks/use-equal-memo @items)
empty? (and (str/empty? @value)
(zero? (count @items)))
klass (str (get props :class) " "
(stl/css-case
:focus @focus?
:valid (and touched? (not error))
:invalid (and touched? error)
:empty empty?
:custom-multi-input true))
in-klass (str class " "
(stl/css-case
:inside-input true
:no-padding (pos? (count @items))
:invalid (and (some? valid-item-fn)
touched?
(not (str/empty? @value))
(not (valid-item-fn @value)))))
on-focus
(mf/use-fn #(reset! focus? true))
on-change
(mf/use-fn
(fn [event]
(let [content (-> event dom/get-target dom/get-input-value)]
(reset! value content))))
update-form!
(mf/use-fn
(mf/deps form)
(fn [items]
(let [value (str/join " " (map :text items))]
(fm/update-input-value! form input-name value))))
on-key-down
(mf/use-fn
(mf/deps @value)
(fn [event]
(let [val (cond-> @value trim str/trim)]
(cond
(or (kbd/enter? event) (kbd/comma? event) (kbd/space? event))
(do
(dom/prevent-default event)
(dom/stop-propagation event)
;; Once enter/comma is pressed we mark it as touched
(swap! form assoc-in [:touched input-name] true)
;; Empty values means "submit" the form (whent some items have been added
(when (and (kbd/enter? event) (str/empty? @value) (not-empty @items))
(when (fn? on-submit)
(on-submit form event)))
;; If we have a string in the input we add it only if valid
(when (and (valid-item-fn val) (not (str/empty? @value)))
(reset! value "")
;; Once added the form is back as "untouched"
(swap! form assoc-in [:touched input-name] false)
;; This split will allow users to copy comma/space separated values of emails
(doseq [val (str/split val #",|\s+")]
(swap! items conj-dedup {:text (str/trim val)
:valid (valid-item-fn val)
:caution (caution-item-fn val)}))))
(and (kbd/backspace? event) (str/empty? @value))
(do
(dom/prevent-default event)
(dom/stop-propagation event)
(swap! items (fn [items] (if (c/empty? items) items (pop items)))))))))
on-paste
(mf/use-fn
(fn [event]
(when-let [clipboard-data (.-clipboardData event)]
(let [paste-data (.getData clipboard-data "text")]
(when (and (string? paste-data)
(re-find #"[,\s]" paste-data))
(dom/prevent-default event)
(dom/stop-propagation event)
;; Mark as touched
(swap! form assoc-in [:touched input-name] true)
;; Split pasted text by commas and/or whitespace, add each valid part
(let [parts (->> (str/split paste-data #",|\s+")
(map str/trim)
(remove str/empty?))]
(doseq [part parts]
(when (valid-item-fn part)
(swap! items conj-dedup {:text part
:valid true
:caution (caution-item-fn part)})))
;; Reset input value and mark as untouched after successful paste
(reset! value "")
(swap! form assoc-in [:touched input-name] false)))))))
on-blur
(mf/use-fn
(fn [_]
(reset! focus? false)
(when-not (get-in @form [:touched input-name])
(swap! form assoc-in [:touched input-name] true))))
remove-item!
(mf/use-fn
(fn [item]
(swap! items #(into [] (remove (fn [x] (= x item))) %))))
manage-key-down
(mf/use-fn
(fn [item event]
(when (kbd/enter? event)
(remove-item! item))))]
(mf/with-effect [result @value]
(let [val (cond-> @value trim str/trim)
values (conj-dedup result {:text val :valid (valid-item-fn val)})
values (filterv #(:valid %) values)]
(update-form! values)))
[:div {:class klass}
[:input {:id (name input-name)
:name (name input-name)
:class in-klass
:type "text"
:auto-focus auto-focus?
:on-focus on-focus
:on-blur on-blur
:on-key-down on-key-down
:on-paste on-paste
:value @value
:on-change on-change
:placeholder (when empty? label)}]
[:label {:for (name input-name)} label]
(when-let [items (seq @items)]
[:div {:class (stl/css :selected-items)}
(for [item items]
[:div {:class (stl/css :selected-item)
:key (:text item)
:tab-index "0"
:on-key-down (partial manage-key-down item)}
[:span {:class (stl/css-case :around true
:invalid (not (:valid item))
:caution (:caution item))}
[:span {:class (stl/css :text)} (:text item)]
[:button {:class (stl/css :icon)
:on-click #(remove-item! item)} deprecated-icon/close]]])])]))

View File

@ -117,14 +117,15 @@
on-paste on-paste
(mf/use-fn (mf/use-fn
(fn [event] (fn [event]
(let [paste-data (-> event .-clipboardData (.getData "text"))] (when-let [clipboard-data (.-clipboardData event)]
(when (and (string? paste-data) (let [paste-data (.getData clipboard-data "text")]
(re-find #"[,\s]" paste-data)) (when (and (string? paste-data)
(dom/prevent-default event) (re-find #"[,\s]" paste-data))
(dom/stop-propagation event) (dom/prevent-default event)
(dom/stop-propagation event)
;; Mark as touched ;; Mark as touched
(swap! form assoc-in [:touched name] true) (swap! form assoc-in [:touched name] true)
;; Split pasted text by commas and/or whitespace, add each valid part ;; Split pasted text by commas and/or whitespace, add each valid part
(let [parts (->> (str/split paste-data #",|\s+") (let [parts (->> (str/split paste-data #",|\s+")

View File

@ -47,7 +47,6 @@
[app.main.ui.workspace.webgl-unavailable-modal] [app.main.ui.workspace.webgl-unavailable-modal]
[app.util.debug :as dbg] [app.util.debug :as dbg]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.globals :as globals]
[app.util.i18n :as i18n :refer [tr]] [app.util.i18n :as i18n :refer [tr]]
[goog.events :as events] [goog.events :as events]
[okulary.core :as l] [okulary.core :as l]
@ -180,7 +179,7 @@
(mf/with-effect [] (mf/with-effect []
(let [focus-out #(st/emit! (dw/workspace-focus-lost)) (let [focus-out #(st/emit! (dw/workspace-focus-lost))
key (events/listen globals/window "blur" focus-out)] key (events/listen js/window "blur" focus-out)]
(partial events/unlistenByKey key))) (partial events/unlistenByKey key)))
(mf/with-effect [file-id page-id] (mf/with-effect [file-id page-id]
@ -262,7 +261,7 @@
(let [handle-wasm-render (let [handle-wasm-render
(fn [_] (fn [_]
(reset! first-frame-rendered? true)) (reset! first-frame-rendered? true))
listener-key (events/listen globals/document "penpot:wasm:render" handle-wasm-render)] listener-key (events/listen js/document "penpot:wasm:render" handle-wasm-render)]
(fn [] (fn []
(events/unlistenByKey listener-key)))) (events/unlistenByKey listener-key))))

View File

@ -55,7 +55,7 @@
nil))) nil)))
(defn- styles-fn [shape styles content] (defn- styles-fn [shape styles content]
(let [data (if (= (.getText ^js content) "") (let [data (if (and content (= (.getText ^js content) ""))
(-> ^js (.getData content) (-> ^js (.getData content)
(.toJS) (.toJS)
(js->clj :keywordize-keys true)) (js->clj :keywordize-keys true))

View File

@ -7,62 +7,75 @@
(ns app.main.ui.workspace.viewport.viewport-ref (ns app.main.ui.workspace.viewport.viewport-ref
(:require (:require
[app.common.data :as d] [app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt] [app.common.geom.point :as gpt]
[app.main.refs :as refs]
[app.main.store :as st] [app.main.store :as st]
[app.util.dom :as dom] [app.util.dom :as dom]
[app.util.mouse :as mse] [app.util.mouse :as mse]
[goog.events :as events] [rumext.v2 :as mf]))
[rumext.v2 :as mf])
(:import goog.events.EventType))
(defonce viewport-ref (atom nil)) (defonce viewport-ref (atom nil))
(defonce current-observer (atom nil))
(defonce viewport-brect (atom nil)) (defonce viewport-brect (atom nil))
(defn init-observer (defn- init-observer
[node on-change-bounds] [node]
(let [on-change-bounds
(fn [_]
(let [brect (dom/get-bounding-rect node)
brect (gpt/point (d/parse-integer (:left brect))
(d/parse-integer (:top brect)))]
(reset! viewport-brect brect)))
(let [observer (js/ResizeObserver. on-change-bounds)] observer
(when (some? @current-observer) (js/ResizeObserver. on-change-bounds)]
(.disconnect @current-observer))
(reset! current-observer observer) (.observe observer node)
observer))
(when (some? node)
(.observe observer node))))
(defn on-change-bounds
[_]
(when @viewport-ref
(let [brect (dom/get-bounding-rect @viewport-ref)
brect (gpt/point (d/parse-integer (:left brect))
(d/parse-integer (:top brect)))]
(reset! viewport-brect brect))))
(defn create-viewport-ref (defn create-viewport-ref
[] []
(let [ref (mf/use-ref nil)] (let [node-ref (mf/use-ref nil)
[ref handler-ref (mf/use-ref nil)
(mf/use-memo observer-ref (mf/use-ref nil)
#(fn [node] callback (mf/use-fn
(mf/set-ref-val! ref node) (fn [node]
(reset! viewport-ref node) ;; Dispose all previous resources
(when (some? node) (when-let [observer (mf/ref-val observer-ref)]
(events/listen node EventType.MOUSELEAVE (fn [] (st/emit! (mse/->BlurEvent))))) (.disconnect ^js observer)
(init-observer node on-change-bounds)))])) (mf/set-ref-val! observer-ref nil))
(when-let [handler (mf/ref-val handler-ref)]
(when-let [node (mf/ref-val node-ref)]
(.removeEventListener ^js node "mouseleave" handler)
(mf/set-ref-val! handler-ref nil)))
;; Reset the ref values to the current node (can be nil)
(mf/set-ref-val! node-ref node)
(reset! viewport-ref node)
(when (some? node)
(let [handler (fn [] (st/emit! (mse/->BlurEvent)))
observer (init-observer node)]
(.addEventListener ^js node "mouseleave" handler)
(mf/set-ref-val! handler-ref handler)
(mf/set-ref-val! observer-ref observer)))))]
[node-ref callback]))
(defn point->viewport (defn point->viewport
[pt] [pt]
(let [zoom (dm/get-in @st/state [:workspace-local :zoom] 1)] (let [zoom (d/nilv @refs/selected-zoom 1)
(when (and (some? @viewport-ref) viewport-node @viewport-ref
(some? @viewport-brect)) viewport-brect @viewport-brect]
(let [vbox (.. ^js @viewport-ref -viewBox -baseVal)
brect @viewport-brect
box (gpt/point (.-x vbox) (.-y vbox))
zoom (gpt/point zoom)]
(-> (gpt/subtract pt brect) (when (and (some? viewport-brect)
(some? viewport-node))
(let [vbox (.. ^js viewport-node -viewBox -baseVal)
box (gpt/point (.-x vbox) (.-y vbox))
zoom (gpt/point zoom)]
(-> (gpt/subtract pt viewport-brect)
(gpt/divide zoom) (gpt/divide zoom)
(gpt/add box)))))) (gpt/add box))))))
@ -71,8 +84,8 @@
Unlike point->viewport, this does NOT convert to canvas coordinates - Unlike point->viewport, this does NOT convert to canvas coordinates -
it just subtracts the viewport's bounding rect offset." it just subtracts the viewport's bounding rect offset."
[pt] [pt]
(when (some? @viewport-brect) (when-let [brect @viewport-brect]
(gpt/subtract pt @viewport-brect))) (gpt/subtract pt brect)))
(defn inside-viewport? (defn inside-viewport?
[target] [target]

View File

@ -115,13 +115,13 @@
([e] ([e]
(get-data e "penpot/data")) (get-data e "penpot/data"))
([e data-type] ([e data-type]
(let [dt (.-dataTransfer e) (when-let [dt (.-dataTransfer e)]
data (.getData dt data-type)] (let [data (.getData dt data-type)]
(cond-> data (cond-> data
(and (some? data) (not= data "") (and (some? data) (not= data "")
(or (str/starts-with? data-type "penpot") (or (str/starts-with? data-type "penpot")
(= data-type "application/json"))) (= data-type "application/json")))
(t/decode-str))))) (t/decode-str))))))
(defn get-files (defn get-files
[e] [e]

View File

@ -60,12 +60,14 @@
(defn get-editor-block-data (defn get-editor-block-data
[block] [block]
(-> (.getData ^js block) (when (some? block)
(immutable-map->map))) (-> (.getData ^js block)
(immutable-map->map))))
(defn get-editor-block-type (defn get-editor-block-type
[block] [block]
(.getType ^js block)) (when (some? block)
(.getType ^js block)))
(defn get-editor-current-block-data (defn get-editor-current-block-data
[state] [state]

View File

@ -82,6 +82,10 @@
(defn read-as-text (defn read-as-text
[entry] [entry]
(when (nil? entry)
(ex/raise :type :assertion
:code :invalid-entry
:hint "cannot read zip entry: entry is nil"))
(let [writer (new zip/TextWriter)] (let [writer (new zip/TextWriter)]
(.getData entry writer))) (.getData entry writer)))

View File

@ -7,6 +7,7 @@
(ns app.worker.import (ns app.worker.import
(:refer-clojure :exclude [resolve]) (:refer-clojure :exclude [resolve])
(:require (:require
[app.common.exceptions :as ex]
[app.common.json :as json] [app.common.json :as json]
[app.common.logging :as log] [app.common.logging :as log]
[app.common.schema :as sm] [app.common.schema :as sm]
@ -44,10 +45,15 @@
(def conjv (fnil conj [])) (def conjv (fnil conj []))
(defn- read-zip-manifest (defn read-zip-manifest
[zip-reader] [zip-reader]
(->> (rx/from (uz/get-entry zip-reader "manifest.json")) (->> (rx/from (uz/get-entry zip-reader "manifest.json"))
(rx/mapcat uz/read-as-text) (rx/mapcat (fn [entry]
(if (nil? entry)
(rx/throw (ex/error :type :validation
:code :invalid-penpot-file
:hint "Not a valid Penpot file: manifest.json is missing"))
(uz/read-as-text entry))))
(rx/map json/decode))) (rx/map json/decode)))
(defn slurp-uri (defn slurp-uri

View File

@ -0,0 +1,49 @@
;; 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 Sucursal en España SL
(ns frontend-tests.data.store-test
"Unit tests for app.main.store.
Tests cover:
- format-last-events empty, single, multi, column alignment"
(:require
[app.main.store :as st]
[cljs.test :as t :include-macros true]
[cuerdas.core :as str]))
(t/deftest format-last-events-empty
(t/testing "empty events produce empty string"
(t/is (= "" (st/format-last-events [])))))
(t/deftest format-last-events-single
(t/testing "a single event shows (+0ms) and its name"
(let [result (st/format-last-events [{:name ":test/event" :t (js/Date. 1000)}])]
(t/is (str/includes? result "(+0ms)"))
(t/is (str/includes? result ":test/event"))
(t/is (= 1 (count (str/split result "\n")))))))
(t/deftest format-last-events-multi
(t/testing "multiple events show correct deltas and event names"
(let [events [{:name ":event/a" :t (js/Date. 0)}
{:name ":event/b" :t (js/Date. 500)}
{:name ":event/c" :t (js/Date. 2500)}]
lines (str/split (st/format-last-events events) "\n")]
(t/is (= 3 (count lines)))
(t/is (some #(str/includes? % ":event/a") lines))
(t/is (some #(str/includes? % ":event/b") lines))
(t/is (some #(str/includes? % ":event/c") lines))
(t/is (str/includes? (nth lines 0) "(+0ms)"))
(t/is (str/includes? (nth lines 1) "(+500ms)"))
(t/is (str/includes? (nth lines 2) "(+2000ms)")))))
(t/deftest format-last-events-alignment
(t/testing "event names start at the same column across all lines"
(let [events [{:name ":evt-a" :t (js/Date. 0)}
{:name ":evt-b" :t (js/Date. 500)}]
lines (str/split (st/format-last-events events) "\n")
col-a (.indexOf (nth lines 0) ":evt-a")
col-b (.indexOf (nth lines 1) ":evt-b")]
(t/is (pos? col-a))
(t/is (= col-a col-b)))))

View File

@ -10,6 +10,7 @@
[frontend-tests.copy-as-svg-test] [frontend-tests.copy-as-svg-test]
[frontend-tests.data.nitrate-test] [frontend-tests.data.nitrate-test]
[frontend-tests.data.repo-test] [frontend-tests.data.repo-test]
[frontend-tests.data.store-test]
[frontend-tests.data.uploads-test] [frontend-tests.data.uploads-test]
[frontend-tests.data.viewer-test] [frontend-tests.data.viewer-test]
[frontend-tests.data.workspace-colors-test] [frontend-tests.data.workspace-colors-test]
@ -48,6 +49,7 @@
[frontend-tests.plugins.value-objects-test] [frontend-tests.plugins.value-objects-test]
[frontend-tests.render-wasm.process-objects-test] [frontend-tests.render-wasm.process-objects-test]
[frontend-tests.svg-fills-test] [frontend-tests.svg-fills-test]
[frontend-tests.text-editor-paste-guard-test]
[frontend-tests.tokens.copy-paste-props-test] [frontend-tests.tokens.copy-paste-props-test]
[frontend-tests.tokens.import-export-test] [frontend-tests.tokens.import-export-test]
[frontend-tests.tokens.logic.token-actions-test] [frontend-tests.tokens.logic.token-actions-test]
@ -69,7 +71,10 @@
[frontend-tests.util-object-test] [frontend-tests.util-object-test]
[frontend-tests.util-range-tree-test] [frontend-tests.util-range-tree-test]
[frontend-tests.util-simple-math-test] [frontend-tests.util-simple-math-test]
[frontend-tests.util-text-editor-test]
[frontend-tests.util-webapi-test] [frontend-tests.util-webapi-test]
[frontend-tests.util-zip-test]
[frontend-tests.util.dom.dnd-test]
[frontend-tests.worker-snap-test] [frontend-tests.worker-snap-test]
[goog.object :as gobj])) [goog.object :as gobj]))
@ -91,6 +96,7 @@
'frontend-tests.copy-as-svg-test 'frontend-tests.copy-as-svg-test
'frontend-tests.data.nitrate-test 'frontend-tests.data.nitrate-test
'frontend-tests.data.repo-test 'frontend-tests.data.repo-test
'frontend-tests.data.uploads-test 'frontend-tests.data.uploads-test
'frontend-tests.data.viewer-test 'frontend-tests.data.viewer-test
'frontend-tests.data.workspace-colors-test 'frontend-tests.data.workspace-colors-test
@ -144,13 +150,17 @@
'frontend-tests.ui.gradient-handlers-test 'frontend-tests.ui.gradient-handlers-test
'frontend-tests.ui.layout-container-multiple-test 'frontend-tests.ui.layout-container-multiple-test
'frontend-tests.ui.measures-menu-props-test 'frontend-tests.ui.measures-menu-props-test
'frontend-tests.text-editor-paste-guard-test
'frontend-tests.ui.settings-password-schema-test 'frontend-tests.ui.settings-password-schema-test
'frontend-tests.ui.settings-shortcuts-test 'frontend-tests.ui.settings-shortcuts-test
'frontend-tests.util-clipboard-test 'frontend-tests.util-clipboard-test
'frontend-tests.util-object-test 'frontend-tests.util-object-test
'frontend-tests.util-range-tree-test 'frontend-tests.util-range-tree-test
'frontend-tests.util-simple-math-test 'frontend-tests.util-simple-math-test
'frontend-tests.util-text-editor-test
'frontend-tests.util-webapi-test 'frontend-tests.util-webapi-test
'frontend-tests.util.dom.dnd-test
'frontend-tests.util-zip-test
'frontend-tests.worker-snap-test]) 'frontend-tests.worker-snap-test])
(assert (every? find-ns-obj test-namespaces) (assert (every? find-ns-obj test-namespaces)

View File

@ -0,0 +1,62 @@
;; 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 frontend-tests.text-editor-paste-guard-test
"Regression tests for the Cannot read properties of undefined
(reading getData) family of bugs. Each test verifies that the inner
getData call is now guarded by an outer when-let on clipboardData
so that a synthetic event with no clipboardData no longer throws."
(:require
[cljs.test :as t :include-macros true]))
(defn- guarded-get-data-text
"Mirrors the body of the fixed paste handlers in main/ui/forms.cljs and
main/ui/components/forms.cljs."
[event]
(when-let [clipboard-data (.-clipboardData event)]
(.getData clipboard-data "text")))
(defn- guarded-get-data-text-plain
"Mirrors the body of the fixed paste handler in
main/ui/workspace/shapes/text/v3_editor.cljs."
[event]
(when-let [clipboard-data (.-clipboardData event)]
(.getData clipboard-data "text/plain")))
(t/deftest guarded-paste-handlers-do-not-throw-on-missing-clipboardData
(t/testing "event without clipboardData returns nil (no throw)"
(t/is (nil? (guarded-get-data-text #js {})))
(t/is (nil? (guarded-get-data-text-plain #js {}))))
(t/testing "event with explicit nil clipboardData returns nil"
(t/is (nil? (guarded-get-data-text #js {:clipboardData nil})))
(t/is (nil? (guarded-get-data-text-plain #js {:clipboardData nil}))))
(t/testing "event with valid clipboardData returns the text"
(let [text-cb (fn [t] (if (= t "text") "hello" nil))
text-plain-cb (fn [t] (if (= t "text/plain") "hello" nil))
cb #js {:getData (fn [t] (if (= t "text") "hello" nil))}
cbp #js {:getData (fn [t] (if (= t "text/plain") "hello" nil))}]
(t/is (= "hello" (guarded-get-data-text #js {:clipboardData cb})))
(t/is (= "hello" (guarded-get-data-text-plain #js {:clipboardData cbp}))))))
;; Mirrors the fixed styles-fn body in main/ui/workspace/shapes/text/editor.cljs.
;; The fix adds an (and content ...) guard so getText and getData are never
;; called on a nil content object.
(defn- guarded-styles-fn-branch
"Returns the data that styles-fn would use for a given content. When content
is nil, the function falls back to the styles-only branch and returns :fallback
(the real function calls legacy.txt/styles-to-attrs which we don't exercise
here — we only verify the guard itself prevents the getText/getData throws)."
[content]
(if (and content (= (.getText ^js content) ""))
(-> ^js (.getData content)
(.toJS)
(js->clj :keywordize-keys true))
:fallback))
(t/deftest guarded-styles-fn-branch-does-not-throw-on-nil-content
(t/testing "nil content falls back to the styles branch (no throw)"
(t/is (= :fallback (guarded-styles-fn-branch nil)))
(t/is (= :fallback (guarded-styles-fn-branch js/undefined)))))

View File

@ -0,0 +1,23 @@
;; 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 frontend-tests.util.dom.dnd-test
(:require
[app.util.dom.dnd :as dnd]
[cljs.test :as t :include-macros true]))
(t/deftest get-data-returns-nil-when-event-has-no-dataTransfer
(t/testing "event without dataTransfer"
(t/is (nil? (dnd/get-data #js {}))))
(t/testing "event with explicit nil dataTransfer"
(t/is (nil? (dnd/get-data #js {:dataTransfer nil}))))
(t/testing "explicit data-type also returns nil for missing dataTransfer"
(t/is (nil? (dnd/get-data #js {} "penpot/data")))))
(t/deftest get-data-reads-from-dataTransfer
(t/testing "dataTransfer with matching key returns the value (non-decoded type)"
(let [dt #js {:getData (fn [_type] "hello")}]
(t/is (= "hello" (dnd/get-data #js {:dataTransfer dt} "text/plain"))))))

View File

@ -0,0 +1,18 @@
;; 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 frontend-tests.util-text-editor-test
(:require
[app.util.text-editor :as te]
[cljs.test :as t :include-macros true]))
(t/deftest get-editor-block-data-returns-nil-for-nil-block
(t/is (nil? (te/get-editor-block-data nil)))
(t/is (nil? (te/get-editor-block-data js/undefined))))
(t/deftest get-editor-block-type-returns-nil-for-nil-block
(t/is (nil? (te/get-editor-block-type nil)))
(t/is (nil? (te/get-editor-block-type js/undefined))))

View File

@ -0,0 +1,44 @@
;; 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 frontend-tests.util-zip-test
(:require
[app.util.zip :as-alias uz]
[app.worker.import :as worker.import]
[beicon.v2.core :as rx]
[cljs.test :as t :include-macros true]
[frontend-tests.helpers.mock :as mock]
[promesa.core :as p]))
(t/deftest read-as-text-nil-entry-raises-typed-error
(t/testing "read-as-text guards against nil entry"
(t/is (thrown-with-msg? js/Error #"nil"
(app.util.zip/read-as-text nil)))))
(t/deftest read-zip-manifest-missing-throws-validation-error
(t/async
done
(t/testing "read-zip-manifest rejects ZIPs without manifest.json"
(mock/with-mocks
{app.util.zip/get-entry (mock/stub (fn [_ _] (p/resolved nil)))}
(fn [done']
(->> (worker.import/read-zip-manifest #js {})
(rx/subs!
(fn [_]
(t/is false "expected validation error to be thrown")
(done'))
(fn [err]
(let [data (ex-data err)]
(t/is (= :invalid-penpot-file (:code data))
"missing manifest.json raises typed :invalid-penpot-file error")
(t/is (string? (:hint data))
"missing manifest.json error carries a :hint")
(t/is (re-find #"manifest\.json" (:hint data))
"missing manifest.json :hint mentions manifest.json")
(t/is (nil? (re-find #"getData" (:hint data)))
"missing manifest.json :hint does not leak the raw TypeError text")
(done'))))))
done))))