mirror of
https://github.com/penpot/penpot.git
synced 2025-12-11 22:14:05 +01:00
✨ Add text editor v2 integration tests
This commit is contained in:
58
frontend/playwright/data/text-editor/get-file-blank.json
Normal file
58
frontend/playwright/data/text-editor/get-file-blank.json
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"~:features": {
|
||||||
|
"~#set": [
|
||||||
|
"layout/grid",
|
||||||
|
"styles/v2",
|
||||||
|
"fdata/pointer-map",
|
||||||
|
"fdata/objects-map",
|
||||||
|
"components/v2",
|
||||||
|
"fdata/shape-data-type"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"~:permissions": {
|
||||||
|
"~:type": "~:membership",
|
||||||
|
"~:is-owner": true,
|
||||||
|
"~:is-admin": true,
|
||||||
|
"~:can-edit": true,
|
||||||
|
"~:can-read": true,
|
||||||
|
"~:is-logged": true
|
||||||
|
},
|
||||||
|
"~:has-media-trimmed": false,
|
||||||
|
"~:comment-thread-seqn": 0,
|
||||||
|
"~:name": "New File 1",
|
||||||
|
"~:revn": 11,
|
||||||
|
"~:modified-at": "~m1713873823633",
|
||||||
|
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||||
|
"~:is-shared": false,
|
||||||
|
"~:version": 46,
|
||||||
|
"~:project-id": "~uc7ce0794-0992-8105-8004-38e630f7920b",
|
||||||
|
"~:created-at": "~m1713536343369",
|
||||||
|
"~:data": {
|
||||||
|
"~:pages": [
|
||||||
|
"~u66697432-c33d-8055-8006-2c62cc084cad"
|
||||||
|
],
|
||||||
|
"~:pages-index": {
|
||||||
|
"~u66697432-c33d-8055-8006-2c62cc084cad": {
|
||||||
|
"~#penpot/pointer": [
|
||||||
|
"~ude58c8f6-c5c2-8196-8004-3df9e2e52d88",
|
||||||
|
{
|
||||||
|
"~:created-at": "~m1713873823636"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:id": "~uc7ce0794-0992-8105-8004-38f280443849",
|
||||||
|
"~:options": {
|
||||||
|
"~:components-v2": true
|
||||||
|
},
|
||||||
|
"~:recent-colors": [
|
||||||
|
{
|
||||||
|
"~:color": "#0000ff",
|
||||||
|
"~:opacity": 1,
|
||||||
|
"~:id": null,
|
||||||
|
"~:file-id": null,
|
||||||
|
"~:image": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
345
frontend/playwright/data/text-editor/get-file-lorem-ipsum.json
Normal file
345
frontend/playwright/data/text-editor/get-file-lorem-ipsum.json
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
{
|
||||||
|
"~:features": {
|
||||||
|
"~#set": [
|
||||||
|
"fdata/path-data",
|
||||||
|
"plugins/runtime",
|
||||||
|
"design-tokens/v1",
|
||||||
|
"layout/grid",
|
||||||
|
"styles/v2",
|
||||||
|
"fdata/pointer-map",
|
||||||
|
"fdata/objects-map",
|
||||||
|
"components/v2",
|
||||||
|
"fdata/shape-data-type",
|
||||||
|
"text-editor/v2"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad",
|
||||||
|
"~:permissions": {
|
||||||
|
"~:type": "~:membership",
|
||||||
|
"~:is-owner": true,
|
||||||
|
"~:is-admin": true,
|
||||||
|
"~:can-edit": true,
|
||||||
|
"~:can-read": true,
|
||||||
|
"~:is-logged": true
|
||||||
|
},
|
||||||
|
"~:has-media-trimmed": false,
|
||||||
|
"~:comment-thread-seqn": 0,
|
||||||
|
"~:name": "Bug 11552",
|
||||||
|
"~:revn": 3,
|
||||||
|
"~:modified-at": "~m1753957736516",
|
||||||
|
"~:vern": 0,
|
||||||
|
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
|
||||||
|
"~:is-shared": false,
|
||||||
|
"~:migrations": {
|
||||||
|
"~#ordered-set": [
|
||||||
|
"legacy-2",
|
||||||
|
"legacy-3",
|
||||||
|
"legacy-5",
|
||||||
|
"legacy-6",
|
||||||
|
"legacy-7",
|
||||||
|
"legacy-8",
|
||||||
|
"legacy-9",
|
||||||
|
"legacy-10",
|
||||||
|
"legacy-11",
|
||||||
|
"legacy-12",
|
||||||
|
"legacy-13",
|
||||||
|
"legacy-14",
|
||||||
|
"legacy-16",
|
||||||
|
"legacy-17",
|
||||||
|
"legacy-18",
|
||||||
|
"legacy-19",
|
||||||
|
"legacy-25",
|
||||||
|
"legacy-26",
|
||||||
|
"legacy-27",
|
||||||
|
"legacy-28",
|
||||||
|
"legacy-29",
|
||||||
|
"legacy-31",
|
||||||
|
"legacy-32",
|
||||||
|
"legacy-33",
|
||||||
|
"legacy-34",
|
||||||
|
"legacy-36",
|
||||||
|
"legacy-37",
|
||||||
|
"legacy-38",
|
||||||
|
"legacy-39",
|
||||||
|
"legacy-40",
|
||||||
|
"legacy-41",
|
||||||
|
"legacy-42",
|
||||||
|
"legacy-43",
|
||||||
|
"legacy-44",
|
||||||
|
"legacy-45",
|
||||||
|
"legacy-46",
|
||||||
|
"legacy-47",
|
||||||
|
"legacy-48",
|
||||||
|
"legacy-49",
|
||||||
|
"legacy-50",
|
||||||
|
"legacy-51",
|
||||||
|
"legacy-52",
|
||||||
|
"legacy-53",
|
||||||
|
"legacy-54",
|
||||||
|
"legacy-55",
|
||||||
|
"legacy-56",
|
||||||
|
"legacy-57",
|
||||||
|
"legacy-59",
|
||||||
|
"legacy-62",
|
||||||
|
"legacy-65",
|
||||||
|
"legacy-66",
|
||||||
|
"legacy-67",
|
||||||
|
"0001-remove-tokens-from-groups",
|
||||||
|
"0002-normalize-bool-content-v2",
|
||||||
|
"0002-clean-shape-interactions",
|
||||||
|
"0003-fix-root-shape",
|
||||||
|
"0003-convert-path-content-v2",
|
||||||
|
"0004-clean-shadow-color",
|
||||||
|
"0005-deprecate-image-type",
|
||||||
|
"0006-fix-old-texts-fills",
|
||||||
|
"0007-clear-invalid-strokes-and-fills-v2",
|
||||||
|
"0008-fix-library-colors-v4",
|
||||||
|
"0009-clean-library-colors",
|
||||||
|
"0009-add-partial-text-touched-flags"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"~:version": 67,
|
||||||
|
"~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669",
|
||||||
|
"~:created-at": "~m1753957644225",
|
||||||
|
"~:data": {
|
||||||
|
"~:pages": ["~u238a17e0-75ff-8075-8006-934586ea2231"],
|
||||||
|
"~:pages-index": {
|
||||||
|
"~u238a17e0-75ff-8075-8006-934586ea2231": {
|
||||||
|
"~:objects": {
|
||||||
|
"~u00000000-0000-0000-0000-000000000000": {
|
||||||
|
"~#shape": {
|
||||||
|
"~:y": 0,
|
||||||
|
"~:hide-fill-on-export": false,
|
||||||
|
"~:transform": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1.0,
|
||||||
|
"~:b": 0.0,
|
||||||
|
"~:c": 0.0,
|
||||||
|
"~:d": 1.0,
|
||||||
|
"~:e": 0.0,
|
||||||
|
"~:f": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:rotation": 0,
|
||||||
|
"~:name": "Root Frame",
|
||||||
|
"~:width": 0.01,
|
||||||
|
"~:type": "~:frame",
|
||||||
|
"~:points": [
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 0.0,
|
||||||
|
"~:y": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 0.01,
|
||||||
|
"~:y": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 0.01,
|
||||||
|
"~:y": 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 0.0,
|
||||||
|
"~:y": 0.01
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:r2": 0,
|
||||||
|
"~:proportion-lock": false,
|
||||||
|
"~:transform-inverse": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1.0,
|
||||||
|
"~:b": 0.0,
|
||||||
|
"~:c": 0.0,
|
||||||
|
"~:d": 1.0,
|
||||||
|
"~:e": 0.0,
|
||||||
|
"~:f": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:r3": 0,
|
||||||
|
"~:r1": 0,
|
||||||
|
"~:id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:strokes": [],
|
||||||
|
"~:x": 0,
|
||||||
|
"~:proportion": 1.0,
|
||||||
|
"~:r4": 0,
|
||||||
|
"~:selrect": {
|
||||||
|
"~#rect": {
|
||||||
|
"~:x": 0,
|
||||||
|
"~:y": 0,
|
||||||
|
"~:width": 0.01,
|
||||||
|
"~:height": 0.01,
|
||||||
|
"~:x1": 0,
|
||||||
|
"~:y1": 0,
|
||||||
|
"~:x2": 0.01,
|
||||||
|
"~:y2": 0.01
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-color": "#FFFFFF",
|
||||||
|
"~:fill-opacity": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:flip-x": null,
|
||||||
|
"~:height": 0.01,
|
||||||
|
"~:flip-y": null,
|
||||||
|
"~:shapes": ["~ucc6f0580-449c-8019-8006-9345db077fa0"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~ucc6f0580-449c-8019-8006-9345db077fa0": {
|
||||||
|
"~#shape": {
|
||||||
|
"~:y": 438,
|
||||||
|
"~:transform": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1.0,
|
||||||
|
"~:b": 0.0,
|
||||||
|
"~:c": 0.0,
|
||||||
|
"~:d": 1.0,
|
||||||
|
"~:e": 0.0,
|
||||||
|
"~:f": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:rotation": 0,
|
||||||
|
"~:grow-type": "~:auto-width",
|
||||||
|
"~:content": {
|
||||||
|
"~:type": "root",
|
||||||
|
"~:key": "1s4am1jl24s",
|
||||||
|
"~:children": [
|
||||||
|
{
|
||||||
|
"~:type": "paragraph-set",
|
||||||
|
"~:children": [
|
||||||
|
{
|
||||||
|
"~:line-height": "1.2",
|
||||||
|
"~:font-style": "normal",
|
||||||
|
"~:children": [
|
||||||
|
{
|
||||||
|
"~:line-height": "1.2",
|
||||||
|
"~:font-style": "normal",
|
||||||
|
"~:typography-ref-id": null,
|
||||||
|
"~:text-transform": "none",
|
||||||
|
"~:font-id": "sourcesanspro",
|
||||||
|
"~:key": "13p0zwl2yhc",
|
||||||
|
"~:font-size": "14",
|
||||||
|
"~:font-weight": "400",
|
||||||
|
"~:typography-ref-file": null,
|
||||||
|
"~:font-variant-id": "regular",
|
||||||
|
"~:text-decoration": "none",
|
||||||
|
"~:letter-spacing": "0",
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-color": "#000000",
|
||||||
|
"~:fill-opacity": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:font-family": "sourcesanspro",
|
||||||
|
"~:text": "Lorem ipsum"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:typography-ref-id": null,
|
||||||
|
"~:text-transform": "none",
|
||||||
|
"~:text-align": "left",
|
||||||
|
"~:font-id": "sourcesanspro",
|
||||||
|
"~:key": "20hf3kmyoub",
|
||||||
|
"~:font-size": "14",
|
||||||
|
"~:font-weight": "400",
|
||||||
|
"~:typography-ref-file": null,
|
||||||
|
"~:text-direction": "ltr",
|
||||||
|
"~:type": "paragraph",
|
||||||
|
"~:font-variant-id": "regular",
|
||||||
|
"~:text-decoration": "none",
|
||||||
|
"~:letter-spacing": "0",
|
||||||
|
"~:fills": [
|
||||||
|
{
|
||||||
|
"~:fill-color": "#000000",
|
||||||
|
"~:fill-opacity": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:font-family": "sourcesanspro"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:vertical-align": "top"
|
||||||
|
},
|
||||||
|
"~:hide-in-viewer": false,
|
||||||
|
"~:name": "Lorem ipsum",
|
||||||
|
"~:width": 77,
|
||||||
|
"~:type": "~:text",
|
||||||
|
"~:points": [
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 404,
|
||||||
|
"~:y": 438
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 481,
|
||||||
|
"~:y": 438
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 481,
|
||||||
|
"~:y": 455
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"~#point": {
|
||||||
|
"~:x": 404,
|
||||||
|
"~:y": 455
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"~:transform-inverse": {
|
||||||
|
"~#matrix": {
|
||||||
|
"~:a": 1.0,
|
||||||
|
"~:b": 0.0,
|
||||||
|
"~:c": 0.0,
|
||||||
|
"~:d": 1.0,
|
||||||
|
"~:e": 0.0,
|
||||||
|
"~:f": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0",
|
||||||
|
"~:parent-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:frame-id": "~u00000000-0000-0000-0000-000000000000",
|
||||||
|
"~:x": 404,
|
||||||
|
"~:selrect": {
|
||||||
|
"~#rect": {
|
||||||
|
"~:x": 404,
|
||||||
|
"~:y": 438,
|
||||||
|
"~:width": 77,
|
||||||
|
"~:height": 17,
|
||||||
|
"~:x1": 404,
|
||||||
|
"~:y1": 438,
|
||||||
|
"~:x2": 481,
|
||||||
|
"~:y2": 455
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:flip-x": null,
|
||||||
|
"~:height": 17,
|
||||||
|
"~:flip-y": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2231",
|
||||||
|
"~:name": "Page 1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"~:id": "~u238a17e0-75ff-8075-8006-934586ea2230",
|
||||||
|
"~:options": {
|
||||||
|
"~:components-v2": true,
|
||||||
|
"~:base-font-size": "16px"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1 @@
|
|||||||
{
|
w
|
||||||
"~:revn": 2,
|
|
||||||
"~:lagged": []
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|||||||
4
frontend/playwright/data/text-editor/update-file.json
Normal file
4
frontend/playwright/data/text-editor/update-file.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"~:revn": 2,
|
||||||
|
"~:lagged": []
|
||||||
|
}
|
||||||
36
frontend/playwright/helpers/Clipboard.js
Normal file
36
frontend/playwright/helpers/Clipboard.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export class Clipboard {
|
||||||
|
static Permission = {
|
||||||
|
ONLY_READ: ['clipboard-read'],
|
||||||
|
ONLY_WRITE: ['clipboard-write'],
|
||||||
|
ALL: ['clipboard-read', 'clipboard-write']
|
||||||
|
}
|
||||||
|
|
||||||
|
static enable(context, permissions) {
|
||||||
|
return context.grantPermissions(permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
static writeText(page, text) {
|
||||||
|
return page.evaluate((text) => navigator.clipboard.writeText(text), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
static readText(page) {
|
||||||
|
return page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(page, context) {
|
||||||
|
this.page = page
|
||||||
|
this.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
enable(permissions) {
|
||||||
|
return Clipboard.enable(this.context, permissions);
|
||||||
|
}
|
||||||
|
|
||||||
|
writeText(text) {
|
||||||
|
return Clipboard.writeText(this.page, text);
|
||||||
|
}
|
||||||
|
|
||||||
|
readText() {
|
||||||
|
return Clipboard.readText(this.page);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
frontend/playwright/helpers/Transit.js
Normal file
30
frontend/playwright/helpers/Transit.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export class Transit {
|
||||||
|
static parse(value) {
|
||||||
|
if (typeof value !== 'string')
|
||||||
|
return value
|
||||||
|
|
||||||
|
if (value.startsWith('~'))
|
||||||
|
return value.slice(2)
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
static get(object, ...path) {
|
||||||
|
let aux = object;
|
||||||
|
for (const name of path) {
|
||||||
|
if (typeof name !== 'string') {
|
||||||
|
if (!(name in aux)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
aux = aux[name];
|
||||||
|
} else {
|
||||||
|
const transitName = `~:${name}`;
|
||||||
|
if (!(transitName in aux)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
aux = aux[transitName];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return this.parse(aux);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,27 @@
|
|||||||
export class BasePage {
|
export class BasePage {
|
||||||
|
/**
|
||||||
|
* Mocks multiple RPC calls in a single call.
|
||||||
|
*
|
||||||
|
* @param {Page} page
|
||||||
|
* @param {object<string, string>} paths
|
||||||
|
* @param {*} options
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
static async mockRPCs(page, paths, options) {
|
||||||
|
for (const [path, jsonFilename] of Object.entries(paths)) {
|
||||||
|
await this.mockRPC(page, path, jsonFilename, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mocks an RPC call using a file.
|
||||||
|
*
|
||||||
|
* @param {Page} page
|
||||||
|
* @param {string} path
|
||||||
|
* @param {string} jsonFilename
|
||||||
|
* @param {*} options
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
static async mockRPC(page, path, jsonFilename, options) {
|
static async mockRPC(page, path, jsonFilename, options) {
|
||||||
if (!page) {
|
if (!page) {
|
||||||
throw new TypeError("Invalid page argument. Must be a Playwright page.");
|
throw new TypeError("Invalid page argument. Must be a Playwright page.");
|
||||||
@@ -93,6 +116,10 @@ export class BasePage {
|
|||||||
return this.#page;
|
return this.#page;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async mockRPCs(paths, options) {
|
||||||
|
return BasePage.mockRPCs(this.page, paths, options);
|
||||||
|
}
|
||||||
|
|
||||||
async mockRPC(path, jsonFilename, options) {
|
async mockRPC(path, jsonFilename, options) {
|
||||||
return BasePage.mockRPC(this.page, path, jsonFilename, options);
|
return BasePage.mockRPC(this.page, path, jsonFilename, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,146 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
import { BaseWebSocketPage } from "./BaseWebSocketPage";
|
import { BaseWebSocketPage } from "./BaseWebSocketPage";
|
||||||
|
import { Transit } from '../../helpers/Transit';
|
||||||
|
|
||||||
export class WorkspacePage extends BaseWebSocketPage {
|
export class WorkspacePage extends BaseWebSocketPage {
|
||||||
|
static TextEditor = class TextEditor {
|
||||||
|
constructor(workspacePage) {
|
||||||
|
this.workspacePage = workspacePage;
|
||||||
|
|
||||||
|
// locators.
|
||||||
|
this.fontSize = this.workspacePage.rightSidebar.getByRole("textbox", {
|
||||||
|
name: "Font Size",
|
||||||
|
});
|
||||||
|
this.lineHeight = this.workspacePage.rightSidebar.getByRole("textbox", {
|
||||||
|
name: "Line Height",
|
||||||
|
});
|
||||||
|
this.letterSpacing = this.workspacePage.rightSidebar.getByRole(
|
||||||
|
"textbox",
|
||||||
|
{
|
||||||
|
name: "Letter Spacing",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get page() {
|
||||||
|
return this.workspacePage.page;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForStyle(locator, styleName) {
|
||||||
|
return locator.evaluate(
|
||||||
|
(element, styleName) => element.style.getPropertyValue(styleName),
|
||||||
|
styleName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForEditor() {
|
||||||
|
return this.page.waitForSelector('[data-itype="editor"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForRoot() {
|
||||||
|
return this.page.waitForSelector('[data-itype="root"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForParagraph(nth) {
|
||||||
|
if (!nth) {
|
||||||
|
return this.page.waitForSelector('[data-itype="paragraph"]');
|
||||||
|
}
|
||||||
|
return this.page.waitForSelector(
|
||||||
|
`[data-itype="paragraph"]:nth-child(${nth})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForParagraphStyle(nth, styleName) {
|
||||||
|
const paragraph = await this.waitForParagraph(nth);
|
||||||
|
return this.waitForStyle(paragraph, styleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForTextSpan(nth = 0) {
|
||||||
|
if (!nth) {
|
||||||
|
return this.page.waitForSelector('[data-itype="inline"]');
|
||||||
|
}
|
||||||
|
return this.page.waitForSelector(
|
||||||
|
`[data-itype="inline"]:nth-child(${nth})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForTextSpanContent(nth = 0) {
|
||||||
|
const textSpan = await this.waitForTextSpan(nth);
|
||||||
|
const textContent = await textSpan.textContent();
|
||||||
|
return textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForTextSpanStyle(nth, styleName) {
|
||||||
|
const textSpan = await this.waitForTextSpan(nth);
|
||||||
|
return this.waitForStyle(textSpan, styleName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async startEditing() {
|
||||||
|
await this.page.keyboard.press("Enter");
|
||||||
|
return this.waitForEditor();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopEditing() {
|
||||||
|
return this.page.keyboard.press("Escape");
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveToLeft(amount = 0) {
|
||||||
|
for (let i = 0; i < amount; i++) {
|
||||||
|
await this.page.keyboard.press("ArrowLeft");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveToRight(amount = 0) {
|
||||||
|
for (let i = 0; i < amount; i++) {
|
||||||
|
await this.page.keyboard.press("ArrowRight");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveFromStart(offset = 0) {
|
||||||
|
await this.page.keyboard.press("ArrowLeft");
|
||||||
|
await this.moveToRight(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveFromEnd(offset = 0) {
|
||||||
|
await this.page.keyboard.press("ArrowRight");
|
||||||
|
await this.moveToLeft(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectFromStart(length, offset = 0) {
|
||||||
|
await this.moveFromStart(offset);
|
||||||
|
await this.page.keyboard.down("Shift");
|
||||||
|
await this.moveToRight(length);
|
||||||
|
await this.page.keyboard.up("Shift");
|
||||||
|
}
|
||||||
|
|
||||||
|
async selectFromEnd(length, offset = 0) {
|
||||||
|
await this.moveFromEnd(offset);
|
||||||
|
await this.page.keyboard.down("Shift");
|
||||||
|
await this.moveToLeft(length);
|
||||||
|
await this.page.keyboard.up("Shift");
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeNumericInput(locator, newValue) {
|
||||||
|
await expect(locator).toBeVisible();
|
||||||
|
await locator.focus();
|
||||||
|
await locator.fill(`${newValue}`);
|
||||||
|
await locator.blur();
|
||||||
|
}
|
||||||
|
|
||||||
|
changeFontSize(newValue) {
|
||||||
|
return this.changeNumericInput(this.fontSize, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeLineHeight(newValue) {
|
||||||
|
return this.changeNumericInput(this.lineHeight, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
changeLetterSpacing(newValue) {
|
||||||
|
return this.changeNumericInput(this.letterSpacing, newValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This should be called on `test.beforeEach`.
|
* This should be called on `test.beforeEach`.
|
||||||
*
|
*
|
||||||
@@ -11,50 +150,21 @@ export class WorkspacePage extends BaseWebSocketPage {
|
|||||||
static async init(page) {
|
static async init(page) {
|
||||||
await BaseWebSocketPage.initWebSockets(page);
|
await BaseWebSocketPage.initWebSockets(page);
|
||||||
|
|
||||||
await BaseWebSocketPage.mockRPC(
|
await BaseWebSocketPage.mockRPCs(page, {
|
||||||
page,
|
"get-profile": "logged-in-user/get-profile-logged-in.json",
|
||||||
"get-profile",
|
"get-team-users?file-id=*":
|
||||||
"logged-in-user/get-profile-logged-in.json",
|
|
||||||
);
|
|
||||||
await BaseWebSocketPage.mockRPC(
|
|
||||||
page,
|
|
||||||
"get-team-users?file-id=*",
|
|
||||||
"logged-in-user/get-team-users-single-user.json",
|
"logged-in-user/get-team-users-single-user.json",
|
||||||
);
|
"get-comment-threads?file-id=*":
|
||||||
await BaseWebSocketPage.mockRPC(
|
|
||||||
page,
|
|
||||||
"get-comment-threads?file-id=*",
|
|
||||||
"workspace/get-comment-threads-empty.json",
|
"workspace/get-comment-threads-empty.json",
|
||||||
);
|
"get-project?id=*": "workspace/get-project-default.json",
|
||||||
await BaseWebSocketPage.mockRPC(
|
"get-team?id=*": "workspace/get-team-default.json",
|
||||||
page,
|
"get-teams": "get-teams.json",
|
||||||
"get-project?id=*",
|
"get-team-members?team-id=*":
|
||||||
"workspace/get-project-default.json",
|
|
||||||
);
|
|
||||||
await BaseWebSocketPage.mockRPC(
|
|
||||||
page,
|
|
||||||
"get-team?id=*",
|
|
||||||
"workspace/get-team-default.json",
|
|
||||||
);
|
|
||||||
await BaseWebSocketPage.mockRPC(page, "get-teams", "get-teams.json");
|
|
||||||
|
|
||||||
await BaseWebSocketPage.mockRPC(
|
|
||||||
page,
|
|
||||||
"get-team-members?team-id=*",
|
|
||||||
"logged-in-user/get-team-members-your-penpot.json",
|
"logged-in-user/get-team-members-your-penpot.json",
|
||||||
);
|
"get-profiles-for-file-comments?file-id=*":
|
||||||
|
|
||||||
await BaseWebSocketPage.mockRPC(
|
|
||||||
page,
|
|
||||||
"get-profiles-for-file-comments?file-id=*",
|
|
||||||
"workspace/get-profile-for-file-comments.json",
|
"workspace/get-profile-for-file-comments.json",
|
||||||
);
|
"update-profile-props": "workspace/update-profile-empty.json",
|
||||||
|
});
|
||||||
await BaseWebSocketPage.mockRPC(
|
|
||||||
page,
|
|
||||||
"update-profile-props",
|
|
||||||
"workspace/update-profile-empty.json",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f7920a";
|
static anyTeamId = "c7ce0794-0992-8105-8004-38e630f7920a";
|
||||||
@@ -62,9 +172,20 @@ export class WorkspacePage extends BaseWebSocketPage {
|
|||||||
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
|
static anyFileId = "c7ce0794-0992-8105-8004-38f280443849";
|
||||||
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
|
static anyPageId = "c7ce0794-0992-8105-8004-38f28044384a";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebSocket mock
|
||||||
|
*
|
||||||
|
* @type {MockWebSocketHelper}
|
||||||
|
*/
|
||||||
#ws = null;
|
#ws = null;
|
||||||
|
|
||||||
constructor(page) {
|
/**
|
||||||
|
* Constructor
|
||||||
|
*
|
||||||
|
* @param {Page} page
|
||||||
|
* @param {} [options]
|
||||||
|
*/
|
||||||
|
constructor(page, options) {
|
||||||
super(page);
|
super(page);
|
||||||
this.pageName = page.getByTestId("page-name");
|
this.pageName = page.getByTestId("page-name");
|
||||||
|
|
||||||
@@ -112,11 +233,14 @@ export class WorkspacePage extends BaseWebSocketPage {
|
|||||||
"tokens-context-menu-for-set",
|
"tokens-context-menu-for-set",
|
||||||
);
|
);
|
||||||
this.contextMenuForShape = page.getByTestId("context-menu");
|
this.contextMenuForShape = page.getByTestId("context-menu");
|
||||||
|
if (options?.textEditor) {
|
||||||
|
this.textEditor = new WorkspacePage.TextEditor(this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async goToWorkspace({
|
async goToWorkspace({
|
||||||
fileId = WorkspacePage.anyFileId,
|
fileId = this.fileId ?? WorkspacePage.anyFileId,
|
||||||
pageId = WorkspacePage.anyPageId,
|
pageId = this.pageId ?? WorkspacePage.anyPageId,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
await this.page.goto(
|
await this.page.goto(
|
||||||
`/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
|
`/#/workspace?team-id=${WorkspacePage.anyTeamId}&file-id=${fileId}&page-id=${pageId}`,
|
||||||
@@ -141,48 +265,59 @@ export class WorkspacePage extends BaseWebSocketPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setupEmptyFile() {
|
async setupEmptyFile() {
|
||||||
await this.mockRPC(
|
await this.mockRPCs({
|
||||||
"get-profile",
|
"get-profile": "logged-in-user/get-profile-logged-in.json",
|
||||||
"logged-in-user/get-profile-logged-in.json",
|
"get-team-users?file-id=*":
|
||||||
);
|
"logged-in-user/get-team-users-single-user.json ",
|
||||||
await this.mockRPC(
|
"get-comment-threads?file-id=*":
|
||||||
"get-team-users?file-id=*",
|
|
||||||
"logged-in-user/get-team-users-single-user.json",
|
|
||||||
);
|
|
||||||
await this.mockRPC(
|
|
||||||
"get-comment-threads?file-id=*",
|
|
||||||
"workspace/get-comment-threads-empty.json",
|
"workspace/get-comment-threads-empty.json",
|
||||||
);
|
"get-project?id=*": "workspace/get-project-default.json",
|
||||||
await this.mockRPC(
|
"get-team?id=*": "workspace/get-team-default.json",
|
||||||
"get-project?id=*",
|
"get-profiles-for-file-comments?file-id=*":
|
||||||
"workspace/get-project-default.json",
|
|
||||||
);
|
|
||||||
await this.mockRPC("get-team?id=*", "workspace/get-team-default.json");
|
|
||||||
await this.mockRPC(
|
|
||||||
"get-profiles-for-file-comments?file-id=*",
|
|
||||||
"workspace/get-profile-for-file-comments.json",
|
"workspace/get-profile-for-file-comments.json",
|
||||||
);
|
"get-file-object-thumbnails?file-id=*":
|
||||||
await this.mockRPC(/get\-file\?/, "workspace/get-file-blank.json");
|
|
||||||
await this.mockRPC(
|
|
||||||
"get-file-object-thumbnails?file-id=*",
|
|
||||||
"workspace/get-file-object-thumbnails-blank.json",
|
"workspace/get-file-object-thumbnails-blank.json",
|
||||||
);
|
"get-font-variants?team-id=*": "workspace/get-font-variants-empty.json",
|
||||||
await this.mockRPC(
|
"get-file-fragment?file-id=*": "workspace/get-file-fragment-blank.json",
|
||||||
"get-font-variants?team-id=*",
|
"get-file-libraries?file-id=*": "workspace/get-file-libraries-empty.json",
|
||||||
"workspace/get-font-variants-empty.json",
|
});
|
||||||
);
|
|
||||||
await this.mockRPC(
|
if (this.textEditor) {
|
||||||
"get-file-fragment?file-id=*",
|
await this.mockRPC("update-file?id=*", "text-editor/update-file.json");
|
||||||
"workspace/get-file-fragment-blank.json",
|
|
||||||
);
|
|
||||||
await this.mockRPC(
|
|
||||||
"get-file-libraries?file-id=*",
|
|
||||||
"workspace/get-file-libraries-empty.json",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async mockGetFile(jsonFile) {
|
// by default we mock the blank file.
|
||||||
await this.mockRPC(/get\-file\?/, jsonFile);
|
await this.mockGetFile("workspace/get-file-blank.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
async mockGetFile(jsonFilename, options) {
|
||||||
|
const page = this.page;
|
||||||
|
const jsonPath = `playwright/data/${jsonFilename}`;
|
||||||
|
const body = await readFile(jsonPath, "utf-8");
|
||||||
|
const payload = JSON.parse(body);
|
||||||
|
|
||||||
|
const fileId = Transit.get(payload, "id");
|
||||||
|
const pageId = Transit.get(payload, "data", "pages", 0);
|
||||||
|
const teamId = Transit.get(payload, "team-id");
|
||||||
|
|
||||||
|
this.fileId = fileId ?? this.anyFileId;
|
||||||
|
this.pageId = pageId ?? this.anyPageId;
|
||||||
|
this.teamId = teamId ?? this.anyTeamId;
|
||||||
|
|
||||||
|
const path = /get\-file\?/;
|
||||||
|
const url = typeof path === "string" ? `**/api/main/methods/${path}` : path;
|
||||||
|
const interceptConfig = {
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/transit+json",
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
return page.route(url, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
...interceptConfig,
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// await this.mockRPC(/get\-file\?/, jsonFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
async mockGetAsset(regex, asset) {
|
async mockGetAsset(regex, asset) {
|
||||||
@@ -190,22 +325,15 @@ export class WorkspacePage extends BaseWebSocketPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async setupFileWithComments() {
|
async setupFileWithComments() {
|
||||||
await this.mockRPC(
|
await this.mockRPCs({
|
||||||
"get-comment-threads?file-id=*",
|
"get-comment-threads?file-id=*":
|
||||||
"workspace/get-comment-threads-unread.json",
|
"workspace/get-comment-threads-unread.json",
|
||||||
);
|
"get-file-fragment?file-id=*&fragment-id=*":
|
||||||
await this.mockRPC(
|
|
||||||
"get-file-fragment?file-id=*&fragment-id=*",
|
|
||||||
"viewer/get-file-fragment-single-board.json",
|
"viewer/get-file-fragment-single-board.json",
|
||||||
);
|
"get-comments?thread-id=*": "workspace/get-thread-comments.json",
|
||||||
await this.mockRPC(
|
"update-comment-thread-status":
|
||||||
"get-comments?thread-id=*",
|
|
||||||
"workspace/get-thread-comments.json",
|
|
||||||
);
|
|
||||||
await this.mockRPC(
|
|
||||||
"update-comment-thread-status",
|
|
||||||
"workspace/update-comment-thread-status.json",
|
"workspace/update-comment-thread-status.json",
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async clickWithDragViewportAt(x, y, width, height) {
|
async clickWithDragViewportAt(x, y, width, height) {
|
||||||
@@ -223,6 +351,67 @@ export class WorkspacePage extends BaseWebSocketPage {
|
|||||||
await this.page.mouse.up();
|
await this.page.mouse.up();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clicks and moves from the coordinates x1,y1 to x2,y2
|
||||||
|
*
|
||||||
|
* @param {number} x1
|
||||||
|
* @param {number} y1
|
||||||
|
* @param {number} x2
|
||||||
|
* @param {number} y2
|
||||||
|
*/
|
||||||
|
async clickAndMove(x1, y1, x2, y2) {
|
||||||
|
await this.page.waitForTimeout(100);
|
||||||
|
await this.viewport.hover({ position: { x: x1, y: y1 } });
|
||||||
|
await this.page.mouse.down();
|
||||||
|
await this.viewport.hover({ position: { x: x2, y: y2 } });
|
||||||
|
await this.page.mouse.up();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new Text Shape in the specified coordinates
|
||||||
|
* with an initial text.
|
||||||
|
*
|
||||||
|
* @param {number} x1
|
||||||
|
* @param {number} y1
|
||||||
|
* @param {number} x2
|
||||||
|
* @param {number} y2
|
||||||
|
* @param {string} initialText
|
||||||
|
* @param {*} [options]
|
||||||
|
*/
|
||||||
|
async createTextShape(x1, y1, x2, y2, initialText, options) {
|
||||||
|
const timeToWait = options?.timeToWait ?? 100;
|
||||||
|
await this.page.keyboard.press("T");
|
||||||
|
await this.page.waitForTimeout(timeToWait);
|
||||||
|
await this.clickAndMove(x1, y1, x2, y2);
|
||||||
|
await this.page.waitForTimeout(timeToWait);
|
||||||
|
if (initialText) {
|
||||||
|
await this.page.keyboard.type(initialText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the selected element into the clipboard.
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async copy() {
|
||||||
|
return this.page.keyboard.press("Control+C");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pastes something from the clipboard.
|
||||||
|
*
|
||||||
|
* @param {"keyboard"|"context-menu"} [kind="keyboard"]
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async paste(kind = "keyboard") {
|
||||||
|
if (kind === "context-menu") {
|
||||||
|
await this.viewport.click({ button: "right" });
|
||||||
|
return this.page.getByText("PasteCtrlV").click();
|
||||||
|
}
|
||||||
|
return this.page.keyboard.press("Control+V");
|
||||||
|
}
|
||||||
|
|
||||||
async panOnViewportAt(x, y, width, height) {
|
async panOnViewportAt(x, y, width, height) {
|
||||||
await this.page.waitForTimeout(100);
|
await this.page.waitForTimeout(100);
|
||||||
await this.viewport.hover({ position: { x, y } });
|
await this.viewport.hover({ position: { x, y } });
|
||||||
@@ -250,6 +439,11 @@ export class WorkspacePage extends BaseWebSocketPage {
|
|||||||
await this.page.waitForTimeout(500);
|
await this.page.waitForTimeout(500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async doubleClickLeafLayer(name, clickOptions = {}) {
|
||||||
|
await this.clickLeafLayer(name, clickOptions);
|
||||||
|
await this.clickLeafLayer(name, clickOptions);
|
||||||
|
}
|
||||||
|
|
||||||
async clickToggableLayer(name, clickOptions = {}) {
|
async clickToggableLayer(name, clickOptions = {}) {
|
||||||
const layer = this.layers
|
const layer = this.layers
|
||||||
.getByTestId("layer-row")
|
.getByTestId("layer-row")
|
||||||
|
|||||||
@@ -1,12 +1,317 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { Clipboard } from '../../helpers/Clipboard';
|
||||||
import { WorkspacePage } from "../pages/WorkspacePage";
|
import { WorkspacePage } from "../pages/WorkspacePage";
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
const timeToWait = 100;
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page, context }) => {
|
||||||
|
await Clipboard.enable(context, Clipboard.Permission.ONLY_WRITE);
|
||||||
|
|
||||||
await WorkspacePage.init(page);
|
await WorkspacePage.init(page);
|
||||||
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
|
await WorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-v2"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
|
test.afterEach(async ({ context}) => {
|
||||||
|
context.clearPermissions();
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Create a new text shape", async ({ page }) => {
|
||||||
|
const initialText = "Lorem ipsum";
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
await workspace.createTextShape(190, 150, 300, 200, initialText);
|
||||||
|
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe(initialText);
|
||||||
|
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Create a new text shape from pasting text", async ({ page, context }) => {
|
||||||
|
const textToPaste = "Lorem ipsum";
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true,
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockRPC(
|
||||||
|
"update-file?id=*",
|
||||||
|
"text-editor/update-file.json",
|
||||||
|
);
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
|
||||||
|
await Clipboard.writeText(page, textToPaste);
|
||||||
|
|
||||||
|
await workspace.clickAt(190, 150);
|
||||||
|
await workspace.paste("keyboard");
|
||||||
|
|
||||||
|
await page.waitForTimeout(timeToWait);
|
||||||
|
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe(textToPaste);
|
||||||
|
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Create a new text shape from pasting text using context menu", async ({ page, context }) => {
|
||||||
|
const textToPaste = "Lorem ipsum";
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
|
||||||
|
await Clipboard.writeText(page, textToPaste);
|
||||||
|
|
||||||
|
await workspace.clickAt(190, 150);
|
||||||
|
await workspace.paste("context-menu");
|
||||||
|
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe(textToPaste);
|
||||||
|
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Update an already created text shape by appending text", async ({ page }) => {
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.moveFromEnd(0);
|
||||||
|
await page.keyboard.type(" dolor sit amet");
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe("Lorem ipsum dolor sit amet");
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update an already created text shape by prepending text", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.moveFromStart(0);
|
||||||
|
await page.keyboard.type("Dolor sit amet ");
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update an already created text shape by inserting text in between", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.moveFromStart(5);
|
||||||
|
await page.keyboard.type(" dolor sit amet");
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe("Lorem dolor sit amet ipsum");
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update a new text shape appending text by pasting text", async ({ page, context }) => {
|
||||||
|
const textToPaste = " dolor sit amet";
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
|
||||||
|
await Clipboard.writeText(page, textToPaste);
|
||||||
|
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.moveFromEnd();
|
||||||
|
await workspace.paste("keyboard");
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe("Lorem ipsum dolor sit amet");
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update a new text shape prepending text by pasting text", async ({
|
||||||
|
page, context
|
||||||
|
}) => {
|
||||||
|
const textToPaste = "Dolor sit amet ";
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
|
||||||
|
await Clipboard.writeText(page, textToPaste);
|
||||||
|
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.moveFromStart();
|
||||||
|
await workspace.paste("keyboard");
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe("Dolor sit amet Lorem ipsum");
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update a new text shape replacing (starting) text with pasted text", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const textToPaste = "Dolor sit amet";
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.selectFromStart(5);
|
||||||
|
|
||||||
|
await Clipboard.writeText(page, textToPaste);
|
||||||
|
|
||||||
|
await workspace.paste("keyboard");
|
||||||
|
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe("Dolor sit amet ipsum");
|
||||||
|
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update a new text shape replacing (ending) text with pasted text", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const textToPaste = "dolor sit amet";
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.selectFromEnd(5);
|
||||||
|
|
||||||
|
await Clipboard.writeText(page, textToPaste);
|
||||||
|
|
||||||
|
await workspace.paste("keyboard");
|
||||||
|
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe("Lorem dolor sit amet");
|
||||||
|
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update a new text shape replacing (in between) text with pasted text", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const textToPaste = "dolor sit amet";
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.selectFromStart(5, 3);
|
||||||
|
|
||||||
|
await Clipboard.writeText(page, textToPaste);
|
||||||
|
|
||||||
|
await workspace.paste("keyboard");
|
||||||
|
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe("Lordolor sit ametsum");
|
||||||
|
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Update text font size selecting a part of it (starting)", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.mockRPC(
|
||||||
|
"update-file?id=*",
|
||||||
|
"text-editor/update-file.json",
|
||||||
|
);
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.selectFromStart(5);
|
||||||
|
await workspace.textEditor.changeFontSize(36);
|
||||||
|
|
||||||
|
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
|
||||||
|
expect(textContent1).toBe("Lorem");
|
||||||
|
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
|
||||||
|
expect(textContent2).toBe(" ipsum");
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("Update text line height selecting a part of it (starting)", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true,
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.selectFromStart(5);
|
||||||
|
await workspace.textEditor.changeLineHeight(1.4);
|
||||||
|
|
||||||
|
const lineHeight = await workspace.textEditor.waitForParagraphStyle(1, 'line-height');
|
||||||
|
expect(lineHeight).toBe("1.4");
|
||||||
|
|
||||||
|
const textContent = await workspace.textEditor.waitForTextSpanContent();
|
||||||
|
expect(textContent).toBe("Lorem ipsum");
|
||||||
|
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip("Update text letter spacing selecting a part of it (starting)", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = new WorkspacePage(page, {
|
||||||
|
textEditor: true,
|
||||||
|
});
|
||||||
|
await workspace.setupEmptyFile();
|
||||||
|
await workspace.mockGetFile("text-editor/get-file-lorem-ipsum.json");
|
||||||
|
await workspace.mockRPC("update-file?id=*", "text-editor/update-file.json");
|
||||||
|
await workspace.goToWorkspace();
|
||||||
|
await workspace.clickLeafLayer("Lorem ipsum");
|
||||||
|
await workspace.textEditor.startEditing();
|
||||||
|
await workspace.textEditor.selectFromStart(5);
|
||||||
|
await workspace.textEditor.changeLetterSpacing(10);
|
||||||
|
|
||||||
|
const textContent1 = await workspace.textEditor.waitForTextSpanContent(1);
|
||||||
|
expect(textContent1).toBe("Lorem");
|
||||||
|
const textContent2 = await workspace.textEditor.waitForTextSpanContent(2);
|
||||||
|
expect(textContent2).toBe(" ipsum");
|
||||||
|
await workspace.textEditor.stopEditing();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
|
||||||
const workspace = new WorkspacePage(page);
|
const workspace = new WorkspacePage(page);
|
||||||
await workspace.setupEmptyFile();
|
await workspace.setupEmptyFile();
|
||||||
await workspace.mockGetFile("text-editor/get-file-11552.json");
|
await workspace.mockGetFile("text-editor/get-file-11552.json");
|
||||||
@@ -14,21 +319,16 @@ test.skip("BUG 11552 - Apply styles to the current caret", async ({ page }) => {
|
|||||||
"update-file?id=*",
|
"update-file?id=*",
|
||||||
"text-editor/update-file-11552.json",
|
"text-editor/update-file-11552.json",
|
||||||
);
|
);
|
||||||
|
await workspace.goToWorkspace();
|
||||||
await workspace.goToWorkspace({
|
await workspace.doubleClickLeafLayer("Lorem ipsum");
|
||||||
fileId: "238a17e0-75ff-8075-8006-934586ea2230",
|
|
||||||
pageId: "238a17e0-75ff-8075-8006-934586ea2231",
|
|
||||||
});
|
|
||||||
await workspace.clickLeafLayer("Lorem ipsum");
|
|
||||||
await workspace.clickLeafLayer("Lorem ipsum");
|
|
||||||
|
|
||||||
const fontSizeInput = workspace.rightSidebar.getByRole("textbox", {
|
const fontSizeInput = workspace.rightSidebar.getByRole("textbox", {
|
||||||
name: "Font Size",
|
name: "Font Size",
|
||||||
});
|
});
|
||||||
await expect(fontSizeInput).toBeVisible();
|
await expect(fontSizeInput).toBeVisible();
|
||||||
|
|
||||||
await workspace.page.keyboard.press("Enter");
|
await page.keyboard.press("Enter");
|
||||||
await workspace.page.keyboard.press("ArrowRight");
|
await page.keyboard.press("ArrowRight");
|
||||||
|
|
||||||
await fontSizeInput.fill("36");
|
await fontSizeInput.fill("36");
|
||||||
|
|
||||||
|
|||||||
@@ -831,7 +831,8 @@
|
|||||||
(effect [_ state _]
|
(effect [_ state _]
|
||||||
(when (features/active-feature? state "text-editor/v2")
|
(when (features/active-feature? state "text-editor/v2")
|
||||||
(let [instance (:workspace-editor state)
|
(let [instance (:workspace-editor state)
|
||||||
attrs-to-override (some-> (editor.v2/getCurrentStyle instance) (styles/get-styles-from-style-declaration))
|
attrs-to-override (some-> (editor.v2/getCurrentStyle instance)
|
||||||
|
(styles/get-styles-from-style-declaration))
|
||||||
overriden-attrs (merge attrs-to-override attrs)
|
overriden-attrs (merge attrs-to-override attrs)
|
||||||
styles (styles/attrs->styles overriden-attrs)]
|
styles (styles/attrs->styles overriden-attrs)]
|
||||||
(editor.v2/applyStylesToSelection instance styles))))))
|
(editor.v2/applyStylesToSelection instance styles))))))
|
||||||
|
|||||||
@@ -378,6 +378,7 @@
|
|||||||
:step 0.1
|
:step 0.1
|
||||||
:default-value "1.2"
|
:default-value "1.2"
|
||||||
:class (stl/css :line-height-input)
|
:class (stl/css :line-height-input)
|
||||||
|
:aria-label (tr "inspect.attributes.typography.line-height")
|
||||||
:value (attr->string line-height)
|
:value (attr->string line-height)
|
||||||
:placeholder (if (= :multiple line-height) (tr "settings.multiple") "--")
|
:placeholder (if (= :multiple line-height) (tr "settings.multiple") "--")
|
||||||
:nillable (= :multiple line-height)
|
:nillable (= :multiple line-height)
|
||||||
@@ -396,6 +397,7 @@
|
|||||||
:step 0.1
|
:step 0.1
|
||||||
:default-value "0"
|
:default-value "0"
|
||||||
:class (stl/css :letter-spacing-input)
|
:class (stl/css :letter-spacing-input)
|
||||||
|
:aria-label (tr "inspect.attributes.typography.letter-spacing")
|
||||||
:value (attr->string letter-spacing)
|
:value (attr->string letter-spacing)
|
||||||
:placeholder (if (= :multiple letter-spacing) (tr "settings.multiple") "--")
|
:placeholder (if (= :multiple letter-spacing) (tr "settings.multiple") "--")
|
||||||
:on-change #(handle-change % :letter-spacing)
|
:on-change #(handle-change % :letter-spacing)
|
||||||
|
|||||||
@@ -48,6 +48,9 @@
|
|||||||
"This function strips units from attr values and un-scapes font-family"
|
"This function strips units from attr values and un-scapes font-family"
|
||||||
[k v]
|
[k v]
|
||||||
(cond
|
(cond
|
||||||
|
(= v "mixed")
|
||||||
|
:multiple
|
||||||
|
|
||||||
(and (or (= k :font-size)
|
(and (or (= k :font-size)
|
||||||
(= k :letter-spacing))
|
(= k :letter-spacing))
|
||||||
(= (str/slice v -2) "px"))
|
(= (str/slice v -2) "px"))
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import LayoutType from "./layout/LayoutType.js";
|
|||||||
* @typedef {Object} TextEditorOptions
|
* @typedef {Object} TextEditorOptions
|
||||||
* @property {CSSStyleDeclaration|Object.<string,*>} [styleDefaults]
|
* @property {CSSStyleDeclaration|Object.<string,*>} [styleDefaults]
|
||||||
* @property {SelectionControllerDebug} [debug]
|
* @property {SelectionControllerDebug} [debug]
|
||||||
|
* @property {boolean} [shouldUpdatePositionOnScroll=false]
|
||||||
* @property {boolean} [allowHTMLPaste=false]
|
* @property {boolean} [allowHTMLPaste=false]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -92,6 +93,21 @@ export class TextEditor extends EventTarget {
|
|||||||
*/
|
*/
|
||||||
#canvas = null;
|
#canvas = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text editor options.
|
||||||
|
*
|
||||||
|
* @type {TextEditorOptions}
|
||||||
|
*/
|
||||||
|
#options = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A boolean indicating that this instance was
|
||||||
|
* disposed or not.
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
#isDisposed = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructor.
|
* Constructor.
|
||||||
*
|
*
|
||||||
@@ -101,9 +117,9 @@ export class TextEditor extends EventTarget {
|
|||||||
*/
|
*/
|
||||||
constructor(element, canvas, options) {
|
constructor(element, canvas, options) {
|
||||||
super();
|
super();
|
||||||
if (!(element instanceof HTMLElement))
|
if (!(element instanceof HTMLElement)) {
|
||||||
throw new TypeError("Invalid text editor element");
|
throw new TypeError("Invalid text editor element");
|
||||||
|
}
|
||||||
this.#element = element;
|
this.#element = element;
|
||||||
this.#canvas = canvas;
|
this.#canvas = canvas;
|
||||||
this.#events = {
|
this.#events = {
|
||||||
@@ -119,6 +135,7 @@ export class TextEditor extends EventTarget {
|
|||||||
keydown: this.#onKeyDown,
|
keydown: this.#onKeyDown,
|
||||||
};
|
};
|
||||||
this.#styleDefaults = options?.styleDefaults;
|
this.#styleDefaults = options?.styleDefaults;
|
||||||
|
this.#options = options;
|
||||||
this.#setup(options);
|
this.#setup(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,14 +167,18 @@ export class TextEditor extends EventTarget {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Setups the root element.
|
* Setups the root element.
|
||||||
|
*
|
||||||
|
* @param {TextEditorOptions} options
|
||||||
*/
|
*/
|
||||||
#setupRoot() {
|
#setupRoot(options) {
|
||||||
this.#root = createEmptyRoot(this.#styleDefaults);
|
this.#root = createEmptyRoot(this.#styleDefaults);
|
||||||
this.#element.appendChild(this.#root);
|
this.#element.appendChild(this.#root);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setups event listeners.
|
* Setups event listeners.
|
||||||
|
*
|
||||||
|
* @param {TextEditorOptions} options
|
||||||
*/
|
*/
|
||||||
#setupListeners(options) {
|
#setupListeners(options) {
|
||||||
this.#changeController.addEventListener("change", this.#onChange);
|
this.#changeController.addEventListener("change", this.#onChange);
|
||||||
@@ -174,18 +195,61 @@ export class TextEditor extends EventTarget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setups the elements, the properties and the
|
* Disposes everything.
|
||||||
* initial content.
|
|
||||||
*/
|
*/
|
||||||
#setup(options) {
|
dispose() {
|
||||||
this.#setupElementProperties(options);
|
if (this.#isDisposed) {
|
||||||
this.#setupRoot(options);
|
return this;
|
||||||
|
}
|
||||||
|
this.#isDisposed = true;
|
||||||
|
|
||||||
|
// Dispose change controller.
|
||||||
|
this.#changeController.removeEventListener("change", this.#onChange);
|
||||||
|
this.#changeController.dispose();
|
||||||
|
this.#changeController = null;
|
||||||
|
|
||||||
|
// Disposes selection controller.
|
||||||
|
this.#selectionController.removeEventListener(
|
||||||
|
"stylechange",
|
||||||
|
this.#onStyleChange,
|
||||||
|
);
|
||||||
|
this.#selectionController.dispose();
|
||||||
|
this.#selectionController = null;
|
||||||
|
|
||||||
|
// Disposes the rest of event listeners.
|
||||||
|
removeEventListeners(this.#element, this.#events);
|
||||||
|
if (this.#options.shouldUpdatePositionOnScroll) {
|
||||||
|
window.removeEventListener("scroll", this.#onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disposes references to DOM elements.
|
||||||
|
this.#element = null;
|
||||||
|
this.#root = null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setups controllers.
|
||||||
|
*
|
||||||
|
* @param {TextEditorOptions} options
|
||||||
|
*/
|
||||||
|
#setupControllers(options) {
|
||||||
this.#changeController = new ChangeController(this);
|
this.#changeController = new ChangeController(this);
|
||||||
this.#selectionController = new SelectionController(
|
this.#selectionController = new SelectionController(
|
||||||
this,
|
this,
|
||||||
document.getSelection(),
|
document.getSelection(),
|
||||||
options,
|
options,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setups the elements, the properties and the
|
||||||
|
* initial content.
|
||||||
|
*/
|
||||||
|
#setup(options) {
|
||||||
|
this.#setupElementProperties(options);
|
||||||
|
this.#setupRoot(options);
|
||||||
|
this.#setupControllers(options);
|
||||||
this.#setupListeners(options);
|
this.#setupListeners(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,7 +306,9 @@ export class TextEditor extends EventTarget {
|
|||||||
* @param {CustomEvent} e
|
* @param {CustomEvent} e
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
#onChange = (e) => this.dispatchEvent(new e.constructor(e.type, e));
|
#onChange = (e) => {
|
||||||
|
this.dispatchEvent(new e.constructor(e.type, e));
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatchs a `stylechange` event.
|
* Dispatchs a `stylechange` event.
|
||||||
@@ -421,6 +487,15 @@ export class TextEditor extends EventTarget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicates that the TextEditor was disposed.
|
||||||
|
*
|
||||||
|
* @type {boolean}
|
||||||
|
*/
|
||||||
|
get isDisposed() {
|
||||||
|
return this.#isDisposed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root element that contains all the paragraphs.
|
* Root element that contains all the paragraphs.
|
||||||
*
|
*
|
||||||
@@ -478,6 +553,15 @@ export class TextEditor extends EventTarget {
|
|||||||
return this.#selectionController.currentStyle;
|
return this.#selectionController.currentStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Text editor options
|
||||||
|
*
|
||||||
|
* @type {TextEditorOptions}
|
||||||
|
*/
|
||||||
|
get options() {
|
||||||
|
return this.#options;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Focus the element
|
* Focus the element
|
||||||
*/
|
*/
|
||||||
@@ -540,7 +624,8 @@ export class TextEditor extends EventTarget {
|
|||||||
* Applies the current styles to the selection or
|
* Applies the current styles to the selection or
|
||||||
* the current DOM node at the caret.
|
* the current DOM node at the caret.
|
||||||
*
|
*
|
||||||
* @param {*} styles
|
* @param {Object.<string, *>} styles
|
||||||
|
* @returns {TextEditor}
|
||||||
*/
|
*/
|
||||||
applyStylesToSelection(styles) {
|
applyStylesToSelection(styles) {
|
||||||
this.#selectionController.startMutation();
|
this.#selectionController.startMutation();
|
||||||
@@ -553,6 +638,8 @@ export class TextEditor extends EventTarget {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects all content.
|
* Selects all content.
|
||||||
|
*
|
||||||
|
* @returns {TextEditor}
|
||||||
*/
|
*/
|
||||||
selectAll() {
|
selectAll() {
|
||||||
this.#selectionController.selectAll();
|
this.#selectionController.selectAll();
|
||||||
@@ -562,30 +649,12 @@ export class TextEditor extends EventTarget {
|
|||||||
/**
|
/**
|
||||||
* Moves cursor to end.
|
* Moves cursor to end.
|
||||||
*
|
*
|
||||||
* @returns
|
* @returns {TextEditor}
|
||||||
*/
|
*/
|
||||||
cursorToEnd() {
|
cursorToEnd() {
|
||||||
this.#selectionController.cursorToEnd();
|
this.#selectionController.cursorToEnd();
|
||||||
return this;
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -615,47 +684,98 @@ export function createRootFromString(string) {
|
|||||||
return root;
|
return root;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isEditor(instance) {
|
/**
|
||||||
|
* Returns true if the passed object is a TextEditor
|
||||||
|
* instance.
|
||||||
|
*
|
||||||
|
* @param {*} instance
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isTextEditor(instance) {
|
||||||
return instance instanceof TextEditor;
|
return instance instanceof TextEditor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Convenience function based API for Text Editor */
|
/**
|
||||||
|
* Returns the root element of a TextEditor
|
||||||
|
* instance.
|
||||||
|
*
|
||||||
|
* @param {TextEditor} instance
|
||||||
|
* @returns {HTMLDivElement}
|
||||||
|
*/
|
||||||
export function getRoot(instance) {
|
export function getRoot(instance) {
|
||||||
if (isEditor(instance)) {
|
if (isTextEditor(instance)) {
|
||||||
return instance.root;
|
return instance.root;
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the root of the text editor.
|
||||||
|
*
|
||||||
|
* @param {TextEditor} instance
|
||||||
|
* @param {HTMLDivElement} root
|
||||||
|
* @returns {TextEditor}
|
||||||
|
*/
|
||||||
export function setRoot(instance, root) {
|
export function setRoot(instance, root) {
|
||||||
if (isEditor(instance)) {
|
if (isTextEditor(instance)) {
|
||||||
instance.root = root;
|
instance.root = root;
|
||||||
}
|
}
|
||||||
|
|
||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new TextEditor instance.
|
||||||
|
*
|
||||||
|
* @param {HTMLDivElement} element
|
||||||
|
* @param {HTMLCanvasElement} canvas
|
||||||
|
* @param {TextEditorOptions} options
|
||||||
|
* @returns {TextEditor}
|
||||||
|
*/
|
||||||
export function create(element, canvas, options) {
|
export function create(element, canvas, options) {
|
||||||
return new TextEditor(element, canvas, { ...options });
|
return new TextEditor(element, canvas, { ...options });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current style of the TextEditor instance.
|
||||||
|
*
|
||||||
|
* @param {TextEditor} instance
|
||||||
|
* @returns {CSSStyleDeclaration|undefined}
|
||||||
|
*/
|
||||||
export function getCurrentStyle(instance) {
|
export function getCurrentStyle(instance) {
|
||||||
if (isEditor(instance)) {
|
if (isTextEditor(instance)) {
|
||||||
return instance.currentStyle;
|
return instance.currentStyle;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies the specified styles to the TextEditor
|
||||||
|
* passed.
|
||||||
|
*
|
||||||
|
* @param {TextEditor} instance
|
||||||
|
* @param {Object.<string, *>} styles
|
||||||
|
* @returns {TextEditor|null}
|
||||||
|
*/
|
||||||
export function applyStylesToSelection(instance, styles) {
|
export function applyStylesToSelection(instance, styles) {
|
||||||
if (isEditor(instance)) {
|
if (isTextEditor(instance)) {
|
||||||
return instance.applyStylesToSelection(styles);
|
return instance.applyStylesToSelection(styles);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes the current instance resources by nullifying
|
||||||
|
* every property.
|
||||||
|
*
|
||||||
|
* @param {TextEditor} instance
|
||||||
|
* @returns {TextEditor|null}
|
||||||
|
*/
|
||||||
export function dispose(instance) {
|
export function dispose(instance) {
|
||||||
if (isEditor(instance)) {
|
if (isTextEditor(instance)) {
|
||||||
instance.dispose();
|
return instance.dispose();
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TextEditor;
|
export default TextEditor;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
mapContentFragmentFromHTML,
|
mapContentFragmentFromHTML,
|
||||||
mapContentFragmentFromString,
|
mapContentFragmentFromString,
|
||||||
} from "../content/dom/Content.js";
|
} from "../content/dom/Content.js";
|
||||||
|
import { TextEditor } from "../TextEditor.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a DocumentFragment from text/html.
|
* Returns a DocumentFragment from text/html.
|
||||||
@@ -38,19 +39,26 @@ function getPlainFragmentFromClipboardData(selectionController, clipboardData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a DocumentFragment (or null) if it contains
|
* Returns a document fragment of html data.
|
||||||
* a compatible clipboardData type.
|
|
||||||
*
|
*
|
||||||
* @param {DataTransfer} clipboardData
|
* @param {DataTransfer} clipboardData
|
||||||
* @returns {DocumentFragment|null}
|
* @returns {DocumentFragment}
|
||||||
*/
|
*/
|
||||||
function getFragmentFromClipboardData(selectionController, clipboardData) {
|
function getFormattedOrPlainFragmentFromClipboardData(
|
||||||
|
selectionController,
|
||||||
|
clipboardData,
|
||||||
|
) {
|
||||||
if (clipboardData.types.includes("text/html")) {
|
if (clipboardData.types.includes("text/html")) {
|
||||||
return getFormattedFragmentFromClipboardData(selectionController, clipboardData)
|
return getFormattedFragmentFromClipboardData(
|
||||||
|
selectionController,
|
||||||
|
clipboardData,
|
||||||
|
);
|
||||||
} else if (clipboardData.types.includes("text/plain")) {
|
} else if (clipboardData.types.includes("text/plain")) {
|
||||||
return getPlainFragmentFromClipboardData(selectionController, clipboardData)
|
return getPlainFragmentFromClipboardData(
|
||||||
|
selectionController,
|
||||||
|
clipboardData,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,18 +79,37 @@ export function paste(event, editor, selectionController) {
|
|||||||
|
|
||||||
let fragment = null;
|
let fragment = null;
|
||||||
if (editor?.options?.allowHTMLPaste) {
|
if (editor?.options?.allowHTMLPaste) {
|
||||||
fragment = getFragmentFromClipboardData(selectionController, event.clipboardData);
|
fragment = getFormattedOrPlainFragmentFromClipboardData(event.clipboardData);
|
||||||
} else {
|
} else {
|
||||||
fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData);
|
fragment = getPlainFragmentFromClipboardData(selectionController, event.clipboardData);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!fragment) {
|
if (!fragment) {
|
||||||
|
// NOOP
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectionController.isCollapsed) {
|
if (selectionController.isCollapsed) {
|
||||||
|
const hasOnlyOneParagraph = fragment.children.length === 1;
|
||||||
|
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
|
||||||
|
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force";
|
||||||
|
if (hasOnlyOneParagraph
|
||||||
|
&& hasOnlyOneTextSpan
|
||||||
|
&& forceTextSpan) {
|
||||||
|
selectionController.insertIntoFocus(fragment.textContent);
|
||||||
|
} else {
|
||||||
selectionController.insertPaste(fragment);
|
selectionController.insertPaste(fragment);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const hasOnlyOneParagraph = fragment.children.length === 1;
|
||||||
|
const hasOnlyOneTextSpan = fragment.firstElementChild.children.length === 1;
|
||||||
|
const forceTextSpan = fragment.firstElementChild.dataset.textSpan === "force";
|
||||||
|
if (hasOnlyOneParagraph
|
||||||
|
&& hasOnlyOneTextSpan
|
||||||
|
&& forceTextSpan) {
|
||||||
|
selectionController.replaceText(fragment.textContent);
|
||||||
} else {
|
} else {
|
||||||
selectionController.replaceWithPaste(fragment);
|
selectionController.replaceWithPaste(fragment);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,14 +230,19 @@ export function mapContentFragmentFromString(string, styleDefaults) {
|
|||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line === "") {
|
if (line === "") {
|
||||||
fragment.appendChild(createEmptyParagraph(styleDefaults));
|
|
||||||
} else {
|
|
||||||
fragment.appendChild(
|
fragment.appendChild(
|
||||||
createParagraph(
|
createEmptyParagraph(styleDefaults)
|
||||||
[createTextSpan(new Text(line), styleDefaults)],
|
|
||||||
styleDefaults,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
const textSpan = createTextSpan(new Text(line), styleDefaults);
|
||||||
|
const paragraph = createParagraph(
|
||||||
|
[textSpan],
|
||||||
|
styleDefaults,
|
||||||
|
);
|
||||||
|
if (lines.length === 1) {
|
||||||
|
paragraph.dataset.textSpan = "force";
|
||||||
|
}
|
||||||
|
fragment.appendChild(paragraph);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fragment;
|
return fragment;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
* Copyright (c) KALEIDOS INC
|
* Copyright (c) KALEIDOS INC
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import StyleDeclaration from '../../controllers/StyleDeclaration.js';
|
||||||
import { getFills } from "./Color.js";
|
import { getFills } from "./Color.js";
|
||||||
|
|
||||||
const DEFAULT_FONT_SIZE = "16px";
|
const DEFAULT_FONT_SIZE = "16px";
|
||||||
@@ -386,7 +387,8 @@ export function setStylesFromDeclaration(
|
|||||||
* @returns {HTMLElement}
|
* @returns {HTMLElement}
|
||||||
*/
|
*/
|
||||||
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
|
export function setStyles(element, allowedStyles, styleObjectOrDeclaration) {
|
||||||
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration) {
|
if (styleObjectOrDeclaration instanceof CSSStyleDeclaration
|
||||||
|
|| styleObjectOrDeclaration instanceof StyleDeclaration) {
|
||||||
return setStylesFromDeclaration(
|
return setStylesFromDeclaration(
|
||||||
element,
|
element,
|
||||||
allowedStyles,
|
allowedStyles,
|
||||||
@@ -426,13 +428,15 @@ export function mergeStyles(allowedStyles, styleDeclaration, newStyles) {
|
|||||||
const mergedStyles = {};
|
const mergedStyles = {};
|
||||||
for (const [styleName, styleUnit] of allowedStyles) {
|
for (const [styleName, styleUnit] of allowedStyles) {
|
||||||
if (styleName in newStyles) {
|
if (styleName in newStyles) {
|
||||||
mergedStyles[styleName] = newStyles[styleName];
|
const styleValue = newStyles[styleName];
|
||||||
|
mergedStyles[styleName] = styleValue;
|
||||||
} else {
|
} else {
|
||||||
mergedStyles[styleName] = getStyleFromDeclaration(
|
const styleValue = getStyleFromDeclaration(
|
||||||
styleDeclaration,
|
styleDeclaration,
|
||||||
styleName,
|
styleName,
|
||||||
styleUnit,
|
styleUnit,
|
||||||
);
|
);
|
||||||
|
mergedStyles[styleName] = styleValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return mergedStyles;
|
return mergedStyles;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
* Copyright (c) KALEIDOS INC
|
* Copyright (c) KALEIDOS INC
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import SafeGuard from '../../controllers/SafeGuard.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Iterator direction.
|
* Iterator direction.
|
||||||
*
|
*
|
||||||
@@ -245,6 +247,51 @@ export class TextNodeIterator {
|
|||||||
this.#currentNode = previousNode;
|
this.#currentNode = previousNode;
|
||||||
return this.#currentNode;
|
return this.#currentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an array of text nodes.
|
||||||
|
*
|
||||||
|
* @param {TextNode} startNode
|
||||||
|
* @param {TextNode} endNode
|
||||||
|
* @returns {Array<TextNode>}
|
||||||
|
*/
|
||||||
|
collectFrom(startNode, endNode) {
|
||||||
|
const nodes = [];
|
||||||
|
for (const node of this.iterateFrom(startNode, endNode)) {
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iterates over a list of nodes.
|
||||||
|
*
|
||||||
|
* @param {TextNode} startNode
|
||||||
|
* @param {TextNode} endNode
|
||||||
|
* @yields {TextNode}
|
||||||
|
*/
|
||||||
|
* iterateFrom(startNode, endNode) {
|
||||||
|
const comparedPosition = startNode.compareDocumentPosition(
|
||||||
|
endNode
|
||||||
|
);
|
||||||
|
this.#currentNode = startNode;
|
||||||
|
SafeGuard.start();
|
||||||
|
while (this.#currentNode !== endNode) {
|
||||||
|
yield this.#currentNode;
|
||||||
|
SafeGuard.update();
|
||||||
|
if (comparedPosition === Node.DOCUMENT_POSITION_PRECEDING) {
|
||||||
|
if (!this.previousNode()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (comparedPosition === Node.DOCUMENT_POSITION_FOLLOWING) {
|
||||||
|
if (!this.nextNode()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default TextNodeIterator;
|
export default TextNodeIterator;
|
||||||
|
|||||||
@@ -28,7 +28,20 @@ export function update() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let timeoutId = 0
|
||||||
|
export function throwAfter(error, timeout = SAFE_GUARD_TIME) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
throw error
|
||||||
|
}, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function throwCancel() {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
start,
|
start,
|
||||||
update,
|
update,
|
||||||
}
|
throwAfter,
|
||||||
|
throwCancel,
|
||||||
|
};
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { isRoot, setRootStyles } from "../content/dom/Root.js";
|
|||||||
import { SelectionDirection } from "./SelectionDirection.js";
|
import { SelectionDirection } from "./SelectionDirection.js";
|
||||||
import SafeGuard from "./SafeGuard.js";
|
import SafeGuard from "./SafeGuard.js";
|
||||||
import { sanitizeFontFamily } from "../content/dom/Style.js";
|
import { sanitizeFontFamily } from "../content/dom/Style.js";
|
||||||
|
import StyleDeclaration from './StyleDeclaration.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Supported options for the SelectionController.
|
* Supported options for the SelectionController.
|
||||||
@@ -64,39 +65,7 @@ import { sanitizeFontFamily } from "../content/dom/Style.js";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* SelectionController uses the same concepts used by the Selection API but extending it to support
|
* SelectionController uses the same concepts used by the Selection API but extending it to support
|
||||||
* our own internal model based on paragraphs (in drafconst textEditorMock = TextEditorMock.createTextEditorMockWithParagraphs([
|
* our own internal model based on paragraphs (in draft.js they were called blocks) and text spans.
|
||||||
createParagraph([createTextSpan(new Text("Hello, "))]),
|
|
||||||
createEmptyParagraph(),
|
|
||||||
createParagraph([createTextSpan(new Text("World!"))]),
|
|
||||||
]);
|
|
||||||
const root = textEditorMock.root;
|
|
||||||
const selection = document.getSelection();
|
|
||||||
const selectionController = new SelectionController(
|
|
||||||
textEditorMock,
|
|
||||||
selection
|
|
||||||
);
|
|
||||||
focus(
|
|
||||||
selection,
|
|
||||||
textEditorMock,
|
|
||||||
root.childNodes.item(2).firstChild.firstChild,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
selectionController.mergeBackwardParagraph();
|
|
||||||
expect(textEditorMock.root).toBeInstanceOf(HTMLDivElement);
|
|
||||||
expect(textEditorMock.root.children.length).toBe(2);
|
|
||||||
expect(textEditorMock.root.dataset.itype).toBe("root");
|
|
||||||
expect(textEditorMock.root.firstChild).toBeInstanceOf(HTMLDivElement);
|
|
||||||
expect(textEditorMock.root.firstChild.dataset.itype).toBe("paragraph");
|
|
||||||
expect(textEditorMock.root.firstChild.firstChild).toBeInstanceOf(
|
|
||||||
HTMLSpanElement
|
|
||||||
);
|
|
||||||
expect(textEditorMock.root.firstChild.firstChild.dataset.itype).toBe(
|
|
||||||
"span"
|
|
||||||
);
|
|
||||||
expect(textEditorMock.root.textContent).toBe("Hello, World!");
|
|
||||||
expect(textEditorMock.root.firstChild.textContent).toBe("Hello, ");
|
|
||||||
expect(textEditorMock.root.lastChild.textContent).toBe("World!");
|
|
||||||
t.js they were called blocks) and text spans.
|
|
||||||
*/
|
*/
|
||||||
export class SelectionController extends EventTarget {
|
export class SelectionController extends EventTarget {
|
||||||
/**
|
/**
|
||||||
@@ -164,21 +133,12 @@ export class SelectionController extends EventTarget {
|
|||||||
#textNodeIterator = null;
|
#textNodeIterator = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSSStyleDeclaration that we can mutate
|
* StyleDeclaration that we can mutate
|
||||||
* to handle style changes.
|
* to handle style changes.
|
||||||
*
|
*
|
||||||
* @type {CSSStyleDeclaration}
|
* @type {StyleDeclaration}
|
||||||
*/
|
*/
|
||||||
#currentStyle = null;
|
#currentStyle = new StyleDeclaration();
|
||||||
|
|
||||||
/**
|
|
||||||
* Element used to have a custom CSSStyleDeclaration
|
|
||||||
* that we can modify to handle style changes when the
|
|
||||||
* selection is changed.
|
|
||||||
*
|
|
||||||
* @type {HTMLDivElement}
|
|
||||||
*/
|
|
||||||
#inertElement = null;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @type {SelectionControllerDebug}
|
* @type {SelectionControllerDebug}
|
||||||
@@ -275,19 +235,58 @@ export class SelectionController extends EventTarget {
|
|||||||
*
|
*
|
||||||
* @param {HTMLElement} element
|
* @param {HTMLElement} element
|
||||||
*/
|
*/
|
||||||
#applyStylesToCurrentStyle(element) {
|
#applyStylesFromElementToCurrentStyle(element) {
|
||||||
for (let index = 0; index < element.style.length; index++) {
|
for (let index = 0; index < element.style.length; index++) {
|
||||||
const styleName = element.style.item(index);
|
const styleName = element.style.item(index);
|
||||||
|
|
||||||
let styleValue = element.style.getPropertyValue(styleName);
|
let styleValue = element.style.getPropertyValue(styleName);
|
||||||
if (styleName === "font-family") {
|
if (styleName === "font-family") {
|
||||||
styleValue = sanitizeFontFamily(styleValue);
|
styleValue = sanitizeFontFamily(styleValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#currentStyle.setProperty(styleName, styleValue);
|
this.#currentStyle.setProperty(styleName, styleValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies some styles to the currentStyle
|
||||||
|
* CSSStyleDeclaration
|
||||||
|
*
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
*/
|
||||||
|
#mergeStylesFromElementToCurrentStyle(element) {
|
||||||
|
for (let index = 0; index < element.style.length; index++) {
|
||||||
|
const styleName = element.style.item(index);
|
||||||
|
let styleValue = element.style.getPropertyValue(styleName);
|
||||||
|
if (styleName === "font-family") {
|
||||||
|
styleValue = sanitizeFontFamily(styleValue);
|
||||||
|
}
|
||||||
|
this.#currentStyle.mergeProperty(styleName, styleValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates current styles based on the currently selected text spans.
|
||||||
|
*
|
||||||
|
* @param {HTMLSpanElement} startNode
|
||||||
|
* @param {HTMLSpanElement} endNode
|
||||||
|
*/
|
||||||
|
#updateCurrentStyleFrom(startNode, endNode) {
|
||||||
|
this.#applyDefaultStylesToCurrentStyle();
|
||||||
|
const root = startNode.parentElement.parentElement.parentElement;
|
||||||
|
this.#applyStylesFromElementToCurrentStyle(root);
|
||||||
|
// FIXME: I don't like this approximation. Having to iterate nodes twice
|
||||||
|
// is bad for performance. I think we need another way of "computing"
|
||||||
|
// the cascade.
|
||||||
|
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) {
|
||||||
|
const paragraph = textNode.parentElement.parentElement;
|
||||||
|
this.#applyStylesFromElementToCurrentStyle(paragraph);
|
||||||
|
}
|
||||||
|
for (const textNode of this.#textNodeIterator.iterateFrom(startNode, endNode)) {
|
||||||
|
const textSpan = textNode.parentElement;
|
||||||
|
this.#mergeStylesFromElementToCurrentStyle(textSpan);
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates current styles based on the currently selected text span.
|
* Updates current styles based on the currently selected text span.
|
||||||
*
|
*
|
||||||
@@ -297,20 +296,14 @@ export class SelectionController extends EventTarget {
|
|||||||
#updateCurrentStyle(textSpan) {
|
#updateCurrentStyle(textSpan) {
|
||||||
this.#applyDefaultStylesToCurrentStyle();
|
this.#applyDefaultStylesToCurrentStyle();
|
||||||
const root = textSpan.parentElement.parentElement;
|
const root = textSpan.parentElement.parentElement;
|
||||||
this.#applyStylesToCurrentStyle(root);
|
this.#applyStylesFromElementToCurrentStyle(root);
|
||||||
const paragraph = textSpan.parentElement;
|
const paragraph = textSpan.parentElement;
|
||||||
this.#applyStylesToCurrentStyle(paragraph);
|
this.#applyStylesFromElementToCurrentStyle(paragraph);
|
||||||
this.#applyStylesToCurrentStyle(textSpan);
|
this.#applyStylesFromElementToCurrentStyle(textSpan);
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
#updateState() {
|
||||||
* This is called on every `selectionchange` because it is dispatched
|
|
||||||
* only by the `document` object.
|
|
||||||
*
|
|
||||||
* @param {Event} e
|
|
||||||
*/
|
|
||||||
#onSelectionChange = (e) => {
|
|
||||||
// If we're outside the contenteditable element, then
|
// If we're outside the contenteditable element, then
|
||||||
// we return.
|
// we return.
|
||||||
if (!this.hasFocus) {
|
if (!this.hasFocus) {
|
||||||
@@ -320,17 +313,23 @@ export class SelectionController extends EventTarget {
|
|||||||
let focusNodeChanges = false;
|
let focusNodeChanges = false;
|
||||||
let anchorNodeChanges = false;
|
let anchorNodeChanges = false;
|
||||||
|
|
||||||
if (this.#focusNode !== this.#selection.focusNode) {
|
if (
|
||||||
|
this.#focusNode !== this.#selection.focusNode ||
|
||||||
|
this.#focusOffset !== this.#selection.focusOffset
|
||||||
|
) {
|
||||||
this.#focusNode = this.#selection.focusNode;
|
this.#focusNode = this.#selection.focusNode;
|
||||||
|
this.#focusOffset = this.#selection.focusOffset;
|
||||||
focusNodeChanges = true;
|
focusNodeChanges = true;
|
||||||
}
|
}
|
||||||
this.#focusOffset = this.#selection.focusOffset;
|
|
||||||
|
|
||||||
if (this.#anchorNode !== this.#selection.anchorNode) {
|
if (
|
||||||
|
this.#anchorNode !== this.#selection.anchorNode ||
|
||||||
|
this.#anchorOffset !== this.#selection.anchorOffset
|
||||||
|
) {
|
||||||
this.#anchorNode = this.#selection.anchorNode;
|
this.#anchorNode = this.#selection.anchorNode;
|
||||||
|
this.#anchorOffset = this.#selection.anchorOffset;
|
||||||
anchorNodeChanges = true;
|
anchorNodeChanges = true;
|
||||||
}
|
}
|
||||||
this.#anchorOffset = this.#selection.anchorOffset;
|
|
||||||
|
|
||||||
// We need to handle multi selection from firefox
|
// We need to handle multi selection from firefox
|
||||||
// and remove all the old ranges and just keep the
|
// and remove all the old ranges and just keep the
|
||||||
@@ -359,7 +358,7 @@ export class SelectionController extends EventTarget {
|
|||||||
// If focus node changed, we need to retrieve all the
|
// If focus node changed, we need to retrieve all the
|
||||||
// styles of the current text span and dispatch an event
|
// styles of the current text span and dispatch an event
|
||||||
// to notify that the styles have changed.
|
// to notify that the styles have changed.
|
||||||
if (focusNodeChanges) {
|
if (focusNodeChanges || anchorNodeChanges) {
|
||||||
this.#notifyStyleChange();
|
this.#notifyStyleChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,43 +371,42 @@ export class SelectionController extends EventTarget {
|
|||||||
if (this.#debug) {
|
if (this.#debug) {
|
||||||
this.#debug.update(this);
|
this.#debug.update(this);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is called on every `selectionchange` because it is dispatched
|
||||||
|
* only by the `document` object.
|
||||||
|
*
|
||||||
|
* @param {Event} e
|
||||||
|
*/
|
||||||
|
#onSelectionChange = (_) => this.#updateState();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifies that the styles have changed.
|
* Notifies that the styles have changed.
|
||||||
*/
|
*/
|
||||||
#notifyStyleChange() {
|
#notifyStyleChange() {
|
||||||
const textSpan = this.focusTextSpan;
|
if (this.#selection.isCollapsed) {
|
||||||
if (textSpan) {
|
// CARET
|
||||||
this.#updateCurrentStyle(textSpan);
|
const textSpan =
|
||||||
this.dispatchEvent(
|
this.focusTextSpan ??
|
||||||
new CustomEvent("stylechange", {
|
|
||||||
detail: this.#currentStyle,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const firstTextSpan =
|
|
||||||
this.#textEditor.root?.firstElementChild?.firstElementChild;
|
this.#textEditor.root?.firstElementChild?.firstElementChild;
|
||||||
if (firstTextSpan) {
|
|
||||||
this.#updateCurrentStyle(firstTextSpan);
|
this.#updateCurrentStyle(textSpan);
|
||||||
|
} else {
|
||||||
|
// SELECTION.
|
||||||
|
this.#updateCurrentStyleFrom(this.#anchorNode, this.#focusNode);
|
||||||
|
}
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent("stylechange", {
|
new CustomEvent("stylechange", {
|
||||||
detail: this.#currentStyle,
|
detail: this.#currentStyle,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setups
|
* Setups
|
||||||
*/
|
*/
|
||||||
#setup() {
|
#setup() {
|
||||||
// This element is not attached to the DOM
|
|
||||||
// so it doesn't trigger style or layout calculations.
|
|
||||||
// That's why it's called "inertElement".
|
|
||||||
this.#inertElement = document.createElement("div");
|
|
||||||
this.#currentStyle = this.#inertElement.style;
|
|
||||||
this.#applyDefaultStylesToCurrentStyle();
|
this.#applyDefaultStylesToCurrentStyle();
|
||||||
|
|
||||||
if (this.#selection.rangeCount > 0) {
|
if (this.#selection.rangeCount > 0) {
|
||||||
@@ -428,6 +426,22 @@ export class SelectionController extends EventTarget {
|
|||||||
document.addEventListener("selectionchange", this.#onSelectionChange);
|
document.addEventListener("selectionchange", this.#onSelectionChange);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disposes the current resources.
|
||||||
|
*/
|
||||||
|
dispose() {
|
||||||
|
document.removeEventListener("selectionchange", this.#onSelectionChange);
|
||||||
|
this.#textEditor = null;
|
||||||
|
this.#ranges.clear();
|
||||||
|
this.#ranges = null;
|
||||||
|
this.#range = null;
|
||||||
|
this.#selection = null;
|
||||||
|
this.#focusNode = null;
|
||||||
|
this.#anchorNode = null;
|
||||||
|
this.#mutations.dispose();
|
||||||
|
this.#mutations = null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a Range-like object.
|
* Returns a Range-like object.
|
||||||
*
|
*
|
||||||
@@ -502,6 +516,8 @@ export class SelectionController extends EventTarget {
|
|||||||
* Marks the start of a mutation.
|
* Marks the start of a mutation.
|
||||||
*
|
*
|
||||||
* Clears all the mutations kept in CommandMutations.
|
* Clears all the mutations kept in CommandMutations.
|
||||||
|
*
|
||||||
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
startMutation() {
|
startMutation() {
|
||||||
this.#mutations.clear();
|
this.#mutations.clear();
|
||||||
@@ -512,7 +528,7 @@ export class SelectionController extends EventTarget {
|
|||||||
/**
|
/**
|
||||||
* Marks the end of a mutation.
|
* Marks the end of a mutation.
|
||||||
*
|
*
|
||||||
* @returns
|
* @returns {CommandMutations}
|
||||||
*/
|
*/
|
||||||
endMutation() {
|
endMutation() {
|
||||||
return this.#mutations;
|
return this.#mutations;
|
||||||
@@ -520,6 +536,8 @@ export class SelectionController extends EventTarget {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Selects all content.
|
* Selects all content.
|
||||||
|
*
|
||||||
|
* @returns {SelectionController}
|
||||||
*/
|
*/
|
||||||
selectAll() {
|
selectAll() {
|
||||||
if (this.#textEditor.isEmpty) {
|
if (this.#textEditor.isEmpty) {
|
||||||
@@ -558,23 +576,15 @@ export class SelectionController extends EventTarget {
|
|||||||
this.#selection.removeAllRanges();
|
this.#selection.removeAllRanges();
|
||||||
this.#selection.addRange(range);
|
this.#selection.addRange(range);
|
||||||
|
|
||||||
// Ensure internal state is synchronized
|
this.#updateState();
|
||||||
this.#focusNode = this.#selection.focusNode;
|
|
||||||
this.#focusOffset = this.#selection.focusOffset;
|
|
||||||
this.#anchorNode = this.#selection.anchorNode;
|
|
||||||
this.#anchorOffset = this.#selection.anchorOffset;
|
|
||||||
this.#range = range;
|
|
||||||
this.#ranges.clear();
|
|
||||||
this.#ranges.add(range);
|
|
||||||
|
|
||||||
// Notify style changes
|
|
||||||
this.#notifyStyleChange();
|
|
||||||
|
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Moves cursor to end.
|
* Moves cursor to end.
|
||||||
|
*
|
||||||
|
* @returns {SelectionController}
|
||||||
*/
|
*/
|
||||||
cursorToEnd() {
|
cursorToEnd() {
|
||||||
const range = document.createRange(); //Create a range (a range is a like the selection but invisible)
|
const range = document.createRange(); //Create a range (a range is a like the selection but invisible)
|
||||||
@@ -662,22 +672,6 @@ export class SelectionController extends EventTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Disposes the current resources.
|
|
||||||
*/
|
|
||||||
dispose() {
|
|
||||||
document.removeEventListener("selectionchange", this.#onSelectionChange);
|
|
||||||
this.#textEditor = null;
|
|
||||||
this.#ranges.clear();
|
|
||||||
this.#ranges = null;
|
|
||||||
this.#range = null;
|
|
||||||
this.#selection = null;
|
|
||||||
this.#focusNode = null;
|
|
||||||
this.#anchorNode = null;
|
|
||||||
this.#mutations.dispose();
|
|
||||||
this.#mutations = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the current selection.
|
* Returns the current selection.
|
||||||
*
|
*
|
||||||
@@ -1114,8 +1108,8 @@ export class SelectionController extends EventTarget {
|
|||||||
return isParagraphEnd(this.focusNode, this.focusOffset);
|
return isParagraphEnd(this.focusNode, this.focusOffset);
|
||||||
}
|
}
|
||||||
|
|
||||||
#getFragmentInlineTextNode(fragment) {
|
#getFragmentTextSpanTextNode(fragment) {
|
||||||
if (isInline(fragment.firstElementChild.lastChild)) {
|
if (isTextSpan(fragment.firstElementChild.lastChild)) {
|
||||||
return fragment.firstElementChild.firstElementChild.lastChild;
|
return fragment.firstElementChild.firstElementChild.lastChild;
|
||||||
}
|
}
|
||||||
return fragment.firstElementChild.lastChild;
|
return fragment.firstElementChild.lastChild;
|
||||||
@@ -1131,11 +1125,15 @@ export class SelectionController extends EventTarget {
|
|||||||
* @param {DocumentFragment} fragment
|
* @param {DocumentFragment} fragment
|
||||||
*/
|
*/
|
||||||
insertPaste(fragment) {
|
insertPaste(fragment) {
|
||||||
|
const hasOnlyOneParagraph = fragment.children.length === 1;
|
||||||
|
const forceTextSpan =
|
||||||
|
fragment.firstElementChild?.dataset?.textSpan === "force";
|
||||||
if (
|
if (
|
||||||
fragment.children.length === 1 &&
|
hasOnlyOneParagraph &&
|
||||||
fragment.firstElementChild?.dataset?.textSpan === "force"
|
forceTextSpan
|
||||||
) {
|
) {
|
||||||
const collapseNode = fragment.firstElementChild.firstChild;
|
// first text span
|
||||||
|
const collapseNode = fragment.firstElementChild.firstElementChild;
|
||||||
if (this.isTextSpanStart) {
|
if (this.isTextSpanStart) {
|
||||||
this.focusTextSpan.before(...fragment.firstElementChild.children);
|
this.focusTextSpan.before(...fragment.firstElementChild.children);
|
||||||
} else if (this.isTextSpanEnd) {
|
} else if (this.isTextSpanEnd) {
|
||||||
@@ -1147,7 +1145,9 @@ export class SelectionController extends EventTarget {
|
|||||||
newTextSpan,
|
newTextSpan,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.collapse(collapseNode, collapseNode.nodeValue?.length || 0);
|
// collapseNode could be a <br>, that's why we need to
|
||||||
|
// make `nodeValue` as optional.
|
||||||
|
return this.collapse(collapseNode, collapseNode?.nodeValue?.length || 0);
|
||||||
}
|
}
|
||||||
const collapseNode = this.#getFragmentParagraphTextNode(fragment);
|
const collapseNode = this.#getFragmentParagraphTextNode(fragment);
|
||||||
if (this.isParagraphStart) {
|
if (this.isParagraphStart) {
|
||||||
@@ -1393,9 +1393,16 @@ export class SelectionController extends EventTarget {
|
|||||||
this.focusOffset,
|
this.focusOffset,
|
||||||
newText,
|
newText,
|
||||||
);
|
);
|
||||||
|
this.collapse(this.focusNode, this.focusOffset + newText.length);
|
||||||
} else if (this.isLineBreakFocus) {
|
} else if (this.isLineBreakFocus) {
|
||||||
const textNode = new Text(newText);
|
const textNode = new Text(newText);
|
||||||
|
// the focus node is a <span>.
|
||||||
|
if (isTextSpan(this.focusNode)) {
|
||||||
|
this.focusNode.firstElementChild.replaceWith(textNode);
|
||||||
|
// the focus node is a <br>.
|
||||||
|
} else {
|
||||||
this.focusNode.replaceWith(textNode);
|
this.focusNode.replaceWith(textNode);
|
||||||
|
}
|
||||||
this.collapse(textNode, newText.length);
|
this.collapse(textNode, newText.length);
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Unknown node type");
|
throw new Error("Unknown node type");
|
||||||
|
|||||||
108
frontend/text-editor/src/editor/controllers/StyleDeclaration.js
Normal file
108
frontend/text-editor/src/editor/controllers/StyleDeclaration.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
export class StyleDeclaration {
|
||||||
|
static Property = class Property {
|
||||||
|
static NULL = '["~#\'",null]';
|
||||||
|
|
||||||
|
static Default = new Property("", "", "");
|
||||||
|
|
||||||
|
name;
|
||||||
|
value = "";
|
||||||
|
priority = "";
|
||||||
|
|
||||||
|
constructor(name, value = "", priority = "") {
|
||||||
|
this.name = name;
|
||||||
|
this.value = value ?? "";
|
||||||
|
this.priority = priority ?? "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
#items = new Map();
|
||||||
|
|
||||||
|
get cssFloat() {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
get cssText() {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
get parentRule() {
|
||||||
|
throw new Error("Not implemented");
|
||||||
|
}
|
||||||
|
|
||||||
|
get length() {
|
||||||
|
return this.#items.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
#getProperty(name) {
|
||||||
|
return this.#items.get(name) ?? StyleDeclaration.Property.Default;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPropertyPriority(name) {
|
||||||
|
const { priority } = this.#getProperty(name);
|
||||||
|
return priority ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
getPropertyValue(name) {
|
||||||
|
const { value } = this.#getProperty(name);
|
||||||
|
return value ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
item(index) {
|
||||||
|
return Array.from(this.#items).at(index).name;
|
||||||
|
}
|
||||||
|
|
||||||
|
removeProperty(name) {
|
||||||
|
const value = this.getPropertyValue(name);
|
||||||
|
this.#items.delete(name);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
setProperty(name, value, priority) {
|
||||||
|
this.#items.set(name, new StyleDeclaration.Property(name, value, priority));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Non compatible methods */
|
||||||
|
#isQuotedValue(a, b) {
|
||||||
|
if (a.startsWith('"') && b.startsWith('"')) {
|
||||||
|
return a === b;
|
||||||
|
} else if (a.startsWith('"') && !b.startsWith('"')) {
|
||||||
|
return a.slice(1, -1) === b;
|
||||||
|
} else if (!a.startsWith('"') && b.startsWith('"')) {
|
||||||
|
return a === b.slice(1, -1);
|
||||||
|
}
|
||||||
|
return a === b;
|
||||||
|
}
|
||||||
|
|
||||||
|
mergeProperty(name, value) {
|
||||||
|
const currentValue = this.getPropertyValue(name);
|
||||||
|
if (this.#isQuotedValue(currentValue, value)) {
|
||||||
|
return this.setProperty(name, value);
|
||||||
|
} else if (currentValue === "" && value === StyleDeclaration.Property.NULL) {
|
||||||
|
return this.setProperty(name, value);
|
||||||
|
} else if (currentValue === "" && ["initial", "none"].includes(value)) {
|
||||||
|
return this.setProperty(name, value);
|
||||||
|
} else if (currentValue !== value) {
|
||||||
|
return this.setProperty(name, "mixed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fromCSSStyleDeclaration(cssStyleDeclaration) {
|
||||||
|
for (let index = 0; index < cssStyleDeclaration.length; index++) {
|
||||||
|
const name = cssStyleDeclaration.item(index);
|
||||||
|
const value = cssStyleDeclaration.getPropertyValue(name);
|
||||||
|
const priority = cssStyleDeclaration.getPropertyPriority(name);
|
||||||
|
this.setProperty(name, value, priority);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toObject() {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Array.from(this.#items.entries(), ([name, property]) => [
|
||||||
|
name,
|
||||||
|
property.value,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StyleDeclaration
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, test, expect } from "vitest";
|
||||||
|
import { StyleDeclaration } from "./StyleDeclaration.js";
|
||||||
|
|
||||||
|
describe("StyleDeclaration", () => {
|
||||||
|
test("Create a new StyleDeclaration", () => {
|
||||||
|
const styleDeclaration = new StyleDeclaration();
|
||||||
|
expect(styleDeclaration).toBeInstanceOf(StyleDeclaration);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Uninmplemented getters should throw", () => {
|
||||||
|
expect(() => styleDeclaration.cssFloat).toThrow();
|
||||||
|
expect(() => styleDeclaration.cssText).toThrow();
|
||||||
|
expect(() => styleDeclaration.parentRule).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Set property", () => {
|
||||||
|
const styleDeclaration = new StyleDeclaration();
|
||||||
|
styleDeclaration.setProperty("line-height", "1.2");
|
||||||
|
expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2");
|
||||||
|
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Remove property", () => {
|
||||||
|
const styleDeclaration = new StyleDeclaration();
|
||||||
|
styleDeclaration.setProperty("line-height", "1.2");
|
||||||
|
expect(styleDeclaration.getPropertyValue("line-height")).toBe("1.2");
|
||||||
|
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
|
||||||
|
styleDeclaration.removeProperty("line-height");
|
||||||
|
expect(styleDeclaration.getPropertyValue("line-height")).toBe("");
|
||||||
|
expect(styleDeclaration.getPropertyPriority("line-height")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user