Compare commits

...

37 Commits

Author SHA1 Message Date
AzazelN28
19b9b3cbd9 🐛 Fix missing main menu entry version history 2024-11-22 15:35:13 +01:00
Andrey Antukh
9485ce03b5 Merge pull request #5338 from penpot/azazeln28-fix-missing-text-editor-changes
📎 Fix some text editor missing changes
2024-11-22 12:54:42 +01:00
Aitor Moreno
ba832389d1 Merge pull request #5354 from penpot/superalex-fix-text-layer-default-name-with-v2-editor
🐛 Fix text layer default name with v2 text editor
2024-11-22 12:20:59 +01:00
Alejandro Alonso
a8ee9be7b9 🐛 Fix text layer default name with v2 text editor 2024-11-22 11:14:39 +01:00
AzazelN28
c8c83c1e1d 📎 Fix some missing changes 2024-11-22 11:06:16 +01:00
Andrey Antukh
afcfbdedda Merge pull request #5341 from penpot/palba-fix-leave-team
🐛 Fix bad redirect after leaving team
2024-11-22 08:31:21 +01:00
Pablo Alba
fa8665df88 Merge pull request #5337 from penpot/juanfran-fix-typo-keepaspectratio
🐛 Fix typo in keepAspectRatio #9336
2024-11-21 09:13:21 +01:00
Pablo Alba
0cae9d6ad5 🐛 Fix bad redirect after leaving team 2024-11-20 15:37:08 +01:00
Juanfran
2f4cb19745 🐛 Fix typo in keepAspectRatio #9336 2024-11-20 12:43:51 +01:00
Alejandro
b80ccbec0f Merge pull request #5334 from penpot/niwinz-bug-features
🐛 Preserve frontend-only flags already present on team
2024-11-20 06:27:24 +01:00
Andrey Antukh
246415be2b Merge pull request #5306 from penpot/azazeln28-update-text-editor
⬆️ Update text editor
2024-11-19 19:20:04 +01:00
Andrey Antukh
7faa9e970e 🐛 Fix esm module incompatibilities on text-editor with node 2024-11-19 17:20:16 +01:00
Andrey Antukh
04a0d867b0 Import text-editor code into the repository 2024-11-19 17:05:30 +01:00
Andrey Antukh
a18214a1a5 🐛 Preserve frontend-only flags already present on team 2024-11-19 16:39:21 +01:00
AzazelN28
68397edd4d 🐛 Fix text editor selection 2024-11-19 14:47:38 +01:00
AzazelN28
1e2d9a15a3 🐛 Fix text editor shortcuts 2024-11-19 14:47:38 +01:00
AzazelN28
0f101fad9f ⬆️ Update text editor 2024-11-19 14:47:38 +01:00
Andrey Antukh
a91737b4d7 Merge pull request #5331 from penpot/alotor-exit-comments
🐛 Fix escape key to exit comments mode
2024-11-19 10:59:13 +01:00
alonso.torres
284d5ecb77 🐛 Fix escape key to exit comments mode 2024-11-19 10:44:54 +01:00
Alejandro
5d95d755ad Merge pull request #5315 from penpot/niwinz-team-access-request-quotes
🎉 Add quote definitions for team access requests
2024-11-19 06:52:04 +01:00
Andrey Antukh
4466abd150 Merge pull request #5320 from penpot/alotor-fix-problem-layouts
🐛 Fix problem with layout reflow
2024-11-18 18:44:42 +01:00
Andrey Antukh
27690c3da6 Add test runner for cljs on common module
In the same way as frontend tests are run and fix some
tokens related tests
2024-11-18 17:51:23 +01:00
alonso.torres
f436d72f51 Changed some events for versions 2024-11-18 16:38:38 +01:00
Alejandro
20ea188070 Merge pull request #5321 from penpot/niwinz-improvements-features
 Feature flags improvements
2024-11-18 15:52:20 +01:00
Alejandro
c4f076910b Merge pull request #5326 from penpot/alotor-fix-problem-texts
🐛 Fix problem with texts crashing
2024-11-18 12:59:40 +01:00
alonso.torres
72f2395142 🐛 Fix problem with texts crashing 2024-11-18 12:41:21 +01:00
Andrey Antukh
47d28758d7 Clean frontend and backend features on exportation 2024-11-15 15:57:25 +01:00
Andrey Antukh
b7573c0b72 Change frontend-only features automatic team assignation rules
The frontend-only features are now ignored from files and from teams
and they do not autoassigns automatically to team and file on file
creation or update operations.
2024-11-15 15:57:25 +01:00
alonso.torres
2ed743b6be 🐛 Fix problem with layout reflow 2024-11-15 15:12:52 +01:00
Andrey Antukh
036e335fc4 🎉 Add quote definitions for team access requests 2024-11-15 11:14:30 +01:00
Andrey Antukh
0e99b37c21 Merge remote-tracking branch 'origin/main' into staging 2024-11-15 10:17:18 +01:00
Praveen Juge
3cdbd7f381 📚 Fix spelling: change assents to assets 2024-11-15 09:50:44 +01:00
Andrey Antukh
76caff2b61 Merge pull request #5313 from penpot/alotor-bugfixes-enable-plugins-default
🐛 Activate plugin feature by default
2024-11-15 09:46:59 +01:00
Andrey Antukh
bb370b3e50 Merge pull request #5314 from penpot/superalex-fix-wasm-build
🐛 Fix compilation for wasm communication in release mode
2024-11-15 09:44:24 +01:00
Andrey Antukh
45d56f40e1 Merge remote-tracking branch 'origin/develop' into staging 2024-11-15 09:35:21 +01:00
Alejandro Alonso
4a1ab75d8f 🐛 Fix compilation for wasm communication in release mode 2024-11-15 09:30:59 +01:00
alonso.torres
a58ad2298a 🐛 Activate plugin feature by default 2024-11-15 09:04:59 +01:00
105 changed files with 12080 additions and 204 deletions

View File

@@ -33,6 +33,12 @@ jobs:
command: |
clojure -M:dev:test
- run:
name: "NODE tests"
working_directory: "./common"
command: |
yarn run test
- save_cache:
paths:
- ~/.m2

View File

@@ -35,6 +35,8 @@
### :bug: Bugs fixed
- Fix problem creating manual overlay interactions [Taiga #9146](https://tree.taiga.io/project/penpot/issue/9146)
- Fix plugins list default URL
- Activate plugins feature by default
## 2.3.2

View File

@@ -134,6 +134,16 @@
(update :data feat.fdata/process-pointers deref)
(update :data feat.fdata/process-objects (partial into {}))))))))
(defn clean-file-features
[file]
(update file :features (fn [features]
(if (set? features)
(-> features
(cfeat/migrate-legacy-features)
(set/difference cfeat/frontend-only-features)
(set/difference cfeat/backend-only-features))
#{}))))
(defn get-project
[cfg project-id]
(db/get cfg :project {:id project-id}))
@@ -445,8 +455,11 @@
(fn [features]
(let [features (cfeat/check-supported-features! features)]
(-> (::features cfg #{})
(set/difference cfeat/frontend-only-features)
(set/union features))))))
(set/union features)
;; We never want to store
;; frontend-only features on file
(set/difference cfeat/frontend-only-features))))))
_ (when (contains? cf/flags :file-schema-validation)
(fval/validate-file-schema! file))

View File

