Compare commits

...

18 Commits

Author SHA1 Message Date
Andrey Antukh
0fbe52e062 WIP 2025-11-17 23:01:00 +01:00
Andrey Antukh
e600d23bed WIP 2025-11-17 22:49:24 +01:00
Andrey Antukh
c3bf31e199 WIP: fix 2025-11-17 21:42:03 +01:00
Andrey Antukh
0788163744 WIP ci 2025-11-17 19:31:04 +01:00
Andrey Antukh
835f0fae6a WIP: ci 2025-11-17 19:20:36 +01:00
Andrey Antukh
50368c5dd8 WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
2bbc746919 WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
12e88da901 WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
2ba27314d0 WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
80b86c81c1 WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
ef12a28411 WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
8aa523c6cc WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
42e17481ae WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
aab79bdfb3 WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
0fbce5eb17 WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
4ae5f8da94 WIP 2025-11-17 19:19:08 +01:00
Andrey Antukh
5b3f1e41e4 WIP 2025-11-17 19:19:08 +01:00
Aitor Moreno
f811198b07 ♻️ Refactor clipboard 2025-11-17 19:19:08 +01:00
34 changed files with 2574 additions and 2189 deletions

View File

@@ -114,7 +114,7 @@ jobs:
# uses the same cache as this task so we prepopulate it
command: |
yarn install
yarn run playwright install chromium
yarn run playwright install chromium --with-deps
- run:
name: "lint scss on frontend"
@@ -249,8 +249,8 @@ jobs:
name: "integration tests"
working_directory: "./frontend"
command: |
yarn run playwright install chromium
yarn run test:e2e -x --workers=4
yarn run playwright install chromium --with-deps
yarn run test:e2e -x --workers=4 --reporter=line
test-backend:
docker:

59
.github/workflows/test-integration.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: "Integration Tests"
on:
pull_request:
types:
- opened
- edited
- reopened
- synchronize
pull_request_target:
types:
- opened
- edited
- reopened
- synchronize
push:
branches:
- main
- develop
- staging
jobs:
test-integration:
name: "test1"
runs-on: ubuntu-24.04
container: penpotapp/devenv:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Bundle
working-directory: ./frontend
run: |
yarn install
yarn run build:app:assets
yarn run build:app
yarn run build:app:libs
- name: Build WASM
working-directory: "./render-wasm"
run: |
EMSDK_QUIET=1 . /opt/emsdk/emsdk_env.sh
./build release
- name: Build Bundle
working-directory: ./frontend
run: |
yarn run playwright install chromium --with-deps
yarn run test:e2e -x --workers=4 --reporter=line
- name: Upload test result
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: frontend/test-results/
overwrite: true
retention-days: 7

View File