@@ -508,15 +508,6 @@
(update :object-id #(str/replace-first % #"^(.*?)/" (str file-id "/")))))
thumbnails))
(defn- clean-features
[file]
(update file :features (fn [features]
(if (set? features)
(-> features
(cfeat/migrate-legacy-features)
(set/difference cfeat/backend-only-features))
#{}))))
(defmethod read-section :v1/files
[{:keys [::db/conn ::input ::project-id ::bfc/overwrite ::name] :as system}]
@@ -527,7 +518,7 @@
file-id (:id file)
file-id' (bfc/lookup-index file-id)
file (clean-features file)
file (bfc/clean-file-features file)
thumbnails (:thumbnails file)]
(when (not= file-id expected-file-id)

View File

@@ -12,6 +12,7 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.features :as cfeat]
[app.common.json :as json]
[app.common.logging :as l]
[app.common.schema :as sm]
@@ -55,7 +56,8 @@
[:map
[:id ::sm/uuid]
[:name :string]
[:project-id ::sm/uuid]]]]
[:project-id ::sm/uuid]
[:features ::cfeat/features]]]]
[:relations {:optional true}
[:vector
@@ -203,7 +205,10 @@
(dissoc :libraries))
embed-assets
(update :data #(bfc/embed-assets cfg % file-id)))))
(update :data #(bfc/embed-assets cfg % file-id))
:always
(bfc/clean-file-features))))
(defn- resolve-extension
[mtype]
@@ -259,7 +264,8 @@
(vswap! bfc/*state* update :files assoc file-id
{:id file-id
:project-id (:project-id file)
:name (:name file)})
:name (:name file)
:features (:features file)})
(let [file (cond-> (dissoc file :data)
(:options data)

View File

@@ -144,6 +144,8 @@
[:quotes-comments-per-file {:optional true} ::sm/int]
[:quotes-snapshots-per-file {:optional true} ::sm/int]
[:quotes-snapshots-per-team {:optional true} ::sm/int]
[:quotes-team-access-requests-per-team {:optional true} ::sm/int]
[:quotes-team-access-requests-per-requester {:optional true} ::sm/int]
[:auth-data-cookie-domain {:optional true} :string]
[:auth-token-cookie-name {:optional true} :string]

View File

@@ -118,11 +118,12 @@
;; feature on frontend and make it permanent on file
features (-> (:features params #{})
(set/intersection cfeat/no-migration-features)
(set/difference cfeat/frontend-only-features)
(set/union features))
params (-> params
(assoc :profile-id profile-id)
(assoc :features features))]
(assoc :features (set/difference features cfeat/frontend-only-features)))]
(quotes/check! cfg {::quotes/id ::quotes/files-per-project
::quotes/team-id team-id

View File

@@ -147,7 +147,7 @@
params (-> params
(assoc :profile-id profile-id)
(assoc :features features)
(assoc :features (set/difference features cfeat/frontend-only-features))
(assoc :team team)
(assoc :file file)
(assoc :changes changes))

View File

@@ -30,7 +30,8 @@
[app.storage :as sto]
[app.util.services :as sv]
[app.util.time :as dt]
[app.worker :as wrk]))
[app.worker :as wrk]
[clojure.set :as set]))
;; --- Helpers & Specs
@@ -416,6 +417,7 @@
::quotes/profile-id profile-id})
(let [features (-> (cfeat/get-enabled-features cf/flags)
(set/difference cfeat/frontend-only-features)
(cfeat/check-client-features! (:features params)))
params (-> params
(assoc :profile-id profile-id)

View File

@@ -532,17 +532,20 @@
team-owner (get-team-owner conn team-id)
file (when (some? file-id)
(get-file-for-team-access-request cfg file-id))
(get-file-for-team-access-request cfg file-id))]
request (upsert-team-access-request conn team-id profile-id)]
;; FIXME missing quotes
(-> cfg
(assoc ::quotes/profile-id profile-id)
(assoc ::quotes/team-id team-id)
(quotes/check! {::quotes/id ::quotes/team-access-requests-per-team}
{::quotes/id ::quotes/team-access-requests-per-requester}))
(teams/check-profile-muted conn requester)
(teams/check-email-bounce conn (:email team-owner) false)
(teams/check-email-spam conn (:email team-owner) true)
(let [factory (cond
(let [request (upsert-team-access-request conn team-id profile-id)
factory (cond
(and (some? file) (:is-default team) is-viewer)
eml/request-file-access-yourpenpot-view
@@ -565,9 +568,9 @@
:team-id team-id
:file-name (:name file)
:file-id file-id
:page-id (:page-id file)}))
:page-id (:page-id file)})
(with-meta {:request request}
{::audit/props {:request 1}})))
(with-meta {:request request}
{::audit/props {:request 1}}))))

View File

@@ -442,7 +442,7 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: SNAPSHOTS-PER-FILE
;; QUOTE: SNAPSHOTS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:snapshots-per-team
@@ -472,6 +472,57 @@
(assoc ::count-sql [sql:get-snapshots-per-team team-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: TEAM-ACCESS-REQUESTS-PER-TEAM
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:team-access-requests-per-team
[:map
[::profile-id ::sm/uuid]
[::team-id ::sm/uuid]])
(def ^:private valid-team-access-requests-per-team-quote?
(sm/lazy-validator schema:team-access-requests-per-team))
(def ^:private sql:get-team-access-requests-per-team
"SELECT count(*) AS total
FROM team_access_request AS tar
WHERE tar.team_id = ?")
(defmethod check-quote ::team-access-requests-per-team
[{:keys [::profile-id ::team-id ::target] :as quote}]
(assert (valid-team-access-requests-per-team-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-team-access-requests-per-team Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-2 target team-id profile-id profile-id])
(assoc ::count-sql [sql:get-team-access-requests-per-team team-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: TEAM-ACCESS-REQUESTS-PER-REQUESTER
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private schema:team-access-requests-per-requester
[:map
[::profile-id ::sm/uuid]])
(def ^:private valid-team-access-requests-per-requester-quote?
(sm/lazy-validator schema:team-access-requests-per-requester))
(def ^:private sql:get-team-access-requests-per-requester
"SELECT count(*) AS total
FROM team_access_request AS tar
WHERE tar.requester_id = ?")
(defmethod check-quote ::team-access-requests-per-requester
[{:keys [::profile-id ::target] :as quote}]
(assert (valid-team-access-requests-per-requester-quote? quote) "invalid quote parameters")
(-> quote
(assoc ::default (cf/get :quotes-team-access-requests-per-requester Integer/MAX_VALUE))
(assoc ::quote-sql [sql:get-quotes-1 target profile-id])
(assoc ::count-sql [sql:get-team-access-requests-per-requester profile-id])
(generic-check!)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUOTE: DEFAULT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -1,11 +1,11 @@
{
"name": "common",
"version": "1.0.0",
"main": "index.js",
"license": "MPL-2.0",
"author": "Kaleidos INC",
"private": true,
"packageManager": "yarn@4.3.1",
"type": "module",
"repository": {
"type": "git",
"url": "https://github.com/penpot/penpot"
@@ -15,6 +15,8 @@
"sax": "^1.4.1"
},
"devDependencies": {
"concurrently": "^9.0.1",
"nodemon": "^3.1.7",
"shadow-cljs": "2.28.18",
"source-map-support": "^0.5.21",
"ws": "^8.17.0"
@@ -23,9 +25,9 @@
"fmt:clj:check": "cljfmt check --parallel=false src/ test/",
"fmt:clj": "cljfmt fix --parallel=true src/ test/",
"lint:clj": "clj-kondo --parallel=true --lint src/",
"test:watch": "clojure -M:dev:shadow-cljs watch test",
"test:compile": "clojure -M:dev:shadow-cljs compile test --config-merge '{:autorun false}'",
"test:run": "node target/test.js",
"test": "yarn run test:compile && yarn run test:run"
"lint": "yarn run lint:clj",
"watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests/ --exec 'node target/tests/test.js'\"",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "yarn run build:test && node target/tests/test.js"
}
}

View File

@@ -1,19 +1,15 @@
{:deps {:aliases [:dev]}
:builds
{:test
{:target :node-test
:output-to "target/test.js"
:output-dir "target/test/"
:ns-regexp "^common-tests.*-test$"
:autorun true
{:target :esm
:output-dir "target/tests"
:runtime :node
:js-options {:js-provider :import}
:compiler-options
{:output-feature-set :es-next
:output-wrapper false
:source-map true
:source-map-include-sources-content true
:source-map-detail-level :all
:warnings {:fn-deprecated false}}}
:modules
{:test {:init-fn common-tests.runner/-main
:prepend-js "globalThis.navigator = {userAgent: \"\"}"}}}
:bench
{:target :node-script

View File

@@ -59,7 +59,8 @@
#{"fdata/shape-data-type"
"styles/v2"
"layout/grid"
"components/v2"})
"components/v2"
"plugins/runtime"})
;; A set of features which only affects on frontend and can be enabled
;; and disabled freely by the user any time. This features does not
@@ -154,6 +155,7 @@
team-features (into #{} xf-remove-ephimeral (:features team))]
(-> enabled-features
(set/intersection no-migration-features)
(set/difference frontend-only-features)
(set/union team-features))))
(defn check-client-features!

View File

@@ -27,10 +27,22 @@
#?(:clj (Instant/now)
:cljs (.local ^js DateTime)))
#?(:clj
(defn is-after?
[one other]
(.isAfter one other)))
(defn is-after?
"Analgous to: da > db"
[da db]
(let [result (compare da db)]
(cond
(neg? result) false
(zero? result) false
:else true)))
(defn is-before?
[da db]
(let [result (compare da db)]
(cond
(neg? result) true
(zero? result) false
:else false)))
(defn instant?
[o]

View File

@@ -529,13 +529,6 @@
(or (d/not-empty? (dm/get-prop modifiers :geometry-child))
(d/not-empty? (dm/get-prop modifiers :structure-child))))
(defn only-move?
"Returns true if there are only move operations"
[modifiers]
(let [move-op? #(= :move (dm/get-prop % :type))]
(and (every? move-op? (dm/get-prop modifiers :geometry-child))
(every? move-op? (dm/get-prop modifiers :geometry-parent)))))
(defn has-geometry?
[modifiers]
(or (d/not-empty? (dm/get-prop modifiers :geometry-parent))
@@ -550,6 +543,14 @@
[modifiers]
(d/not-empty? (dm/get-prop modifiers :structure-child)))
(defn only-move?
"Returns true if there are only move operations"
[modifiers]
(let [move-op? #(= :move (dm/get-prop % :type))]
(and (not (has-structure? modifiers))
(every? move-op? (dm/get-prop modifiers :geometry-child))
(every? move-op? (dm/get-prop modifiers :geometry-parent)))))
;; Extract subsets of modifiers
(defn select-child

View File

@@ -0,0 +1,89 @@
;; 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 common-tests.runner
(:require
[clojure.test :as t]
[common-tests.colors-test]
[common-tests.data-test]
[common-tests.files-builder-test]
[common-tests.files-changes-test]
[common-tests.files-migrations-test]
[common-tests.geom-point-test]
[common-tests.geom-shapes-test]
[common-tests.geom-test]
[common-tests.logic.chained-propagation-test]
[common-tests.logic.comp-creation-test]
[common-tests.logic.comp-detach-with-nested-test]
[common-tests.logic.comp-remove-swap-slots-test]
[common-tests.logic.comp-reset-test]
[common-tests.logic.comp-sync-test]
[common-tests.logic.comp-touched-test]
[common-tests.logic.copying-and-duplicating-test]
[common-tests.logic.duplicated-pages-test]
[common-tests.logic.move-shapes-test]
[common-tests.logic.multiple-nesting-levels-test]
[common-tests.logic.swap-and-reset-test]
[common-tests.logic.swap-as-override-test]
[common-tests.pages-helpers-test]
[common-tests.record-test]
[common-tests.schema-test]
[common-tests.svg-path-test]
[common-tests.svg-test]
[common-tests.text-test]
[common-tests.time-test]
[common-tests.types-modifiers-test]
[common-tests.types-shape-interactions-test]
[common-tests.types.shape-decode-encode-test]
[common-tests.types.tokens-lib-test]
[common-tests.types.types-component-test]
[common-tests.types.types-libraries-test]))
#?(:cljs (enable-console-print!))
#?(:cljs
(defmethod cljs.test/report [:cljs.test/default :end-run-tests] [m]
(if (cljs.test/successful? m)
(.exit js/process 0)
(.exit js/process 1))))
(defn -main
[& args]
(t/run-tests
'common-tests.colors-test
'common-tests.data-test
'common-tests.files-builder-test
'common-tests.files-changes-test
'common-tests.files-migrations-test
'common-tests.geom-point-test
'common-tests.geom-shapes-test
'common-tests.geom-test
'common-tests.logic.chained-propagation-test
'common-tests.logic.comp-creation-test
'common-tests.logic.comp-detach-with-nested-test
'common-tests.logic.comp-remove-swap-slots-test
'common-tests.logic.comp-reset-test
'common-tests.logic.comp-sync-test
'common-tests.logic.comp-touched-test
'common-tests.logic.copying-and-duplicating-test
'common-tests.logic.duplicated-pages-test
'common-tests.logic.move-shapes-test
'common-tests.logic.multiple-nesting-levels-test
'common-tests.logic.swap-and-reset-test
'common-tests.logic.swap-as-override-test
'common-tests.pages-helpers-test
'common-tests.record-test
'common-tests.schema-test
'common-tests.svg-path-test
'common-tests.svg-test
'common-tests.text-test
'common-tests.types-modifiers-test
'common-tests.types-shape-interactions-test
'common-tests.types.shape-decode-encode-test
'common-tests.types.types-component-test
'common-tests.types.types-libraries-test
'common-tests.types.tokens-lib-test
'common-tests.time-test))

View File

@@ -0,0 +1,16 @@
;; 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 common-tests.time-test
(:require
[app.common.time :as dt]
[clojure.test :as t]))
(t/deftest compare-time
(let [dta (dt/parse-instant 10000)
dtb (dt/parse-instant 20000)]
(t/is (false? (dt/is-after? dta dtb)))
(t/is (true? (dt/is-before? dta dtb)))))

View File

@@ -148,4 +148,4 @@
;; (app.common.pprint/pprint shape)
;; (app.common.pprint/pprint shape-3)
(= shape shape-3)))
{:num 1000})))
{:num 100})))

View File

@@ -14,8 +14,16 @@
[app.common.types.tokens-lib :as ctob]
[clojure.test :as t]))
(t/testing "token"
(t/deftest make-token
(defn setup-virtual-time
[next]
(let [current (volatile! (inst-ms (dt/now)))]
(with-redefs [dt/now #(dt/parse-instant (vswap! current inc))]
(next))))
(t/use-fixtures :once setup-virtual-time)
(t/deftest tokens
(t/testing "make-token"
(let [now (dt/now)
token1 (ctob/make-token :name "test-token-1"
:type :boolean
@@ -40,14 +48,14 @@
(t/is (= (:modified-at token2) now))
(t/is (ctob/valid-token? token2))))
(t/deftest invalid-tokens
(t/testing "invalid-tokens"
(let [args {:name 777
:type :invalid}]
(t/is (thrown-with-msg? Exception #"expected valid token"
(t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception) #"expected valid token"
(apply ctob/make-token args)))
(t/is (false? (ctob/valid-token? {})))))
(t/deftest find-token-value-references
(t/testing "find-token-value-references"
(t/testing "finds references inside curly braces in a string"
(t/is (= #{"foo" "bar"} (ctob/find-token-value-references "{foo} + {bar}")))
(t/testing "ignores extra text"
@@ -57,8 +65,8 @@
(t/testing "handles edge-case for extra curly braces"
(t/is (= #{"foo" "bar"} (ctob/find-token-value-references "{foo}} + {bar}"))))))
(t/testing "token-set"
(t/deftest make-token-set
(t/deftest token-set
(t/testing "make-token-set"
(let [now (dt/now)
token-set1 (ctob/make-token-set :name "test-token-set-1")
token-set2 (ctob/make-token-set :name "test-token-set-2"
@@ -76,13 +84,13 @@
(t/is (= (:modified-at token-set2) now))
(t/is (empty? (:tokens token-set2)))))
(t/deftest invalid-token-set
(t/testing "invalid-token-set"
(let [args {:name 777
:description 999}]
(t/is (thrown-with-msg? Exception #"expected valid token set"
(t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception) #"expected valid token set"
(apply ctob/make-token-set args)))))
(t/deftest move-token-set
(t/testing "move-token-set"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "A"))
(ctob/add-set (ctob/make-token-set :name "B"))
@@ -107,7 +115,7 @@
(t/is (= original-order (move "A" "foo/bar/baz")))
(t/is (= original-order (move "Missing" "Move"))))))
(t/deftest tokens-tree
(t/testing "tokens-tree"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "A"
:tokens {"foo.bar.baz" (ctob/make-token :name "foo.bar.baz"
@@ -125,8 +133,8 @@
(t/is (= (get-in expected ["foo" "bar" "bam" :name]) "foo.bar.bam"))
(t/is (= (get-in expected ["baz" "boo" :name]) "baz.boo")))))
(t/testing "token-theme"
(t/deftest make-token-theme
(t/deftest token-theme
(t/testing "make-token-theme"
(let [now (dt/now)
token-theme1 (ctob/make-token-theme :name "test-token-theme-1")
token-theme2 (ctob/make-token-theme :name "test-token-theme-2"
@@ -150,24 +158,24 @@
(t/is (= (:modified-at token-theme2) now))
(t/is (empty? (:sets token-theme2)))))
(t/deftest invalid-token-theme
(t/testing "invalid-token-theme"
(let [args {:name 777
:group nil
:description 999
:is-source 42}]
(t/is (thrown-with-msg? Exception #"expected valid token theme"
(t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception) #"expected valid token theme"
(apply ctob/make-token-theme args))))))
(t/testing "tokens-lib"
(t/deftest make-tokens-lib
(t/deftest tokens-lib
(t/testing "make-tokens-lib"
(let [tokens-lib (ctob/make-tokens-lib)]
(t/is (= (ctob/set-count tokens-lib) 0))))
(t/deftest invalid-tokens-lib
(t/testing "invalid-tokens-lib"
(let [args {:sets nil
:themes nil}]
(t/is (thrown-with-msg? Exception #"expected valid tokens lib"
(t/is (thrown-with-msg? #?(:cljs js/Error :clj Exception) #"expected valid tokens lib"
(apply ctob/make-tokens-lib args))))))
@@ -263,8 +271,8 @@
(t/is (nil? token-set')))))
(t/testing "token in a lib"
(t/deftest add-token
(t/deftest token-in-a-lib
(t/testing "add-token"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set")))
token (ctob/make-token :name "test-token"
@@ -283,7 +291,7 @@
(t/is (= (:name token') "test-token"))
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
(t/deftest update-token
(t/testing "update-token"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set"
@@ -324,7 +332,7 @@
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))
(t/is (dt/is-after? (:modified-at token') (:modified-at token)))))
(t/deftest rename-token
(t/testing "rename-token"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set"
@@ -356,7 +364,7 @@
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))
(t/is (dt/is-after? (:modified-at token') (:modified-at token)))))
(t/deftest delete-token
(t/testing "delete-token"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set"
@@ -377,7 +385,7 @@
(t/is (nil? token'))
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
(t/deftest list-active-themes-tokens-in-order
(t/testing "list-active-themes-tokens-in-order"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-theme (ctob/make-token-theme :name "out-of-order-theme"
;; Out of order sets in theme
@@ -405,8 +413,8 @@
(t/is (= ["set-a-token" "set-b-token"] expected-token-names)))))
(t/testing "token-theme in a lib"
(t/deftest add-token-theme
(t/deftest token-theme-in-a-lib
(t/testing "add-token-theme"
(let [tokens-lib (ctob/make-tokens-lib)
token-theme (ctob/make-token-theme :name "test-token-theme")
tokens-lib' (ctob/add-theme tokens-lib token-theme)
@@ -418,7 +426,7 @@
(t/is (= (first token-themes') token-theme))
(t/is (= token-theme' token-theme))))
(t/deftest update-token-theme
(t/testing "update-token-theme"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-theme (ctob/make-token-theme :name "test-token-theme")))
@@ -440,7 +448,7 @@
(t/is (= (:description token-theme') "some description"))
(t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme)))))
(t/deftest rename-token-theme
(t/testing "rename-token-theme"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-theme (ctob/make-token-theme :name "test-token-theme")))
@@ -457,7 +465,7 @@
(t/is (= (:name token-theme') "updated-name"))
(t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme)))))
(t/deftest delete-token-theme
(t/testing "delete-token-theme"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-theme (ctob/make-token-theme :name "test-token-theme")))
@@ -470,7 +478,7 @@
(t/is (= (ctob/theme-count tokens-lib') 0))
(t/is (nil? token-theme'))))
(t/deftest toggle-set-in-theme
(t/testing "toggle-set-in-theme"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "token-set-1"))
(ctob/add-set (ctob/make-token-set :name "token-set-2"))
@@ -487,8 +495,8 @@
(t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme))))))
(t/testing "serialization"
(t/deftest transit-serialization
(t/deftest serialization
(t/testing "transit-serialization"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set" (ctob/make-token :name "test-token"
@@ -503,23 +511,24 @@
(t/is (= (ctob/set-count tokens-lib') 1))
(t/is (= (ctob/theme-count tokens-lib') 1))))
(t/deftest fressian-serialization
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set" (ctob/make-token :name "test-token"
:type :boolean
:value true))
(ctob/add-theme (ctob/make-token-theme :name "test-token-theme"))
(ctob/toggle-set-in-theme "" "test-token-theme" "test-token-set"))
encoded-blob (fres/encode tokens-lib)
tokens-lib' (fres/decode encoded-blob)]
#?(:clj
(t/testing "fressian-serialization"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set" (ctob/make-token :name "test-token"
:type :boolean
:value true))
(ctob/add-theme (ctob/make-token-theme :name "test-token-theme"))
(ctob/toggle-set-in-theme "" "test-token-theme" "test-token-set"))
encoded-blob (fres/encode tokens-lib)
tokens-lib' (fres/decode encoded-blob)]
(t/is (ctob/valid-tokens-lib? tokens-lib'))
(t/is (= (ctob/set-count tokens-lib') 1))
(t/is (= (ctob/theme-count tokens-lib') 1)))))
(t/is (ctob/valid-tokens-lib? tokens-lib'))
(t/is (= (ctob/set-count tokens-lib') 1))
(t/is (= (ctob/theme-count tokens-lib') 1))))))
(t/testing "grouping"
(t/deftest split-and-join
(t/deftest grouping
(t/testing "split-and-join"
(let [name "group/subgroup/name"
path (ctob/split-path name "/")
name' (ctob/join-path path "/")]
@@ -528,14 +537,14 @@
(t/is (= (nth path 2) "name"))
(t/is (= name' name))))
(t/deftest remove-spaces
(t/testing "remove-spaces"
(let [name "group / subgroup / name"
path (ctob/split-path name "/")]
(t/is (= (first path) "group"))
(t/is (= (second path) "subgroup"))
(t/is (= (nth path 2) "name"))))
(t/deftest group-and-ungroup
(t/testing "group-and-ungroup"
(let [token-set1 (ctob/make-token-set :name "token-set1")
token-set2 (ctob/make-token-set :name "some group/token-set2")
@@ -548,7 +557,7 @@
(t/is (= (:name token-set1'') "token-set1"))
(t/is (= (:name token-set2'') "some group/token-set2"))))
(t/deftest get-groups-str
(t/testing "get-groups-str"
(let [token-set1 (ctob/make-token-set :name "token-set1")
token-set2 (ctob/make-token-set :name "some-group/token-set2")
token-set3 (ctob/make-token-set :name "some-group/some-subgroup/token-set3")]
@@ -556,7 +565,7 @@
(t/is (= (ctob/get-groups-str token-set2 "/") "some-group"))
(t/is (= (ctob/get-groups-str token-set3 "/") "some-group/some-subgroup"))))
(t/deftest get-final-name
(t/testing "get-final-name"
(let [token-set1 (ctob/make-token-set :name "token-set1")
token-set2 (ctob/make-token-set :name "some-group/token-set2")
token-set3 (ctob/make-token-set :name "some-group/some-subgroup/token-set3")]
@@ -565,7 +574,7 @@
(t/is (= (ctob/get-final-name token-set3 "/") "token-set3"))))
(t/testing "grouped tokens"
(t/deftest grouped-tokens
(t/testing "grouped-tokens"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set"
@@ -599,7 +608,7 @@
(t/is (= (:name (nth tokens-list 3)) "group1.subgroup11.token4"))
(t/is (= (:name (nth tokens-list 4)) "group2.token5"))))
(t/deftest update-token-in-groups
(t/testing "update-token-in-groups"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set"
@@ -634,7 +643,7 @@
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))
(t/is (dt/is-after? (:modified-at token') (:modified-at token)))))
(t/deftest rename-token-in-groups
(t/testing "rename-token-in-groups"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set"
@@ -668,7 +677,7 @@
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))
(t/is (dt/is-after? (:modified-at token') (:modified-at token)))))
(t/deftest move-token-of-group
(t/testing "move-token-of-group"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set"
@@ -703,7 +712,7 @@
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))
(t/is (dt/is-after? (:modified-at token') (:modified-at token)))))
(t/deftest delete-token-in-group
(t/testing "delete-token-in-group"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "test-token-set"))
(ctob/add-token-in-set "test-token-set"
@@ -727,7 +736,7 @@
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set))))))
(t/testing "grouped sets"
(t/deftest grouped-sets
(t/testing "grouped-sets"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "token-set-1"))
(ctob/add-set (ctob/make-token-set :name "group1/token-set-2"))
@@ -786,7 +795,7 @@
(t/is (= (ctob/group? (second node-set5)) false))
(t/is (= (:name (second node-set5)) "group2/token-set-5"))))
(t/deftest update-set-in-groups
(t/testing "update-set-in-groups"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "token-set-1"))
(ctob/add-set (ctob/make-token-set :name "group1/token-set-2"))
@@ -812,7 +821,7 @@
(t/is (= (:description token-set') "some description"))
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
(t/deftest rename-set-in-groups
(t/testing "rename-set-in-groups"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "token-set-1"))
(ctob/add-set (ctob/make-token-set :name "group1/token-set-2"))
@@ -839,7 +848,7 @@
(t/is (= (:description token-set') nil))
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
(t/deftest move-set-of-group
(t/testing "move-set-of-group"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "token-set-1"))
(ctob/add-set (ctob/make-token-set :name "group1/token-set-2"))
@@ -868,7 +877,7 @@
(t/is (= (:description token-set') nil))
(t/is (dt/is-after? (:modified-at token-set') (:modified-at token-set)))))
(t/deftest delete-set-in-group
(t/testing "delete-set-in-group"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "token-set-1"))
(ctob/add-set (ctob/make-token-set :name "group1/token-set-2")))
@@ -884,7 +893,7 @@
(t/is (nil? token-set')))))
(t/testing "grouped themes"
(t/deftest grouped-themes
(t/testing "grouped-themes"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
(ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2"))
@@ -941,7 +950,7 @@
(t/is (= (ctob/group? (second node-theme4)) false))
(t/is (= (:name (second node-theme4)) "token-theme-4"))))
(t/deftest update-theme-in-groups
(t/testing "update-theme-in-groups"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
(ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2"))
@@ -967,7 +976,7 @@
(t/is (= (:description token-theme') "some description"))
(t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme)))))
(t/deftest get-theme-groups
(t/testing "get-theme-groups"
(let [token-lib (-> (ctob/make-tokens-lib)
(ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
(ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2"))
@@ -976,13 +985,14 @@
token-groups (ctob/get-theme-groups token-lib)]
(t/is (= token-groups ["group1" "group2"]))))
(t/deftest rename-theme-in-groups
(t/testing "rename-theme-in-groups"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
(ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2"))
(ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-3"))
(ctob/add-theme (ctob/make-token-theme :group "group2" :name "token-theme-4")))
tokens-lib' (-> tokens-lib
(ctob/update-theme "group1" "token-theme-2"
(fn [token-theme]
@@ -1003,7 +1013,7 @@
(t/is (= (:description token-theme') nil))
(t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme)))))
(t/deftest move-theme-of-group
(t/testing "move-theme-of-group"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
(ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2"))
@@ -1033,7 +1043,7 @@
(t/is (= (:description token-theme') nil))
(t/is (dt/is-after? (:modified-at token-theme') (:modified-at token-theme)))))
(t/deftest delete-theme-in-group
(t/testing "delete-theme-in-group"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-theme (ctob/make-token-theme :group "" :name "token-theme-1"))
(ctob/add-theme (ctob/make-token-theme :group "group1" :name "token-theme-2")))
@@ -1049,8 +1059,8 @@
(t/is (nil? token-theme'))))))
#?(:clj
(t/testing "dtcg encoding/decoding"
(t/deftest decode-dtcg-json
(t/deftest dtcg-encoding-decoding
(t/testing "decode-dtcg-json"
(let [json (-> (slurp "test/common_tests/types/data/tokens-multi-set-example.json")
(tr/decode-str))
lib (ctob/decode-dtcg-json (ctob/ensure-tokens-lib nil) json)
@@ -1078,7 +1088,7 @@
(t/testing "invalid tokens got discarded"
(t/is (nil? (get-set-token "typography" "H1.Bold"))))))
(t/deftest encode-dtcg-json
(t/testing "encode-dtcg-json"
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "core"
:tokens {"colors.red.600"
@@ -1111,7 +1121,7 @@
"$type" "color"}}}}}
expected))))
(t/deftest encode-decode-dtcg-json
(t/testing "encode-decode-dtcg-json"
(with-redefs [dt/now (constantly #inst "2024-10-16T12:01:20.257840055-00:00")]
(let [tokens-lib (-> (ctob/make-tokens-lib)
(ctob/add-set (ctob/make-token-set :name "core"

View File

@@ -88,7 +88,7 @@ __metadata:
languageName: node
linkType: hard
"ansi-styles@npm:^4.0.0":
"ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0":
version: 4.3.0
resolution: "ansi-styles@npm:4.3.0"
dependencies:
@@ -104,6 +104,16 @@ __metadata:
languageName: node
linkType: hard
"anymatch@npm:~3.1.2":
version: 3.1.3
resolution: "anymatch@npm:3.1.3"
dependencies:
normalize-path: "npm:^3.0.0"
picomatch: "npm:^2.0.4"
checksum: 10c0/57b06ae984bc32a0d22592c87384cd88fe4511b1dd7581497831c56d41939c8a001b28e7b853e1450f2bf61992dfcaa8ae2d0d161a0a90c4fb631ef07098fbac
languageName: node
linkType: hard
"asn1.js@npm:^4.10.1":
version: 4.10.1
resolution: "asn1.js@npm:4.10.1"
@@ -139,6 +149,13 @@ __metadata:
languageName: node
linkType: hard
"binary-extensions@npm:^2.0.0":
version: 2.3.0
resolution: "binary-extensions@npm:2.3.0"
checksum: 10c0/75a59cafc10fb12a11d510e77110c6c7ae3f4ca22463d52487709ca7f18f69d886aa387557cc9864fbdb10153d0bdb4caacabf11541f55e89ed6e18d12ece2b5
languageName: node
linkType: hard
"bn.js@npm:^4.0.0, bn.js@npm:^4.1.0, bn.js@npm:^4.11.9":
version: 4.12.0
resolution: "bn.js@npm:4.12.0"
@@ -153,6 +170,16 @@ __metadata:
languageName: node
linkType: hard
"brace-expansion@npm:^1.1.7":
version: 1.1.11
resolution: "brace-expansion@npm:1.1.11"
dependencies:
balanced-match: "npm:^1.0.0"
concat-map: "npm:0.0.1"
checksum: 10c0/695a56cd058096a7cb71fb09d9d6a7070113c7be516699ed361317aca2ec169f618e28b8af352e02ab4233fb54eb0168460a40dc320bab0034b36ab59aaad668
languageName: node
linkType: hard
"brace-expansion@npm:^2.0.1":
version: 2.0.1
resolution: "brace-expansion@npm:2.0.1"
@@ -162,6 +189,15 @@ __metadata:
languageName: node
linkType: hard
"braces@npm:~3.0.2":
version: 3.0.3
resolution: "braces@npm:3.0.3"
dependencies:
fill-range: "npm:^7.1.1"
checksum: 10c0/7c6dfd30c338d2997ba77500539227b9d1f85e388a5f43220865201e407e076783d0881f2d297b9f80951b4c957fcf0b51c1d2d24227631643c3f7c284b0aa04
languageName: node
linkType: hard
"brorand@npm:^1.0.1, brorand@npm:^1.1.0":
version: 1.1.0
resolution: "brorand@npm:1.1.0"
@@ -308,6 +344,35 @@ __metadata:
languageName: node
linkType: hard
"chalk@npm:^4.1.2":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
dependencies:
ansi-styles: "npm:^4.1.0"
supports-color: "npm:^7.1.0"
checksum: 10c0/4a3fef5cc34975c898ffe77141450f679721df9dde00f6c304353fa9c8b571929123b26a0e4617bde5018977eb655b31970c297b91b63ee83bb82aeb04666880
languageName: node
linkType: hard
"chokidar@npm:^3.5.2":
version: 3.6.0
resolution: "chokidar@npm:3.6.0"
dependencies:
anymatch: "npm:~3.1.2"
braces: "npm:~3.0.2"
fsevents: "npm:~2.3.2"
glob-parent: "npm:~5.1.2"
is-binary-path: "npm:~2.1.0"
is-glob: "npm:~4.0.1"
normalize-path: "npm:~3.0.0"
readdirp: "npm:~3.6.0"
dependenciesMeta:
fsevents:
optional: true
checksum: 10c0/8361dcd013f2ddbe260eacb1f3cb2f2c6f2b0ad118708a343a5ed8158941a39cb8fb1d272e0f389712e74ee90ce8ba864eece9e0e62b9705cb468a2f6d917462
languageName: node
linkType: hard
"chownr@npm:^2.0.0":
version: 2.0.0
resolution: "chownr@npm:2.0.0"
@@ -332,6 +397,17 @@ __metadata:
languageName: node
linkType: hard
"cliui@npm:^8.0.1":
version: 8.0.1
resolution: "cliui@npm:8.0.1"
dependencies:
string-width: "npm:^4.2.0"
strip-ansi: "npm:^6.0.1"
wrap-ansi: "npm:^7.0.0"
checksum: 10c0/4bda0f09c340cbb6dfdc1ed508b3ca080f12992c18d68c6be4d9cf51756033d5266e61ec57529e610dacbf4da1c634423b0c1b11037709cc6b09045cbd815df5
languageName: node
linkType: hard
"color-convert@npm:^2.0.1":
version: 2.0.1
resolution: "color-convert@npm:2.0.1"
@@ -352,14 +428,41 @@ __metadata:
version: 0.0.0-use.local
resolution: "common@workspace:."
dependencies:
concurrently: "npm:^9.0.1"
luxon: "npm:^3.4.4"
nodemon: "npm:^3.1.7"
sax: "npm:^1.4.1"
shadow-cljs: "npm:2.28.11"
shadow-cljs: "npm:2.28.18"
source-map-support: "npm:^0.5.21"
ws: "npm:^8.17.0"
languageName: unknown
linkType: soft
"concat-map@npm:0.0.1":
version: 0.0.1
resolution: "concat-map@npm:0.0.1"
checksum: 10c0/c996b1cfdf95b6c90fee4dae37e332c8b6eb7d106430c17d538034c0ad9a1630cb194d2ab37293b1bdd4d779494beee7786d586a50bd9376fd6f7bcc2bd4c98f
languageName: node
linkType: hard
"concurrently@npm:^9.0.1":
version: 9.1.0
resolution: "concurrently@npm:9.1.0"
dependencies:
chalk: "npm:^4.1.2"
lodash: "npm:^4.17.21"
rxjs: "npm:^7.8.1"
shell-quote: "npm:^1.8.1"
supports-color: "npm:^8.1.1"
tree-kill: "npm:^1.2.2"
yargs: "npm:^17.7.2"
bin:
conc: dist/bin/concurrently.js
concurrently: dist/bin/concurrently.js
checksum: 10c0/f2f42f94dde508bfbaf47b5ac654db9e8a4bf07d3d7b6267dd058ae6f362eec677ae7c8ede398d081e5fd0d1de5811dc9a53e57d3f1f68e72ac6459db9e0896b
languageName: node
linkType: hard
"console-browserify@npm:^1.1.0":
version: 1.2.0
resolution: "console-browserify@npm:1.2.0"
@@ -460,6 +563,18 @@ __metadata:
languageName: node
linkType: hard
"debug@npm:^4":
version: 4.3.7
resolution: "debug@npm:4.3.7"
dependencies:
ms: "npm:^2.1.3"
peerDependenciesMeta:
supports-color:
optional: true
checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b
languageName: node
linkType: hard
"define-data-property@npm:^1.0.1, define-data-property@npm:^1.1.4":
version: 1.1.4
resolution: "define-data-property@npm:1.1.4"
@@ -585,6 +700,13 @@ __metadata:
languageName: node
linkType: hard
"escalade@npm:^3.1.1":
version: 3.2.0
resolution: "escalade@npm:3.2.0"
checksum: 10c0/ced4dd3a78e15897ed3be74e635110bbf3b08877b0a41be50dcb325ee0e0b5f65fc2d50e9845194d7c4633f327e2e1c6cce00a71b617c5673df0374201d67f65
languageName: node
linkType: hard
"events@npm:^3.0.0":
version: 3.3.0
resolution: "events@npm:3.3.0"
@@ -610,6 +732,15 @@ __metadata:
languageName: node
linkType: hard
"fill-range@npm:^7.1.1":
version: 7.1.1
resolution: "fill-range@npm:7.1.1"
dependencies:
to-regex-range: "npm:^5.0.1"
checksum: 10c0/b75b691bbe065472f38824f694c2f7449d7f5004aa950426a2c28f0306c60db9b880c0b0e4ed819997ffb882d1da02cfcfc819bddc94d71627f5269682edf018
languageName: node
linkType: hard
"foreground-child@npm:^3.1.0":
version: 3.1.1
resolution: "foreground-child@npm:3.1.1"
@@ -638,6 +769,25 @@ __metadata:
languageName: node
linkType: hard
"fsevents@npm:~2.3.2":
version: 2.3.3
resolution: "fsevents@npm:2.3.3"
dependencies:
node-gyp: "npm:latest"
checksum: 10c0/a1f0c44595123ed717febbc478aa952e47adfc28e2092be66b8ab1635147254ca6cfe1df792a8997f22716d4cbafc73309899ff7bfac2ac3ad8cf2e4ecc3ec60
conditions: os=darwin
languageName: node
linkType: hard
"fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin<compat/fsevents>":
version: 2.3.3
resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin<compat/fsevents>::version=2.3.3&hash=df0bf1"
dependencies:
node-gyp: "npm:latest"
conditions: os=darwin
languageName: node
linkType: hard
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
@@ -645,6 +795,13 @@ __metadata:
languageName: node
linkType: hard
"get-caller-file@npm:^2.0.5":
version: 2.0.5
resolution: "get-caller-file@npm:2.0.5"
checksum: 10c0/c6c7b60271931fa752aeb92f2b47e355eac1af3a2673f47c9589e8f8a41adc74d45551c1bc57b5e66a80609f10ffb72b6f575e4370d61cc3f7f3aaff01757cde
languageName: node
linkType: hard
"get-intrinsic@npm:^1.1.3, get-intrinsic@npm:^1.2.4":
version: 1.2.4
resolution: "get-intrinsic@npm:1.2.4"
@@ -658,6 +815,15 @@ __metadata:
languageName: node
linkType: hard
"glob-parent@npm:~5.1.2":
version: 5.1.2
resolution: "glob-parent@npm:5.1.2"
dependencies:
is-glob: "npm:^4.0.1"
checksum: 10c0/cab87638e2112bee3f839ef5f6e0765057163d39c66be8ec1602f3823da4692297ad4e972de876ea17c44d652978638d2fd583c6713d0eb6591706825020c9ee
languageName: node
linkType: hard
"glob@npm:^10.2.2, glob@npm:^10.3.10":
version: 10.3.16
resolution: "glob@npm:10.3.16"
@@ -689,6 +855,20 @@ __metadata:
languageName: node
linkType: hard
"has-flag@npm:^3.0.0":
version: 3.0.0
resolution: "has-flag@npm:3.0.0"
checksum: 10c0/1c6c83b14b8b1b3c25b0727b8ba3e3b647f99e9e6e13eb7322107261de07a4c1be56fc0d45678fc376e09772a3a1642ccdaf8fc69bdf123b6c086598397ce473
languageName: node
linkType: hard
"has-flag@npm:^4.0.0":
version: 4.0.0
resolution: "has-flag@npm:4.0.0"
checksum: 10c0/2e789c61b7888d66993e14e8331449e525ef42aac53c627cc53d1c3334e768bcb6abdc4f5f0de1478a25beec6f0bd62c7549058b7ac53e924040d4f301f02fd1
languageName: node
linkType: hard
"has-property-descriptors@npm:^1.0.0, has-property-descriptors@npm:^1.0.2":
version: 1.0.2
resolution: "has-property-descriptors@npm:1.0.2"
@@ -813,6 +993,13 @@ __metadata:
languageName: node
linkType: hard
"ignore-by-default@npm:^1.0.1":
version: 1.0.1
resolution: "ignore-by-default@npm:1.0.1"
checksum: 10c0/9ab6e70e80f7cc12735def7ecb5527cfa56ab4e1152cd64d294522827f2dcf1f6d85531241537dc3713544e88dd888f65cb3c49c7b2cddb9009087c75274e533
languageName: node
linkType: hard
"imurmurhash@npm:^0.1.4":
version: 0.1.4
resolution: "imurmurhash@npm:0.1.4"
@@ -851,6 +1038,22 @@ __metadata:
languageName: node
linkType: hard
"is-binary-path@npm:~2.1.0":
version: 2.1.0
resolution: "is-binary-path@npm:2.1.0"
dependencies:
binary-extensions: "npm:^2.0.0"
checksum: 10c0/a16eaee59ae2b315ba36fad5c5dcaf8e49c3e27318f8ab8fa3cdb8772bf559c8d1ba750a589c2ccb096113bb64497084361a25960899cb6172a6925ab6123d38
languageName: node
linkType: hard
"is-extglob@npm:^2.1.1":
version: 2.1.1
resolution: "is-extglob@npm:2.1.1"
checksum: 10c0/5487da35691fbc339700bbb2730430b07777a3c21b9ebaecb3072512dfd7b4ba78ac2381a87e8d78d20ea08affb3f1971b4af629173a6bf435ff8a4c47747912
languageName: node
linkType: hard
"is-fullwidth-code-point@npm:^3.0.0":
version: 3.0.0
resolution: "is-fullwidth-code-point@npm:3.0.0"
@@ -858,6 +1061,15 @@ __metadata:
languageName: node
linkType: hard
"is-glob@npm:^4.0.1, is-glob@npm:~4.0.1":
version: 4.0.3
resolution: "is-glob@npm:4.0.3"
dependencies:
is-extglob: "npm:^2.1.1"
checksum: 10c0/17fb4014e22be3bbecea9b2e3a76e9e34ff645466be702f1693e8f1ee1adac84710d0be0bd9f967d6354036fd51ab7c2741d954d6e91dae6bb69714de92c197a
languageName: node
linkType: hard
"is-lambda@npm:^1.0.1":
version: 1.0.1
resolution: "is-lambda@npm:1.0.1"
@@ -865,6 +1077,13 @@ __metadata:
languageName: node
linkType: hard
"is-number@npm:^7.0.0":
version: 7.0.0
resolution: "is-number@npm:7.0.0"
checksum: 10c0/b4686d0d3053146095ccd45346461bc8e53b80aeb7671cc52a4de02dbbf7dc0d1d2a986e2fe4ae206984b4d34ef37e8b795ebc4f4295c978373e6575e295d811
languageName: node
linkType: hard
"isarray@npm:^1.0.0, isarray@npm:~1.0.0":
version: 1.0.0
resolution: "isarray@npm:1.0.0"
@@ -906,6 +1125,13 @@ __metadata:
languageName: node
linkType: hard
"lodash@npm:^4.17.21":
version: 4.17.21
resolution: "lodash@npm:4.17.21"
checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c
languageName: node
linkType: hard
"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0":
version: 10.2.2
resolution: "lru-cache@npm:10.2.2"
@@ -977,6 +1203,15 @@ __metadata:
languageName: node
linkType: hard
"minimatch@npm:^3.1.2":
version: 3.1.2
resolution: "minimatch@npm:3.1.2"
dependencies:
brace-expansion: "npm:^1.1.7"
checksum: 10c0/0262810a8fc2e72cca45d6fd86bd349eee435eb95ac6aa45c9ea2180e7ee875ef44c32b55b5973ceabe95ea12682f6e3725cbb63d7a2d1da3ae1163c8b210311
languageName: node
linkType: hard
"minimatch@npm:^9.0.1":
version: 9.0.4
resolution: "minimatch@npm:9.0.4"
@@ -1086,6 +1321,13 @@ __metadata:
languageName: node
linkType: hard
"ms@npm:^2.1.3":
version: 2.1.3
resolution: "ms@npm:2.1.3"
checksum: 10c0/d924b57e7312b3b63ad21fc5b3dc0af5e78d61a1fc7cfb5457edaf26326bf62be5307cc87ffb6862ef1c2b33b0233cdb5d4f01c4c958cc0d660948b65a287a48
languageName: node
linkType: hard
"negotiator@npm:^0.6.3":
version: 0.6.3
resolution: "negotiator@npm:0.6.3"
@@ -1144,6 +1386,26 @@ __metadata:
languageName: node
linkType: hard
"nodemon@npm:^3.1.7":
version: 3.1.7
resolution: "nodemon@npm:3.1.7"
dependencies:
chokidar: "npm:^3.5.2"
debug: "npm:^4"
ignore-by-default: "npm:^1.0.1"
minimatch: "npm:^3.1.2"
pstree.remy: "npm:^1.1.8"
semver: "npm:^7.5.3"
simple-update-notifier: "npm:^2.0.0"
supports-color: "npm:^5.5.0"
touch: "npm:^3.1.0"
undefsafe: "npm:^2.0.5"
bin:
nodemon: bin/nodemon.js
checksum: 10c0/e0b46939abdbce251b1d6281005a5763cee57db295bb00bc4a753b0f5320dac00fe53547fb6764c70a086cf6d1238875cccb800fbc71544b3ecbd3ef71183c87
languageName: node
linkType: hard
"nopt@npm:^7.0.0":
version: 7.2.1
resolution: "nopt@npm:7.2.1"
@@ -1155,6 +1417,13 @@ __metadata:
languageName: node
linkType: hard
"normalize-path@npm:^3.0.0, normalize-path@npm:~3.0.0":
version: 3.0.0
resolution: "normalize-path@npm:3.0.0"
checksum: 10c0/e008c8142bcc335b5e38cf0d63cfd39d6cf2d97480af9abdbe9a439221fd4d749763bab492a8ee708ce7a194bb00c9da6d0a115018672310850489137b3da046
languageName: node
linkType: hard
"object-inspect@npm:^1.13.1":
version: 1.13.1
resolution: "object-inspect@npm:1.13.1"
@@ -1255,6 +1524,13 @@ __metadata:
languageName: node
linkType: hard
"picomatch@npm:^2.0.4, picomatch@npm:^2.2.1":
version: 2.3.1
resolution: "picomatch@npm:2.3.1"
checksum: 10c0/26c02b8d06f03206fc2ab8d16f19960f2ff9e81a658f831ecb656d8f17d9edc799e8364b1f4a7873e89d9702dff96204be0fa26fe4181f6843f040f819dac4be
languageName: node
linkType: hard
"proc-log@npm:^3.0.0":
version: 3.0.0
resolution: "proc-log@npm:3.0.0"
@@ -1293,6 +1569,13 @@ __metadata:
languageName: node
linkType: hard
"pstree.remy@npm:^1.1.8":
version: 1.1.8
resolution: "pstree.remy@npm:1.1.8"
checksum: 10c0/30f78c88ce6393cb3f7834216cb6e282eb83c92ccb227430d4590298ab2811bc4a4745f850a27c5178e79a8f3e316591de0fec87abc19da648c2b3c6eb766d14
languageName: node
linkType: hard
"public-encrypt@npm:^4.0.0":
version: 4.0.3
resolution: "public-encrypt@npm:4.0.3"
@@ -1375,6 +1658,15 @@ __metadata:
languageName: node
linkType: hard
"readdirp@npm:~3.6.0":
version: 3.6.0
resolution: "readdirp@npm:3.6.0"
dependencies:
picomatch: "npm:^2.2.1"
checksum: 10c0/6fa848cf63d1b82ab4e985f4cf72bd55b7dcfd8e0a376905804e48c3634b7e749170940ba77b32804d5fe93b3cc521aa95a8d7e7d725f830da6d93f3669ce66b
languageName: node
linkType: hard
"readline-sync@npm:^1.4.7":
version: 1.4.10
resolution: "readline-sync@npm:1.4.10"
@@ -1382,6 +1674,13 @@ __metadata:
languageName: node
linkType: hard
"require-directory@npm:^2.1.1":
version: 2.1.1
resolution: "require-directory@npm:2.1.1"
checksum: 10c0/83aa76a7bc1531f68d92c75a2ca2f54f1b01463cb566cf3fbc787d0de8be30c9dbc211d1d46be3497dac5785fe296f2dd11d531945ac29730643357978966e99
languageName: node
linkType: hard
"retry@npm:^0.12.0":
version: 0.12.0
resolution: "retry@npm:0.12.0"
@@ -1399,6 +1698,15 @@ __metadata:
languageName: node
linkType: hard
"rxjs@npm:^7.8.1":
version: 7.8.1
resolution: "rxjs@npm:7.8.1"
dependencies:
tslib: "npm:^2.1.0"
checksum: 10c0/3c49c1ecd66170b175c9cacf5cef67f8914dcbc7cd0162855538d365c83fea631167cacb644b3ce533b2ea0e9a4d0b12175186985f89d75abe73dbd8f7f06f68
languageName: node
linkType: hard
"safe-buffer@npm:^5.0.1, safe-buffer@npm:^5.1.0, safe-buffer@npm:^5.1.1, safe-buffer@npm:^5.1.2, safe-buffer@npm:^5.2.0, safe-buffer@npm:^5.2.1, safe-buffer@npm:~5.2.0":
version: 5.2.1
resolution: "safe-buffer@npm:5.2.1"
@@ -1436,6 +1744,15 @@ __metadata:
languageName: node
linkType: hard
"semver@npm:^7.5.3":
version: 7.6.3
resolution: "semver@npm:7.6.3"
bin:
semver: bin/semver.js
checksum: 10c0/88f33e148b210c153873cb08cfe1e281d518aaa9a666d4d148add6560db5cd3c582f3a08ccb91f38d5f379ead256da9931234ed122057f40bb5766e65e58adaf
languageName: node
linkType: hard
"set-function-length@npm:^1.2.1":
version: 1.2.2
resolution: "set-function-length@npm:1.2.2"
@@ -1476,9 +1793,9 @@ __metadata:
languageName: node
linkType: hard
"shadow-cljs@npm:2.28.11":
version: 2.28.11
resolution: "shadow-cljs@npm:2.28.11"
"shadow-cljs@npm:2.28.18":
version: 2.28.18
resolution: "shadow-cljs@npm:2.28.18"
dependencies:
node-libs-browser: "npm:^2.2.1"
readline-sync: "npm:^1.4.7"
@@ -1488,7 +1805,7 @@ __metadata:
ws: "npm:^7.4.6"
bin:
shadow-cljs: cli/runner.js
checksum: 10c0/c5c77d524ee8f44e4ae2ddc196af170d02405cc8731ea71f852c7b220fc1ba8aaf5cf33753fd8a7566c8749bb75d360f903dfb0d131bcdc6c2c33f44404bd6a3
checksum: 10c0/4732cd11a5722644a0a91ae5560a55f62432ae5317bd2d1fd5bf12af8354c81776f4fcfce5c477b43af1ac2ecd4a216887337e1b92cca37a1b8cb9c157a393c1
languageName: node
linkType: hard
@@ -1508,6 +1825,13 @@ __metadata:
languageName: node
linkType: hard
"shell-quote@npm:^1.8.1":
version: 1.8.1
resolution: "shell-quote@npm:1.8.1"
checksum: 10c0/8cec6fd827bad74d0a49347057d40dfea1e01f12a6123bf82c4649f3ef152fc2bc6d6176e6376bffcd205d9d0ccb4f1f9acae889384d20baff92186f01ea455a
languageName: node
linkType: hard
"side-channel@npm:^1.0.6":
version: 1.0.6
resolution: "side-channel@npm:1.0.6"
@@ -1527,6 +1851,15 @@ __metadata:
languageName: node
linkType: hard
"simple-update-notifier@npm:^2.0.0":
version: 2.0.0
resolution: "simple-update-notifier@npm:2.0.0"
dependencies:
semver: "npm:^7.5.3"
checksum: 10c0/2a00bd03bfbcbf8a737c47ab230d7920f8bfb92d1159d421bdd194479f6d01ebc995d13fbe13d45dace23066a78a3dc6642999b4e3b38b847e6664191575b20c
languageName: node
linkType: hard
"smart-buffer@npm:^4.2.0":
version: 4.2.0
resolution: "smart-buffer@npm:4.2.0"
@@ -1627,7 +1960,7 @@ __metadata:
languageName: node
linkType: hard
"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0":
"string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0, string-width@npm:^4.2.0, string-width@npm:^4.2.3":
version: 4.2.3
resolution: "string-width@npm:4.2.3"
dependencies:
@@ -1685,6 +2018,33 @@ __metadata:
languageName: node
linkType: hard
"supports-color@npm:^5.5.0":
version: 5.5.0
resolution: "supports-color@npm:5.5.0"
dependencies:
has-flag: "npm:^3.0.0"
checksum: 10c0/6ae5ff319bfbb021f8a86da8ea1f8db52fac8bd4d499492e30ec17095b58af11f0c55f8577390a749b1c4dde691b6a0315dab78f5f54c9b3d83f8fb5905c1c05
languageName: node
linkType: hard
"supports-color@npm:^7.1.0":
version: 7.2.0
resolution: "supports-color@npm:7.2.0"
dependencies:
has-flag: "npm:^4.0.0"
checksum: 10c0/afb4c88521b8b136b5f5f95160c98dee7243dc79d5432db7efc27efb219385bbc7d9427398e43dd6cc730a0f87d5085ce1652af7efbe391327bc0a7d0f7fc124
languageName: node
linkType: hard
"supports-color@npm:^8.1.1":
version: 8.1.1
resolution: "supports-color@npm:8.1.1"
dependencies:
has-flag: "npm:^4.0.0"
checksum: 10c0/ea1d3c275dd604c974670f63943ed9bd83623edc102430c05adb8efc56ba492746b6e95386e7831b872ec3807fd89dd8eb43f735195f37b5ec343e4234cc7e89
languageName: node
linkType: hard
"tar@npm:^6.1.11, tar@npm:^6.1.2":
version: 6.2.1
resolution: "tar@npm:6.2.1"
@@ -1715,6 +2075,40 @@ __metadata:
languageName: node
linkType: hard
"to-regex-range@npm:^5.0.1":
version: 5.0.1
resolution: "to-regex-range@npm:5.0.1"
dependencies:
is-number: "npm:^7.0.0"
checksum: 10c0/487988b0a19c654ff3e1961b87f471702e708fa8a8dd02a298ef16da7206692e8552a0250e8b3e8759270f62e9d8314616f6da274734d3b558b1fc7b7724e892
languageName: node
linkType: hard
"touch@npm:^3.1.0":
version: 3.1.1
resolution: "touch@npm:3.1.1"
bin:
nodetouch: bin/nodetouch.js
checksum: 10c0/d2e4d269a42c846a22a29065b9af0b263de58effc85a1764bb7a2e8fc4b47700e9e2fcbd7eb1f5bffbb7c73d860f93600cef282b93ddac8f0b62321cb498b36e
languageName: node
linkType: hard
"tree-kill@npm:^1.2.2":
version: 1.2.2
resolution: "tree-kill@npm:1.2.2"
bin:
tree-kill: cli.js
checksum: 10c0/7b1b7c7f17608a8f8d20a162e7957ac1ef6cd1636db1aba92f4e072dc31818c2ff0efac1e3d91064ede67ed5dc57c565420531a8134090a12ac10cf792ab14d2
languageName: node
linkType: hard
"tslib@npm:^2.1.0":
version: 2.8.1
resolution: "tslib@npm:2.8.1"
checksum: 10c0/9c4759110a19c53f992d9aae23aac5ced636e99887b51b9e61def52611732872ff7668757d4e4c61f19691e36f4da981cd9485e869b4a7408d689f6bf1f14e62
languageName: node
linkType: hard
"tty-browserify@npm:0.0.0":
version: 0.0.0
resolution: "tty-browserify@npm:0.0.0"
@@ -1722,6 +2116,13 @@ __metadata:
languageName: node
linkType: hard
"undefsafe@npm:^2.0.5":
version: 2.0.5
resolution: "undefsafe@npm:2.0.5"
checksum: 10c0/96c0466a5fbf395917974a921d5d4eee67bca4b30d3a31ce7e621e0228c479cf893e783a109af6e14329b52fe2f0cb4108665fad2b87b0018c0df6ac771261d5
languageName: node
linkType: hard
"unique-filename@npm:^3.0.0":
version: 3.0.0
resolution: "unique-filename@npm:3.0.0"
@@ -1815,7 +2216,7 @@ __metadata:
languageName: node
linkType: hard
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0, wrap-ansi@npm:^7.0.0":
version: 7.0.0
resolution: "wrap-ansi@npm:7.0.0"
dependencies:
@@ -1874,9 +2275,38 @@ __metadata:
languageName: node
linkType: hard
"y18n@npm:^5.0.5":
version: 5.0.8
resolution: "y18n@npm:5.0.8"
checksum: 10c0/4df2842c36e468590c3691c894bc9cdbac41f520566e76e24f59401ba7d8b4811eb1e34524d57e54bc6d864bcb66baab7ffd9ca42bf1eda596618f9162b91249
languageName: node
linkType: hard
"yallist@npm:^4.0.0":
version: 4.0.0
resolution: "yallist@npm:4.0.0"
checksum: 10c0/2286b5e8dbfe22204ab66e2ef5cc9bbb1e55dfc873bbe0d568aa943eb255d131890dfd5bf243637273d31119b870f49c18fcde2c6ffbb7a7a092b870dc90625a
languageName: node
linkType: hard
"yargs-parser@npm:^21.1.1":
version: 21.1.1
resolution: "yargs-parser@npm:21.1.1"
checksum: 10c0/f84b5e48169479d2f402239c59f084cfd1c3acc197a05c59b98bab067452e6b3ea46d4dd8ba2985ba7b3d32a343d77df0debd6b343e5dae3da2aab2cdf5886b2
languageName: node
linkType: hard
"yargs@npm:^17.7.2":
version: 17.7.2
resolution: "yargs@npm:17.7.2"
dependencies:
cliui: "npm:^8.0.1"
escalade: "npm:^3.1.1"
get-caller-file: "npm:^2.0.5"
require-directory: "npm:^2.1.1"
string-width: "npm:^4.2.3"
y18n: "npm:^5.0.5"
yargs-parser: "npm:^21.1.1"
checksum: 10c0/ccd7e723e61ad5965fffbb791366db689572b80cca80e0f96aad968dfff4156cd7cd1ad18607afe1046d8241e6fb2d6c08bf7fa7bfb5eaec818735d8feac8f05
languageName: node
linkType: hard

View File

@@ -65,7 +65,7 @@ You will be able to share your plugin with the <a target="_blank" href="https://
### My plugin works on my local machine, but I couldnt install it on Penpot. What could be the problem?
The url you that you need to provide in the plugin manager should look <a target="_blank" href="/plugins/create-a-plugin/#2.6.-step-6.-configure-the-manifest-file">like this</a>: <code class="language-bash">https:\/\/yourdomain.com/assents/manifest.json</code>
The url you that you need to provide in the plugin manager should look <a target="_blank" href="/plugins/create-a-plugin/#2.6.-step-6.-configure-the-manifest-file">like this</a>: <code class="language-bash">https:\/\/yourdomain.com/assets/manifest.json</code>
### Where can I get support if I find a bug or an unexpected behavior?

View File

@@ -35,7 +35,7 @@
"lint:scss:fix": "yarn run prettier -c resources/styles -c src/**/*.scss -w",
"build:test": "clojure -M:dev:shadow-cljs compile test",
"test": "yarn run build:test && node target/tests/test.js",
"watch:test": "concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -w target/tests/test.js --exec 'sleep 2 && node target/tests/test.js'\"",
"watch:test": "mkdir -p target/tests && concurrently \"clojure -M:dev:shadow-cljs watch test\" \"nodemon -C -d 2 -w target/tests --exec 'node target/tests/test.js'\"",
"test:e2e": "playwright test --project default",
"translations": "node ./scripts/translations.js",
"watch:app:assets": "node ./scripts/watch.js",
@@ -100,7 +100,7 @@
"@penpot/hljs": "portal:./vendor/hljs",
"@penpot/mousetrap": "portal:./vendor/mousetrap",
"@penpot/svgo": "penpot/svgo#c6fba7a4dcfbc27b643e7fc0c94fc98cf680b77b",
"@penpot/text-editor": "penpot/penpot-text-editor#a100aad8d0efcbb070bed9144dbd2782547e78ba",
"@penpot/text-editor": "portal:./text-editor",
"@tokens-studio/sd-transforms": "^0.16.1",
"compression": "^1.7.4",
"date-fns": "^4.1.0",

View File

@@ -90,7 +90,6 @@
"unknown"
date)))
;; --- Global Config Vars
(def default-theme "default")

View File

@@ -30,6 +30,7 @@
[app.util.i18n :as i18n]
[app.util.theme :as theme]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[debug]
[features]
[potok.v2.core :as ptk]
@@ -38,11 +39,11 @@
(log/setup! {:app :info})
(when (= :browser cf/target)
(log/info :message "Welcome to penpot"
:version (:full cf/version)
(log/info :version (:full cf/version)
:asserts *assert*
:build-date cf/build-date
:public-uri (dm/str cf/public-uri)))
:public-uri (dm/str cf/public-uri))
(log/info :flags (str/join "," (map name cf/flags))))
(declare reinit)

View File

@@ -1118,6 +1118,9 @@
(rx/of (rt/nav :dashboard-projects {:team-id team-id}))))))
([team-id]
(ptk/reify ::go-to-projects-1
ptk/UpdateEvent
(update [_ state]
(assoc state :current-team-id team-id))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (rt/nav :dashboard-projects {:team-id team-id}))))))

View File

@@ -15,6 +15,7 @@
[app.main.data.changes :as dch]
[app.main.data.comments :as dcm]
[app.main.data.events :as ev]
[app.main.data.workspace :as dw]
[app.main.data.workspace.common :as dwco]
[app.main.data.workspace.drawing :as dwd]
[app.main.data.workspace.state-helpers :as wsh]
@@ -60,7 +61,11 @@
(let [local (:comments-local state)]
(cond
(:draft local) (rx/of (dcm/close-thread))
(:open local) (rx/of (dcm/close-thread)))))))
(:open local) (rx/of (dcm/close-thread))
:else
(rx/of (dw/clear-edition-mode)
(dw/deselect-all true)))))))
;; Event responsible of the what should be executed when user clicked
;; on the comments layer. An option can be create a new draft thread,

View File

@@ -12,6 +12,7 @@
[app.main.data.shortcuts :as ds]
[app.main.data.workspace.texts :as dwt]
[app.main.data.workspace.undo :as dwu]
[app.main.features :as features]
[app.main.fonts :as fonts]
[app.main.refs :as refs]
[app.main.store :as st]
@@ -113,18 +114,24 @@
(defn calculate-text-values
[shape]
(let [state-map (deref refs/workspace-editor-state)
editor-state (get state-map (:id shape))]
(let [state-map (if (features/active-feature? @st/state "text-editor/v2")
(deref refs/workspace-v2-editor-state)
(deref refs/workspace-editor-state))
editor-state (get state-map (:id shape))
editor-instance (when (features/active-feature? @st/state "text-editor/v2")
(deref refs/workspace-editor))]
(d/merge
(dwt/current-root-values
{:shape shape
: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}))))

View File

@@ -117,7 +117,7 @@
(rx/of (ptk/event ::ev/event {::ev/name "rename-version"}))))))
(defn restore-version
[project-id file-id id]
[project-id file-id id origin]
(dm/assert! (uuid? project-id))
(dm/assert! (uuid? file-id))
(dm/assert! (uuid? id))
@@ -132,7 +132,17 @@
(rx/take 1)
(rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id id}))
(rx/map #(dw/initialize-file project-id file-id)))
(rx/of (ptk/event ::ev/event {::ev/name "restore-version"}))))))
(case origin
:version
(rx/of (ptk/event ::ev/event {::ev/name "restore-pin-version"}))
:snapshot
(rx/of (ptk/event ::ev/event {::ev/name "restore-autosave"}))
:plugin
(rx/of (ptk/event ::ev/event {::ev/name "restore-version-plugin"}))
(rx/empty))))))
(defn delete-version
[file-id id]

View File

@@ -33,10 +33,13 @@
(defn get-team-enabled-features
[state]
(-> global-enabled-features
(set/union (:features-runtime state #{}))
(set/intersection cfeat/no-migration-features)
(set/union (:features-team state #{}))))
(let [runtime-features (:features-runtime state #{})
team-features (->> (:features-team state #{})
(into #{} cfeat/xf-remove-ephimeral))]
(-> global-enabled-features
(set/union runtime-features)
(set/intersection cfeat/no-migration-features)
(set/union team-features))))
(def features-ref
(l/derived get-team-enabled-features st/state =))
@@ -124,9 +127,9 @@
(let [features (get-team-enabled-features state)]
(if (contains? features "render-wasm/v1")
(render.wasm/initialize true)
(render.wasm/initialize false)))
(render.wasm/initialize false))
(log/trc :hint "initialized features"
:team (str/join "," (:features-team state))
:runtime (str/join "," (:features-runtime state)))))))
(log/inf :hint "initialized"
:enabled (str/join "," features)
:runtime (str/join "," (:features-runtime state))))))))

View File

@@ -524,6 +524,19 @@
(when (kbd/enter? event)
(on-add-shared event))))
on-show-version-history
(mf/use-fn
(mf/deps file-id)
(fn [_]
(st/emit! (dw/toggle-layout-flag :document-history))))
on-show-version-history-key-down
(mf/use-fn
(mf/deps on-show-version-history)
(fn [event]
(when (kbd/enter? event)
(on-show-version-history event))))
on-export-shapes
(mf/use-fn #(st/emit! (de/show-workspace-export-dialog {:origin "workspace:menu"})))
@@ -575,14 +588,23 @@
:on-click on-remove-shared
:on-key-down on-remove-shared-key-down
:id "file-menu-remove-shared"}
[:span {:class (stl/css :item-name)} (tr "dashboard.unpublish-shared")]])
[:span {:class (stl/css :item-name)}
(tr "dashboard.unpublish-shared")]])
(when can-edit
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-add-shared
:on-key-down on-add-shared-key-down
:id "file-menu-add-shared"}
[:span {:class (stl/css :item-name)} (tr "dashboard.add-shared")]]))
[:span {:class (stl/css :item-name)}
(tr "dashboard.add-shared")]]))
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-show-version-history
:on-key-down on-show-version-history-key-down
:id "file-menu-show-version-history"}
[:span {:class (stl/css :item-name)}
(tr "dashboard.show-version-history")]]
[:> dropdown-menu-item* {:class (stl/css :submenu-item)
:on-click on-export-shapes

View File

@@ -27,6 +27,13 @@
[app.util.text.content.styles :as styles]
[rumext.v2 :as mf]))
(defn- gen-name
[editor]
(when (some? editor)
(let [editor-root (.-root editor)
result (.-textContent editor-root)]
(when (not= result "") result))))
(defn- initialize-event-handlers
"Internal editor events handler initializer/destructor"
[shape-id content selection-ref editor-ref container-ref]
@@ -51,6 +58,8 @@
instance
(dwt/create-editor editor-node options)
update-name? (nil? content)
on-key-up
(fn [event]
(dom/stop-propagation event)
@@ -60,7 +69,7 @@
on-blur
(fn []
(when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
(st/emit! (dwt/v2-update-text-shape-content shape-id content true)))
(st/emit! (dwt/v2-update-text-shape-content shape-id content update-name? (gen-name instance))))
(let [container-node (mf/ref-val container-ref)]
(dom/set-style! container-node "opacity" 0)))

View File

@@ -15,6 +15,7 @@
[app.common.math :as mth]
[app.common.text :as txt]
[app.common.types.modifiers :as ctm]
[app.common.uuid :as uuid]
[app.main.data.workspace.modifiers :as mdwm]
[app.main.data.workspace.texts :as dwt]
[app.main.fonts :as fonts]
@@ -184,7 +185,7 @@
(mf/use-fn
(fn [shape node]
;; Unique to indentify the pending state
(let [uid (js/Symbol)]
(let [uid (uuid/next)]
(swap! pending-update* assoc uid (:id shape))
(p/then
(update-text-shape shape node)

View File

@@ -274,10 +274,11 @@
(fn [id label]
(st/emit! (dwv/rename-version file-id id label))))
handle-restore-version
(mf/use-fn
(mf/deps project-id file-id)
(fn [id]
(fn [origin id]
(st/emit!
(ntf/dialog
:content (tr "workspace.versions.restore-warning")
@@ -287,9 +288,21 @@
:callback #(st/emit! (ntf/hide))}
{:label (tr "labels.restore")
:type :primary
:callback #(st/emit! (dwv/restore-version project-id file-id id))}]
:callback #(st/emit! (dwv/restore-version project-id file-id id origin))}]
:tag :restore-dialog))))
handle-restore-version-pinned
(mf/use-fn
(mf/deps handle-restore-version)
(fn [id]
(handle-restore-version :version id)))
handle-restore-version-snapshot
(mf/use-fn
(mf/deps handle-restore-version)
(fn [id]
(handle-restore-version :snapshot id)))
handle-delete-version
(mf/use-fn
(mf/deps file-id)
@@ -362,7 +375,7 @@
:editing? (= (:id entry) editing)
:profile (get users (:profile-id entry))
:on-rename-version handle-rename-version
:on-restore-version handle-restore-version
:on-restore-version handle-restore-version-pinned
:on-delete-version handle-delete-version}]
:snapshot
@@ -371,7 +384,7 @@
:entry entry
:is-expanded (contains? @expanded idx-entry)
:on-toggle-expand handle-toggle-expand
:on-restore-snapshot handle-restore-version
:on-restore-snapshot handle-restore-version-snapshot
:on-pin-snapshot handle-pin-version}]
nil))])])]))

View File