@@ -90,6 +90,10 @@
[{:fill-color clr/black
:fill-opacity 1}])
(def default-paragraph-attrs
{:text-align "left"
:text-direction "ltr"})
(def default-text-attrs
{:font-id "sourcesanspro"
:font-family "sourcesanspro"

View File

@@ -82,7 +82,7 @@
"nodemon": "^3.1.10",
"npm-run-all": "^4.1.5",
"p-limit": "^6.2.0",
"playwright": "1.52.0",
"playwright": "1.56.1",
"postcss": "^8.5.4",
"postcss-clean": "^1.2.2",
"prettier": "3.5.3",

View File

@@ -11,15 +11,19 @@ import { defineConfig, devices } from "@playwright/test";
*/
export default defineConfig({
testDir: "./playwright",
outputDir: './test-results',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
forbidOnly: false,
// forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests by default; can be overriden with --workers */
workers: 1,
/* Timeout for expects (longer in CI) */
timeout: 40000,
expect: {
timeout: process.env.CI ? 20000 : 5000,
},
@@ -45,6 +49,10 @@ export default defineConfig({
name: "default",
use: { ...devices["Desktop Chrome"] },
testDir: "./playwright/ui/specs",
use: {
video: 'retain-on-failure',
trace: 'on'
}
},
{
name: "ds",

View File

File diff suppressed because it is too large Load Diff

View File

@@ -1,39 +0,0 @@
{
"~:id": "~ue179d9df-de35-80bf-8005-2861e849b3f7",
"~:file-id": "~ue179d9df-de35-80bf-8005-283bbd5516b0",
"~:created-at": "~m1729604566293",
"~:data": {
"~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8": {
"~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe38dba8",
"~:name": "F",
"~:path": "",
"~:modified-at": "~m1729604566311",
"~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcc",
"~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1"
},
"~u6ad3e6b9-c5a0-80cf-8005-283bbe39bb51": {
"~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe39bb51",
"~:name": "E",
"~:path": "",
"~:modified-at": "~m1729604566311",
"~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcd",
"~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1"
},
"~u6ad3e6b9-c5a0-80cf-8005-283bbe3a9014": {
"~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3a9014",
"~:name": "C",
"~:path": "",
"~:modified-at": "~m1729604566311",
"~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bcf",
"~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1"
},
"~u6ad3e6b9-c5a0-80cf-8005-283bbe3b1793": {
"~:id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe3b1793",
"~:name": "B",
"~:path": "",
"~:modified-at": "~m1729604566311",
"~:main-instance-id": "~u6ad3e6b9-c5a0-80cf-8005-283bbe378bd0",
"~:main-instance-page": "~ue179d9df-de35-80bf-8005-283bbd5516b1"
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,6 @@
"~:revn": 2,
"~:created-at": "~m1730199694953",
"~:created-by": "user",
"~:profile-id": "~u4678a621-b446-818a-8004-e7b734def799"
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b"
}
]

View File

@@ -5,6 +5,6 @@
"~:revn": 2,
"~:created-at": "~m1730199694953",
"~:created-by": "user",
"~:profile-id": "~u4678a621-b446-818a-8004-e7b734def799"
"~:profile-id": "~uc7ce0794-0992-8105-8004-38e630f29a9b"
}
]

View File

@@ -35,27 +35,61 @@ const setupVariantsFileWithVariant = async (workspacePage) => {
await workspacePage.clickLeafLayer("Rectangle");
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.waitForTimeout(500);
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.waitForTimeout(500);
// We wait until layer-row starts looking like it an component
await workspacePage.page.getByTestId("layer-row")
.filter({ hasText: "Rectangle"})
.getByTestId("icon-component")
.waitFor();
};
const findVariant = async (workspacePage, num_variant) => {
const container = await workspacePage.layers
.getByTestId("layer-row")
.filter({ has: workspacePage.page.getByText("Rectangle") })
.filter({ has: workspacePage.page.getByTestId("icon-component") })
.nth(num_variant);
const findVariant = async (workspacePage, index) => {
const container = workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Rectangle"})
.filter({ has: workspacePage.page.getByTestId("icon-component") })
.nth(index);
const variant1 = await workspacePage.layers
.getByTestId("layer-row")
.filter({ has: workspacePage.page.getByText("Value 1") })
.filter({ has: workspacePage.page.getByTestId("icon-variant") })
.nth(num_variant);
const variant1 = workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Value 1" })
.filter({ has: workspacePage.page.getByTestId("icon-variant") })
.nth(index);
const variant2 = await workspacePage.layers
.getByTestId("layer-row")
.filter({ has: workspacePage.page.getByText("Value 2") })
.filter({ has: workspacePage.page.getByTestId("icon-variant") })
.nth(num_variant);
const variant2 = workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Value 2" })
.filter({ has: workspacePage.page.getByTestId("icon-variant") })
.nth(index);
await container.waitFor();
return {
container: container,
variant1: variant1,
variant2: variant2,
};
};
const findVariantNoWait = (workspacePage, index) => {
const container = workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Rectangle"})
.filter({ has: workspacePage.page.getByTestId("icon-component") })
.nth(index);
const variant1 = workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Value 1" })
.nth(index);
const variant2 = workspacePage.layers
.getByTestId("layer-row")
.filter({ hasText: "Value 2" })
.nth(index);
return {
container: container,
@@ -138,27 +172,33 @@ test("User copy paste a variant container", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupVariantsFileWithVariant(workspacePage);
const variant = await findVariant(workspacePage, 0);
const variant = findVariantNoWait(workspacePage, 0);
// await variant.container.waitFor();
// Select the variant container
await variant.container.click();
//Copy the variant container
await workspacePage.page.waitForTimeout(1000);
// Copy the variant container
await workspacePage.page.keyboard.press("Control+c");
//Paste the variant container
await workspacePage.clickAt(500, 500);
// Paste the variant container
await workspacePage.clickAt(400,400);
await workspacePage.page.keyboard.press("Control+v");
const variant_original = await findVariant(workspacePage, 1);
const variant_duplicate = await findVariant(workspacePage, 0);
const variantDuplicate = findVariantNoWait(workspacePage, 0);
const variantOriginal = findVariantNoWait(workspacePage, 1);
// Expand the layers
await variant_duplicate.container.getByRole("button").first().click();
await variantDuplicate.container.waitFor();
await variantDuplicate.container.locator("button").first().click();
// The variants are valid
await validateVariant(variant_original);
await validateVariant(variant_duplicate);
// // The variants are valid
// // await variantOriginal.container.waitFor();
await validateVariant(variantOriginal);
await validateVariant(variantDuplicate);
});
test("User cut paste a variant container", async ({ page }) => {
@@ -172,21 +212,23 @@ test("User cut paste a variant container", async ({ page }) => {
//Cut the variant container
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
//Paste the variant container
await workspacePage.clickAt(500, 500);
await workspacePage.page.keyboard.press("Control+v");
await workspacePage.page.waitForTimeout(500);
const variant_pasted = await findVariant(workspacePage, 0);
const variantPasted = await findVariant(workspacePage, 0);
// Expand the layers
await variant_pasted.container.getByRole("button").first().click();
await variantPasted.container.locator("button").first().click();
// The variants are valid
await validateVariant(variant_pasted);
await validateVariant(variantPasted);
});
test("[Bugfixing] User cut paste a variant container into a board, and undo twice", async ({
test("User cut paste a variant container into a board, and undo twice", async ({
page,
}) => {
const workspacePage = new WorkspacePage(page);
@@ -205,6 +247,7 @@ test("[Bugfixing] User cut paste a variant container into a board, and undo twic
//Cut the variant container
await workspacePage.page.keyboard.press("Control+x");
await workspacePage.page.waitForTimeout(500);
//Select the board
await workspacePage.clickLeafLayer("Board");
@@ -215,11 +258,12 @@ test("[Bugfixing] User cut paste a variant container into a board, and undo twic
//Undo twice
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.keyboard.press("Control+z");
await workspacePage.page.waitForTimeout(500);
const variant_after_undo = await findVariant(workspacePage, 0);
const variantAfterUndo = await findVariant(workspacePage, 0);
// The variants are valid
await validateVariant(variant_after_undo);
await validateVariant(variantAfterUndo);
});
test("User copy paste a variant", async ({ page }) => {
@@ -364,7 +408,7 @@ test("User drag and drop a component with path inside a variant", async ({
const workspacePage = new WorkspacePage(page);
await setupVariantsFileWithVariant(workspacePage);
const variant = await findVariant(workspacePage, 0);
const variant = findVariantNoWait(workspacePage, 0);
//Create a component
await workspacePage.ellipseShapeButton.click();
@@ -404,11 +448,12 @@ test("User cut paste a variant into another container", async ({ page }) => {
await workspacePage.page.keyboard.press("Control+k");
await workspacePage.page.keyboard.press("Control+k");
const variant_origin = await findVariant(workspacePage, 1);
const variant_target = await findVariant(workspacePage, 0);
const variantOrigin = await findVariantNoWait(workspacePage, 1);
// Select the variant1
await variant_origin.variant1.click();
await variantOrigin.variant1.waitFor();
await variantOrigin.variant1.click();
await variantOrigin.variant1.click();
//Cut the variant
await workspacePage.page.keyboard.press("Control+x");
@@ -417,7 +462,7 @@ test("User cut paste a variant into another container", async ({ page }) => {
await workspacePage.layers.getByText("Ellipse").first().click();
await workspacePage.page.keyboard.press("Control+v");
const variant3 = await workspacePage.layers
const variant3 = workspacePage.layers
.getByTestId("layer-row")
.filter({ has: workspacePage.page.getByText("Value 1, rectangle") })
.filter({ has: workspacePage.page.getByTestId("icon-variant") })

View File

@@ -46,6 +46,9 @@ test("Save and restore version", async ({ page }) => {
await page.getByLabel("History").click();
const saveVersionButton = page.getByRole("button", { name: "Save version" });
await saveVersionButton.waitFor();
await workspacePage.mockRPC(
"create-file-snapshot",
"workspace/versions-take-snapshot-1.json",
@@ -56,18 +59,22 @@ test("Save and restore version", async ({ page }) => {
"workspace/versions-snapshot-2.json",
);
await page.getByRole("button", { name: "Save version" }).click();
await workspacePage.mockRPC(
"update-file-snapshot",
"workspace/versions-update-snapshot-1.json",
);
await saveVersionButton.click();
await workspacePage.mockRPC(
"get-file-snapshots?file-id=*",
"workspace/versions-snapshot-3.json",
);
const textbox = page.getByRole("textbox");
await textbox.waitFor();
await page.getByRole("textbox").fill("INIT");
await page.getByRole("textbox").press("Enter");
@@ -76,14 +83,14 @@ test("Save and restore version", async ({ page }) => {
.locator("div")
.nth(3)
.hover();
await page.getByRole("button", { name: "Open version menu" }).click();
await page.getByRole("button", { name: "Restore" }).click();
await workspacePage.mockRPC(
"restore-file-snapshot",
"workspace/versions-restore-snapshot-1.json",
);
await page.getByRole("button", { name: "Open version menu" }).click();
await page.getByRole("button", { name: "Restore" }).click();
await page.getByRole("button", { name: "Restore" }).click();
// check that the history panel is closed after restore

View File

@@ -248,14 +248,6 @@ test("Bug 9066 - Problem with grid layout", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await workspacePage.setupEmptyFile(page);
await workspacePage.mockRPC(/get\-file\?/, "workspace/get-file-9066.json");
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=e179d9df-de35-80bf-8005-2861e849b3f7",
"workspace/get-file-fragment-9066-1.json",
);
await workspacePage.mockRPC(
"get-file-fragment?file-id=*&fragment-id=e179d9df-de35-80bf-8005-2861e849785e",
"workspace/get-file-fragment-9066-2.json",
);
await workspacePage.mockRPC(
"update-file?id=*",

View File

@@ -20,8 +20,8 @@
[app.main.features :as features]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.util.clipboard :as clipboard]
[app.util.storage :as storage]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[clojure.string :as str]
[potok.v2.core :as ptk]))
@@ -417,7 +417,7 @@
(rx/map (fn [fragment]
(assoc cf/public-uri :fragment fragment)))
(rx/tap (fn [uri]
(wapi/write-to-clipboard (str uri))))
(clipboard/to-clipboard (str uri))))
(rx/tap on-success)
(rx/ignore)
(rx/catch on-error))))))

View File

@@ -46,6 +46,7 @@
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.streams :as ms]
[app.util.clipboard :as clipboard]
[app.util.code-gen.markup-svg :as svg]
[app.util.code-gen.style-css :as css]
[app.util.globals :as ug]
@@ -59,7 +60,6 @@
[potok.v2.core :as ptk]
[promesa.core :as p]))
(defn copy-selected
[]
(letfn [(sort-selected [state data]
@@ -183,7 +183,7 @@
(let [text (wapi/get-current-selected-text)]
(if-not (str/empty? text)
(try
(wapi/write-to-clipboard text)
(clipboard/to-clipboard text)
(catch :default e
(on-copy-error e)))
@@ -227,7 +227,7 @@
(rx/map #(t/encode-str % {:type :json-verbose}))
(rx/map #(wapi/create-blob % "text/plain"))
(rx/subs! resolve reject))))]
(->> (rx/from (wapi/write-to-clipboard-promise "text/plain" resolve-data-promise))
(->> (rx/from (clipboard/to-clipboard-promise "text/plain" resolve-data-promise))
(rx/catch on-copy-error)
(rx/ignore)))
@@ -240,7 +240,7 @@
(rx/map (partial sort-selected state))
(rx/map (partial advance-copies state selected))
(rx/map #(t/encode-str % {:type :json-verbose}))
(rx/map wapi/write-to-clipboard)
(rx/map clipboard/to-clipboard)
(rx/catch on-copy-error)
(rx/ignore))))))))))
@@ -252,49 +252,47 @@
(declare ^:private paste-svg-text)
(declare ^:private paste-shapes)
(defn create-paste-from-blob
[in-viewport?]
(fn [blob]
(js/console.log "create-paste-from-blob" blob)
(let [type (.-type blob)
result (cond
(= type "image/svg+xml")
(->> (rx/from (.text blob))
(rx/map paste-svg-text))
(some #(= type %) clipboard/image-types)
(rx/of (paste-image blob))
(= type "text/html")
(->> (rx/from (.text blob))
(rx/map paste-html-text))
(= type "application/transit+json")
(->> (rx/from (.text blob))
(rx/map (fn [text]
(let [transit-data (t/decode-str text)]
(assoc transit-data :in-viewport in-viewport?))))
(rx/map paste-transit-shapes))
:else
(->> (rx/from (.text blob))
(rx/map paste-text)))]
result)))
(def default-paste-from-blob (create-paste-from-blob false))
(defn paste-from-clipboard
"Perform a `paste` operation using the Clipboard API."
[]
(letfn [(decode-entry [entry]
(try
[:transit (t/decode-str entry)]
(catch :default _cause
[:text entry])))
(process-entry [[type data]]
(case type
:text
(cond
(str/empty? data)
(rx/empty)
(re-find #"<svg\s" data)
(rx/of (paste-svg-text data))
:else
(rx/of (paste-text data)))
:transit
(rx/of (paste-transit-shapes data))))
(on-error [cause]
(let [data (ex-data cause)]
(if (:not-implemented data)
(rx/of (ntf/warn (tr "errors.clipboard-not-implemented")))
(js/console.error "Clipboard error:" cause))
(rx/empty)))]
(ptk/reify ::paste-from-clipboard
ptk/WatchEvent
(watch [_ _ _]
(->> (rx/concat
(->> (wapi/read-from-clipboard)
(rx/map decode-entry)
(rx/mapcat process-entry))
(->> (wapi/read-image-from-clipboard)
(rx/map paste-image)))
(rx/take 1)
(rx/catch on-error))))))
(ptk/reify ::paste-from-clipboard
ptk/WatchEvent
(watch [_ _ _]
(prn "paste-from-clipboard")
(->> (clipboard/from-dom-api)
(rx/mapcat default-paste-from-blob)
(rx/take 1)))))
(defn paste-from-event
"Perform a `paste` operation from user emmited event."
@@ -302,6 +300,7 @@
(ptk/reify ::paste-from-event
ptk/WatchEvent
(watch [_ state _]
(prn "paste-from-event")
(let [objects (dsh/lookup-page-objects state)
edit-id (dm/get-in state [:workspace-local :edition])
is-editing? (and edit-id (= :text (get-in objects [edit-id :type])))]
@@ -310,30 +309,8 @@
;; we forbid that scenario so the default behaviour is executed
(if is-editing?
(rx/empty)
(let [pdata (wapi/read-from-paste-event event)
image-data (some-> pdata wapi/extract-images)
text-data (some-> pdata wapi/extract-text)
html-data (some-> pdata wapi/extract-html-text)
transit-data (ex/ignoring (some-> text-data t/decode-str))]
(cond
(and (string? text-data) (re-find #"<svg\s" text-data))
(rx/of (paste-svg-text text-data))
(seq image-data)
(->> (rx/from image-data)
(rx/map paste-image))
(coll? transit-data)
(rx/of (paste-transit-shapes (assoc transit-data :in-viewport in-viewport?)))
(and (string? html-data) (d/not-empty? html-data))
(rx/of (paste-html-text html-data text-data))
(and (string? text-data) (d/not-empty? text-data))
(rx/of (paste-text text-data))
:else
(rx/empty))))))))
(->> (clipboard/from-synthetic-event event)
(rx/mapcat (create-paste-from-blob in-viewport?))))))))
(defn copy-selected-svg
[]
@@ -352,7 +329,7 @@
shapes (mapv maybe-translate selected)
svg-formatted (svg/generate-formatted-markup objects shapes)]
(wapi/write-to-clipboard svg-formatted)))))
(clipboard/to-clipboard svg-formatted)))))
(defn copy-selected-css
[]
@@ -362,7 +339,7 @@
(let [objects (dsh/lookup-page-objects state)
selected (->> (dsh/lookup-selected state) (mapv (d/getf objects)))
css (css/generate-style objects selected selected {:with-prelude? false})]
(wapi/write-to-clipboard css)))))
(clipboard/to-clipboard css)))))
(defn copy-selected-css-nested
[]
@@ -374,7 +351,7 @@
(cfh/selected-with-children objects)
(mapv (d/getf objects)))
css (css/generate-style objects selected selected {:with-prelude? false})]
(wapi/write-to-clipboard css)))))
(clipboard/to-clipboard css)))))
(defn copy-selected-text
[]
@@ -405,7 +382,7 @@
(-> shape :content txt/content->text))))
(str/join "\n"))]
(wapi/write-to-clipboard text)))))
(clipboard/to-clipboard text)))))
(defn copy-selected-props
[]
@@ -474,7 +451,7 @@
(rx/map #(wapi/create-blob % "text/plain"))
(rx/subs! resolve reject))))]
(->> (rx/from (wapi/write-to-clipboard-promise "text/plain" resolve-data-promise))
(->> (rx/from (clipboard/to-clipboard-promise "text/plain" resolve-data-promise))
(rx/catch on-copy-error)
(rx/ignore)))
;; FIXME: this is to support Firefox versions below 116 that don't support
@@ -482,7 +459,7 @@
;; https://caniuse.com/?search=ClipboardItem
(->> (rx/of copy-data)
(rx/mapcat resolve-images)
(rx/map #(wapi/write-to-clipboard (t/encode-str % {:type :json-verbose})))
(rx/map #(clipboard/to-clipboard (t/encode-str % {:type :json-verbose})))
(rx/catch on-copy-error)
(rx/ignore))))))))))))
@@ -502,7 +479,8 @@
(js/console.error "Clipboard error:" cause))
(rx/empty)))]
(->> (wapi/read-from-clipboard)
(->> (clipboard/from-dom-api)
(rx/mapcat #(.text %))
(rx/map decode-entry)
(rx/take 1)
(rx/catch on-error)))))))
@@ -968,17 +946,21 @@
(deref ms/mouse-position)))
(defn- paste-html-text
[html text]
[html]
(js/console.log html)
(dm/assert! (string? html))
(ptk/reify ::paste-html-text
ptk/WatchEvent
(watch [_ state _]
(let [style (deref refs/workspace-clipboard-style)
root (dwtxt/create-root-from-html html style)
text (.-textContent root)
content (tc/dom->cljs root)]
(js/console.log "root" root "content" content)
(when (types.text/valid-content? content)
(js/console.log "valid-content")
(let [id (uuid/next)
width (max 8 (min (* 7 (count text)) 700))
width (max 8 (min (* 7 (count text)) 700))
height 16
{:keys [x y]} (calculate-paste-position state)
@@ -1051,4 +1033,4 @@
(ptk/reify ::copy-link-to-clipboard
ptk/WatchEvent
(watch [_ _ _]
(wapi/write-to-clipboard (rt/get-current-href)))))
(clipboard/to-clipboard (rt/get-current-href)))))

View File

@@ -18,6 +18,7 @@
[app.main.store :as st]
[app.main.ui.context :as ctx]
[app.main.ui.debug.icons-preview :refer [icons-preview]]
[app.main.ui.debug.playground :refer [playground]]
[app.main.ui.ds.product.loader :refer [loader*]]
[app.main.ui.error-boundary :refer [error-boundary*]]
[app.main.ui.exports.files]
@@ -209,6 +210,10 @@
(when *assert*
[:& icons-preview])
:debug-playground
(when *assert*
[:& playground])
(:dashboard-search
:dashboard-recent
:dashboard-files

View File

@@ -10,10 +10,10 @@
[app.common.data.macros :as dm]
[app.main.data.event :as-alias ev]
[app.main.ui.icons :as deprecated-icon]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[rumext.v2 :as mf]))
(mf/defc copy-button*
@@ -34,7 +34,7 @@
(reset! active* true)
(tm/schedule 1000 #(reset! active* false))
(when (fn? on-copied) (on-copied event))
(wapi/write-to-clipboard
(clipboard/to-clipboard
(if (fn? data) (data) data)))))]
[:button {:class class

View File

@@ -0,0 +1,51 @@
(ns app.main.ui.debug.playground
#_(:require-macros [app.main.style :as stl])
(:require
[app.util.clipboard :as clipboard]
[beicon.v2.core :as rx]
[rumext.v2 :as mf]))
(mf/defc playground-clipboard
{::mf/wrap-props false
::mf/private true}
[]
(let [on-paste (mf/use-fn
(fn [e]
(let [stream (clipboard/from-event e)]
(rx/sub! stream
(fn [data]
(js/console.log "data" data))))))
on-dragover (mf/use-fn
(fn [e]
(.preventDefault e)))
on-drop (mf/use-fn
(fn [e]
(.preventDefault e)
(let [stream (clipboard/from-drop-event e)]
(rx/sub! stream
(fn [data]
(js/console.log "data" data))))))
on-click (mf/use-fn
(fn [e]
(js/console.log "event" e)
(let [stream (clipboard/from-dom-api)]
(rx/sub! stream
(fn [data]
(js/console.log "data" data))))))]
(.addEventListener js/window "paste" on-paste)
(.addEventListener js/window "drop" on-drop)
(.addEventListener js/window "dragover" on-dragover)
[:button#paste {:on-click on-click} "Paste"]))
(mf/defc playground
{::mf/wrap-props false
::mf/private true}
[]
[:& playground-clipboard])

View File

View File

@@ -23,11 +23,11 @@
[app.main.ui.hooks.resize :refer [use-resize-hook]]
[app.main.ui.icons :as deprecated-icon]
[app.main.ui.shapes.text.fontfaces :refer [shapes->fonts]]
[app.util.clipboard :as clipboard]
[app.util.code-beautify :as cb]
[app.util.code-gen :as cg]
[app.util.dom :as dom]
[app.util.http :as http]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[okulary.core :as l]
@@ -202,7 +202,7 @@
(mf/use-fn
(mf/deps style-code markup-code images-data)
(fn []
(wapi/write-to-clipboard (gen-all-code style-code markup-code images-data))
(clipboard/to-clipboard (gen-all-code style-code markup-code images-data))
(let [origin (if (= :workspace from)
"workspace"
"viewer")]

View File

@@ -16,8 +16,8 @@
[app.main.ui.inspect.styles.property-detail-copiable :refer [property-detail-copiable*]]
[app.main.ui.inspect.styles.rows.color-properties-row :refer [color-properties-row*]]
[app.main.ui.inspect.styles.rows.properties-row :refer [properties-row*]]
[app.util.clipboard :as clipboard]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -88,7 +88,7 @@
(.toUpperCase text)
text)]
(reset! copied* true)
(wapi/write-to-clipboard formatted-text)
(clipboard/to-clipboard formatted-text)
(tm/schedule 1000 #(reset! copied* false)))))
composite-typography-token (get-resolved-token :typography shape resolved-tokens)]
[:div {:class (stl/css :text-properties)}

View File

@@ -14,10 +14,10 @@
[app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.formats :as fmt]
[app.main.ui.inspect.styles.property-detail-copiable :refer [property-detail-copiable*]]
[app.util.clipboard :as clipboard]
[app.util.color :as uc]
[app.util.i18n :refer [tr]]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -86,7 +86,7 @@
(mf/deps copied formatted-color-value)
(fn []
(reset! copied* true)
(wapi/write-to-clipboard copiable-value)
(clipboard/to-clipboard copiable-value)
(tm/schedule 1000 #(reset! copied* false))))]
[:*
[:dl {:class [(stl/css :property-row) class]

View File

@@ -12,9 +12,9 @@
format-token-value]]
[app.main.ui.ds.tooltip :refer [tooltip*]]
[app.main.ui.inspect.styles.property-detail-copiable :refer [property-detail-copiable*]]
[app.util.clipboard :as clipboard]
[app.util.i18n :refer [tr]]
[app.util.timers :as tm]
[app.util.webapi :as wapi]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
@@ -43,7 +43,7 @@
(mf/deps copied)
(fn []
(reset! copied* true)
(wapi/write-to-clipboard copiable-value)
(clipboard/to-clipboard copiable-value)
(tm/schedule 1000 #(reset! copied* false))))]
[:dl {:class [(stl/css :property-row) class]
:data-testid "property-row"}

View File

@@ -10,8 +10,8 @@
[app.common.data :as d]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.util.clipboard :as clipboard]
[app.util.i18n :refer [tr]]
[app.util.webapi :as wapi]
[rumext.v2 :as mf]))
(defn- panel->title
@@ -50,7 +50,7 @@
(mf/use-fn
(mf/deps shorthand)
(fn []
(wapi/write-to-clipboard (str shorthand))))]
(clipboard/to-clipboard (str shorthand))))]
[:article {:class (stl/css :style-box)}
[:header {:class (stl/css :disclosure-header)}
[:button {:class (stl/css :disclosure-button)

View File

@@ -48,6 +48,9 @@
(when *assert*
["/debug/icons-preview" :debug-icons-preview])
(when *assert*
["/debug/playground" :debug-playground])
;; Used for export
["/render-sprite/:file-id" :render-sprite]

View File

@@ -16,10 +16,10 @@
[app.main.ui.components.context-menu-a11y :refer [context-menu*]]
[app.main.ui.components.forms :as fm]
[app.main.ui.icons :as deprecated-icon]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.keyboard :as kbd]
[app.util.webapi :as wapi]
[okulary.core :as l]
[rumext.v2 :as mf]))
@@ -97,7 +97,7 @@
(mf/deps created)
(fn [event]
(dom/prevent-default event)
(wapi/write-to-clipboard (:token created))
(clipboard/to-clipboard (:token created))
(st/emit! (ntf/show {:level :info
:type :toast
:content (tr "dashboard.access-tokens.copied-success")

View File

@@ -21,9 +21,9 @@
[app.main.store :as st]
[app.main.ui.components.select :refer [select]]
[app.main.ui.icons :as deprecated-icon]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.i18n :as i18n :refer [tr]]
[app.util.webapi :as wapi]
[potok.v2.core :as ptk]
[rumext.v2 :as mf]))
@@ -133,7 +133,7 @@
copy-link
(fn [_]
(wapi/write-to-clipboard current-link)
(clipboard/to-clipboard current-link)
(st/emit! (ntf/show {:level :info
:type :toast
:content (tr "common.share-link.link-copied-success")

View File

@@ -34,11 +34,11 @@
[app.main.ui.context :as ctx]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.workspace.sidebar.assets.common :as cmm]
[app.util.clipboard :as clipboard]
[app.util.dom :as dom]
[app.util.i18n :refer [tr] :as i18n]
[app.util.shape-icon :as usi]
[app.util.timers :as timers]
[app.util.webapi :as wapi]
[beicon.v2.core :as rx]
[okulary.core :as l]
[potok.v2.core :as ptk]
@@ -181,7 +181,9 @@
handle-hover-copy-paste
(mf/use-callback
(fn []
(->> (wapi/read-from-clipboard)
(->> (clipboard/from-dom-api)
;: FIXME: use specific API for access .text
(rx/mapcat #(.text %))
(rx/take 1)
(rx/subs!
(fn [data]

View File

@@ -0,0 +1,161 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.util.clipboard
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.transit :as t]
[app.util.dom :as dom]
[app.util.object :as obj]
[beicon.v2.core :as rx]))
(def max-parseable-size
(* 16 1024 1024))
(def image-types
["image/webp"
"image/png"
"image/jpeg"
"image/svg+xml"])
(def allowed-types
(d/ordered-set
"image/webp",
"image/png",
"image/jpeg",
"image/svg+xml",
"application/transit+json",
"text/html",
"text/plain"))
(def exclusive-types
(d/ordered-set
"application/transit+json",
"text/html",
"text/plain"))
(def clipboard-settings
#js {:decodeTransit t/decode-str})
(defn- parse-pain-text
[text]
(or (when (ex/ignoring (t/decode-str text))
(new js/Blob #js [text] #js {:type "application/transit+json"}))
(when (re-seq #"^<svg[\s>]" text)
(new js/Blob #js [text] #js {:type "image/svg+xml"}))
(new js/Blob #js [text] #js {:type "text/plain"})))
(defn from-dom-api
[]
(let [api (.-clipboard js/navigator)]
(->> (rx/from (.read ^js api))
(rx/mapcat (comp rx/from obj/into-array))
(rx/mapcat (fn [item]
(let [allowed-types'
(->> (seq (.-types item))
(filter (fn [type] (contains? allowed-types type)))
(sort-by (fn [type] (d/index-of allowed-types type)))
(into (d/ordered-set)))
main-type
(first allowed-types')]
(cond->> (rx/from (.getType ^js item main-type))
(and (= (count allowed-types') 1)
(= "text/plain" main-type))
(rx/mapcat (fn [blob]
(if (>= max-parseable-size (.-size ^js blob))
(->> (rx/from (.text ^js blob))
(rx/map parse-pain-text))
(rx/of blob)))))))))))
(defn- from-data-transfer
"Get clipboard stream from DataTransfer instance"
[data-transfer]
(let [sorted-items
(->> (seq (.-items ^js data-transfer))
(filter (fn [item]
(contains? allowed-types (.-type ^js item))))
(sort-by (fn [item] (d/index-of allowed-types (.-type item)))))]
(->> (rx/from sorted-items)
(rx/mapcat (fn [item]
(let [kind (.-kind ^js item)
type (.-type ^js item)]
(cond
(= kind "file")
(rx/of (.getAsFile ^js item))
(= kind "string")
(->> (rx/create (fn [subs]
(.getAsString ^js item
(fn [text]
(rx/push! subs (d/vec2 type text))
(rx/end! subs)))))
(rx/map (fn [[type text]]
(if (= type "text/plain")
(parse-pain-text text)
(new js/Blob #js [text] #js {:type type})))))
:else
(rx/empty)))))
(rx/filter some?)
(rx/reduce (fn [filtered item]
(js/console.log "AAA" item)
(let [type (.-type ^js item)]
(if (and (contains? exclusive-types type)
(some (fn [item] (contains? exclusive-types type)) filtered))
filtered
(conj filtered item))))
(d/ordered-set))
(rx/mapcat (comp rx/from seq)))))
(defn from-event
"Get clipboard stream from event"
[event]
(let [cdata (.-clipboardData ^js event)]
(from-data-transfer cdata)))
(defn from-synthetic-event
"Get clipboard stream from syntetic event"
[event]
(let [target
(dom/get-target event)
content-editable?
(dom/is-content-editable? target)
is-input?
(= (dom/get-tag-name target) "INPUT")]
;; ignore when pasting into an editable control
(when-not (or content-editable? is-input?)
(-> event
(dom/event->browser-event)
(from-event)))))
(defn from-drop-event
"Get clipboard stream from drop event"
[event]
(from-data-transfer (.-dataTransfer ^js event)))
;; FIXME: rename to `write-text`
(defn to-clipboard
[data]
(assert (string? data) "`data` should be string")
(let [clipboard (unchecked-get js/navigator "clipboard")]
(.writeText ^js clipboard data)))
(defn- create-clipboard-item
[mimetype promise]
(js/ClipboardItem.
(js-obj mimetype promise)))
;; FIXME: this API is very confuse
(defn to-clipboard-promise
[mimetype promise]
(let [clipboard (unchecked-get js/navigator "clipboard")
data (create-clipboard-item mimetype promise)]
(.write ^js clipboard #js [data])))

View File

@@ -0,0 +1,151 @@
/**
*
* 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
*/
const maxParseableSize = 16 * 1024 * 1024;
const allowedTypes = [
"image/webp",
"image/png",
"image/jpeg",
"image/svg+xml",
"application/transit+json",
"text/html",
"text/plain",
];
const exclusiveTypes = [
"application/transit+json",
"text/html",
"text/plain"
];
/**
* @typedef {Object} ClipboardSettings
* @property {Function} [decodeTransit]
*/
/**
*
* @param {string} text
* @param {ClipboardSettings} options
* @param {Blob} [defaultReturn]
* @returns {Blob}
*/
function parseClipboardItemText(
text,
options,
defaultReturn = new Blob([text], { type: "text/plain" }),
) {
let decodedTransit = false;
try { decodedTransit = options?.decodeTransit?.(text) ?? false }
catch (error) { /* NOOP */ }
if (/^<svg[\s>]/i.test(text)) {
return new Blob([text], { type: "image/svg+xml" });
} else if (decodedTransit) {
return new Blob([text], { type: "application/transit+json" });
}
return defaultReturn;
}
/**
*
* @param {ClipboardSettings} [options]
* @returns {Promise<Array<Blob>>}
*/
export async function fromClipboard(options) {
const items = await navigator.clipboard.read();
console.log("items", items);
return Promise.all(
Array.from(items).map(async (item) => {
const itemAllowedTypes = Array.from(item.types)
.filter((type) => allowedTypes.includes(type))
.sort((a, b) => allowedTypes.indexOf(a) - allowedTypes.indexOf(b));
if (
itemAllowedTypes.length === 1 &&
itemAllowedTypes.at(0) === "text/plain"
) {
const blob = await item.getType("text/plain");
if (blob.size < maxParseableSize) {
const text = await blob.text();
return parseClipboardItemText(text, options);
}
return blob;
}
const type = itemAllowedTypes.at(0);
return item.getType(type);
}),
);
}
/**
*
* @param {DataTransfer} dataTransfer
* @param {ClipboardSettings} [options]
* @returns {Promise<Array<Blob>>}
*/
export async function fromDataTransfer(dataTransfer, options) {
const items = await Promise.all(
Array.from(dataTransfer.items)
.filter((item) => allowedTypes.includes(item.type))
.sort(
(a, b) => allowedTypes.indexOf(a.type) - allowedTypes.indexOf(b.type),
)
.map(async (item) => {
if (item.kind === "file") {
return Promise.resolve(item.getAsFile());
} else if (item.kind === "string") {
return new Promise((resolve) => {
const type = item.type;
item.getAsString((text) => {
if (type === "text/plain") {
return resolve(parseClipboardItemText(text, options));
}
return resolve(new Blob([text], { type }));
});
});
}
return Promise.resolve(null);
}),
);
return items
.filter((item) => !!item)
.reduce((filtered, item) => {
if (
exclusiveTypes.includes(item.type) &&
filtered.find((filteredItem) =>
exclusiveTypes.includes(filteredItem.type),
)
) {
return filtered;
}
filtered.push(item);
return filtered;
}, []);
}
/**
*
* @param {*} clipboardData
* @param {ClipboardSettings} [options]
* @returns {Promise<Array<Blob>>}
*/
export function fromClipboardData(clipboardData, options) {
return fromDataTransfer(clipboardData, options);
}
/**
*
* @param {*} e
* @param {ClipboardSettings} [options]
* @returns {Promise<Array<Blob>>}
*/
export function fromClipboardEvent(e, options) {
return fromClipboardData(e.clipboardData, options);
}

View File

@@ -23,7 +23,7 @@
(extend-type BrowserEvent
cljs.core/IDeref
(-deref [it] (.getBrowserEvent it)))
(-deref [it] (.getBrowserEvent ^js it)))
(declare get-window-size)
@@ -360,6 +360,10 @@
(when (some? el)
(.-innerText el)))
(defn is-content-editable?
[^js el]
(.-isContentEditable ^js el))
(defn query
([^string selector]
(query globals/document selector))

View File

@@ -37,34 +37,36 @@
(.-textContent element)))
(defn get-attrs-from-styles
[element attrs]
[element attrs defaults]
(reduce (fn [acc key]
(let [style (.-style element)]
(if (contains? styles/mapping key)
(let [style-name (styles/get-style-name-as-css-variable key)
[_ style-decode] (get styles/mapping key)
value (style-decode (.getPropertyValue style style-name))]
(assoc acc key value))
(let [style-name (styles/get-style-name key)]
(assoc acc key (styles/normalize-attr-value key (.getPropertyValue style style-name))))))) {} attrs))
(assoc acc key (if (empty? value) (get defaults key) value)))
(let [style-name (styles/get-style-name key)
value (styles/normalize-attr-value key (.getPropertyValue style style-name))]
(assoc acc key (if (empty? value) (get defaults key) value)))))) {} attrs))
(defn get-inline-styles
[element]
(get-attrs-from-styles element txt/text-node-attrs))
(get-attrs-from-styles element txt/text-node-attrs (txt/get-default-text-attrs)))
(defn get-paragraph-styles
[element]
(get-attrs-from-styles element (d/concat-set txt/paragraph-attrs txt/text-node-attrs)))
(get-attrs-from-styles element (d/concat-set txt/paragraph-attrs txt/text-node-attrs) (d/merge txt/default-paragraph-attrs txt/default-text-attrs)))
(defn get-root-styles
[element]
(get-attrs-from-styles element txt/root-attrs))
(get-attrs-from-styles element txt/root-attrs txt/default-root-attrs))
(defn create-inline
[element]
(d/merge {:text (get-inline-text element)
:key (.-id element)}
(get-inline-styles element)))
(let [text (get-inline-text element)]
(d/merge {:text text
:key (.-id element)}
(get-inline-styles element))))
(defn create-paragraph
[element]
@@ -76,7 +78,7 @@
(defn create-root
[element]
(let [root-styles (get-root-styles element)]
(d/merge {:type "root",
(d/merge {:type "root"
:key (.-id element)
:children [{:type "paragraph-set"
:children (mapv create-paragraph (.-children element))}]}

View File

@@ -7,7 +7,6 @@
(ns app.util.webapi
"HTML5 web api helpers."
(:require
[app.common.data :as d]
[app.common.exceptions :as ex]
[app.common.logging :as log]
[app.util.globals :as globals]
@@ -115,68 +114,6 @@
[]
(.. js/window getSelection toString))
(defn write-to-clipboard
[data]
(assert (string? data) "`data` should be string")
(let [cboard (unchecked-get js/navigator "clipboard")]
(.writeText ^js cboard data)))
(defn write-to-clipboard-promise
[mimetype promise]
(let [cboard (unchecked-get js/navigator "clipboard")
data (js/ClipboardItem.
(-> (obj/create)
(obj/set! mimetype promise)))]
(.write ^js cboard #js [data])))
(defn read-from-clipboard
[]
(try
(let [cboard (unchecked-get js/navigator "clipboard")]
(if (.-readText ^js cboard)
(rx/from (.readText ^js cboard))
(rx/throw (ex-info "This browser does not implement read from clipboard protocol"
{:not-implemented true}))))
(catch :default cause
(rx/throw cause))))
(defn read-image-from-clipboard
[]
(try
(let [cboard (unchecked-get js/navigator "clipboard")
read-item (fn [item]
(let [img-type (->> (.-types ^js item)
(d/seek #(str/starts-with? % "image/")))]
(if img-type
(rx/from (.getType ^js item img-type))
(rx/empty))))]
(->> (rx/from (.read ^js cboard)) ;; Get a stream of item lists
(rx/mapcat identity) ;; Convert each item into an emission
(rx/switch-map read-item)))
(catch :default cause
(rx/throw cause))))
(defn read-from-paste-event
[event]
(let [target (.-target ^js event)]
(when (and (not (.-isContentEditable ^js target)) ;; ignore when pasting into
(not= (.-tagName ^js target) "INPUT")) ;; an editable control
(.. ^js event getBrowserEvent -clipboardData))))
(defn extract-html-text
[clipboard-data]
(.getData clipboard-data "text/html"))
(defn extract-text
[clipboard-data]
(.getData clipboard-data "text"))
(defn extract-images
"Get image files from clipboard data. Returns a native js array."
[clipboard-data]
(let [files (obj/into-array (.-files ^js clipboard-data))]
(.filter ^js files #(str/starts-with? (obj/get % "type") "image/"))))
(defn create-canvas-element
[width height]
(let [canvas (.createElement js/document "canvas")]

View File

@@ -4352,7 +4352,7 @@ __metadata:
npm-run-all: "npm:^4.1.5"
opentype.js: "npm:^1.3.4"
p-limit: "npm:^6.2.0"
playwright: "npm:1.52.0"
playwright: "npm:1.56.1"
postcss: "npm:^8.5.4"
postcss-clean: "npm:^1.2.2"
postcss-modules: "npm:^6.0.1"