@@ -171,6 +171,7 @@
(and (some? drawing-obj) (= :path (:type drawing-obj))))
node-editing? (and edition (= :path (get-in base-objects [edition :type])))
text-editing? (and edition (= :text (get-in base-objects [edition :type])))
grid-editing? (and edition (ctl/grid-layout? base-objects edition))
mode-inspect? (= options-mode :inspect)

View File

@@ -40,7 +40,7 @@
:else
(let [project-id (:current-project-id @st/state)]
(st/emit! (dwv/restore-version project-id $file $version)))))
(st/emit! (dwv/restore-version project-id $file $version :plugin)))))
(remove
[_]

View File

@@ -103,7 +103,7 @@
;; height: number;
;; mtype?: string;
;; id: string;
;; keepApectRatio?: boolean;
;; keepAspectRatio?: boolean;
;; };
(defn format-image
[{:keys [name width height mtype id keep-aspect-ratio] :as image}]

View File

@@ -66,7 +66,7 @@
;; height: number;
;; mtype?: string;
;; id: string;
;; keepApectRatio?: boolean;
;; keepAspectRatio?: boolean;
;;}
(defn parse-image-data
[^js image-data]
@@ -77,7 +77,7 @@
:width (obj/get image-data "width")
:height (obj/get image-data "height")
:mtype (obj/get image-data "mtype")
:keep-aspect-ratio (obj/get image-data "keepApectRatio")})))
:keep-aspect-ratio (obj/get image-data "keepAspectRatio")})))
;; export type Gradient = {
;; type: 'linear' | 'radial';

View File

@@ -12,7 +12,6 @@
[app.common.types.shape.impl :as ctsi]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.util.object :as obj]
[promesa.core :as p]))
(defn initialize
@@ -28,21 +27,24 @@
(defn create-shape
[id]
(let [buffer (uuid/uuid->u32 id)]
(._create_shape ^js internal-module (aget buffer 0) (aget buffer 1) (aget buffer 2) (aget buffer 3))))
(let [buffer (uuid/uuid->u32 id)
create-shape (unchecked-get internal-module "_create_shape")]
(^function create-shape (aget buffer 0) (aget buffer 1) (aget buffer 2) (aget buffer 3))))
(defn use-shape
[id]
(let [buffer (uuid/uuid->u32 id)]
(._use_shape ^js internal-module (aget buffer 0) (aget buffer 1) (aget buffer 2) (aget buffer 3))))
(let [buffer (uuid/uuid->u32 id)
use-shape (unchecked-get internal-module "_use_shape")]
(^function use-shape (aget buffer 0) (aget buffer 1) (aget buffer 2) (aget buffer 3))))
(defn set-shape-selrect
[selrect]
(let [x1 (:x1 selrect)
y1 (:y1 selrect)
x2 (:x2 selrect)
y2 (:y2 selrect)]
(._set_shape_selrect ^js internal-module x1 y1 x2 y2)))
y2 (:y2 selrect)
set-shape-selrect (unchecked-get internal-module "_set_shape_selrect")]
(^function set-shape-selrect x1 y1 x2 y2)))
(defn set-shape-transform
[transform]
@@ -51,27 +53,33 @@
c (:c transform)
d (:d transform)
e (:e transform)
f (:f transform)]
(._set_shape_transform ^js internal-module a b c d e f)))
f (:f transform)
set-shape-transform (unchecked-get internal-module "_set_shape_transform")]
(^function set-shape-transform a b c d e f)))
(defn set-shape-rotation
[rotation]
(._set_shape_rotation ^js internal-module rotation))
(let [set-shape-rotation (unchecked-get internal-module "_set_shape_rotation")]
(^function set-shape-rotation rotation)))
(defn set-shape-children
[shape_ids]
(._clear_shape_children ^js internal-module)
(doseq [id shape_ids]
(let [buffer (uuid/uuid->u32 id)]
(._add_shape_child ^js internal-module (aget buffer 0) (aget buffer 1) (aget buffer 2) (aget buffer 3)))))
(let [clear-shape-children (unchecked-get internal-module "_clear_shape_children")
add-shape-child (unchecked-get internal-module "_add_shape_child")]
(^function clear-shape-children)
(doseq [id shape_ids]
(let [buffer (uuid/uuid->u32 id)]
(^function add-shape-child (aget buffer 0) (aget buffer 1) (aget buffer 2) (aget buffer 3))))))
(defn set-shape-fills
[fills]
(._clear_shape_fills ^js internal-module)
(doseq [fill (filter #(contains? % :fill-color) fills)]
(let [a (:fill-opacity fill)
[r g b] (cc/hex->rgb (:fill-color fill))]
(._add_shape_solid_fill ^js internal-module r g b a))))
(let [clear-shape-fills (unchecked-get internal-module "_clear_shape_fills")
add-shape-fill (unchecked-get internal-module "_add_shape_solid_fill")]
(^function clear-shape-fills)
(doseq [fill (filter #(contains? % :fill-color) fills)]
(let [a (:fill-opacity fill)
[r g b] (cc/hex->rgb (:fill-color fill))]
(^function add-shape-fill r g b a)))))
(defn set-objects
[objects]
@@ -98,9 +106,10 @@
[zoom vbox]
(js/requestAnimationFrame
(fn []
(let [pan-x (- (dm/get-prop vbox :x))
pan-y (- (dm/get-prop vbox :y))]
(._draw_all_shapes ^js internal-module zoom pan-x pan-y)))))
(let [pan-x (- (dm/get-prop vbox :x))
pan-y (- (dm/get-prop vbox :y))
draw-all-shapes (unchecked-get internal-module "_draw_all_shapes")]
(^function draw-all-shapes zoom pan-x pan-y)))))
(defn cancel-draw
[frame-id]
@@ -129,12 +138,10 @@
handle (.registerContext ^js gl context #js {"majorVersion" 2})]
(.makeContextCurrent ^js gl handle)
;; Initialize Skia
(init-fn (.-width ^js canvas)
(.-height ^js canvas))
(^function init-fn (.-width ^js canvas)
(.-height ^js canvas))
(set! (.-width canvas) (.-clientWidth ^js canvas))
(set! (.-height canvas) (.-clientHeight ^js canvas))
(obj/set! js/window "shape_list" (fn [] ((unchecked-get internal-module "_shape_list"))))))
(set! (.-height canvas) (.-clientHeight ^js canvas))))
(defonce module
(if (exists? js/dynamicImport)

View File

@@ -16,7 +16,7 @@
(defn v2-closest-text-editor-content
[target]
(.closest ^js target ".text-editor-content"))
(.closest ^js target "[data-itype=\"editor\"]"))
(defn closest-text-editor-content
[target]
@@ -34,7 +34,7 @@
(defn v2-get-text-editor-content
[]
(dom/get-element-by-class "text-editor-content"))
(dom/query "[data-itype=\"editor\"]"))
(defn get-text-editor-content
[]

View File

@@ -0,0 +1,71 @@
root = true
[*]
charset = utf-8
indent_size = 2
indent_style = space
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
cpp_indent_braces=false
cpp_indent_multi_line_relative_to=innermost_parenthesis
cpp_indent_within_parentheses=indent
cpp_indent_preserve_within_parentheses=false
cpp_indent_case_labels=false
cpp_indent_case_contents=true
cpp_indent_case_contents_when_block=false
cpp_indent_lambda_braces_when_parameter=true
cpp_indent_goto_labels=one_left
cpp_indent_preprocessor=leftmost_column
cpp_indent_access_specifiers=false
cpp_indent_namespace_contents=true
cpp_indent_preserve_comments=false
cpp_new_line_before_open_brace_namespace=ignore
cpp_new_line_before_open_brace_type=ignore
cpp_new_line_before_open_brace_function=ignore
cpp_new_line_before_open_brace_block=ignore
cpp_new_line_before_open_brace_lambda=ignore
cpp_new_line_scope_braces_on_separate_lines=false
cpp_new_line_close_brace_same_line_empty_type=false
cpp_new_line_close_brace_same_line_empty_function=false
cpp_new_line_before_catch=true
cpp_new_line_before_else=true
cpp_new_line_before_while_in_do_while=false
cpp_space_before_function_open_parenthesis=remove
cpp_space_within_parameter_list_parentheses=false
cpp_space_between_empty_parameter_list_parentheses=false
cpp_space_after_keywords_in_control_flow_statements=true
cpp_space_within_control_flow_statement_parentheses=false
cpp_space_before_lambda_open_parenthesis=false
cpp_space_within_cast_parentheses=false
cpp_space_after_cast_close_parenthesis=false
cpp_space_within_expression_parentheses=false
cpp_space_before_block_open_brace=true
cpp_space_between_empty_braces=false
cpp_space_before_initializer_list_open_brace=false
cpp_space_within_initializer_list_braces=true
cpp_space_preserve_in_initializer_list=true
cpp_space_before_open_square_bracket=false
cpp_space_within_square_brackets=false
cpp_space_before_empty_square_brackets=false
cpp_space_between_empty_square_brackets=false
cpp_space_group_square_brackets=true
cpp_space_within_lambda_brackets=false
cpp_space_between_empty_lambda_brackets=false
cpp_space_before_comma=false
cpp_space_after_comma=true
cpp_space_remove_around_member_operators=true
cpp_space_before_inheritance_colon=true
cpp_space_before_constructor_colon=true
cpp_space_remove_before_semicolon=true
cpp_space_after_semicolon=false
cpp_space_remove_around_unary_operator=true
cpp_space_around_binary_operator=insert
cpp_space_around_assignment_operator=insert
cpp_space_pointer_reference_alignment=left
cpp_space_around_ternary_operator=insert
cpp_wrap_preserve_blocks=one_liners

342
frontend/text-editor/.gitignore vendored Normal file
View File

@@ -0,0 +1,342 @@
# Created by https://www.toptal.com/developers/gitignore/api/linux,osx,windows,vim,emacs,sublimetext,visualstudiocode,node
# Edit at https://www.toptal.com/developers/gitignore?templates=linux,osx,windows,vim,emacs,sublimetext,visualstudiocode,node
### Emacs ###
# -*- mode: gitignore; -*-
*~
\#*\#
/.emacs.desktop
/.emacs.desktop.lock
*.elc
auto-save-list
tramp
.\#*
# Org-mode
.org-id-locations
*_archive
# flymake-mode
*_flymake.*
# eshell files
/eshell/history
/eshell/lastdir
# elpa packages
/elpa/
# reftex files
*.rel
# AUCTeX auto folder
/auto/
# cask packages
.cask/
dist/
# Flycheck
flycheck_*.el
# server auth directory
/server/
# projectiles files
.projectile
# directory configuration
.dir-locals.el
# network security
/network-security.data
### Linux ###
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
### OSX ###
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### SublimeText ###
# Cache files for Sublime Text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
# Workspace files are user-specific
*.sublime-workspace
# Project files should be checked into the repository, unless a significant
# proportion of contributors will probably not be using Sublime Text
# *.sublime-project
# SFTP configuration file
sftp-config.json
sftp-config-alt*.json
# Package control specific files
Package Control.last-run
Package Control.ca-list
Package Control.ca-bundle
Package Control.system-ca-bundle
Package Control.cache/
Package Control.ca-certs/
Package Control.merged-ca-bundle
Package Control.user-ca-bundle
oscrypto-ca-bundle.crt
bh_unicode_properties.cache
# Sublime-github package stores a github token in this file
# https://packagecontrol.io/packages/sublime-github
GitHub.sublime-settings
### Vim ###
# Swap
[._]*.s[a-v][a-z]
!*.svg # comment out if you don't need vector files
[._]*.sw[a-p]
[._]s[a-rt-v][a-z]
[._]ss[a-gi-z]
[._]sw[a-p]
# Session
Session.vim
Sessionx.vim
# Temporary
.netrwhist
# Auto-generated tag files
tags
# Persistent undo
[._]*.un~
### VisualStudioCode ###
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
### VisualStudioCode Patch ###
# Ignore all local history of files
.history
.ionide
### Windows ###
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/linux,osx,windows,vim,emacs,sublimetext,visualstudiocode,node
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
vite.config.js.timestamp*

View File

@@ -0,0 +1,7 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": false
}

View File

@@ -0,0 +1,106 @@
# Penpot Text Editor
## How to run it
### Development
To start the development environment run:
```sh
yarn run dev
```
### Testing
For running unit tests and running coverage:
```sh
yarn run test
yarn run coverage
```
> If you want, you can run the [vitest](https://vitest.dev/) UI by running:
```sh
yarn run test:ui
```
## How to build it
The editor can be built and updated inside Penpot using the following command:
```sh
PENPOT_SOURCE_PATH=/path/to/penpot/repository yarn build:update
```
This command is going to search for the file located in `frontend/src/app/main/ui/workspace/shapes/text/new_editor/TextEditor.js` and update it.
## How it works?
The text editor divides the content in three elements: `root`, `paragraph` and `inline`. An `inline` in terms of content is a styled element that it is displayed in a line inside a block and an `inline` only can have one child (a Text node). A `paragraph` is a **block** element that can contain multiple `inline`s (**inline** elements).
```html
<div data-itype="root">
<div data-itype="paragraph">
<span data-itype="inline">Hello, </span>
<span data-itype="inline" style="font-weight: bold">World!</span>
</div>
</div>
```
This way we only need to deal with a structure like this, where circular nodes are `HTMLElement`s and square nodes are `Text`. Also with an structure like this we have a predictable and ordered tree where we can find our position easily to do any operation (remove, insert, replace, etc).
```mermaid
flowchart TB
root((root)) --> paragraph((paragraph))
paragraph --> inline_1((inline))
paragraph --> inline_2((inline))
inline_1 --> text_1[Hello, ]
inline_2 --> text_2[World!]
```
This is compatible with the way Penpot stores text content.
```mermaid
flowchart TB
root((root)) --> paragraph-set((paragraph-set))
paragraph-set --> paragraph((paragraph))
paragraph --> text((text))
```
## How the code is organized?
- `editor`: contains everything related to the TextEditor. Where `TextEditor.js` is the main file where all the basic code of the editor is handled. This has been designed so that in the future, when the Web Components API is more stable and has features such as handling selection events within shadow roots we will be able to update this class with little effort.
- `editor/clipboard`: Event handlers for clipboard events.
- `editor/commands`: Event handlers for input events (commands) that modifies the content of the TextEditor.
- `editor/content`: Code related to handling elements like text nodes, paragraphs, line breaks, etc. This are a series of utility functions that can perform some verifications and mutations on DOM nodes.
- `editor/controllers`: There are two controllers; `ChangeController` that handles when a change in the content should be notified and `SelectionController` that handles operations on selections and text, this is where all the mutations on DOM nodes are performed.
## Implementation
Everything is implemented in JavaScript using `beforeinput` and `InputEvent` for the user events. `blur` and `focus` are used to handle imposter selections.
### Why imposter selections?
Normally when you click on another UI element, the current selection is replaced by the selection of the new UI element.
## References
- [InputEvent](https://w3c.github.io/input-events/#interface-InputEvent): the main event used for handling user input.
- [Selection](https://developer.mozilla.org/en-US/docs/Web/API/Selection): for handling selections.
- [Range](https://developer.mozilla.org/en-US/docs/Web/API/Range): for handling range selections. <sup>1</sup>
- [Node](https://developer.mozilla.org/en-US/docs/Web/API/Node): for operator functions like `compareDocumentPosition` or `nodeType`.
- [Text](https://developer.mozilla.org/en-US/docs/Web/API/Range): for operator functions like `splitText`.
- [Element](https://developer.mozilla.org/en-US/docs/Web/API/Element): for operator functions like `after`, `before`, `append`, `remove`, `prepend`, etc.
<sup>1</sup>: Firefox is the only browser right now (2024-07-08) that has support for multiple selection ranges so we have to deal with this special case removing old ranges.
## For future reference
In a near future maybe we could take a lot at the [EditContext API](https://developer.mozilla.org/en-US/docs/Web/API/EditContext_API).
## FAQ
### Sometimes I receive 'TypeError: Cannot read from private field'
Sometimes, when you update the TextEditor source code, this exception could raise because shadow-cljs updated the code but keeps a reference to the old instance of the text editor, so the new code tries to read a private field from an old instance.

View File

@@ -0,0 +1,7 @@
{
"compilerOptions": {
"paths": {
"~/*": ["./src/*"]
}
}
}

View File

@@ -0,0 +1,29 @@
{
"name": "@penpot/text-editor",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "src/editor/TextEditor.js",
"scripts": {
"dev": "vite",
"coverage": "vitest run --coverage",
"test": "vitest --run",
"test:watch": "vitest",
"test:watch:ui": "vitest --ui",
"test:watch:e2e": "vitest --browser"
},
"devDependencies": {
"@playwright/test": "^1.45.1",
"@types/node": "^20.14.10",
"@vitest/browser": "^1.6.0",
"@vitest/coverage-v8": "^1.6.0",
"@vitest/ui": "^1.6.0",
"esbuild": "^0.24.0",
"jsdom": "^25.0.0",
"playwright": "^1.45.1",
"prettier": "^3.3.3",
"vite": "^5.3.1",
"vitest": "^1.6.0"
},
"packageManager": "yarn@4.3.1"
}

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>

After

Width:  |  Height:  |  Size: 995 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1,32 @@
/**
* 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
*/
/**
* Adds a series of listeners.
*
* @param {EventTarget} target
* @param {Object.<string, Function>} object
* @param {EventListenerOptions} [options]
*/
export function addEventListeners(target, object, options) {
Object.entries(object).forEach(([type, listener]) =>
target.addEventListener(type, listener, options)
);
}
/**
* Removes a series of listeners.
*
* @param {EventTarget} target
* @param {Object.<string, Function>} object
*/
export function removeEventListeners(target, object) {
Object.entries(object).forEach(([type, listener]) =>
target.removeEventListener(type, listener)
);
}

View File

@@ -0,0 +1,29 @@
import { describe, test, expect, vi } from "vitest";
import { addEventListeners, removeEventListeners } from "./Event.js";
/* @vitest-environment jsdom */
describe("Event", () => {
test("addEventListeners should add event listeners to an element using an object", () => {
const clickSpy = vi.fn();
const events = {
click: clickSpy,
};
const element = document.createElement("div");
addEventListeners(element, events);
element.dispatchEvent(new Event("click"));
expect(clickSpy).toBeCalled();
});
test("removeEventListeners should remove event listeners to an element using an object", () => {
const clickSpy = vi.fn();
const events = {
click: clickSpy,
};
const element = document.createElement("div");
addEventListeners(element, events);
element.dispatchEvent(new Event("click"));
removeEventListeners(element, events);
element.dispatchEvent(new Event("click"));
expect(clickSpy).toBeCalledTimes(1);
});
});

View File

@@ -0,0 +1,65 @@
::selection {
background-color: red;
}
.selection-imposter-rect {
background-color: red;
position: absolute;
}
.text-editor-selection-imposter {
pointer-events: none;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
}
.text-editor-container {
height: 100%;
display: flex;
flex-direction: column;
}
.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; */
color: black;
div {
line-height: inherit;
user-select: text;
white-space: pre;
margin: 0px;
/* font-size: 0px; */
}
span {
line-break: auto;
line-height: inherit;
}
}
.align-top[data-itype="root"] {
justify-content: flex-start;
}
.align-center[data-itype="root"] {
justify-content: center;
}
.align-bottom[data-itype="root"] {
justify-content: flex-end;
}

View File

@@ -0,0 +1,545 @@
/**
* 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 clipboard from "./clipboard/index.js";
import commands from "./commands/index.js";
import ChangeController from "./controllers/ChangeController.js";
import SelectionController from "./controllers/SelectionController.js";
import { createSelectionImposterFromClientRects } from "./selection/Imposter.js";
import { addEventListeners, removeEventListeners } from "./Event.js";
import { createRoot, createEmptyRoot } from "./content/dom/Root.js";
import { createParagraph } from "./content/dom/Paragraph.js";
import { createEmptyInline, createInline } from "./content/dom/Inline.js";
import { isLineBreak } from "./content/dom/LineBreak.js";
import LayoutType from "./layout/LayoutType.js";
/**
* Text Editor.
*/
export class TextEditor extends EventTarget {
/**
* Element content editable to be used by the TextEditor
*
* @type {HTMLElement}
*/
#element = null;
/**
* Map/Dictionary of events.
*
* @type {Object.<string, Function>}
*/
#events = null;
/**
* Root element that will contain the content.
*
* @type {HTMLElement}
*/
#root = null;
/**
* Change controller controls when we should notify changes.
*
* @type {ChangeController}
*/
#changeController = null;
/**
* Selection controller controls the current/saved selection.
*
* @type {SelectionController}
*/
#selectionController = null;
/**
* Selection imposter keeps selection elements.
*
* @type {HTMLElement}
*/
#selectionImposterElement = null;
/**
* Style defaults.
*
* @type {Object.<string, *>}
*/
#styleDefaults = null;
/**
* FIXME: There is a weird case where the events
* `beforeinput` and `input` have different `data` when
* characters are deleted when the input type is
* `insertCompositionText`.
*/
#fixInsertCompositionText = false;
/**
* Constructor.
*
* @param {HTMLElement} element
*/
constructor(element, options) {
super();
if (!(element instanceof HTMLElement))
throw new TypeError("Invalid text editor element");
this.#element = element;
this.#selectionImposterElement = options?.selectionImposterElement;
this.#events = {
blur: this.#onBlur,
focus: this.#onFocus,
paste: this.#onPaste,
cut: this.#onCut,
copy: this.#onCopy,
beforeinput: this.#onBeforeInput,
input: this.#onInput,
};
this.#styleDefaults = options?.styleDefaults;
this.#setup(options);
}
/**
* Setups editor properties.
*/
#setupElementProperties() {
if (!this.#element.isContentEditable) {
this.#element.contentEditable = "true";
// In `jsdom` it isn't enough to set the attribute 'contentEditable'
// to `true` to work.
// FIXME: Remove this when `jsdom` implements this interface.
if (!this.#element.isContentEditable) {
this.#element.setAttribute("contenteditable", "true");
}
}
if (this.#element.spellcheck) this.#element.spellcheck = false;
if (this.#element.autocapitalize) this.#element.autocapitalize = false;
if (!this.#element.autofocus) this.#element.autofocus = true;
if (!this.#element.role || this.#element.role !== "textbox")
this.#element.role = "textbox";
if (this.#element.ariaAutoComplete) this.#element.ariaAutoComplete = false;
if (!this.#element.ariaMultiLine) this.#element.ariaMultiLine = true;
this.#element.dataset.itype = "editor";
}
/**
* Setups the root element.
*/
#setupRoot() {
this.#root = createEmptyRoot(this.#styleDefaults);
this.#element.appendChild(this.#root);
}
/**
* Dispatchs a `change` event.
*
* @param {CustomEvent} e
* @returns {void}
*/
#onChange = (e) => this.dispatchEvent(new e.constructor(e.type, e));
/**
* Dispatchs a `stylechange` event.
*
* @param {CustomEvent} e
* @returns {void}
*/
#onStyleChange = (e) => {
if (this.#selectionImposterElement.children.length > 0) {
// We need to recreate the selection imposter when we've
// already have one.
this.#createSelectionImposter();
}
this.dispatchEvent(new e.constructor(e.type, e));
};
/**
* Setups the elements, the properties and the
* initial content.
*/
#setup(options) {
this.#setupElementProperties();
this.#setupRoot();
this.#changeController = new ChangeController(this);
this.#changeController.addEventListener("change", this.#onChange);
this.#selectionController = new SelectionController(
this,
document.getSelection(),
options
);
this.#selectionController.addEventListener(
"stylechange",
this.#onStyleChange
);
addEventListeners(this.#element, this.#events, {
capture: true,
});
}
/**
* Creates the selection imposter.
*/
#createSelectionImposter() {
// We only create a selection imposter if there's any selection
// and if there is a selection imposter element to attach the
// rects.
if (
this.#selectionImposterElement &&
!this.#selectionController.isCollapsed
) {
const rects = this.#selectionController.range?.getClientRects();
if (rects) {
const rect = this.#selectionImposterElement.getBoundingClientRect();
this.#selectionImposterElement.replaceChildren(
createSelectionImposterFromClientRects(rect, rects)
);
}
}
}
/**
* On blur we create a new FakeSelection if there's any.
*
* @param {FocusEvent} e
*/
#onBlur = (e) => {
this.#changeController.notifyImmediately();
this.#selectionController.saveSelection();
this.#createSelectionImposter();
this.dispatchEvent(new FocusEvent(e.type, e));
};
/**
* On focus we should restore the FakeSelection from the current
* selection.
*
* @param {FocusEvent} e
*/
#onFocus = (e) => {
this.#selectionController.restoreSelection();
if (this.#selectionImposterElement) {
this.#selectionImposterElement.replaceChildren();
}
this.dispatchEvent(new FocusEvent(e.type, e));
};
/**
* Event called when the user pastes some text into the
* editor.
*
* @param {ClipboardEvent} e
*/
#onPaste = (e) => {
clipboard.paste(e, this, this.#selectionController);
this.#notifyLayout(LayoutType.FULL, null);
};
/**
* Event called when the user cuts some text from the
* editor.
*
* @param {ClipboardEvent} e
*/
#onCut = (e) => clipboard.cut(e, this, this.#selectionController);
/**
* Event called when the user copies some text from the
* editor.
*
* @param {ClipboardEvent} e
*/
#onCopy = (e) => clipboard.copy(e, this, this.#selectionController);
/**
* Event called before the DOM is modified.
*
* @param {InputEvent} e
*/
#onBeforeInput = (e) => {
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
return;
}
if (e.inputType === "insertCompositionText" && !e.data) {
e.preventDefault();
this.#fixInsertCompositionText = true;
return;
}
if (!(e.inputType in commands)) {
if (e.inputType !== "insertCompositionText") {
e.preventDefault();
}
return;
}
if (e.inputType in commands) {
const command = commands[e.inputType];
if (!this.#selectionController.startMutation()) {
return;
}
command(e, this, this.#selectionController);
const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations);
}
};
/**
* Event called after the DOM is modified.
*
* @param {InputEvent} e
*/
#onInput = (e) => {
if (e.inputType === "historyUndo" || e.inputType === "historyRedo") {
return;
}
if (e.inputType === "insertCompositionText" && this.#fixInsertCompositionText) {
e.preventDefault();
this.#fixInsertCompositionText = false;
if (e.data) {
this.#selectionController.fixInsertCompositionText();
}
return;
}
if (e.inputType === "insertCompositionText" && e.data) {
this.#notifyLayout(LayoutType.FULL, null);
}
};
/**
* Notifies that the edited texts needs layout.
*
* @param {'full'|'partial'} type
* @param {CommandMutations} mutations
*/
#notifyLayout(type = LayoutType.FULL, mutations) {
this.dispatchEvent(
new CustomEvent("needslayout", {
detail: {
type: type,
mutations: mutations,
},
})
);
}
/**
* Root element that contains all the paragraphs.
*
* @type {HTMLDivElement}
*/
get root() {
return this.#root;
}
set root(newRoot) {
const previousRoot = this.#root;
this.#root = newRoot;
previousRoot.replaceWith(newRoot);
}
/**
* Element that contains the root and that has the
* contenteditable attribute.
*
* @type {HTMLElement}
*/
get element() {
return this.#element;
}
/**
* Returns true if the content is in an empty state.
*
* @type {boolean}
*/
get isEmpty() {
return (
this.#root.children.length === 1 &&
this.#root.firstElementChild.children.length === 1 &&
isLineBreak(this.#root.firstElementChild.firstElementChild.firstChild)
);
}
/**
* Indicates the amount of paragraphs in the current content.
*
* @type {number}
*/
get numParagraphs() {
return this.#root.children.length;
}
/**
* CSS Style declaration for the current inline. From here we
* can infer root, paragraph and inline declarations.
*
* @type {CSSStyleDeclaration}
*/
get currentStyle() {
return this.#selectionController.currentStyle;
}
/**
* Focus the element
*/
focus() {
return this.#element.focus();
}
/**
* Blurs the element
*/
blur() {
return this.#element.blur();
}
/**
* Creates a new root.
*
* @param {...any} args
* @returns {HTMLDivElement}
*/
createRoot(...args) {
return createRoot(...args);
}
/**
* Creates a new paragraph.
*
* @param {...any} args
* @returns {HTMLDivElement}
*/
createParagraph(...args) {
return createParagraph(...args);
}
/**
* Creates a new inline from a string.
*
* @param {string} text
* @param {Object.<string,*>|CSSStyleDeclaration} styles
* @returns {HTMLSpanElement}
*/
createInlineFromString(text, styles) {
if (text === "") {
return createEmptyInline(styles);
}
return createInline(new Text(text), styles);
}
/**
* Creates a new inline.
*
* @param {...any} args
* @returns {HTMLSpanElement}
*/
createInline(...args) {
return createInline(...args);
}
/**
* Applies the current styles to the selection or
* the current DOM node at the caret.
*
* @param {*} styles
*/
applyStylesToSelection(styles) {
this.#selectionController.startMutation();
this.#selectionController.applyStyles(styles);
const mutations = this.#selectionController.endMutation();
this.#notifyLayout(LayoutType.FULL, mutations);
this.#changeController.notifyImmediately();
return this;
}
/**
* Selects all content.
*/
selectAll() {
this.#selectionController.selectAll();
return this;
}
/**
* Moves cursor to end.
*
* @returns
*/
cursorToEnd() {
this.#selectionController.cursorToEnd();
return this;
}
/**
* Disposes everything.
*/
dispose() {
this.#changeController.removeEventListener("change", this.#onChange);
this.#changeController.dispose();
this.#changeController = null;
this.#selectionController.removeEventListener(
"stylechange",
this.#onStyleChange
);
this.#selectionController.dispose();
this.#selectionController = null;
removeEventListeners(this.#element, this.#events);
this.#element = null;
this.#root = null;
}
}
export function isEditor(instance) {
return (instance instanceof TextEditor);
}
/* Convenience function based API for Text Editor */
export function getRoot(instance) {
if (isEditor(instance)) {
return instance.root;
} else {
return null;
}
}
export function setRoot(instance, root) {
if (isEditor(instance)) {
instance.root = root;
}
return instance;
}
export function create(element, options) {
return new TextEditor(element, {...options});
}
export function getCurrentStyle(instance) {
if (isEditor(instance)) {
return instance.currentStyle;
}
}
export function applyStylesToSelection(instance, styles) {
if (isEditor(instance)) {
return instance.applyStylesToSelection(styles);
}
}
export function dispose(instance) {
if (isEditor(instance)) {
instance.dispose();
}
}
export default TextEditor;

View File

@@ -0,0 +1,100 @@
import { describe, test, expect } from "vitest";
import { TextEditor } from "./TextEditor.js";
/* @vitest-environment jsdom */
describe("TextEditor", () => {
test("Creating TextEditor without element should throw", () => {
expect(() => new TextEditor()).toThrowError("Invalid text editor element");
});
test("Creating TextEditor with element should success", () => {
expect(new TextEditor(document.createElement("div"))).toBeInstanceOf(
TextEditor,
);
});
test("isEmpty should return true when editor is empty", () => {
const textEditor = new TextEditor(document.createElement("div"));
expect(textEditor).toBeInstanceOf(TextEditor);
expect(textEditor.isEmpty).toBe(true);
});
test("Num paragraphs should return 1 when empty", () => {
const textEditor = new TextEditor(document.createElement("div"));
expect(textEditor).toBeInstanceOf(TextEditor);
expect(textEditor.numParagraphs).toBe(1);
});
test("Num paragraphs should return the number of paragraphs", () => {
const textEditor = new TextEditor(document.createElement("div"));
textEditor.root = textEditor.createRoot([
textEditor.createParagraph([
textEditor.createInlineFromString("Hello, World!"),
]),
textEditor.createParagraph([textEditor.createInlineFromString("")]),
textEditor.createParagraph([
textEditor.createInlineFromString("¡Hola, Mundo!"),
]),
textEditor.createParagraph([
textEditor.createInlineFromString("Hallo, Welt!"),
]),
]);
expect(textEditor).toBeInstanceOf(TextEditor);
expect(textEditor.numParagraphs).toBe(4);
});
test("Disposing a TextEditor nullifies everything", () => {
const textEditor = new TextEditor(document.createElement("div"));
expect(textEditor).toBeInstanceOf(TextEditor);
textEditor.dispose();
expect(textEditor.root).toBe(null);
expect(textEditor.element).toBe(null);
});
test("TextEditor focus should focus the contenteditable element", () => {
const textEditorElement = document.createElement("div");
document.body.appendChild(textEditorElement);
const textEditor = new TextEditor(textEditorElement);
expect(textEditor).toBeInstanceOf(TextEditor);
textEditor.focus();
expect(document.activeElement).toBe(textEditor.element);
});
test("TextEditor blur should blur the contenteditable element", () => {
const textEditorElement = document.createElement("div");
document.body.appendChild(textEditorElement);
const textEditor = new TextEditor(textEditorElement);
expect(textEditor).toBeInstanceOf(TextEditor);
textEditor.focus();
textEditor.blur();
expect(document.activeElement).not.toBe(textEditor.element);
});
test("TextEditor focus -> blur -> focus should restore old selection", () => {
const textEditorElement = document.createElement("div");
document.body.appendChild(textEditorElement);
const textEditor = new TextEditor(textEditorElement);
textEditor.root = textEditor.createRoot([
textEditor.createParagraph([
textEditor.createInlineFromString("Hello, World!"),
]),
]);
expect(textEditor).toBeInstanceOf(TextEditor);
textEditor.focus();
textEditor.blur();
textEditor.focus();
expect(document.activeElement).toBe(textEditor.element);
});
test("TextEditor selectAll should select all the contenteditable", () => {
const selection = document.getSelection();
const textEditorElement = document.createElement("div");
document.body.appendChild(textEditorElement);
const textEditor = new TextEditor(textEditorElement);
expect(textEditor).toBeInstanceOf(TextEditor);
textEditor.focus();
textEditor.selectAll();
expect(document.activeElement).toBe(textEditor.element);
expect(selection.containsNode(textEditor.root));
});
});

View File

@@ -0,0 +1,19 @@
/**
* 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
*/
/**
* This event is called when the user copies a text from the
* editor.
*
* TODO: We could transform `--fills` in here to CSS `color`, `background-image`,
* etc. to be more compatible with other applications.
*
* @param {ClipboardEvent} event
* @param {TextEditor} editor
*/
export function copy(event, editor) {}

View File

@@ -0,0 +1,19 @@
/**
* 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
*/
/**
* This event is called when the user copies a text from the
* editor.
*
* TODO: We could transform `--fills` in here to CSS `color`, `background-image`,
* etc. to be more compatible with other applications.
*
* @param {ClipboardEvent} event
* @param {TextEditor} editor
*/
export function cut(event, editor) {}

View File

@@ -0,0 +1,17 @@
/**
* 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 { copy } from "./copy.js";
import { cut } from "./cut.js";
import { paste } from "./paste.js";
export default {
copy,
cut,
paste,
};

View File

@@ -0,0 +1,45 @@
/**
* 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 { mapContentFragmentFromHTML, mapContentFragmentFromString } from "../content/dom/Content.js";
/**
* When the user pastes some HTML, what we do is generate
* a new DOM based on what the user pasted and then we
* insert it in the appropiate part (see `insertFromPaste` command).
*
* @param {ClipboardEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
* @returns {void}
*/
export function paste(event, editor, selectionController) {
// We need to prevent default behavior
// because we don't allow any HTML to
// be pasted.
event.preventDefault();
let fragment = null;
if (event.clipboardData.types.includes("text/html")) {
const html = event.clipboardData.getData("text/html");
fragment = mapContentFragmentFromHTML(html, selectionController.currentStyle);
} else if (event.clipboardData.types.includes("text/plain")) {
const plain = event.clipboardData.getData("text/plain");
fragment = mapContentFragmentFromString(plain, selectionController.currentStyle);
}
if (!fragment) {
return;
}
if (selectionController.isCollapsed) {
selectionController.insertPaste(fragment);
} else {
selectionController.replaceWithPaste(fragment);
}
}

View File

@@ -0,0 +1,66 @@
/**
* 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
*/
/**
* Command mutations
*/
export class CommandMutations {
#added = new Set();
#removed = new Set();
#updated = new Set();
constructor(added, updated, removed) {
if (added && Array.isArray(added)) this.#added = new Set(added);
if (updated && Array.isArray(updated)) this.#updated = new Set(updated);
if (removed && Array.isArray(removed)) this.#removed = new Set(removed);
}
get added() {
return this.#added;
}
get removed() {
return this.#removed;
}
get updated() {
return this.#updated;
}
clear() {
this.#added.clear();
this.#removed.clear();
this.#updated.clear();
}
dispose() {
this.#added.clear();
this.#added = null;
this.#removed.clear();
this.#removed = null;
this.#updated.clear();
this.#updated = null;
}
add(node) {
this.#added.add(node);
return this;
}
remove(node) {
this.#removed.add(node);
return this;
}
update(node) {
this.#updated.add(node);
return this;
}
}
export default CommandMutations;

View File

@@ -0,0 +1,71 @@
import { describe, test, expect } from "vitest";
import CommandMutations from "./CommandMutations.js";
describe("CommandMutations", () => {
test("should create a new CommandMutations", () => {
const mutations = new CommandMutations();
expect(mutations).toHaveProperty("added");
expect(mutations).toHaveProperty("updated");
expect(mutations).toHaveProperty("removed");
});
test("should create an initialized new CommandMutations", () => {
const mutations = new CommandMutations([1], [2], [3]);
expect(mutations.added.size).toBe(1);
expect(mutations.updated.size).toBe(1);
expect(mutations.removed.size).toBe(1);
expect(mutations.added.has(1)).toBe(true);
expect(mutations.updated.has(2)).toBe(true);
expect(mutations.removed.has(3)).toBe(true);
});
test("should add an added node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
expect(mutations.added.has(1)).toBe(true);
});
test("should add an updated node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.update(1);
expect(mutations.updated.has(1)).toBe(true);
});
test("should add an removed node to a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.remove(1);
expect(mutations.removed.has(1)).toBe(true);
});
test("should clear a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
mutations.update(2);
mutations.remove(3);
expect(mutations.added.has(1)).toBe(true);
expect(mutations.added.size).toBe(1);
expect(mutations.updated.has(2)).toBe(true);
expect(mutations.updated.size).toBe(1);
expect(mutations.removed.has(3)).toBe(true);
expect(mutations.removed.size).toBe(1);
mutations.clear();
expect(mutations.added.size).toBe(0);
expect(mutations.added.has(1)).toBe(false);
expect(mutations.updated.size).toBe(0);
expect(mutations.updated.has(1)).toBe(false);
expect(mutations.removed.size).toBe(0);
expect(mutations.removed.has(1)).toBe(false);
});
test("should dispose a CommandMutations", () => {
const mutations = new CommandMutations();
mutations.add(1);
mutations.update(2);
mutations.remove(3);
mutations.dispose();
expect(mutations.added).toBe(null);
expect(mutations.updated).toBe(null);
expect(mutations.removed).toBe(null);
});
});

View File

@@ -0,0 +1,22 @@
/**
* 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
*/
/**
* Remove the current selection as part of a cut.
*
* @param {InputEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
*/
export function deleteByCut(event, editor, selectionController) {
event.preventDefault();
if (selectionController.isCollapsed) {
throw new Error("This should be impossible");
}
return selectionController.removeSelected();
}

View File

@@ -0,0 +1,53 @@
/**
* 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
*/
/**
* delete the content directly before the caret position and this intention is
* not covered by another `inputType` or delete the selection with the
* selection collapsing to its start after the deletion.
*
* @param {InputEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
*/
export function deleteContentBackward(event, editor, selectionController) {
event.preventDefault();
// If the editor is empty this is a no op.
if (editor.isEmpty) return;
// If not is collapsed AKA is a selection, then
// we removeSelected.
if (!selectionController.isCollapsed) {
return selectionController.removeSelected({ direction: 'backward' });
}
// If we're in a text node and the offset is
// greater than 0 (not at the start of the inline)
// we simple remove a character from the text.
if (selectionController.isTextFocus && selectionController.focusOffset > 0) {
return selectionController.removeBackwardText();
// If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph
// with the following paragraph.
} else if (
selectionController.isTextFocus &&
selectionController.focusAtStart
) {
return selectionController.mergeBackwardParagraph();
// If we're at an inline or a line break paragraph
// and there's more than one paragraph, then we should
// remove the next paragraph.
} else if (
selectionController.isInlineFocus ||
selectionController.isLineBreakFocus
) {
return selectionController.removeBackwardParagraph();
}
}

View File

@@ -0,0 +1,54 @@
/**
* 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
*/
/**
* delete the content directly after the caret position and this intention is not covered by
* another inputType or delete the selection with the selection collapsing to its end after the deletion
*
* @param {InputEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
*/
export function deleteContentForward(event, editor, selectionController) {
event.preventDefault();
// If the editor is empty this is a no op.
if (editor.isEmpty) return;
// If not is collapsed AKA is a selection, then
// we removeSelected.
if (!selectionController.isCollapsed) {
return selectionController.removeSelected({ direction: "forward" });
}
// If we're in a text node and the offset is
// greater than 0 (not at the start of the inline)
// we simple remove a character from the text.
if (selectionController.isTextFocus
&& selectionController.focusAtEnd) {
return selectionController.mergeForwardParagraph();
// If we're in a text node but we're at the end of the
// paragraph, we should merge the current paragraph
// with the following paragraph.
} else if (
selectionController.isTextFocus &&
selectionController.focusOffset >= 0
) {
return selectionController.removeForwardText();
// If we're at an inline or a line break paragraph
// and there's more than one paragraph, then we should
// remove the next paragraph.
} else if (
(selectionController.isInlineFocus ||
selectionController.isLineBreakFocus) &&
editor.numParagraphs > 1
) {
return selectionController.removeForwardParagraph();
}
}

View File

@@ -0,0 +1,21 @@
/**
* 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 { insertText } from "./insertText.js";
import { insertParagraph } from "./insertParagraph.js";
import { deleteByCut } from "./deleteByCut.js";
import { deleteContentBackward } from "./deleteContentBackward.js";
import { deleteContentForward } from "./deleteContentForward.js";
export default {
insertText,
insertParagraph,
deleteByCut,
deleteContentBackward,
deleteContentForward,
};

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
*/
/**
* Insert a paragraph
*
* @see https://w3c.github.io/input-events/#interface-InputEvent
* @param {InputEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
*/
export function insertParagraph(event, editor, selectionController) {
event.preventDefault();
if (selectionController.isCollapsed) {
return selectionController.insertParagraph();
}
return selectionController.replaceWithParagraph();
}

View File

@@ -0,0 +1,34 @@
/**
* 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
*/
/**
* Insert typed plain text
*
* @see https://w3c.github.io/input-events/#interface-InputEvent
* @param {InputEvent} event
* @param {TextEditor} editor
* @param {SelectionController} selectionController
*/
export function insertText(event, editor, selectionController) {
event.preventDefault();
if (selectionController.isCollapsed) {
if (selectionController.isTextFocus) {
return selectionController.insertText(event.data);
} else if (selectionController.isLineBreakFocus) {
return selectionController.replaceLineBreak(event.data);
}
} else {
if (selectionController.isMultiParagraph) {
return selectionController.replaceParagraphs(event.data);
} else if (selectionController.isMultiInline) {
return selectionController.replaceInlines(event.data);
} else if (selectionController.isTextSame) {
return selectionController.replaceText(event.data);
}
}
}

View File

@@ -0,0 +1,104 @@
/**
* 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
*/
/**
* Throws if the passed value is not a valid offset value.
*
* @param {*} offset
* @throws {TypeError}
*/
function tryOffset(offset) {
if (!Number.isInteger(offset) || offset < 0)
throw new TypeError("Invalid offset");
}
/**
* Throws if the passed value is not a valid string.
*
* @param {*} str
* @throws {TypeError}
*/
function tryString(str) {
if (typeof str !== "string") throw new TypeError("Invalid string");
}
/**
* Inserts string into a string.
*
* @param {string} str
* @param {number} offset
* @param {string} text
* @returns {string}
*/
export function insertInto(str, offset, text) {
tryString(str);
tryOffset(offset);
tryString(text);
return str.slice(0, offset) + text + str.slice(offset);
}
/**
* Replaces a part of a string with a string.
*
* @param {string} str
* @param {number} startOffset
* @param {number} endOffset
* @param {string} text
* @returns {string}
*/
export function replaceWith(str, startOffset, endOffset, text) {
tryString(str);
tryOffset(startOffset);
tryOffset(endOffset);
tryString(text);
return str.slice(0, startOffset) + text + str.slice(endOffset);
}
/**
* Removes text backward from specified offset.
*
* @param {string} str
* @param {number} offset
* @returns {string}
*/
export function removeBackward(str, offset) {
tryString(str);
tryOffset(offset);
if (offset === 0) {
return str;
}
return str.slice(0, offset - 1) + str.slice(offset);
}
/**
* Removes text forward from specified offset.
*
* @param {string} str
* @param {number} offset
* @returns {string}
*/
export function removeForward(str, offset) {
tryString(str);
tryOffset(offset);
return str.slice(0, offset) + str.slice(offset + 1);
}
/**
* Removes a slice of text.
*
* @param {string} str
* @param {number} start
* @param {number} end
* @returns {string}
*/
export function removeSlice(str, start, end) {
tryString(str);
tryOffset(start);
tryOffset(end);
return str.slice(0, start) + str.slice(end);
}

View File

@@ -0,0 +1,46 @@
import { describe, test, expect } from 'vitest'
import { insertInto, removeBackward, removeForward, replaceWith } from './Text';
describe("Text", () => {
test("* should throw when passed wrong parameters", () => {
expect(() => insertInto(Infinity, Infinity, Infinity)).toThrowError('Invalid string');
expect(() => insertInto('Hello', Infinity, Infinity)).toThrowError('Invalid offset');
expect(() => insertInto('Hello', 0, Infinity)).toThrowError('Invalid string');
});
test("`insertInto` should insert a string into an offset", () => {
expect(insertInto("Hell, World!", 4, "o")).toBe("Hello, World!");
});
test("`replaceWith` should replace a string into a string", () => {
expect(replaceWith("Hello, Something!", 7, 16, "World")).toBe("Hello, World!");
});
test("`removeBackward` should remove string backward from start (offset 0)", () => {
expect(removeBackward("Hello, World!", 0)).toBe("Hello, World!");
});
test("`removeForward` should remove string forward from start (offset 0)", () => {
expect(removeForward("Hello, World!", 0)).toBe("ello, World!");
});
test("`removeBackward` should remove string backward from end", () => {
expect(removeBackward("Hello, World!", "Hello, World!".length)).toBe(
"Hello, World"
);
});
test("`removeForward` should remove string forward from end", () => {
expect(removeForward("Hello, World!", "Hello, World!".length)).toBe(
"Hello, World!"
);
});
test("`removeBackward` should remove string backward from offset 6", () => {
expect(removeBackward("Hello, World!", 6)).toBe("Hello World!");
});
test("`removeForward` should remove string forward from offset 6", () => {
expect(removeForward("Hello, World!", 6)).toBe("Hello,World!");
});
});

View File

@@ -0,0 +1,78 @@
/**
* Canvas used to retrieve colors as CSS hexadecimals.
*
* @type {OffscreenCanvas|HTMLCanvasElement}
*/
let canvas = null; // createCanvas(1, 1);
/**
* Context used to retrieve colors as CSS hexadecimals.
*
* @type {CanvasRenderingContext2D}
*/
let context = null; // canvas.getContext("2d");
/**
* Returns the canvas context.
*
* @returns {CanvasRenderingContext2D}
*/
function getContext() {
if (!canvas) {
canvas = createCanvas(1, 1);
}
if (!context) {
context = canvas.getContext("2d");
}
return context
}
/**
* Creates a new canvas element.
*
* @param {number} width
* @param {number} height
* @returns {OffscreenCanvas|HTMLCanvasElement}
*/
function createCanvas(width, height) {
if ("OffscreenCanvas" in globalThis) {
return new OffscreenCanvas(width, height);
}
return document.createElement("canvas");
}
/**
* Returns a byte representation as an hex.
*
* @param {number} byte
* @returns {string}
*/
export function getByteAsHex(byte) {
return byte.toString(16).padStart(2, "0");
}
/**
* Returns a color definition from a fillStyle color.
*
* @param {string} fillStyle
* @returns {[string, number]}
*/
export function getColor(fillStyle) {
const context = getContext();
context.fillStyle = fillStyle;
context.fillRect(0, 0, 1, 1);
const imageData = context.getImageData(0, 0, 1, 1);
const [r, g, b, a] = imageData.data;
return [`#${getByteAsHex(r)}${getByteAsHex(g)}${getByteAsHex(b)}`, a / 255.0];
}
/**
* Returns a fill from a fillStyle color.
*
* @param {string} fillStyle
* @returns {string}
*/
export function getFills(fillStyle) {
const [color, opacity] = getColor(fillStyle);
return `[["^ ","~:fill-color","${color}","~:fill-opacity",${opacity}]]`;
}

View File

@@ -0,0 +1,102 @@
/**
* 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 { createInline } from "./Inline.js";
import {
createEmptyParagraph,
createParagraph,
isLikeParagraph,
} from "./Paragraph.js";
import { isDisplayBlock, normalizeStyles } from "./Style.js";
/**
* Maps any HTML into a valid content DOM element.
*
* @param {Document} document
* @param {HTMLElement} root
* @param {CSSStyleDeclaration} [styleDefaults]
* @returns {DocumentFragment}
*/
export function mapContentFragmentFromDocument(document, root, styleDefaults) {
const nodeIterator = document.createNodeIterator(
root,
NodeFilter.SHOW_TEXT
);
const fragment = document.createDocumentFragment();
let currentParagraph = null;
let currentNode = nodeIterator.nextNode();
while (currentNode) {
// We cannot call document.defaultView because it is `null`.
const currentStyle = normalizeStyles(currentNode, styleDefaults);
if (
isDisplayBlock(currentNode.parentElement.style) ||
isDisplayBlock(currentStyle) ||
isLikeParagraph(currentNode.parentElement)
) {
if (currentParagraph) {
fragment.appendChild(currentParagraph);
}
currentParagraph = createParagraph(undefined, currentStyle);
} else {
if (currentParagraph === null) {
currentParagraph = createParagraph(undefined, currentStyle);
}
}
currentParagraph.appendChild(
createInline(new Text(currentNode.nodeValue), currentStyle)
);
currentNode = nodeIterator.nextNode();
}
fragment.appendChild(currentParagraph);
return fragment;
}
/**
* Maps any HTML into a valid content DOM element.
*
* @param {string} html
* @param {CSSStyleDeclaration} [styleDefaults]
* @returns {DocumentFragment}
*/
export function mapContentFragmentFromHTML(html, styleDefaults) {
const parser = new DOMParser();
const htmlDocument = parser.parseFromString(html, "text/html");
return mapContentFragmentFromDocument(
htmlDocument,
htmlDocument.documentElement,
styleDefaults
);
}
/**
* Maps a plain text into a valid content DOM element.
*
* @param {string} string
* @param {CSSStyleDeclaration} [styleDefaults]
* @returns {DocumentFragment}
*/
export function mapContentFragmentFromString(string, styleDefaults) {
const lines = string.replace(/\r/g, "").split("\n");
const fragment = document.createDocumentFragment();
for (const line of lines) {
if (line === "") {
fragment.appendChild(createEmptyParagraph(styleDefaults));
} else {
fragment.appendChild(
createParagraph([
createInline(new Text(line), styleDefaults)
], styleDefaults)
);
}
}
return fragment;
}

View File

@@ -0,0 +1,98 @@
import { describe, test, expect } from "vitest";
import {
mapContentFragmentFromHTML,
mapContentFragmentFromString,
} from "./Content.js";
/* @vitest-environment jsdom */
describe("Content", () => {
test("mapContentFragmentFromHTML should return a valid content for the editor", () => {
const inertElement = document.createElement("div");
const contentFragment = mapContentFragmentFromHTML(
"<div>Hello, World!</div>",
inertElement.style,
);
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(1);
expect(contentFragment.firstElementChild).toBeInstanceOf(HTMLDivElement);
expect(contentFragment.firstElementChild.firstElementChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(
contentFragment.firstElementChild.firstElementChild.firstChild,
).toBeInstanceOf(Text);
expect(contentFragment.textContent).toBe("Hello, World!");
});
test("mapContentFragmentFromHTML should return a valid content for the editor (multiple inlines)", () => {
const inertElement = document.createElement("div");
const contentFragment = mapContentFragmentFromHTML(
"<div>Hello,<br/><span> World!</span><br/></div>",
inertElement.style,
);
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(1);
expect(contentFragment.firstElementChild).toBeInstanceOf(HTMLDivElement);
expect(contentFragment.firstElementChild.children).toHaveLength(2);
expect(contentFragment.firstElementChild.firstElementChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(
contentFragment.firstElementChild.firstElementChild.firstChild,
).toBeInstanceOf(Text);
expect(contentFragment.textContent).toBe("Hello, World!");
});
test("mapContentFragmentFromHTML should return a valid content for the editor (multiple paragraphs)", () => {
const paragraphs = [
"Lorem ipsum",
"Dolor sit amet",
"Sed iaculis blandit odio ornare sagittis.",
];
const inertElement = document.createElement("div");
const contentFragment = mapContentFragmentFromHTML(
"<div>Lorem ipsum</div><div>Dolor sit amet</div><div><br/></div><div>Sed iaculis blandit odio ornare sagittis.</div>",
inertElement.style,
);
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(3);
for (let index = 0; index < contentFragment.children.length; index++) {
expect(contentFragment.children.item(index)).toBeInstanceOf(
HTMLDivElement,
);
expect(
contentFragment.children.item(index).firstElementChild,
).toBeInstanceOf(HTMLSpanElement);
expect(
contentFragment.children.item(index).firstElementChild.firstChild,
).toBeInstanceOf(Text);
expect(contentFragment.children.item(index).textContent).toBe(
paragraphs[index],
);
}
expect(contentFragment.textContent).toBe(
"Lorem ipsumDolor sit ametSed iaculis blandit odio ornare sagittis.",
);
});
test("mapContentFragmentFromString should return a valid content for the editor", () => {
const contentFragment = mapContentFragmentFromString("Hello, \nWorld!");
expect(contentFragment).toBeInstanceOf(DocumentFragment);
expect(contentFragment.children).toHaveLength(2);
expect(contentFragment.children.item(0)).toBeInstanceOf(HTMLDivElement);
expect(contentFragment.children.item(1)).toBeInstanceOf(HTMLDivElement);
expect(contentFragment.children.item(0).firstElementChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(
contentFragment.children.item(0).firstElementChild.firstChild,
).toBeInstanceOf(Text);
expect(contentFragment.children.item(1).firstElementChild).toBeInstanceOf(
HTMLSpanElement,
);
expect(
contentFragment.children.item(1).firstElementChild.firstChild,
).toBeInstanceOf(Text);
expect(contentFragment.textContent).toBe("Hello, World!");
});
});

View File

@@ -0,0 +1,98 @@
/**
* 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 { setStyles } from "./Style.js";
/**
* @typedef {Object} CreateElementOptions
* @property {Object.<string,*>} [attributes]
* @property {Object.<string,*>} [data]
* @property {Object.<string,*>|CSSStyleDeclaration} [styles]
* @property {Array<[string,?string]>} [allowedStyles]
* @property {Array|Node} [children]
*/
/**
* Creates a new random id to identify content nodes.
*
* @returns {string}
*/
export function createRandomId() {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(36);
}
/**
* Creates a new HTML element.
*
* @param {string} tag
* @param {*} options
* @returns {HTMLElement}
*/
export function createElement(tag, options) {
const element = document.createElement(tag);
if (options?.attributes) {
Object.entries(options.attributes).forEach(([name, value]) =>
element.setAttribute(name, value)
);
}
if (options?.data) {
Object.entries(options.data).forEach(
([name, value]) => (element.dataset[name] = value)
);
}
if (options?.styles && options?.allowedStyles) {
setStyles(element, options.allowedStyles, options.styles);
}
if (options?.children) {
if (Array.isArray(options.children)) {
element.append(...options.children);
} else {
element.appendChild(options.children);
}
}
return element;
}
/**
* Returns true if passed node is an element.
*
* @param {Node} element
* @param {string} nodeName
* @returns {boolean}
*/
export function isElement(element, nodeName) {
return (
element.nodeType === Node.ELEMENT_NODE &&
element.nodeName === nodeName.toUpperCase()
);
}
/**
* Returns true if the specified offset is at the start of the element.
*
* @param {Node} node
* @param {number} offset
* @returns {boolean}
*/
export function isOffsetAtStart(node, offset) {
return offset === 0;
}
/**
* Returns true if the specified offset is at the end of the element.
*
* @param {Node} node
* @param {number} offset
* @returns {boolean}
*/
export function isOffsetAtEnd(node, offset) {
if (node.nodeType === Node.TEXT_NODE) {
return node.nodeValue.length === offset;
}
return true;
}

View File

@@ -0,0 +1,116 @@
import { describe, test, expect } from "vitest";
import {
createElement,
isElement,
createRandomId,
isOffsetAtStart,
isOffsetAtEnd,
} from "./Element.js";
/* @vitest-environment jsdom */
describe("Element", () => {
test("createRandomId should create a new random id", () => {
const randomId = createRandomId();
expect(typeof randomId).toBe("string");
expect(randomId.length).toBeGreaterThan(0);
expect(randomId.length).toBeLessThan(12);
});
test("createElement should create a new element", () => {
const element = createElement("div");
expect(element.nodeType).toBe(Node.ELEMENT_NODE);
expect(element.nodeName).toBe("DIV");
});
test("createElement should create a new element with attributes", () => {
const element = createElement("div", {
attributes: {
"aria-multiline": true,
role: "textbox",
},
});
expect(element.ariaMultiLine).toBe("true");
expect(element.role).toBe("textbox");
});
test("createElement should create a new element with data- properties", () => {
const element = createElement("div", {
data: {
itype: "root",
},
});
expect(element.dataset.itype).toBe("root");
});
test("createElement should create a new element with styles from an object", () => {
const element = createElement("div", {
styles: {
"text-decoration": "underline",
},
allowedStyles: [["text-decoration"]],
});
expect(element.style.textDecoration).toBe("underline");
});
test("createElement should create a new element with a child", () => {
const element = createElement("div", {
children: new Text("Hello, World!"),
});
expect(element.textContent).toBe("Hello, World!");
});
test("createElement should create a new element with children", () => {
const element = createElement("div", {
children: [
createElement("div", {
children: [
createElement("div", {
children: new Text("Hello, World!"),
}),
],
}),
],
});
expect(element.textContent).toBe("Hello, World!");
expect(element.firstChild.nodeType).toBe(Node.ELEMENT_NODE);
expect(element.firstChild.firstChild.nodeType).toBe(Node.ELEMENT_NODE);
expect(element.firstChild.firstChild.firstChild.nodeType).toBe(
Node.TEXT_NODE,
);
});
test("isElement returns true if the passed element is the expected element", () => {
const br = createElement("br");
expect(isElement(br, "br")).toBe(true);
const div = createElement("div");
expect(isElement(div, "div")).toBe(true);
const text = new Text("Hello, World!");
expect(isElement(text, "text")).toBe(false);
});
test("isOffsetAtStart should return true when offset is 0", () => {
const element = createElement("span", {
children: new Text("Hello"),
});
expect(isOffsetAtStart(element, 0)).toBe(true);
});
test("isOffsetAtEnd should return true when offset is the length of the text content", () => {
const element = createElement("span", {
children: new Text("Hello"),
});
expect(isOffsetAtEnd(element, 5)).toBe(true);
});
test("isOffsetAtEnd should return true when the node is a Text and offset is the length of the node", () => {
const element = new Text("Hello");
expect(isOffsetAtEnd(element, 5)).toBe(true);
});
test("isOffsetAtEnd should return true when node is an element", () => {
const element = createElement("span", {
children: createElement("br"),
});
expect(isOffsetAtEnd(element, 5)).toBe(true);
});
});

View File

@@ -0,0 +1,272 @@
/**
* 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 {
createElement,
isElement,
isOffsetAtStart,
isOffsetAtEnd,
} from "./Element.js";
import { createLineBreak, isLineBreak } from "./LineBreak.js";
import { setStyles, mergeStyles } from "./Style.js";
import { createRandomId } from "./Element.js";
export const TAG = "SPAN";
export const TYPE = "inline";
export const QUERY = `[data-itype="${TYPE}"]`;
export const STYLES = [
["--typography-ref-id"],
["--typography-ref-file"],
["--font-id"],
["--font-variant-id"],
["--fills"],
["font-variant"],
["font-family"],
["font-size", "px"],
["font-weight"],
["font-style"],
["line-height"],
["letter-spacing", "px"],
["text-decoration"],
["text-transform"],
];
/**
* Returns true if passed node is an inline.
*
* @param {Node} node
* @returns {boolean}
*/
export function isInline(node) {
if (!node) return false;
if (!isElement(node, TAG)) return false;
if (node.dataset.itype !== TYPE) return false;
return true;
}
/**
* Returns true if the passed node "behaves" like an
* inline.
*
* @param {Node} element
* @returns {boolean}
*/
export function isLikeInline(element) {
return element
? [
"A",
"ABBR",
"ACRONYM",
"B",
"BDO",
"BIG",
"BR",
"BUTTON",
"CITE",
"CODE",
"DFN",
"EM",
"I",
"IMG",
"INPUT",
"KBD",
"LABEL",
"MAP",
"OBJECT",
"OUTPUT",
"Q",
"SAMP",
"SCRIPT",
"SELECT",
"SMALL",
"SPAN",
"STRONG",
"SUB",
"SUP",
"TEXTAREA",
"TIME",
"TT",
"VAR",
].includes(element.nodeName)
: false;
}
/**
* Creates a new Inline
*
* @param {Text|HTMLBRElement} text
* @param {Object.<string, *>|CSSStyleDeclaration} styles
* @param {Object.<string, *>} [attrs]
* @returns {HTMLSpanElement}
*/
export function createInline(textOrLineBreak, styles, attrs) {
if (
!(textOrLineBreak instanceof HTMLBRElement) &&
!(textOrLineBreak instanceof Text)
) {
throw new TypeError("Invalid inline child");
}
if (textOrLineBreak instanceof Text
&& textOrLineBreak.nodeValue.length === 0) {
console.trace("nodeValue", textOrLineBreak.nodeValue)
throw new TypeError("Invalid inline child, cannot be an empty text");
}
return createElement(TAG, {
attributes: { id: createRandomId(), ...attrs },
data: { itype: TYPE },
styles: styles,
allowedStyles: STYLES,
children: textOrLineBreak,
});
}
/**
* Creates a new inline from an older inline. This only
* merges styles from the older inline to the new inline.
*
* @param {HTMLSpanElement} inline
* @param {Object.<string, *>} textOrLineBreak
* @param {Object.<string, *>|CSSStyleDeclaration} styles
* @param {Object.<string, *>} [attrs]
* @returns {HTMLSpanElement}
*/
export function createInlineFrom(inline, textOrLineBreak, styles, attrs) {
return createInline(
textOrLineBreak,
mergeStyles(STYLES, inline.style, styles),
attrs
);
}
/**
* Creates a new empty inline.
*
* @param {Object.<string,*>|CSSStyleDeclaration} styles
* @returns {HTMLSpanElement}
*/
export function createEmptyInline(styles) {
return createInline(createLineBreak(), styles);
}
/**
* Sets the inline styles.
*
* @param {HTMLSpanElement} element
* @param {Object.<string,*>|CSSStyleDeclaration} styles
* @returns {HTMLSpanElement}
*/
export function setInlineStyles(element, styles) {
return setStyles(element, STYLES, styles);
}
/**
* Gets the closest inline from a node.
*
* @param {Node} node
* @returns {HTMLElement|null}
*/
export function getInline(node) {
if (!node) return null; // FIXME: Should throw?
if (isInline(node)) return node;
if (node.nodeType === Node.TEXT_NODE) {
const inline = node?.parentElement;
if (!inline) return null;
if (!isInline(inline)) return null;
return inline;
}
return node.closest(QUERY);
}
/**
* Returns true if we are at the start offset
* of an inline.
*
* NOTE: Only the first inline returns this as true
*
* @param {TextNode|HTMLBRElement} node
* @param {number} offset
* @returns {boolean}
*/
export function isInlineStart(node, offset) {
const inline = getInline(node);
if (!inline) return false;
return isOffsetAtStart(inline, offset);
}
/**
* Returns true if we are at the end offset
* of an inline.
*
* @param {TextNode|HTMLBRElement} node
* @param {number} offset
* @returns {boolean}
*/
export function isInlineEnd(node, offset) {
const inline = getInline(node);
if (!inline) return false;
return isOffsetAtEnd(inline.firstChild, offset);
}
/**
* Splits an inline.
*
* @param {HTMLSpanElement} inline
* @param {number} offset
*/
export function splitInline(inline, offset) {
const textNode = inline.firstChild;
const style = inline.style;
const newTextNode = textNode.splitText(offset);
return createInline(newTextNode, style);
}
/**
* Returns all the inlines of a paragraph starting at
* the specified inline.
*
* @param {HTMLSpanElement} startInline
* @returns {Array<HTMLSpanElement>}
*/
export function getInlinesFrom(startInline) {
const inlines = [];
let currentInline = startInline;
let index = 0;
while (currentInline) {
if (index > 0) inlines.push(currentInline);
currentInline = currentInline.nextElementSibling;
index++;
}
return inlines;
}
/**
* Returns the length of an inline.
*
* @param {HTMLElement} inline
* @returns {number}
*/
export function getInlineLength(inline) {
if (!isInline(inline)) throw new Error("Invalid inline");
if (isLineBreak(inline.firstChild)) return 0;
return inline.firstChild.nodeValue.length;
}
/**
* Merges two inlines.
*
* @param {HTMLSpanElement} a
* @param {HTMLSpanElement} b
* @returns {HTMLSpanElement}
*/
export function mergeInlines(a, b) {
a.append(...b.childNodes);
b.remove();
// We need to normalize Text nodes.
a.normalize();
return a;
}

View File

@@ -0,0 +1,123 @@
import { describe, test, expect } from "vitest";
import {
createEmptyInline,
createInline,
getInline,
getInlineLength,
isInline,
isInlineEnd,
isInlineStart,
isLikeInline,
splitInline,
TAG,
TYPE,
} from "./Inline.js";
import { createLineBreak } from "./LineBreak.js";
/* @vitest-environment jsdom */
describe("Inline", () => {
test("createInline should throw when passed an invalid child", () => {
expect(() => createInline("Hello, World!")).toThrowError(
"Invalid inline child",
);
});
test("createInline creates a new inline element with a <br> inside", () => {
const inline = createInline(createLineBreak());
expect(inline).toBeInstanceOf(HTMLSpanElement);
expect(inline.dataset.itype).toBe(TYPE);
expect(inline.nodeName).toBe(TAG);
expect(inline.textContent).toBe("");
expect(inline.firstChild).toBeInstanceOf(HTMLBRElement);
});
test("createInline creates a new inline element with a text inside", () => {
const inline = createInline(new Text("Hello, World!"));
expect(inline).toBeInstanceOf(HTMLSpanElement);
expect(inline.dataset.itype).toBe(TYPE);
expect(inline.nodeName).toBe(TAG);
expect(inline.textContent).toBe("Hello, World!");
expect(inline.firstChild).toBeInstanceOf(Text);
});
test("createEmptyInline creates a new empty inline element with a <br> inside", () => {
const emptyInline = createEmptyInline();
expect(emptyInline).toBeInstanceOf(HTMLSpanElement);
expect(emptyInline.dataset.itype).toBe(TYPE);
expect(emptyInline.nodeName).toBe(TAG);
expect(emptyInline.textContent).toBe("");
expect(emptyInline.firstChild).toBeInstanceOf(HTMLBRElement);
});
test("isInline should return true on elements that are inlines", () => {
const inline = createInline(new Text("Hello, World!"));
expect(isInline(inline)).toBe(true);
const a = document.createElement("a");
expect(isInline(a)).toBe(false);
const b = null;
expect(isInline(b)).toBe(false);
const c = document.createElement("span");
expect(isInline(c)).toBe(false);
});
test("isLikeInline should return true on elements that have inline behavior by default", () => {
expect(isLikeInline(Infinity)).toBe(false);
expect(isLikeInline(null)).toBe(false);
expect(isLikeInline(document.createElement("A"))).toBe(true);
});
// FIXME: Should throw?
test("isInlineStart returns false when passed node is not an inline", () => {
const inline = document.createElement("div");
expect(isInlineStart(inline, 0)).toBe(false);
expect(isInlineStart(inline, "Hello, World!".length)).toBe(false);
});
test("isInlineStart returns if we're at the start of an inline", () => {
const inline = createInline(new Text("Hello, World!"));
expect(isInlineStart(inline, 0)).toBe(true);
expect(isInlineStart(inline, "Hello, World!".length)).toBe(false);
});
// FIXME: Should throw?
test("isInlineEnd returns false when passed node is not an inline", () => {
const inline = document.createElement("div");
expect(isInlineEnd(inline, 0)).toBe(false);
expect(isInlineEnd(inline, "Hello, World!".length)).toBe(false);
});
test("isInlineEnd returns if we're in the end of an inline", () => {
const inline = createInline(new Text("Hello, World!"));
expect(isInlineEnd(inline, 0)).toBe(false);
expect(isInlineEnd(inline, "Hello, World!".length)).toBe(true);
});
test("getInline ", () => {
expect(getInline(null)).toBe(null);
});
test("getInlineLength throws when the passed node is not an inline", () => {
const inline = document.createElement("div");
expect(() => getInlineLength(inline)).toThrowError("Invalid inline");
});
test("getInlineLength returns the length of the inline content", () => {
const inline = createInline(new Text("Hello, World!"));
expect(getInlineLength(inline)).toBe(13);
});
test("getInlineLength should return 0 when the inline content is a <br>", () => {
const emptyInline = createEmptyInline();
expect(getInlineLength(emptyInline)).toBe(0);
});
test("splitInline returns a new inline from the splitted inline", () => {
const inline = createInline(new Text("Hello, World!"));
const newInline = splitInline(inline, 5);
expect(newInline).toBeInstanceOf(HTMLSpanElement);
expect(newInline.firstChild).toBeInstanceOf(Text);
expect(newInline.textContent).toBe(", World!");
expect(newInline.dataset.itype).toBe(TYPE);
expect(newInline.nodeName).toBe(TAG);
});
});

View File

@@ -0,0 +1,28 @@
/**
* 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
*/
export const TAG = "BR";
/**
* Creates a new line break.
*
* @returns {HTMLBRElement}
*/
export function createLineBreak() {
return document.createElement(TAG);
}
/**
* Returns true if the passed node is a line break.
*
* @param {Node} node
* @returns {boolean}
*/
export function isLineBreak(node) {
return node && node.nodeType === Node.ELEMENT_NODE && node.nodeName === TAG;
}

View File

@@ -0,0 +1,11 @@
import { describe, expect, test } from "vitest";
import { createLineBreak } from "./LineBreak.js";
/* @vitest-environment jsdom */
describe("LineBreak", () => {
test("createLineBreak should return a <br> element", () => {
const br = createLineBreak();
expect(br.nodeType).toBe(Node.ELEMENT_NODE);
expect(br.nodeName).toBe("BR");
});
});

View File

@@ -0,0 +1,258 @@
/**
* 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 {
createRandomId,
createElement,
isElement,
isOffsetAtStart,
isOffsetAtEnd,
} from "./Element.js";
import {
isInline,
isLikeInline,
getInline,
getInlinesFrom,
createInline,
createEmptyInline,
isInlineEnd,
splitInline,
} from "./Inline.js";
import { createLineBreak, isLineBreak } from "./LineBreak.js";
import { setStyles } from "./Style.js";
export const TAG = "DIV";
export const TYPE = "paragraph";
export const QUERY = `[data-itype="${TYPE}"]`;
export const STYLES = [
["--typography-ref-id"],
["--typography-ref-file"],
["--font-id"],
["--font-variant-id"],
["--fills"],
["font-variant"],
["font-family"],
["font-size", "px"],
["font-weight"],
["font-style"],
["line-height"],
["letter-spacing", "px"],
["text-decoration"],
["text-transform"],
["text-align"],
["direction"]
];
/**
* FIXME: This is a fix for Chrome that removes the
* current inline when the last character is deleted
* in `insertCompositionText`.
*
* @param {*} node
*/
export function fixParagraph(node) {
if (!isParagraph(node) || !isLineBreak(node.firstChild)) {
return;
}
const br = createLineBreak();
node.replaceChildren(
createInline(br)
);
return br;
}
/**
* Returns true if the passed node behaves like a paragraph.
*
* NOTE: This is mainly used in paste operations. Every element node
* it's going to be treated as paragraph it
*
* @param {Node} element
* @returns {boolean}
*/
export function isLikeParagraph(element) {
return !isLikeInline(element);
}
/**
* Returns true if we have an empty paragraph.
*
* @param {Node} element
* @returns {boolean}
*/
export function isEmptyParagraph(element) {
if (!isParagraph(element)) throw new TypeError("Invalid paragraph");
const inline = element.firstChild;
if (!isInline(inline)) throw new TypeError("Invalid inline");
return isLineBreak(inline.firstChild);
}
/**
* Returns true if passed node is a paragraph.
*
* @param {Node} node
* @returns {boolean}
*/
export function isParagraph(node) {
if (!node) return false;
if (!isElement(node, TAG)) return false;
if (node.dataset.itype !== TYPE) return false;
return true;
}
/**
* Creates a new paragraph.
*
* @param {Array<HTMLDivElement>} inlines
* @param {Object.<string, *>|CSSStyleDeclaration} styles
* @param {Object.<string, *>} [attrs]
* @returns {HTMLDivElement}
*/
export function createParagraph(inlines, styles, attrs) {
if (inlines && (!Array.isArray(inlines) || !inlines.every(isInline)))
throw new TypeError("Invalid paragraph children");
return createElement(TAG, {
attributes: { id: createRandomId(), ...attrs },
data: { itype: TYPE },
styles: styles,
allowedStyles: STYLES,
children: inlines,
});
}
/**
* Returns a new empty paragraph
*
* @param {Object.<string, *>} styles
* @returns {HTMLDivElement}
*/
export function createEmptyParagraph(styles) {
return createParagraph([
createEmptyInline(styles)
], styles);
}
/**
* Sets the paragraph styles.
*
* @param {HTMLDivElement} element
* @param {Object.<string,*>|CSSStyleDeclaration} styles
* @returns {HTMLDivElement}
*/
export function setParagraphStyles(element, styles) {
return setStyles(element, STYLES, styles);
}
/**
* Gets the closest paragraph from a node.
*
* @param {Text|HTMLBRElement} node
* @returns {HTMLElement|null}
*/
export function getParagraph(node) {
if (!node) return null;
if (isParagraph(node)) return node;
if (node.nodeType === Node.TEXT_NODE
|| isLineBreak(node)) {
const paragraph = node?.parentElement?.parentElement;
if (!paragraph) {
return null;
}
if (!isParagraph(paragraph)) {
return null;
}
return paragraph;
}
return node.closest(QUERY);
}
/**
* Returns if the specified node and offset represents
* the start of the paragraph.
*
* @param {Text|HTMLBRElement} node
* @param {number} offset
* @returns {boolean}
*/
export function isParagraphStart(node, offset) {
const paragraph = getParagraph(node);
if (!paragraph) throw new Error("Can't find the paragraph");
const inline = getInline(node);
if (!inline) throw new Error("Can't find the inline");
return (
paragraph.firstElementChild === inline &&
isOffsetAtStart(inline.firstChild, offset)
);
}
/**
* Returns if the specified node and offset represents
* the end of the paragraph.
*
* @param {Text|HTMLBRElement} node
* @param {number} offset
* @returns {boolean}
*/
export function isParagraphEnd(node, offset) {
const paragraph = getParagraph(node);
if (!paragraph) throw new Error("Cannot find the paragraph");
const inline = getInline(node);
if (!inline) throw new Error("Cannot find the inline");
return (
paragraph.lastElementChild === inline &&
isOffsetAtEnd(inline.firstChild, offset)
);
}
/**
* Splits a paragraph.
*
* @param {HTMLDivElement} paragraph
* @param {HTMLSpanElement} inline
* @param {number} offset
*/
export function splitParagraph(paragraph, inline, offset) {
const style = paragraph.style;
if (isInlineEnd(inline, offset)) {
const newParagraph = createParagraph(getInlinesFrom(inline), style);
return newParagraph;
}
const newInline = splitInline(inline, offset);
const newParagraph = createParagraph([newInline], style);
return newParagraph;
}
/**
* Splits a paragraph at a specified child node index
*
* @param {HTMLDivElement} paragraph
* @param {number} startIndex
*/
export function splitParagraphAtNode(paragraph, startIndex) {
const style = paragraph.style;
const newParagraph = createParagraph(null, style);
const newInlines = [];
for (let index = startIndex; index < paragraph.children.length; index++) {
newInlines.push(paragraph.children.item(index));
}
newParagraph.append(...newInlines);
return newParagraph;
}
/**
* Merges two paragraphs.
*
* @param {HTMLDivElement} a
* @param {HTMLDivElement} b
* @returns {HTMLDivElement}
*/
export function mergeParagraphs(a, b) {
a.append(...b.children);
b.remove();
return a;
}

View File

@@ -0,0 +1,171 @@
import { describe, test, expect } from "vitest";
import {
createEmptyParagraph,
createParagraph,
getParagraph,
isLikeParagraph,
isParagraph,
isParagraphStart,
isParagraphEnd,
TAG,
TYPE,
splitParagraph,
splitParagraphAtNode,
isEmptyParagraph,
} from "./Paragraph.js";
import { createInline, isInline } from "./Inline.js";
/* @vitest-environment jsdom */
describe("Paragraph", () => {
test("createParagraph should throw when passed invalid children", () => {
expect(() => createParagraph(["Whatever"])).toThrowError(
"Invalid paragraph children",
);
});
test("createEmptyParagraph should create a new empty paragraph", () => {
const emptyParagraph = createEmptyParagraph();
expect(emptyParagraph).toBeInstanceOf(HTMLDivElement);
expect(emptyParagraph.nodeName).toBe(TAG);
expect(emptyParagraph.dataset.itype).toBe(TYPE);
expect(isInline(emptyParagraph.firstChild)).toBe(true);
});
test("isParagraph should return true when the passed node is a paragraph", () => {
expect(isParagraph(null)).toBe(false);
expect(isParagraph(document.createElement("div"))).toBe(false);
expect(isParagraph(document.createElement("h1"))).toBe(false);
expect(isParagraph(createEmptyParagraph())).toBe(true);
expect(
isParagraph(createParagraph([createInline(new Text("Hello, World!"))])),
).toBe(true);
});
test("isLikeParagraph should return true when node looks like a paragraph", () => {
const p = document.createElement("p");
expect(isLikeParagraph(p)).toBe(true);
const div = document.createElement("div");
expect(isLikeParagraph(div)).toBe(true);
const h1 = document.createElement("h1");
expect(isLikeParagraph(h1)).toBe(true);
const h2 = document.createElement("h2");
expect(isLikeParagraph(h2)).toBe(true);
const h3 = document.createElement("h3");
expect(isLikeParagraph(h3)).toBe(true);
const h4 = document.createElement("h4");
expect(isLikeParagraph(h4)).toBe(true);
const h5 = document.createElement("h5");
expect(isLikeParagraph(h5)).toBe(true);
const h6 = document.createElement("h6");
expect(isLikeParagraph(h6)).toBe(true);
});
test("getParagraph should return the closest paragraph of the passed node", () => {
const text = new Text("Hello, World!");
const inline = createInline(text);
const paragraph = createParagraph([inline]);
expect(getParagraph(text)).toBe(paragraph);
});
test("getParagraph should return null if there aren't closer paragraph nodes", () => {
const text = new Text("Hello, World!");
const whatever = document.createElement("div");
whatever.appendChild(text);
expect(getParagraph(text)).toBe(null);
});
test("isParagraphStart should return true on an empty paragraph", () => {
const paragraph = createEmptyParagraph();
expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBe(true);
});
test("isParagraphStart should return true on a paragraph", () => {
const paragraph = createParagraph([
createInline(new Text("Hello, World!")),
]);
expect(isParagraphStart(paragraph.firstChild.firstChild, 0)).toBe(true);
});
test("isParagraphEnd should return true on an empty paragraph", () => {
const paragraph = createEmptyParagraph();
expect(isParagraphEnd(paragraph.firstChild.firstChild, 0)).toBe(true);
});
test("isParagraphEnd should return true on a paragraph", () => {
const paragraph = createParagraph([
createInline(new Text("Hello, World!")),
]);
expect(isParagraphEnd(paragraph.firstChild.firstChild, 13)).toBe(true);
});
test("splitParagraph should split a paragraph", () => {
const inline = createInline(new Text("Hello, World!"));
const paragraph = createParagraph([inline]);
const newParagraph = splitParagraph(paragraph, inline, 6);
expect(newParagraph).toBeInstanceOf(HTMLDivElement);
expect(newParagraph.nodeName).toBe(TAG);
expect(newParagraph.dataset.itype).toBe(TYPE);
expect(newParagraph.firstElementChild.textContent).toBe(" World!");
});
test("splitParagraphAtNode should split a paragraph at a specified node", () => {
const helloInline = createInline(new Text("Hello, "));
const worldInline = createInline(new Text("World"));
const exclInline = createInline(new Text("!"));
const paragraph = createParagraph([helloInline, worldInline, exclInline]);
const newParagraph = splitParagraphAtNode(paragraph, 1);
expect(newParagraph).toBeInstanceOf(HTMLDivElement);
expect(newParagraph.nodeName).toBe(TAG);
expect(newParagraph.dataset.itype).toBe(TYPE);
expect(newParagraph.children.length).toBe(2);
expect(newParagraph.textContent).toBe("World!");
});
test("isLikeParagraph should return true if the element it's not an inline element", () => {
const span = document.createElement("span");
const a = document.createElement("a");
const br = document.createElement("br");
const i = document.createElement("span");
const u = document.createElement("span");
const div = document.createElement("div");
const blockquote = document.createElement("blockquote");
const table = document.createElement("table");
expect(isLikeParagraph(span)).toBe(false);
expect(isLikeParagraph(a)).toBe(false);
expect(isLikeParagraph(br)).toBe(false);
expect(isLikeParagraph(i)).toBe(false);
expect(isLikeParagraph(u)).toBe(false);
expect(isLikeParagraph(div)).toBe(true);
expect(isLikeParagraph(blockquote)).toBe(true);
expect(isLikeParagraph(table)).toBe(true);
});
test("isEmptyParagraph should return true if the paragraph is empty", () => {
expect(() => {
isEmptyParagraph(document.createElement("svg"));
}).toThrowError("Invalid paragraph");
expect(() => {
const paragraph = document.createElement("div");
paragraph.dataset.itype = "paragraph";
paragraph.appendChild(document.createElement("svg"));
isEmptyParagraph(paragraph);
}).toThrowError("Invalid inline");
const lineBreak = document.createElement("br");
const emptyInline = document.createElement("span");
emptyInline.dataset.itype = "inline";
emptyInline.appendChild(lineBreak);
const emptyParagraph = document.createElement("div");
emptyParagraph.dataset.itype = "paragraph";
emptyParagraph.appendChild(emptyInline);
expect(isEmptyParagraph(emptyParagraph)).toBe(true);
const nonEmptyInline = document.createElement("span");
nonEmptyInline.dataset.itype = "inline";
nonEmptyInline.appendChild(new Text("Not empty!"));
const nonEmptyParagraph = document.createElement("div");
nonEmptyParagraph.dataset.itype = "paragraph";
nonEmptyParagraph.appendChild(nonEmptyInline);
expect(isEmptyParagraph(nonEmptyParagraph)).toBe(false);
});
});

View File

@@ -0,0 +1,70 @@
/**
* 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 { createRandomId, createElement, isElement } from "./Element.js";
import { createEmptyParagraph, isParagraph } from "./Paragraph.js";
import { setStyles } from "./Style.js";
export const TAG = "DIV";
export const TYPE = "root";
export const QUERY = `[data-itype="${TYPE}"]`;
export const STYLES = [["--vertical-align"]];
/**
* Returns true if passed node is a root.
*
* @param {Node} node
* @returns {boolean}
*/
export function isRoot(node) {
if (!node) return false;
if (!isElement(node, TAG)) return false;
if (node.dataset.itype !== TYPE) return false;
return true;
}
/**
* Create a new root element
*
* @param {Array<HTMLDivElement>} paragraphs
* @param {Object.<string, *>|CSSStyleDeclaration} styles,
* @param {Object.<string, *>} [attrs]
* @returns {HTMLDivElement}
*/
export function createRoot(paragraphs, styles, attrs) {
if (!Array.isArray(paragraphs) || !paragraphs.every(isParagraph))
throw new TypeError("Invalid root children");
return createElement(TAG, {
attributes: { id: createRandomId(), ...attrs },
data: { itype: TYPE },
styles: styles,
allowedStyles: STYLES,
children: paragraphs,
});
}
/**
* Creates a new empty root element
*
* @param {Object.<string,*>|CSSStyleDeclaration} styles
*/
export function createEmptyRoot(styles) {
return createRoot([createEmptyParagraph(styles)], styles);
}
/**
* Sets the root styles.
*
* @param {HTMLDivElement} element
* @param {Object.<string,*>|CSSStyleDeclaration} styles
* @returns {HTMLDivElement}
*/
export function setRootStyles(element, styles) {
return setStyles(element, STYLES, styles);
}

View File

@@ -0,0 +1,35 @@
import { describe, test, expect } from "vitest";
import { createEmptyRoot, createRoot, setRootStyles, TAG, TYPE } from "./Root.js";
/* @vitest-environment jsdom */
describe("Root", () => {
test("createRoot should throw when passed invalid children", () => {
expect(() => createRoot(["Whatever"])).toThrowError(
"Invalid root children",
);
});
test("createEmptyRoot should create a new root with an empty paragraph", () => {
const emptyRoot = createEmptyRoot();
expect(emptyRoot).toBeInstanceOf(HTMLDivElement);
expect(emptyRoot.nodeName).toBe(TAG);
expect(emptyRoot.dataset.itype).toBe(TYPE);
expect(emptyRoot.firstChild).toBeInstanceOf(HTMLDivElement);
expect(emptyRoot.firstChild.firstChild).toBeInstanceOf(HTMLSpanElement);
expect(emptyRoot.firstChild.firstChild.firstChild).toBeInstanceOf(
HTMLBRElement,
);
});
test("setRootStyles should apply only the styles of root to the root", () => {
const emptyRoot = createEmptyRoot();
setRootStyles(emptyRoot, {
["--vertical-align"]: "top",
["font-size"]: "25px",
});
expect(emptyRoot.style.getPropertyValue("--vertical-align")).toBe("top");
// We expect this style to be empty because we don't apply it
// to the root.
expect(emptyRoot.style.getPropertyValue("font-size")).toBe("");
});
});

View File

@@ -0,0 +1,329 @@
/**
* 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 { getFills } from "./Color.js";
const DEFAULT_FONT_SIZE = "16px";
const DEFAULT_LINE_HEIGHT = "1.2";
/**
* Merges two style declarations. `source` -> `target`.
*
* @param {CSSStyleDeclaration} target
* @param {CSSStyleDeclaration} source
* @returns {CSSStyleDeclaration}
*/
export function mergeStyleDeclarations(target, source) {
// This is better but it doesn't work in JSDOM
// for (const styleName of source) {
for (let index = 0; index < source.length; index++) {
const styleName = source.item(index);
target.setProperty(styleName, source.getPropertyValue(styleName));
}
return target
}
/**
* Resets the properties of a style declaration.
*
* @param {CSSStyleDeclaration} styleDeclaration
* @returns {CSSStyleDeclaration}
*/
function resetStyleDeclaration(styleDeclaration) {
for (let index = 0; index < styleDeclaration.length; index++) {
const styleName = styleDeclaration.item(index);
styleDeclaration.removeProperty(styleName);
}
return styleDeclaration
}
/**
* An inert element that only keeps the style
* declaration used for merging other styleDeclarations.
*
* @type {HTMLDivElement|null}
*/
let inertElement = null
/**
* Resets the style declaration of the inert
* element.
*/
function resetInertElement() {
if (!inertElement) throw new Error('Invalid inert element');
resetStyleDeclaration(inertElement.style);
return inertElement;
}
/**
* Returns an instance of a <div> element used
* to keep style declarations.
*
* @returns {HTMLDivElement}
*/
function getInertElement() {
if (!inertElement) {
inertElement = document.createElement("div");
return inertElement;
}
resetInertElement();
return inertElement;
}
/**
* Computes the styles of an element the same way `window.getComputedStyle` does.
*
* @param {Element} element
* @returns {CSSStyleDeclaration}
*/
export function getComputedStyle(element) {
const inertElement = getInertElement();
let currentElement = element;
while (currentElement) {
// This is better but it doesn't work in JSDOM.
// for (const styleName of currentElement.style) {
for (let index = 0; index < currentElement.style.length; index++) {
const styleName = currentElement.style.item(index);
const currentValue = inertElement.style.getPropertyValue(styleName);
if (currentValue) {
const priority = currentElement.style.getPropertyPriority(styleName);
if (priority === "important") {
const newValue = currentElement.style.getPropertyValue(styleName);
inertElement.style.setProperty(styleName, newValue);
}
} else {
inertElement.style.setProperty(
styleName,
currentElement.style.getPropertyValue(styleName)
);
}
}
currentElement = currentElement.parentElement;
}
return inertElement.style;
}
/**
* Normalizes style declaration.
*
* TODO: I think that this also needs to remove some "conflicting"
* CSS properties like `font-family` or some CSS variables.
*
* @param {Node} node
* @param {CSSStyleDeclaration} styleDefaults
* @returns {CSSStyleDeclaration}
*/
export function normalizeStyles(node, styleDefaults) {
const styleDeclaration = mergeStyleDeclarations(
styleDefaults,
getComputedStyle(node.parentElement)
);
// If there's a color property, we should convert it to
// a --fills CSS variable property.
const fills = styleDeclaration.getPropertyValue("--fills");
const color = styleDeclaration.getPropertyValue("color");
if (color && !fills) {
styleDeclaration.removeProperty("color");
styleDeclaration.setProperty("--fills", getFills(color));
}
// If there's a font-family property and not a --font-id, then
// we remove the font-family because it will not work.
const fontFamily = styleDeclaration.getPropertyValue("font-family");
const fontId = styleDeclaration.getPropertyPriority("--font-id");
if (fontFamily && !fontId) {
styleDeclaration.removeProperty("font-family");
}
const fontSize = styleDeclaration.getPropertyValue("font-size");
if (!fontSize || fontSize === "0px") {
styleDeclaration.setProperty("font-size", DEFAULT_FONT_SIZE);
}
const lineHeight = styleDeclaration.getPropertyValue("line-height");
if (!lineHeight || lineHeight === "") {
styleDeclaration.setProperty("line-height", DEFAULT_LINE_HEIGHT);
}
return styleDeclaration
}
/**
* Sets a single style property value of an element.
*
* @param {HTMLElement} element
* @param {string} styleName
* @param {*} styleValue
* @param {string} [styleUnit]
* @returns {HTMLElement}
*/
export function setStyle(element, styleName, styleValue, styleUnit) {
if (
styleName.startsWith("--") &&
typeof styleValue !== "string" &&
typeof styleValue !== "number"
) {
if (styleName === "--fills" && styleValue === null) debugger;
element.style.setProperty(styleName, JSON.stringify(styleValue));
} else {
element.style.setProperty(styleName, styleValue + (styleUnit ?? ""));
}
return element;
}
/**
* Returns the value of a style from a declaration.
*
* @param {CSSStyleDeclaration} style
* @param {string} styleName
* @param {string|undefined} [styleUnit]
* @returns {*}
*/
export function getStyleFromDeclaration(style, styleName, styleUnit) {
if (styleName.startsWith("--")) {
return style.getPropertyValue(styleName);
}
const styleValue = style.getPropertyValue(styleName);
if (styleValue.endsWith(styleUnit)) {
return styleValue.slice(0, -styleUnit.length);
}
return styleValue;
}
/**
* Returns the value of a style.
*
* @param {HTMLElement} element
* @param {string} styleName
* @param {string|undefined} [styleUnit]
* @returns {*}
*/
export function getStyle(element, styleName, styleUnit) {
return getStyleFromDeclaration(element.style, styleName, styleUnit);
}
/**
* Sets the styles of an element using an object and a list of
* allowed styles.
*
* @param {HTMLElement} element
* @param {Array<[string,?string]>} allowedStyles
* @param {Object.<string, *>} styleObject
* @returns {HTMLElement}
*/
export function setStylesFromObject(element, allowedStyles, styleObject) {
for (const [styleName, styleUnit] of allowedStyles) {
if (!(styleName in styleObject)) {
continue;
}
const styleValue = styleObject[styleName];
if (styleValue) {
setStyle(element, styleName, styleValue, styleUnit);
}
}
return element;
}
/**
* Sets the styles of an element using a CSS Style Declaration and a list
* of allowed styles.
*
* @param {HTMLElement} element
* @param {Array<[string,?string]>} allowedStyles
* @param {CSSStyleDeclaration} styleDeclaration
* @returns {HTMLElement}
*/
export function setStylesFromDeclaration(
element,
allowedStyles,
styleDeclaration
) {
for (const [styleName, styleUnit] of allowedStyles) {
const styleValue = getStyleFromDeclaration(styleDeclaration, styleName, styleUnit);
if (styleValue) {
setStyle(element, styleName, styleValue, styleUnit);
}
}
return element;
}
/**
* Sets the styles of an element using an Object or a CSS Style Declaration and
* a list of allowed styles.
*
* @param {HTMLElement} element
* @param {Array<[string,?string]} allowedStyles
* @param {Object.<string,*>|CSSStyleDeclaration} styleObjectOrDeclaration
* @returns {HTMLElement}
*/
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) {
return setStylesFromDeclaration(
element,
allowedStyles,
styleObjectOrDeclaration
);
}
return setStylesFromObject(element, allowedStyles, styleObjectOrDeclaration);
}
/**
* Gets the styles of an element using a list of allowed styles.
*
* @param {HTMLElement} element
* @param {Array<[string,?string]} allowedStyles
* @returns {Object.<string, *>}
*/
export function getStyles(element, allowedStyles) {
const styleObject = {};
for (const [styleName, styleUnit] of allowedStyles) {
const styleValue = getStyle(element, styleName, styleUnit);
if (styleValue) {
styleObject[styleName] = styleValue;
}
}
return styleObject;
}
/**
* Returns a series of merged styles.
*
* @param {Array<[string,?string]} allowedStyles
* @param {CSSStyleDeclaration} styleDeclaration
* @param {Object.<string,*>} newStyles
* @returns {Object.<string,*>}
*/
export function mergeStyles(allowedStyles, styleDeclaration, newStyles) {
const mergedStyles = {};
for (const [styleName, styleUnit] of allowedStyles) {
if (styleName in newStyles) {
mergedStyles[styleName] = newStyles[styleName];
} else {
mergedStyles[styleName] = getStyleFromDeclaration(styleDeclaration, styleName, styleUnit);
}
}
return mergedStyles;
}
/**
* Returns true if the specified style declaration has a display block.
*
* @param {CSSStyleDeclaration} style
* @returns {boolean}
*/
export function isDisplayBlock(style) {
return style.display === "block";
}
/**
* Returns true if the specified style declaration has a display inline
* or inline-block.
*
* @param {CSSStyleDeclaration} style
* @returns {boolean}
*/
export function isDisplayInline(style) {
return style.display === "inline" || style.display === "inline-block";
}

View File

@@ -0,0 +1,82 @@
import { describe, test, expect, vi } from "vitest";
import {
getStyles,
isDisplayBlock,
isDisplayInline,
setStyle,
setStyles,
} from "./Style.js";
/* @vitest-environment jsdom */
describe("Style", () => {
test("setStyle should apply a style to an element", () => {
const element = document.createElement("div");
setStyle(element, "display", "none");
expect(element.style.display).toBe("none");
});
test("setStyles should apply multiple styles to an element using an Object", () => {
const element = document.createElement("div");
setStyles(element, [["display"]], {
"text-decoration": "none",
"font-size": "32px",
display: "none",
});
expect(element.style.display).toBe("none");
expect(element.style.fontSize).toBe("");
expect(element.style.textDecoration).toBe("");
});
test("setStyles should apply multiple styles to an element using a CSSStyleDeclaration", () => {
const a = document.createElement("div");
setStyles(a, [["display"]], {
display: "none",
});
expect(a.style.display).toBe("none");
expect(a.style.fontSize).toBe("");
expect(a.style.textDecoration).toBe("");
const b = document.createElement("div");
setStyles(b, [["display"]], a.style);
expect(b.style.display).toBe("none");
expect(b.style.fontSize).toBe("");
expect(b.style.textDecoration).toBe("");
});
test("getStyles should retrieve a list of allowed styles", () => {
const element = document.createElement("div");
element.style.display = "block";
element.style.textDecoration = "underline";
element.style.fontSize = "32px";
const textDecorationStyles = getStyles(element, [["text-decoration"]]);
expect(textDecorationStyles).toStrictEqual({
"text-decoration": "underline",
});
const displayStyles = getStyles(element, [["display"]]);
expect(displayStyles).toStrictEqual({
display: "block",
});
const fontSizeStyles = getStyles(element, [["font-size", "px"]]);
expect(fontSizeStyles).toStrictEqual({
"font-size": "32",
});
});
test("isDisplayBlock should return true if display is 'block'", () => {
const div = document.createElement("div");
div.style.display = "block";
expect(isDisplayBlock(div.style)).toBe(true);
const span = document.createElement("span");
span.style.display = "inline";
expect(isDisplayBlock(span)).toBe(false);
});
test("isDisplayInline should return true if display is 'inline'", () => {
const span = document.createElement("span");
span.style.display = "inline";
expect(isDisplayInline(span.style)).toBe(true);
const div = document.createElement("div");
div.style.display = "block";
expect(isDisplayInline(div)).toBe(false);
});
});

View File

@@ -0,0 +1,64 @@
/**
* 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 { isInline } from "./Inline.js";
import { isLineBreak } from "./LineBreak.js";
import { isParagraph } from "./Paragraph.js";
import { isRoot } from "./Root.js";
/**
* Returns true if the node is "like"
* text, this means that it is a Text
* node or a <br> element.
*
* @param {Node} node
* @returns {boolean}
*/
export function isTextNode(node) {
if (!node) throw new TypeError("Invalid text node");
return node.nodeType === Node.TEXT_NODE
|| isLineBreak(node);
}
/**
* Returns true if the text node is empty.
*
* @param {Node} node
* @returns {boolean}
*/
export function isEmptyTextNode(node) {
return node.nodeType === Node.TEXT_NODE
&& node.nodeValue === "";
}
/**
* Returns the content length of the
* node.
*
* @param {Node} node
* @returns {number}
*/
export function getTextNodeLength(node) {
if (!node) throw new TypeError("Invalid text node");
if (isLineBreak(node)) return 0;
return node.nodeValue.length;
}
/**
* Gets the closest text node.
*
* @param {Node} node
* @returns {Node}
*/
export function getClosestTextNode(node) {
if (isTextNode(node)) return node;
if (isInline(node)) return node.firstChild;
if (isParagraph(node)) return node.firstChild.firstChild;
if (isRoot(node)) return node.firstChild.firstChild.firstChild;
throw new Error("Cannot find a text node");
}

View File

@@ -0,0 +1,28 @@
import { describe, test, expect } from "vitest";
import { isTextNode, getTextNodeLength } from "./TextNode.js";
import { createLineBreak } from "./LineBreak.js";
/* @vitest-environment jsdom */
describe("TextNode", () => {
test("isTextNode should return true when the passed node is a Text", () => {
expect(isTextNode(new Text("Hello, World!"))).toBe(true);
expect(isTextNode(Infinity)).toBe(false);
expect(isTextNode(true)).toBe(false);
expect(isTextNode("hola")).toBe(false);
expect(isTextNode({})).toBe(false);
expect(isTextNode([])).toBe(false);
expect(() => isTextNode(undefined)).toThrowError("Invalid text node");
expect(() => isTextNode(null)).toThrowError("Invalid text node");
expect(() => isTextNode(0)).toThrowError("Invalid text node");
});
test("getTextNodeLength should return the length of the text node or 0 if it is a <br>", () => {
expect(getTextNodeLength(new Text("Hello, World!"))).toBe(13);
expect(getTextNodeLength(createLineBreak())).toBe(0);
expect(() => getTextNodeLength(undefined)).toThrowError(
"Invalid text node",
);
expect(() => getTextNodeLength(null)).toThrowError("Invalid text node");
expect(() => getTextNodeLength(0)).toThrowError("Invalid text node");
});
});

View File

@@ -0,0 +1,250 @@
/**
* 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
*/
/**
* Iterator direction.
*
* @enum {number}
*/
export const TextNodeIteratorDirection = {
FORWARD: 1,
BACKWARD: 0,
};
/**
* TextNodeIterator
*/
export class TextNodeIterator {
/**
* Returns if a specific node is a text node.
*
* @param {Node} node
* @returns {boolean}
*/
static isTextNode(node) {
return (
node.nodeType === Node.TEXT_NODE ||
(node.nodeType === Node.ELEMENT_NODE && node.nodeName === "BR")
);
}
/**
* Returns if a specific node is a container node.
*
* @param {Node} node
* @returns {boolean}
*/
static isContainerNode(node) {
return node.nodeType === Node.ELEMENT_NODE && node.nodeName !== "BR";
}
/**
* Finds a node from an initial node and down the tree.
*
* @param {Node} startNode
* @param {Node} rootNode
* @param {Set<Node>} skipNodes
* @param {number} direction
* @returns {Node}
*/
static findDown(
startNode,
rootNode,
skipNodes = new Set(),
direction = TextNodeIteratorDirection.FORWARD
) {
if (startNode === rootNode) {
return TextNodeIterator.findDown(
direction === TextNodeIteratorDirection.FORWARD
? startNode.firstChild
: startNode.lastChild,
rootNode,
skipNodes,
direction
);
}
// NOTE: This should not use the SafeGuard
// module.
let safeGuard = Date.now();
let currentNode = startNode;
while (currentNode) {
if (Date.now() - safeGuard >= 1000) {
throw new Error("Iteration timeout");
}
if (skipNodes.has(currentNode)) {
currentNode =
direction === TextNodeIteratorDirection.FORWARD
? currentNode.nextSibling
: currentNode.previousSibling;
continue;
}
if (TextNodeIterator.isTextNode(currentNode)) {
return currentNode;
} else if (TextNodeIterator.isContainerNode(currentNode)) {
return TextNodeIterator.findDown(
direction === TextNodeIteratorDirection.FORWARD
? currentNode.firstChild
: currentNode.lastChild,
rootNode,
skipNodes,
direction
);
}
currentNode =
direction === TextNodeIteratorDirection.FORWARD
? currentNode.nextSibling
: currentNode.previousSibling;
}
return null;
}
/**
* Finds a node from an initial node and up the tree.
*
* @param {Node} startNode
* @param {Node} rootNode
* @param {Set} backTrack
* @param {number} direction
* @returns {Node}
*/
static findUp(
startNode,
rootNode,
backTrack = new Set(),
direction = TextNodeIteratorDirection.FORWARD
) {
backTrack.add(startNode);
if (TextNodeIterator.isTextNode(startNode)) {
return TextNodeIterator.findUp(
startNode.parentNode,
rootNode,
backTrack,
direction
);
} else if (TextNodeIterator.isContainerNode(startNode)) {
const found = TextNodeIterator.findDown(
startNode,
rootNode,
backTrack,
direction
);
if (found) {
return found;
}
if (startNode !== rootNode) {
return TextNodeIterator.findUp(
startNode.parentNode,
rootNode,
backTrack,
direction
);
}
}
return null;
}
/**
* This is the root text node.
*
* @type {HTMLElement}
*/
#rootNode = null;
/**
* This is the current text node.
*
* @type {Text|null}
*/
#currentNode = null;
/**
* Constructor
*
* @param {HTMLElement} rootNode
*/
constructor(rootNode) {
if (!(rootNode instanceof HTMLElement)) {
throw new TypeError("Invalid root node");
}
this.#rootNode = rootNode;
this.#currentNode = TextNodeIterator.findDown(rootNode, rootNode);
}
/**
* Current node we're into.
*
* @type {TextNode|HTMLBRElement}
*/
get currentNode() {
return this.#currentNode;
}
set currentNode(newCurrentNode) {
const isContained =
(newCurrentNode.compareDocumentPosition(this.#rootNode) &
Node.DOCUMENT_POSITION_CONTAINS) ===
Node.DOCUMENT_POSITION_CONTAINS;
if (
!(newCurrentNode instanceof Node) ||
!TextNodeIterator.isTextNode(newCurrentNode) ||
!isContained
) {
throw new TypeError("Invalid new current node");
}
this.#currentNode = newCurrentNode;
}
/**
* Returns the next Text node or <br> element or null if there are.
*
* @returns {Text|HTMLBRElement}
*/
nextNode() {
if (!this.#currentNode) return null;
const nextNode = TextNodeIterator.findUp(
this.#currentNode,
this.#rootNode,
new Set(),
TextNodeIteratorDirection.FORWARD
);
if (!nextNode) {
return null;
}
this.#currentNode = nextNode;
return this.#currentNode;
}
/**
* Returns the previous Text node or <br> element or null.
*
* @returns {Text|HTMLBRElement}
*/
previousNode() {
if (!this.#currentNode) return null;
const previousNode = TextNodeIterator.findUp(
this.#currentNode,
this.#rootNode,
new Set(),
TextNodeIteratorDirection.BACKWARD
);
if (!previousNode) {
return null;
}
this.#currentNode = previousNode;
return this.#currentNode;
}
}
export default TextNodeIterator;

View File

@@ -0,0 +1,73 @@
import { describe, test, expect } from "vitest";
import TextNodeIterator from "./TextNodeIterator.js";
import { createInline } from "./Inline.js";
import { createParagraph } from "./Paragraph.js";
import { createRoot } from "./Root.js";
import { createLineBreak } from "./LineBreak.js";
/* @vitest-environment jsdom */
describe("TextNodeIterator", () => {
test("Create a new TextNodeIterator with an invalid root should throw", () => {
expect(() => new TextNodeIterator(null)).toThrowError("Invalid root node");
expect(() => new TextNodeIterator(Infinity)).toThrowError(
"Invalid root node",
);
expect(() => new TextNodeIterator(1)).toThrowError("Invalid root node");
expect(() => new TextNodeIterator("hola")).toThrowError(
"Invalid root node",
);
});
test("Create a new TextNodeIterator and iterate only over text nodes", () => {
const rootNode = createRoot([
createParagraph([
createInline(new Text("Hello, ")),
createInline(new Text("World!")),
createInline(new Text("Whatever")),
]),
createParagraph([createInline(createLineBreak())]),
createParagraph([
createInline(new Text("This is a ")),
createInline(new Text("test")),
]),
createParagraph([createInline(new Text("Hi!"))]),
]);
const textNodeIterator = new TextNodeIterator(rootNode);
expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, ");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeType).toBe(Node.ELEMENT_NODE);
expect(textNodeIterator.currentNode.nodeName).toBe("BR");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("This is a ");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("test");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Hi!");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("test");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("This is a ");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeType).toBe(Node.ELEMENT_NODE);
expect(textNodeIterator.currentNode.nodeName).toBe("BR");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, ");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
textNodeIterator.previousNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Hello, ");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("World!");
textNodeIterator.nextNode();
expect(textNodeIterator.currentNode.nodeValue).toBe("Whatever");
});
});

View File

@@ -0,0 +1,92 @@
/**
* 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
*/
/**
* Change controller is responsible of notifying when a change happens.
*/
export class ChangeController extends EventTarget {
/**
* Keeps the timeout id.
*
* @type {number}
*/
#timeout = null;
/**
* Keeps the time at which we're going to
* call the debounced change calls.
*
* @type {number}
*/
#time = 1000;
/**
* Keeps if we have some pending changes or not.
*
* @type {boolean}
*/
#hasPendingChanges = false;
/**
* Constructor
*
* @param {number} [time=500]
*/
constructor(time = 500) {
super()
if (typeof time === "number" && (!Number.isInteger(time) || time <= 0)) {
throw new TypeError("Invalid time");
}
this.#time = time ?? 500;
}
/**
* Indicates that there are some pending changes.
*
* @type {boolean}
*/
get hasPendingChanges() {
return this.#hasPendingChanges;
}
#onTimeout = () => {
this.dispatchEvent(new Event("change"));
};
/**
* Tells the ChangeController that a change has been made
* but that you need to delay the notification (and debounce)
* for sometime.
*/
notifyDebounced() {
this.#hasPendingChanges = true;
clearTimeout(this.#timeout);
this.#timeout = setTimeout(this.#onTimeout, this.#time);
}
/**
* Tells the ChangeController that a change should be notified
* immediately.
*/
notifyImmediately() {
clearTimeout(this.#timeout);
this.#onTimeout();
}
/**
* Disposes the referenced resources.
*/
dispose() {
if (this.hasPendingChanges) {
this.notifyImmediately();
}
clearTimeout(this.#timeout);
}
}
export default ChangeController;

View File

@@ -0,0 +1,36 @@
import { expect, describe, test, vi } from "vitest";
import ChangeController from "./ChangeController.js";
describe("ChangeController", () => {
test("Creating a ChangeController without a valid time should throw", () => {
expect(() => new ChangeController(Infinity)).toThrowError("Invalid time");
});
test("A ChangeController should dispatch an event when `notifyImmediately` is called", () => {
const changeListener = vi.fn();
const changeController = new ChangeController(10);
changeController.addEventListener("change", changeListener);
changeController.notifyImmediately();
expect(changeController.hasPendingChanges).toBe(false);
expect(changeListener).toBeCalled(1);
});
test("A ChangeController should dispatch an event when `notifyDebounced` is called", async () => {
return new Promise((resolve) => {
const changeController = new ChangeController(10);
changeController.addEventListener("change", () => resolve());
changeController.notifyDebounced();
expect(changeController.hasPendingChanges).toBe(true);
});
});
test("A ChangeController should dispatch an event when `notifyDebounced` is called and disposed is called right after", async () => {
return new Promise((resolve) => {
const changeController = new ChangeController(10);
changeController.addEventListener("change", () => resolve());
changeController.notifyDebounced();
expect(changeController.hasPendingChanges).toBe(true);
changeController.dispose();
});
});
});

View File

@@ -0,0 +1,34 @@
/**
* Max. amount of time we should allow.
*
* @type {number}
*/
const SAFE_GUARD_TIME = 1000;
/**
* Time at which the safeguard started.
*
* @type {number}
*/
let startTime = Date.now();
/**
* Marks the start of the safeguard.
*/
export function start() {
startTime = Date.now();
}
/**
* Checks if the safeguard should throw.
*/
export function update() {
if (Date.now - startTime >= SAFE_GUARD_TIME) {
throw new Error('Safe guard timeout');
}
}
export default {
start,
update,
}

View File

File diff suppressed because it is too large Load Diff

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
/**
* Indicates the direction of the selection.
*
* @readonly
* @enum {number}
*/
export const SelectionDirection = {
/** The anchorNode is behind the focusNode */
FORWARD: 1,
/** The focusNode and the anchorNode are collapsed */
NONE: 0,
/** The focusNode is behind the anchorNode */
BACKWARD: -1,
};
export default SelectionDirection;

View File

@@ -0,0 +1,75 @@
/**
* 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
*/
/**
* Used for handling debugging.
*/
export class SelectionControllerDebug {
/**
* @type {Object.<string, HTMLElement>}
*/
#elements = null;
/**
* Constructor
*
* @param {Object.<string, HTMLElement>} elements List of elements used to debug the SelectionController
*/
constructor(elements) {
this.#elements = elements;
}
getNodeDescription(node, offset) {
if (!node) return "null";
return `${node.nodeName} ${
node.nodeType === Node.TEXT_NODE
? node.nodeValue + (typeof offset === "number" ? `(${offset})` : "")
: node.dataset.itype
}`;
}
update(selectionController) {
this.#elements.direction.value = selectionController.direction;
this.#elements.multiElement.checked = selectionController.isMulti;
this.#elements.multiInlineElement.checked =
selectionController.isMultiInline;
this.#elements.multiParagraphElement.checked =
selectionController.isMultiParagraph;
this.#elements.isParagraphStart.checked =
selectionController.isParagraphStart;
this.#elements.isParagraphEnd.checked = selectionController.isParagraphEnd;
this.#elements.isInlineStart.checked = selectionController.isInlineStart;
this.#elements.isInlineEnd.checked = selectionController.isInlineEnd;
this.#elements.isTextAnchor.checked = selectionController.isTextAnchor;
this.#elements.isTextFocus.checked = selectionController.isTextFocus;
this.#elements.focusNode.value = this.getNodeDescription(
selectionController.focusNode,
selectionController.focusOffset
);
this.#elements.focusOffset.value = selectionController.focusOffset;
this.#elements.anchorNode.value = this.getNodeDescription(
selectionController.anchorNode,
selectionController.anchorOffset
);
this.#elements.anchorOffset.value = selectionController.anchorOffset;
this.#elements.focusInline.value = this.getNodeDescription(
selectionController.focusInline
);
this.#elements.anchorInline.value = this.getNodeDescription(
selectionController.anchorInline
);
this.#elements.focusParagraph.value = this.getNodeDescription(
selectionController.focusParagraph
);
this.#elements.anchorParagraph.value = this.getNodeDescription(
selectionController.anchorParagraph
);
this.#elements.startContainer.value = this.getNodeDescription(selectionController.startContainer);
this.#elements.endContainer.value = this.getNodeDescription(selectionController.endContainer);
}
}

View File

@@ -0,0 +1,19 @@
/**
* 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
*/
/**
* Enumeration of types of layout.
*
* @enum {string}
*/
export const LayoutType = {
FULL: "full",
PARTIAL: "partial",
};
export default LayoutType;

View File

@@ -0,0 +1,31 @@
/**
* 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
*/
/**
* Creates a new selection imposter from a list of client rects.
*
* @param {DOMRect} referenceRect
* @param {DOMRectList} clientRects
* @returns {DocumentFragment}
*/
export function createSelectionImposterFromClientRects(
referenceRect,
clientRects
) {
const fragment = document.createDocumentFragment();
for (const rect of clientRects) {
const rectElement = document.createElement("div");
rectElement.className = "selection-imposter-rect";
rectElement.style.left = `${rect.x - referenceRect.x}px`;
rectElement.style.top = `${rect.y - referenceRect.y}px`;
rectElement.style.width = `${rect.width}px`;
rectElement.style.height = `${rect.height}px`;
fragment.appendChild(rectElement);
}
return fragment;
}

View File

@@ -0,0 +1,14 @@
import { expect, test } from "vitest";
import { createSelectionImposterFromClientRects } from "./Imposter.js";
/* @vitest-environment jsdom */
test("Create selection DOM rects from client rects", () => {
const rect = new DOMRect(20, 20, 100, 50);
const clientRects = [
new DOMRect(20, 20, 100, 20),
new DOMRect(20, 50, 50, 20),
];
const fragment = createSelectionImposterFromClientRects(rect, clientRects);
expect(fragment).toBeInstanceOf(DocumentFragment);
expect(fragment.childNodes).toHaveLength(2);
});

View File

@@ -0,0 +1,226 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&family=Playwrite+ES:wght@100..400&family=Playwrite+NZ:wght@100..400&family=Playwrite+US+Trad:wght@100..400&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Penpot - Text Editor Playground</title>
<style>
#output {
white-space: pre-wrap;
}
</style>
</head>
<body>
<form>
<fieldset>
<legend>Styles</legend>
<!-- Font -->
<div class="form-group">
<label for="font-family">Font family</label>
<select id="font-family">
<option value="Open+Sans">Open Sans</option>
<option value="sourcesanspro">Source Sans Pro</option>
<option value="whatever">Whatever</option>
</select>
</div>
<div class="form-group">
<label for="font-size">Font size</label>
<input id="font-size" type="number" value="14" />
</div>
<div class="form-group">
<label for="font-weight">Font weight</label>
<select id="font-weight">
<option value="100">100</option>
<option value="200">200</option>
<option value="300">300</option>
<option value="400">400 (normal)</option>
<option value="500">500</option>
<option value="600">600</option>
<option value="700">700 (bold)</option>
<option value="800">800</option>
<option value="900">900</option>
</select>
</div>
<div class="form-group">
<label for="font-style">Font style</label>
<select id="font-style">
<option value="normal">normal</option>
<option value="italic">italic</option>
<option value="oblique">oblique</option>
</select>
</div>
<!-- Text attributes -->
<div class="form-group">
<label for="line-height">Line height</label>
<input id="line-height" type="number" value="1.0" />
</div>
<div class="form-group">
<label for="letter-spacing">Letter spacing</label>
<input id="letter-spacing" type="number" value="0.0" />
</div>
<div class="form-group">
<label for="direction-ltr">LTR</label>
<input id="direction-ltr" type="radio" name="direction" value="ltr" checked />
</div>
<div class="form-group">
<label for="direction-rtl">RTL</label>
<input id="direction-rtl" type="radio" name="direction" value="rtl" />
</div>
<!-- Text Align -->
<div class="form-group">
<label for="text-align-left">Align left</label>
<input id="text-align-left" type="radio" name="text-align" value="left" checked />
</div>
<div class="form-group">
<label for="text-align-center">Align center</label>
<input id="text-align-center" type="radio" name="text-align" value="center" />
</div>
<div class="form-group">
<label for="text-align-right">Align right</label>
<input id="text-align-right" type="radio" name="text-align" value="right" />
</div>
<div class="form-group">
<label for="text-align-justify">Align justify</label>
<input id="text-align-justify" type="radio" name="text-align" value="justify" />
</div>
<!-- Text Transform -->
<div class="form-group">
<label for="text-transform-none">None</label>
<input id="text-transform-none" type="radio" name="text-transform" value="none" checked />
</div>
<div class="form-group">
<label for="text-transform-uppercase">Uppercase</label>
<input id="text-transform-uppercase" type="radio" name="text-transform" value="uppercase" checked />
</div>
<div class="form-group">
<label for="text-transform-capitalize">Capitalize</label>
<input id="text-transform-capitalize" type="radio" name="text-transform" value="capitalize" />
</div>
<div class="form-group">
<label for="text-transform-lowercase">Lowercase</label>
<input id="text-transform-lowercase" type="radio" name="text-transform" value="lowercase" />
</div>
</fieldset>
<fieldset>
<legend>Debug</legend>
<div class="form-group">
<label for="direction">Direction</label>
<input id="direction" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-node">Focus Node</label>
<input id="focus-node" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-offset">Focus offset</label>
<input id="focus-offset" readonly type="number">
</div>
<div class="form-group">
<label for="focus-inline">Focus Inline</label>
<input id="focus-inline" readonly type="text" />
</div>
<div class="form-group">
<label for="focus-paragraph">Focus Paragraph</label>
<input id="focus-paragraph" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-node">Anchor Node</label>
<input id="anchor-node" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-offset">Anchor offset</label>
<input id="anchor-offset" readonly type="number">
</div>
<div class="form-group">
<label for="anchor-inline">Anchor Inline</label>
<input id="anchor-inline" readonly type="text" />
</div>
<div class="form-group">
<label for="anchor-paragraph">Anchor Paragraph</label>
<input id="anchor-paragraph" readonly type="text" />
</div>
<div class="form-group">
<label for="start-container">Start container</label>
<input id="start-container" readonly type="text" />
</div>
<div class="form-group">
<label for="start-offset">Start offset</label>
<input id="start-offset" readonly type="text" />
</div>
<div class="form-group">
<label for="end-container">End container</label>
<input id="end-container" readonly type="text" />
</div>
<div class="form-group">
<label for="end-offset">End offset</label>
<input id="end-offset" readonly type="text" />
</div>
<div class="form-group">
<label for="multi">Multi?</label>
<input id="multi" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="multi-inline">Multi inline?</label>
<input id="multi-inline" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="multi-paragraph">Multi paragraph?</label>
<input id="multi-paragraph" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-text-focus">Is text focus?</label>
<input id="is-text-focus" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-text-anchor">Is text anchor?</label>
<input id="is-text-anchor" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-paragraph-start">Is paragraph start?</label>
<input id="is-paragraph-start" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-paragraph-end">Is paragraph end?</label>
<input id="is-paragraph-end" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-inline-start">Is inline start?</label>
<input id="is-inline-start" readonly type="checkbox" />
</div>
<div class="form-group">
<label for="is-inline-end">Is inline end?</label>
<input id="is-inline-end" readonly type="checkbox">
</div>
</fieldset>
</form>
<!--
Editor
-->
<div class="text-editor-container align-top">
<div
id="text-editor-selection-imposter"
class="text-editor-selection-imposter"></div>
<div
class="text-editor-content"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-autocomplete="none"
spellcheck="false"
autocapitalize="false"></div>
</div>
<!--
Text output
-->
<div id="output"></div>
<script type="module" src="/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,216 @@
/**
* 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 "./style.css";
import "./editor/TextEditor.css";
import { TextEditor } from "./editor/TextEditor";
import { SelectionControllerDebug } from "./editor/debug/SelectionControllerDebug";
const searchParams = new URLSearchParams(location.search);
const debug = searchParams.has("debug")
? searchParams.get("debug").split(",")
: [];
const textEditorSelectionImposterElement = document.getElementById(
"text-editor-selection-imposter"
);
const textEditorElement = document.querySelector(".text-editor-content");
const textEditor = new TextEditor(textEditorElement, {
styleDefaults: {
"font-family": "sourcesanspro",
"font-size": "14",
"font-weight": "400",
"font-style": "normal",
"line-height": "1.2",
"letter-spacing": "0",
"direction": "ltr",
"text-align": "left",
"text-transform": "none",
"text-decoration": "none",
"--typography-ref-id": '["~#\'",null]',
"--typography-ref-file": '["~#\'",null]',
"--font-id": '["~#\'","sourcesanspro"]',
"--fills": '[["^ ","~:fill-color","#000000","~:fill-opacity",1]]'
},
selectionImposterElement: textEditorSelectionImposterElement,
debug: new SelectionControllerDebug({
direction: document.getElementById("direction"),
multiElement: document.getElementById("multi"),
multiInlineElement: document.getElementById("multi-inline"),
multiParagraphElement: document.getElementById("multi-paragraph"),
isParagraphStart: document.getElementById("is-paragraph-start"),
isParagraphEnd: document.getElementById("is-paragraph-end"),
isInlineStart: document.getElementById("is-inline-start"),
isInlineEnd: document.getElementById("is-inline-end"),
isTextAnchor: document.getElementById("is-text-anchor"),
isTextFocus: document.getElementById("is-text-focus"),
focusNode: document.getElementById("focus-node"),
focusOffset: document.getElementById("focus-offset"),
focusInline: document.getElementById("focus-inline"),
focusParagraph: document.getElementById("focus-paragraph"),
anchorNode: document.getElementById("anchor-node"),
anchorOffset: document.getElementById("anchor-offset"),
anchorInline: document.getElementById("anchor-inline"),
anchorParagraph: document.getElementById("anchor-paragraph"),
startContainer: document.getElementById("start-container"),
startOffset: document.getElementById("start-offset"),
endContainer: document.getElementById("end-container"),
endOffset: document.getElementById("end-offset"),
}),
});
const fontFamilyElement = document.getElementById("font-family");
const fontSizeElement = document.getElementById("font-size");
const fontWeightElement = document.getElementById("font-weight");
const fontStyleElement = document.getElementById("font-style");
const directionLTRElement = document.getElementById("direction-ltr");
const directionRTLElement = document.getElementById("direction-rtl");
const lineHeightElement = document.getElementById("line-height");
const letterSpacingElement = document.getElementById("letter-spacing");
const textAlignLeftElement = document.getElementById("text-align-left");
const textAlignCenterElement = document.getElementById("text-align-center");
const textAlignRightElement = document.getElementById("text-align-right");
const textAlignJustifyElement = document.getElementById("text-align-justify");
function onDirectionChange(e) {
if (debug.includes("events")) {
console.log(e);
}
if (e.target.checked) {
textEditor.applyStylesToSelection({
"direction": e.target.value
});
}
}
directionLTRElement.addEventListener("change", onDirectionChange);
directionRTLElement.addEventListener("change", onDirectionChange);
function onTextAlignChange(e) {
if (debug.includes("events")) {
console.log(e);
}
if (e.target.checked) {
textEditor.applyStylesToSelection({
"text-align": e.target.value
});
}
}
textAlignLeftElement.addEventListener("change", onTextAlignChange);
textAlignCenterElement.addEventListener("change", onTextAlignChange);
textAlignRightElement.addEventListener("change", onTextAlignChange);
textAlignJustifyElement.addEventListener("change", onTextAlignChange);
fontFamilyElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"font-family": e.target.value,
});
});
fontWeightElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"font-weight": e.target.value,
});
});
fontSizeElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"font-size": e.target.value,
});
});
lineHeightElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"line-height": e.target.value
})
})
letterSpacingElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"letter-spacing": e.target.value
})
})
fontStyleElement.addEventListener("change", (e) => {
if (debug.includes("events")) {
console.log(e);
}
textEditor.applyStylesToSelection({
"font-style": e.target.value,
});
});
function formatHTML(html, options) {
const spaces = options?.spaces ?? 4;
let indent = 0;
return html.replace(/<\/?(.*?)>/g, (fullMatch) => {
let str = fullMatch + "\n";
if (fullMatch.startsWith("</")) {
--indent;
str = " ".repeat(indent * spaces) + str;
} else {
str = " ".repeat(indent * spaces) + str;
++indent;
if (fullMatch === "<br>") --indent;
}
return str;
});
}
const outputElement = document.getElementById("output");
textEditorElement.addEventListener("input", (e) => {
if (debug.includes("events")) {
console.log(e);
}
outputElement.textContent = formatHTML(textEditor.element.innerHTML);
});
textEditor.addEventListener("stylechange", (e) => {
if (debug.includes("events")) {
console.log(e);
}
const fontSize = parseInt(e.detail.getPropertyValue("font-size"), 10);
const fontWeight = e.detail.getPropertyValue("font-weight");
const fontStyle = e.detail.getPropertyValue("font-style");
const fontFamily = e.detail.getPropertyValue("font-family");
fontFamilyElement.value = fontFamily;
fontSizeElement.value = fontSize;
fontStyleElement.value = fontStyle;
fontWeightElement.value = fontWeight;
const textAlign = e.detail.getPropertyValue("text-align");
textAlignLeftElement.checked = textAlign === "left";
textAlignCenterElement.checked = textAlign === "center";
textAlignRightElement.checked = textAlign === "right";
textAlignJustifyElement.checked = textAlign === "justify";
const direction = e.detail.getPropertyValue("direction");
directionLTRElement.checked = direction === "ltr";
directionRTLElement.checked = direction === "rtl";
});

View File

@@ -0,0 +1,14 @@
:root {
background-color: #333;
color: #eee;
}
.text-editor-container {
background-color: white;
}
#output {
font-family: monospace;
padding: 1rem;
border: 1px solid #333;
}

View File

@@ -0,0 +1,127 @@
import { createRoot } from "../editor/content/dom/Root.js";
import { createParagraph } from "../editor/content/dom/Paragraph.js";
import { createEmptyInline, createInline } from "../editor/content/dom/Inline.js";
import { createLineBreak } from "../editor/content/dom/LineBreak.js";
export class TextEditorMock extends EventTarget {
/**
* Returns the template used for the text editor mock.
*
* @returns {HTMLDivElement}
*/
static getTemplate() {
const container = document.createElement("div");
container.id = "test";
container.innerHTML = `<div class="text-editor-container align-top">
<div
id="text-editor-selection-imposter"
class="text-editor-selection-imposter"></div>
<div
class="text-editor-content"
contenteditable="true"
role="textbox"
aria-multiline="true"
aria-autocomplete="none"
spellcheck="false"
autocapitalize="false"></div>
</div>`;
document.body.appendChild(container);
return container;
}
/**
* Creates an editor with a custom root.
*
* @param {HTMLDivElement} root
* @returns {HTMLDivElement}
*/
static createTextEditorMockWithRoot(root) {
const container = TextEditorMock.getTemplate();
const selectionImposterElement = container.querySelector(
".text-editor-selection-imposter"
);
const textEditorMock = new TextEditorMock(
container.querySelector(".text-editor-content"),
{
root,
selectionImposterElement,
}
);
return textEditorMock;
}
/**
* Creates a TextEditor mock with paragraphs.
*
* @param {Array<HTMLDivElement>} paragraphs
* @returns
*/
static createTextEditorMockWithParagraphs(paragraphs) {
const root = createRoot(paragraphs);
return this.createTextEditorMockWithRoot(root);
}
/**
* Creates an empty TextEditor mock.
*
* @returns
*/
static createTextEditorMockEmpty() {
const root = createRoot([
createParagraph([createInline(createLineBreak())]),
]);
return this.createTextEditorMockWithRoot(root);
}
/**
* Creates a TextEditor mock with some text.
*
* NOTE: If the text is empty an empty inline will be
* created.
*
* @param {string} text
* @returns
*/
static createTextEditorMockWithText(text) {
return this.createTextEditorMockWithParagraphs([
createParagraph([
text.length === 0
? createEmptyInline()
: createInline(new Text(text))
]),
]);
}
/**
* Creates a TextEditor mock with some inlines and
* only one paragraph.
*
* @param {Array<HTMLSpanElement>} inlines
* @returns
*/
static createTextEditorMockWithParagraph(inlines) {
return this.createTextEditorMockWithParagraphs([createParagraph(inlines)]);
}
#element = null;
#root = null;
#selectionImposterElement = null;
constructor(element, options) {
super();
this.#element = element;
this.#root = options?.root;
this.#selectionImposterElement = options?.selectionImposterElement;
this.#element.appendChild(options?.root);
}
get element() {
return this.#element;
}
get root() {
return this.#root;
}
}
export default TextEditorMock;

Some files were not shown because too many files have changed in this diff Show More