diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4ba57dde95..fa21646cac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -146,11 +146,18 @@ jobs: name: "Frontend Tests" runs-on: penpot-runner-02 container: penpotapp/devenv:latest + needs: test-render-wasm steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Restore shared.js + uses: actions/cache/restore@v4 + with: + key: "render-wasm-shared-js-${{ github.sha }}" + path: frontend/src/app/render_wasm/api/shared.js + - name: Unit Tests working-directory: ./frontend run: | @@ -187,6 +194,19 @@ jobs: run: | ./test + - name: Copy shared.js artifact + working-directory: ./render-wasm + run: | + SHARED_FILE=$(find target -name render_wasm_shared.js | head -n 1); + mkdir -p ../frontend/src/app/render_wasm/api; + cp $SHARED_FILE ../frontend/src/app/render_wasm/api/shared.js; + + - name: Cache shared.js + uses: actions/cache@v4 + with: + key: "render-wasm-shared-js-${{ github.sha }}" + path: frontend/src/app/render_wasm/api/shared.js + test-backend: name: "Backend Tests" runs-on: penpot-runner-02 diff --git a/AGENTS.md b/AGENTS.md index 9505d47698..15da56f2ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,44 @@ -# Penpot – Instructions +# IA Agent Guide for Penpot -## Architecture Overview +This document provides comprehensive context and guidelines for AI agents working on this repository. -Penpot is a full-stack design tool composed of several distinct components: + +## STOP - DO NOT PROCEED WITHOUT COMPLETING THESE STEPS + +Before responding to ANY user request, you MUST: + +1. **READ** the CONTRIBUTING.md file +2. **READ** this file and has special focus on your ROLE. + + +## ROLE: SENIOR SOFTWARE ENGINEER + +You are a high-autonomy Senior Software Engineer. You have full +permission to navigate the codebase, modify files, and execute +commands to fulfill your tasks. Your goal is to solve complex +technical tasks with high precision, focusing on maintainability and +performance. + +### OPERATIONAL GUIDELINES + +1. Always begin by analyzing this document and understand the architecture and "Golden Rules". +2. Before writing code, describe your plan. If the task is complex, break it down into atomic steps. +3. Be concise and autonomous as possible in your task. + +### SEARCH STANDARDS + +When searching code, always use `ripgrep` (rg) instead of grep if +available, as it respects `.gitignore` by default. + +If using grep, try to exclude node_modules and .shadow-cljs directories + + +## ARCHITECTURE + +### Overview + +Penpot is a full-stack design tool composed of several distinct +components separated in modules and subdirectories: | Component | Language | Role | |-----------|----------|------| @@ -18,119 +54,6 @@ The monorepo is managed with `pnpm` workspaces. The `manage.sh` orchestrates cross-component builds. `run-ci.sh` defines the CI pipeline. -## Search Standards - -When searching code, always use `ripgrep` (rg) instead of grep if -available, as it respects `.gitignore` by default. - -If using grep, try to exclude node_modules and .shadow-cljs directories - - -## Build, Test & Lint Commands - -### Frontend (`cd frontend`) - -Run `./scripts/setup` for setup all dependencies. - - -```bash -# Build (Producution) -./scripts/build - -# Tests -pnpm run test # Build ClojureScript tests + run node target/tests/test.js - -# Lint -pnpm run lint:js # Linter for JS/TS -pnpm run lint:clj # Linter for CLJ/CLJS/CLJC -pnpm run lint:scss # Linter for SCSS - -# Check Code Formart -pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC -pnpm run check-fmt:js # Format JS/TS -pnpm run check-fmt:scss # Format SCSS - -# Code Format (Automatic Formating) -pnpm run fmt:clj # Format CLJ/CLJS/CLJC -pnpm run fmt:js # Format JS/TS -pnpm run fmt:scss # Format SCSS -``` - -To run a focused ClojureScript unit test: edit -`test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm -run build:test && node target/tests/test.js`. - - -### Backend (`cd backend`) - -Run `pnpm install` for install all dependencies. - -```bash -# Run full test suite -pnpm run test - -# Run single namespace -pnpm run test --focus backend-tests.rpc-doc-test - -# Check Code Format -pnpm run check-fmt - -# Code Format (Automatic Formatting) -pnpm run fmt - -# Code Linter -pnpm run lint -``` - -Test config is in `backend/tests.edn`; test namespaces match -`.*-test$` under `test/` directory. You should not touch this file, -just use it for reference. - - -### Common (`cd common`) - -This contains code that should compile and run under different runtimes: JVM & JS so the commands are -separarated for each runtime. - -```bash -clojure -M:dev:test # Run full test suite under JVM -clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM - -# Run full test suite under JS or JVM runtimes -pnpm run test:js -pnpm run test:jvm - -# Run single namespace (only on JVM) -pnpm run test:jvm --focus common-tests.my-ns-test - -# Lint -pnpm run lint:clj # Lint CLJ/CLJS/CLJC code - -# Check Format -pnpm run check-fmt:clj # Check CLJ/CLJS/CLJS code -pnpm run check-fmt:js # Check JS/TS code - -# Code Format (Automatic Formatting) -pnpm run fmt:clj # Check CLJ/CLJS/CLJS code -pnpm run fmt:js # Check JS/TS code -``` - -To run a focused ClojureScript unit test: edit -`test/common_tests/runner.cljs` to narrow the test suite, then `pnpm -run build:test && node target/tests/test.js`. - - -### Render-WASM (`cd render-wasm`) - -```bash -./test # Rust unit tests (cargo test) -./build # Compile to WASM (requires Emscripten) -cargo fmt --check -./lint --debug -``` - -## Key Conventions - ### Namespace Structure The backend, frontend and exporter are developed using clojure and @@ -162,7 +85,9 @@ overview of the available namespaces. - `app.common.data.macros` – Performance macros used everywhere -### Backend RPC Commands +## Key Conventions + +### Backend RPC The PRC methods are implement in a some kind of multimethod structure using `app.util.serivices` namespace. All RPC methods are collected under `app.rpc` @@ -240,26 +165,7 @@ available, prefer adding a new helper for handling it and the use the new helper. -### CSS (Modules Pattern) - -Styles are co-located with components. Each `.cljs` file has a corresponding -`.scss` file: - -```clojure -;; In the component namespace: -(require '[app.main.style :as stl]) - -;; In the render function: -[:div {:class (stl/css :container :active)}] - -;; Conditional: -[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}] - -;; When you need concat an existing class: -[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}] -``` - -### Integration tests (Playwright) +### Integration Tests (Playwright) Integration tests are developed under `frontend/playwright` directory, we use mocks for remove communication with backend. @@ -286,7 +192,7 @@ Always prefer these macros over their `clojure.core` equivalents — they compil (dm/str "a" "b" "c") ;; string concatenation ``` -### Shared Code under Common (CLJC) +### Shared Code Files in `common/src/app/common/` use reader conditionals to target both runtimes: @@ -300,7 +206,7 @@ Both frontend and backend depend on `common` as a local library (`penpot/common -### Component Standards & Syntax (React & Rumext: mf/defc) +### UI Component Standards & Syntax (React & Rumext: mf/defc) The codebase contains various component patterns. When creating or refactoring components, follow the Modern Syntax rules outlined below. @@ -417,7 +323,113 @@ Examples: - [ ] Does the component name end with *? -## Commit Format Guidelines +### Build, Test & Lint commands + +#### Frontend (`cd frontend`) + +Run `./scripts/setup` for setup all dependencies. + + +```bash +# Build (Producution) +./scripts/build + +# Tests +pnpm run test # Build ClojureScript tests + run node target/tests/test.js + +# Lint +pnpm run lint:js # Linter for JS/TS +pnpm run lint:clj # Linter for CLJ/CLJS/CLJC +pnpm run lint:scss # Linter for SCSS + +# Check Code Formart +pnpm run check-fmt:clj # Format CLJ/CLJS/CLJC +pnpm run check-fmt:js # Format JS/TS +pnpm run check-fmt:scss # Format SCSS + +# Code Format (Automatic Formating) +pnpm run fmt:clj # Format CLJ/CLJS/CLJC +pnpm run fmt:js # Format JS/TS +pnpm run fmt:scss # Format SCSS +``` + +To run a focused ClojureScript unit test: edit +`test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm +run build:test && node target/tests/test.js`. + + +#### Backend (`cd backend`) + +Run `pnpm install` for install all dependencies. + +```bash +# Run full test suite +pnpm run test + +# Run single namespace +pnpm run test --focus backend-tests.rpc-doc-test + +# Check Code Format +pnpm run check-fmt + +# Code Format (Automatic Formatting) +pnpm run fmt + +# Code Linter +pnpm run lint +``` + +Test config is in `backend/tests.edn`; test namespaces match +`.*-test$` under `test/` directory. You should not touch this file, +just use it for reference. + + +#### Common (`cd common`) + +This contains code that should compile and run under different runtimes: JVM & JS so the commands are +separarated for each runtime. + +```bash +clojure -M:dev:test # Run full test suite under JVM +clojure -M:dev:test --focus backend-tests.my-ns-test # Run single namespace under JVM + +# Run full test suite under JS or JVM runtimes +pnpm run test:js +pnpm run test:jvm + +# Run single namespace (only on JVM) +pnpm run test:jvm --focus common-tests.my-ns-test + +# Lint +pnpm run lint:clj # Lint CLJ/CLJS/CLJC code + +# Check Format +pnpm run check-fmt:clj # Check CLJ/CLJS/CLJS code +pnpm run check-fmt:js # Check JS/TS code + +# Code Format (Automatic Formatting) +pnpm run fmt:clj # Check CLJ/CLJS/CLJS code +pnpm run fmt:js # Check JS/TS code +``` + +To run a focused ClojureScript unit test: edit +`test/common_tests/runner.cljs` to narrow the test suite, then `pnpm +run build:test && node target/tests/test.js`. + + +#### Render-WASM (`cd render-wasm`) + +```bash +./test # Rust unit tests (cargo test) +./build # Compile to WASM (requires Emscripten) +cargo fmt --check +./lint --debug +``` + + + + +### Commit Format Guidelines Format: ` ` @@ -456,9 +468,28 @@ applicable. | 🌐 | `:globe_with_meridians:` | Translations | -## SCSS Rules & Migration +### CSS +#### Usage convention for components -### General rules +Styles are co-located with components. Each `.cljs` file has a corresponding +`.scss` file: + +```clojure +;; In the component namespace: +(require '[app.main.style :as stl]) + +;; In the render function: +[:div {:class (stl/css :container :active)}] + +;; Conditional: +[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}] + +;; When you need concat an existing class: +[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}] +``` + +#### Styles rules & migration +##### General - Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss variables and get the already defined properties from `_sizes.scss`. The SCSS @@ -478,7 +509,7 @@ applicable. - Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like `@include flexCenter;`. Write standard CSS (flex/grid) instead. -### Syntax & Structure +##### Syntax & Structure - Use the `@use` instead of `@import`. If you go to refactor existing SCSS file, try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as @@ -489,9 +520,9 @@ applicable. - Leverage component-level CSS variables for state changes (hover/focus) instead of rewriting properties. -### Checklist +##### Checklist -- [ ] No references to `common/refactor/` +- [ ] No references to `common/refactor/` - [ ] All `@import` converted to `@use` (only if refactoring) - [ ] Physical properties (left/right) using logical properties (inline-start/end). - [ ] Typography implemented via `use-typography()` mixin. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2c27605924..187778913b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -206,3 +206,6 @@ Signed-off-by: Andrey Antukh Please, use your real name (sorry, no pseudonyms or anonymous contributions are allowed). + +The commit Signed-off-by is mandatory and should match the commit author. + diff --git a/frontend/playwright/data/workspace/get-file-13272-fragment.json b/frontend/playwright/data/workspace/get-file-13272-fragment.json new file mode 100644 index 0000000000..6510f95e6e --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-13272-fragment.json @@ -0,0 +1,20 @@ +{ + "~:file-id": "~u3b9773cc-d4f1-81e1-8007-b3f8dcaba770", + "~:id": "~u3ac58b88-38b3-80c9-8007-b4f791c7c36b", + "~:created-at": "~m1773398778658", + "~:modified-at": "~m1773398778658", + "~:type": "fragment", + "~:backend": "db", + "~:data": { + "~:objects": { + "~#penpot/objects-map/v2": { + "~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]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~: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,\"^6\",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,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~ub8c8efc9-e8a3-8018-8007-b4f6cd99ad3a\"]]]", + "~ub8c8efc9-e8a3-8018-8007-b4f6c15a473a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",76,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^<\",[\"^ \",\"~:x\",76,\"~:y\",0]],[\"^<\",[\"^ \",\"~:x\",76,\"~:y\",59]],[\"^<\",[\"^ \",\"~:x\",0,\"~:y\",59]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~ub8c8efc9-e8a3-8018-8007-b4f6c15a473a\",\"~:parent-id\",\"~ub8c8efc9-e8a3-8018-8007-b4f6cd99ad3a\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^8\",76,\"~:height\",59,\"~:x1\",0,\"~:y1\",0,\"~:x2\",76,\"~:y2\",59]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^J\",59,\"~:flip-y\",null]]", + "~ub8c8efc9-e8a3-8018-8007-b4f6c7677f74": "[\"~#shape\",[\"^ \",\"~:y\",74,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",95,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",57,\"~:y\",74]],[\"^<\",[\"^ \",\"~:x\",152,\"~:y\",74]],[\"^<\",[\"^ \",\"~:x\",152,\"~:y\",123]],[\"^<\",[\"^ \",\"~:x\",57,\"~:y\",123]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~ub8c8efc9-e8a3-8018-8007-b4f6c7677f74\",\"~:parent-id\",\"~ub8c8efc9-e8a3-8018-8007-b4f6cd99ad3a\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",57,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",57,\"~:y\",74,\"^8\",95,\"~:height\",49,\"~:x1\",57,\"~:y1\",74,\"~:x2\",152,\"~:y2\",123]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#B1B2B5\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^J\",49,\"~:flip-y\",null]]", + "~ub8c8efc9-e8a3-8018-8007-b4f6cd99ad3a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",2,\"~:name\",\"Group\",\"~:width\",152,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",152,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",152,\"~:y\",123]],[\"^:\",[\"^ \",\"~:x\",0,\"~:y\",123]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~ub8c8efc9-e8a3-8018-8007-b4f6cd99ad3a\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",152,\"~:height\",123,\"~:x1\",0,\"~:y1\",0,\"~:x2\",152,\"~:y2\",123]],\"~:fills\",[],\"~:flip-x\",null,\"^D\",123,\"~:flip-y\",null,\"~:shapes\",[\"~ub8c8efc9-e8a3-8018-8007-b4f6c15a473a\",\"~ub8c8efc9-e8a3-8018-8007-b4f6c7677f74\"]]]" + } + }, + "~:id": "~u3b9773cc-d4f1-81e1-8007-b3f8dcaba771", + "~:name": "Page 1" + } +} diff --git a/frontend/playwright/data/workspace/get-file-13272.json b/frontend/playwright/data/workspace/get-file-13272.json new file mode 100644 index 0000000000..50730bfc7e --- /dev/null +++ b/frontend/playwright/data/workspace/get-file-13272.json @@ -0,0 +1,135 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~ud715d0a5-a44e-8056-8005-a79999e18b64", + "~: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 13", + "~:revn": 79, + "~:modified-at": "~m1773398778654", + "~:vern": 0, + "~:id": "~u3b9773cc-d4f1-81e1-8007-b3f8dcaba770", + "~: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", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node", + "0017-fix-layout-flex-dir" + ] + }, + "~:version": 67, + "~:project-id": "~u76eab896-accf-81a5-8007-2b264ebe7817", + "~:created-at": "~m1773332008622", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u3b9773cc-d4f1-81e1-8007-b3f8dcaba771" + ], + "~:pages-index": { + "~u3b9773cc-d4f1-81e1-8007-b3f8dcaba771": { + "~#penpot/pointer": [ + "~u3ac58b88-38b3-80c9-8007-b4f791c7c36b", + { + "~:created-at": "~m1773398778656" + } + ] + } + }, + "~:id": "~u3b9773cc-d4f1-81e1-8007-b3f8dcaba770", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} diff --git a/frontend/playwright/ui/specs/workspace-modifers.spec.js b/frontend/playwright/ui/specs/workspace-modifers.spec.js index 448b620330..4b0fbb6f87 100644 --- a/frontend/playwright/ui/specs/workspace-modifers.spec.js +++ b/frontend/playwright/ui/specs/workspace-modifers.spec.js @@ -72,7 +72,7 @@ test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => { fileId: "3a4d7ec7-c391-8146-8007-9a05c41da6b9", pageId: "95b23c15-79f9-81ba-8007-99d81b5290dd", }); -0 + await workspacePage.clickToggableLayer("Parent"); await workspacePage.clickToggableLayer("Container"); @@ -82,4 +82,33 @@ test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => { await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("76"); }); +test("BUG 13272 - Fix problem with snap to pixel", async ({ page }) => { + const workspacePage = new WasmWorkspacePage(page); + await workspacePage.setupEmptyFile(); + await workspacePage.mockGetFile("workspace/get-file-13272.json"); + await workspacePage.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "workspace/get-file-13272-fragment.json", + ); + + await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json"); + + await workspacePage.goToWorkspace({ + fileId: "3b9773cc-d4f1-81e1-8007-b3f8dcaba770", + pageId: "3b9773cc-d4f1-81e1-8007-b3f8dcaba771", + }); + + await workspacePage.clickToggableLayer("Group"); + await workspacePage.clickLeafLayer("Group"); + + await workspacePage.page.locator('g:nth-child(11) > .cursor-resize-nesw-0').hover(); + await workspacePage.page.mouse.down(); + + await workspacePage.page.mouse.move(1200, 800); + await workspacePage.page.mouse.up(); + + await workspacePage.clickLeafLayer("Rectangle"); + await expect(workspacePage.rightSidebar.getByTitle("Width").getByRole("textbox")).toHaveValue("197.5"); + await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("128.28"); +}); diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index f8be9f857b..c7181abbaf 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -628,12 +628,13 @@ (let [prev-wasm-props (:prev-wasm-props state) wasm-props (:wasm-props state) objects (dsh/lookup-page-objects state) - pixel-precision false] + snap-pixel? + (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))] (set-wasm-props! objects prev-wasm-props wasm-props) (let [structure-entries (parse-structure-modifiers modif-tree)] (wasm.api/set-structure-modifiers structure-entries) (let [geometry-entries (parse-geometry-modifiers modif-tree) - modifiers (wasm.api/propagate-modifiers geometry-entries pixel-precision)] + modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] (wasm.api/set-modifiers modifiers) (let [ids (into [] xf:map-key geometry-entries) selrect (wasm.api/get-selection-rect ids)] diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index d0cb12caeb..576854c163 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -162,7 +162,7 @@ (= key "Backspace") (do (dom/prevent-default event) - (text-editor/text-editor-delete-backward) + (text-editor/text-editor-delete-backward ctrl?) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-delete-backward")) @@ -170,7 +170,7 @@ (= key "Delete") (do (dom/prevent-default event) - (text-editor/text-editor-delete-forward) + (text-editor/text-editor-delete-forward ctrl?) (sync-wasm-text-editor-content!) (wasm.api/request-render "text-delete-forward")) @@ -178,37 +178,37 @@ (= key "ArrowLeft") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 0 shift?) + (text-editor/text-editor-move-cursor 0 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) (= key "ArrowRight") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 1 shift?) + (text-editor/text-editor-move-cursor 1 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) (= key "ArrowUp") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 2 shift?) + (text-editor/text-editor-move-cursor 2 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) (= key "ArrowDown") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 3 shift?) + (text-editor/text-editor-move-cursor 3 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) (= key "Home") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 4 shift?) + (text-editor/text-editor-move-cursor 4 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) (= key "End") (do (dom/prevent-default event) - (text-editor/text-editor-move-cursor 5 shift?) + (text-editor/text-editor-move-cursor 5 ctrl? shift?) (wasm.api/request-render "text-cursor-move")) ;; Let contenteditable handle text input via on-input diff --git a/frontend/src/app/render_wasm/api/shared.js b/frontend/src/app/render_wasm/api/shared.js deleted file mode 100644 index 250cc7bf78..0000000000 --- a/frontend/src/app/render_wasm/api/shared.js +++ /dev/null @@ -1,256 +0,0 @@ -export const GrowType = { - "fixed": 0, - "auto-width": 1, - "auto-height": 2, -}; - -export const RawBlendMode = { - "normal": 3, - "screen": 14, - "overlay": 15, - "darken": 16, - "lighten": 17, - "color-dodge": 18, - "color-burn": 19, - "hard-light": 20, - "soft-light": 21, - "difference": 22, - "exclusion": 23, - "multiply": 24, - "hue": 25, - "saturation": 26, - "color": 27, - "luminosity": 28, -}; - -export const RawBlurType = { - "layer-blur": 0, -}; - -export const RawFillData = { - "solid": 0, - "linear": 1, - "radial": 2, - "image": 3, -}; - -export const RawFontStyle = { - "normal": 0, - "italic": 1, -}; - -export const RawAlignItems = { - "start": 0, - "end": 1, - "center": 2, - "stretch": 3, -}; - -export const RawAlignContent = { - "start": 0, - "end": 1, - "center": 2, - "space-between": 3, - "space-around": 4, - "space-evenly": 5, - "stretch": 6, -}; - -export const RawJustifyItems = { - "start": 0, - "end": 1, - "center": 2, - "stretch": 3, -}; - -export const RawJustifyContent = { - "start": 0, - "end": 1, - "center": 2, - "space-between": 3, - "space-around": 4, - "space-evenly": 5, - "stretch": 6, -}; - -export const RawJustifySelf = { - "none": 0, - "auto": 1, - "start": 2, - "end": 3, - "center": 4, - "stretch": 5, -}; - -export const RawAlignSelf = { - "none": 0, - "auto": 1, - "start": 2, - "end": 3, - "center": 4, - "stretch": 5, -}; - -export const RawVerticalAlign = { - "top": 0, - "center": 1, - "bottom": 2, -}; - -export const RawConstraintH = { - "left": 0, - "right": 1, - "leftright": 2, - "center": 3, - "scale": 4, -}; - -export const RawConstraintV = { - "top": 0, - "bottom": 1, - "topbottom": 2, - "center": 3, - "scale": 4, -}; - -export const RawFlexDirection = { - "row": 0, - "row-reverse": 1, - "column": 2, - "column-reverse": 3, -}; - -export const RawWrapType = { - "wrap": 0, - "nowrap": 1, -}; - -export const RawGridDirection = { - "row": 0, - "column": 1, -}; - -export const RawGridTrackType = { - "percent": 0, - "flex": 1, - "auto": 2, - "fixed": 3, -}; - -export const RawSizing = { - "fill": 0, - "fix": 1, - "auto": 2, -}; - -export const RawBoolType = { - "union": 0, - "difference": 1, - "intersection": 2, - "exclusion": 3, -}; - -export const RawSegmentData = { - "move-to": 1, - "line-to": 2, - "curve-to": 3, - "close": 4, -}; - -export const RawShadowStyle = { - "drop-shadow": 0, - "inner-shadow": 1, -}; - -export const RawShapeType = { - "frame": 0, - "group": 1, - "bool": 2, - "rect": 3, - "path": 4, - "text": 5, - "circle": 6, - "svg-raw": 7, -}; - -export const RawStrokeStyle = { - "solid": 0, - "dotted": 1, - "dashed": 2, - "mixed": 3, -}; - -export const RawStrokeCap = { - "none": 0, - "line-arrow": 1, - "triangle-arrow": 2, - "square-marker": 3, - "circle-marker": 4, - "diamond-marker": 5, - "round": 6, - "square": 7, -}; - -export const RawFillRule = { - "nonzero": 0, - "evenodd": 1, -}; - -export const RawStrokeLineCap = { - "butt": 0, - "round": 1, - "square": 2, -}; - -export const RawStrokeLineJoin = { - "miter": 0, - "round": 1, - "bevel": 2, -}; - -export const RawTextAlign = { - "left": 0, - "center": 1, - "right": 2, - "justify": 3, -}; - -export const RawTextDirection = { - "ltr": 0, - "rtl": 1, -}; - -export const RawTextDecoration = { - "none": 0, - "underline": 1, - "line-through": 2, - "overline": 3, -}; - -export const RawTextTransform = { - "none": 0, - "uppercase": 1, - "lowercase": 2, - "capitalize": 3, -}; - -export const RawGrowType = { - "fixed": 0, - "auto-width": 1, - "auto-height": 2, -}; - -export const CursorDirection = { - "backward": 0, - "forward": 1, - "line-before": 2, - "line-after": 3, - "line-start": 4, - "line-end": 5, -}; - -export const RawTransformEntryKind = { - "parent": 0, - "child": 1, -}; - diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 94ad28b690..e8e540b8f1 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -78,22 +78,28 @@ (h/call wasm/internal-module "_text_editor_insert_text") (mem/free)))) -(defn text-editor-delete-backward [] - (when wasm/context-initialized? - (h/call wasm/internal-module "_text_editor_delete_backward"))) +(defn text-editor-delete-backward + ([] + (text-editor-delete-backward false)) + ([word-boundary] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_delete_backward" word-boundary)))) -(defn text-editor-delete-forward [] - (when wasm/context-initialized? - (h/call wasm/internal-module "_text_editor_delete_forward"))) +(defn text-editor-delete-forward + ([] + (text-editor-delete-forward false)) + ([word-boundary] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_delete_forward" word-boundary)))) (defn text-editor-insert-paragraph [] (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_insert_paragraph"))) (defn text-editor-move-cursor - [direction extend-selection] + [direction word-boundary extend-selection] (when wasm/context-initialized? - (h/call wasm/internal-module "_text_editor_move_cursor" direction (if extend-selection 1 0)))) + (h/call wasm/internal-module "_text_editor_move_cursor" direction word-boundary (if extend-selection 1 0)))) (defn text-editor-select-all [] diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000000..0ab56b6192 --- /dev/null +++ b/opencode.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://opencode.ai/config.json", + "instructions": ["CONTRIBUTING.md", "AGENTS.md"] +} diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index 9c1cdaf526..beb1c1384b 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -123,9 +123,9 @@ fn calculate_cursor_rect( .map(|span| span.text.chars().count()) .sum(); - let (cursor_x, cursor_height) = if para_char_count == 0 { + let (cursor_x, cursor_y, cursor_height) = if para_char_count == 0 { // Empty paragraph - use default height - (0.0, laid_out_para.height()) + (0.0, 0.0, laid_out_para.height()) } else if char_pos == 0 { let rects = laid_out_para.get_rects_for_range( 0..1, @@ -133,9 +133,10 @@ fn calculate_cursor_rect( RectWidthStyle::Tight, ); if !rects.is_empty() { - (rects[0].rect.left(), rects[0].rect.height()) + let r = &rects[0].rect; + (r.left(), r.top(), r.height()) } else { - (0.0, laid_out_para.height()) + (0.0, 0.0, laid_out_para.height()) } } else if char_pos >= para_char_count { let rects = laid_out_para.get_rects_for_range( @@ -144,9 +145,10 @@ fn calculate_cursor_rect( RectWidthStyle::Tight, ); if !rects.is_empty() { - (rects[0].rect.right(), rects[0].rect.height()) + let r = &rects[0].rect; + (r.right(), r.top(), r.height()) } else { - (laid_out_para.longest_line(), laid_out_para.height()) + (laid_out_para.longest_line(), 0.0, laid_out_para.height()) } } else { let rects = laid_out_para.get_rects_for_range( @@ -155,17 +157,18 @@ fn calculate_cursor_rect( RectWidthStyle::Tight, ); if !rects.is_empty() { - (rects[0].rect.left(), rects[0].rect.height()) + let r = &rects[0].rect; + (r.left(), r.top(), r.height()) } else { // Fallback: use glyph position let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0)); - (pos.position as f32, laid_out_para.height()) + (pos.position as f32, 0.0, laid_out_para.height()) } }; return Some(Rect::from_xywh( cursor_x, - y_offset, + y_offset + cursor_y, editor_state.theme.cursor_width, cursor_height, )); diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 0f44d69ae5..9713c066a9 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -133,21 +133,21 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) { let width = bounds.width(); let height = bounds.height(); + let target_width = bounds.width().round(); + let target_height = bounds.height().round(); + let scale_width = if width > 0.1 { - f32::max(0.01, bounds.width().round() / bounds.width()) + f32::max(0.01, target_width / width) } else { 1.0 }; let scale_height = if height > 0.1 { - f32::max(0.01, bounds.height().round() / bounds.height()) + f32::max(0.01, target_height / height) } else { 1.0 }; - if f32::is_finite(scale_width) - && f32::is_finite(scale_height) - && (!math::is_close_to(scale_width, 1.0) || !math::is_close_to(scale_height, 1.0)) - { + if f32::is_finite(scale_width) && f32::is_finite(scale_height) { let mut round_transform = Matrix::scale((scale_width, scale_height)); round_transform.post_concat(&tr); round_transform.pre_concat(&tr_inv); @@ -373,7 +373,7 @@ pub fn propagate_modifiers( if math::identitish(&entry.transform) { Modifier::Reflow(entry.id, false) } else { - Modifier::Transform(*entry) + Modifier::Transform(*entry, pixel_precision) } }) .collect(); @@ -392,9 +392,9 @@ pub fn propagate_modifiers( while !entries.is_empty() { while let Some(modifier) = entries.pop_front() { match modifier { - Modifier::Transform(entry) => propagate_transform( + Modifier::Transform(entry, pixel) => propagate_transform( entry, - pixel_precision, + pixel, state, &mut entries, &mut bounds, diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index a23010585f..f9619eaf74 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -480,8 +480,12 @@ impl TextContent { }; if matches { + // Skia's get_glyph_position_at_coordinate expects coordinates relative to + // the paragraph's top-left. For multi-paragraph or wrapped text, each + // paragraph has its own origin; subtract start_y so we pass paragraph-local coords. + let para_pt = Point::new(point.x, point.y - start_y); let position_with_affinity = - layout_paragraph.get_glyph_position_at_coordinate(*point); + layout_paragraph.get_glyph_position_at_coordinate((para_pt.x, para_pt.y)); if let Some(paragraph) = self.paragraphs().get(paragraph_index) { // Computed position keeps the current position in terms // of number of characters of text. This is used to know diff --git a/render-wasm/src/shapes/transform.rs b/render-wasm/src/shapes/transform.rs index 7e6200c0cb..b0ff2a52d0 100644 --- a/render-wasm/src/shapes/transform.rs +++ b/render-wasm/src/shapes/transform.rs @@ -7,16 +7,16 @@ use skia::Matrix; #[derive(PartialEq, Debug, Clone)] pub enum Modifier { - Transform(TransformEntry), + Transform(TransformEntry, bool), Reflow(Uuid, bool), } impl Modifier { pub fn transform_propagate(id: Uuid, transform: Matrix) -> Self { - Modifier::Transform(TransformEntry::from_propagate(id, transform)) + Modifier::Transform(TransformEntry::from_propagate(id, transform), false) } pub fn parent(id: Uuid, transform: Matrix) -> Self { - Modifier::Transform(TransformEntry::parent(id, transform)) + Modifier::Transform(TransformEntry::parent(id, transform), false) } pub fn reflow(id: Uuid, force_reflow: bool) -> Self { Modifier::Reflow(id, force_reflow) diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index 2766b476c6..65ffbbc19a 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -2,6 +2,7 @@ use crate::shapes::{TextContent, TextPositionWithAffinity}; use crate::uuid::Uuid; +use crate::wasm::text::helpers as text_helpers; use skia_safe::{ textlayout::{Affinity, PositionWithAffinity}, Color, @@ -233,11 +234,14 @@ impl TextEditorState { if offset == chars.len() { offset = offset.saturating_sub(1); - } else if !is_word_char(chars[offset]) && offset > 0 && is_word_char(chars[offset - 1]) { + } else if !text_helpers::is_word_char(chars[offset]) + && offset > 0 + && text_helpers::is_word_char(chars[offset - 1]) + { offset -= 1; } - if !is_word_char(chars[offset]) { + if !text_helpers::is_word_char(chars[offset]) { self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity( position.paragraph, position.offset.min(chars.len()), @@ -248,12 +252,12 @@ impl TextEditorState { } let mut start = offset; - while start > 0 && is_word_char(chars[start - 1]) { + while start > 0 && text_helpers::is_word_char(chars[start - 1]) { start -= 1; } let mut end = offset + 1; - while end < chars.len() && is_word_char(chars[end]) { + while end < chars.len() && text_helpers::is_word_char(chars[end]) { end += 1; } diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index c4f6a682d5..3635e0949f 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -11,6 +11,8 @@ use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_m use crate::error::Error; +pub mod helpers; + const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::(); const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::(); diff --git a/render-wasm/src/wasm/text/helpers.rs b/render-wasm/src/wasm/text/helpers.rs new file mode 100644 index 0000000000..88ec07754c --- /dev/null +++ b/render-wasm/src/wasm/text/helpers.rs @@ -0,0 +1,753 @@ +use crate::shapes::{Paragraph, TextContent, TextPositionWithAffinity}; +use crate::state::TextSelection; + +/// Get total character count in a paragraph. +pub fn paragraph_char_count(para: &Paragraph) -> usize { + para.children() + .iter() + .map(|span| span.text.chars().count()) + .sum() +} + +/// Get the text direction of the span at a given offset in a paragraph. +pub fn get_span_text_direction_at_offset( + para: &Paragraph, + char_offset: usize, +) -> skia_safe::textlayout::TextDirection { + if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) { + if let Some(span) = para.children().get(span_idx) { + return span.text_direction; + } + } + // Fallback to paragraph's text direction + para.text_direction() +} + +/// Clamp a cursor position to valid bounds within the text content. +pub fn clamp_cursor( + position: TextPositionWithAffinity, + paragraphs: &[Paragraph], +) -> TextPositionWithAffinity { + if paragraphs.is_empty() { + return TextPositionWithAffinity::new_without_affinity(0, 0); + } + + let para_idx = position.paragraph.min(paragraphs.len() - 1); + let para_len = paragraph_char_count(¶graphs[para_idx]); + let char_offset = position.offset.min(para_len); + + TextPositionWithAffinity::new_without_affinity(para_idx, char_offset) +} + +/// Move cursor left by one character. +pub fn move_cursor_backward( + cursor: &TextPositionWithAffinity, + paragraphs: &[Paragraph], + word_boundary: bool, +) -> TextPositionWithAffinity { + if !word_boundary { + if cursor.offset > 0 { + return TextPositionWithAffinity::new_without_affinity( + cursor.paragraph, + cursor.offset - 1, + ); + } + if cursor.paragraph > 0 { + let prev_para = cursor.paragraph - 1; + let char_count = paragraph_char_count(¶graphs[prev_para]); + return TextPositionWithAffinity::new_without_affinity(prev_para, char_count); + } + return *cursor; + } + + if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() { + return *cursor; + } + + let end_para = cursor.paragraph; + let end_offset = cursor + .offset + .min(paragraph_char_count(¶graphs[end_para])); + + let mut para_idx = end_para; + let mut offset = end_offset; + let mut phase = 0u8; + + loop { + let current = if offset > 0 { + paragraph_text_char_at(¶graphs[para_idx], offset - 1) + } else if para_idx > 0 { + Some('\n') + } else { + None + }; + + let Some(ch) = current else { + break; + }; + + if offset > 0 { + offset -= 1; + } else { + para_idx -= 1; + offset = paragraph_char_count(¶graphs[para_idx]); + } + + if phase == 0 { + if is_word_char(ch) { + phase = 1; + } + continue; + } + + if !is_word_char(ch) { + if offset < paragraph_char_count(¶graphs[para_idx]) { + offset += 1; + } else if para_idx < end_para || offset < end_offset { + para_idx += 1; + offset = 0; + } + break; + } + } + + TextPositionWithAffinity::new_without_affinity(para_idx, offset) +} + +/// Move cursor right by one character. +pub fn move_cursor_forward( + cursor: &TextPositionWithAffinity, + paragraphs: &[Paragraph], + word_boundary: bool, +) -> TextPositionWithAffinity { + if !word_boundary { + let para = ¶graphs[cursor.paragraph]; + let char_count = paragraph_char_count(para); + if cursor.offset < char_count { + return TextPositionWithAffinity::new_without_affinity( + cursor.paragraph, + cursor.offset + 1, + ); + } + if cursor.paragraph < paragraphs.len() - 1 { + return TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); + } + return *cursor; + } + + if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() { + return *cursor; + } + + let mut para_idx = cursor.paragraph; + let mut offset = cursor + .offset + .min(paragraph_char_count(¶graphs[para_idx])); + let mut phase = 0u8; + + loop { + let para = ¶graphs[para_idx]; + let para_len = paragraph_char_count(para); + + let current = if offset < para_len { + paragraph_text_char_at(para, offset) + } else if para_idx < paragraphs.len() - 1 { + Some('\n') + } else { + None + }; + + let Some(ch) = current else { + break; + }; + + if phase == 0 { + if is_word_char(ch) { + phase = 1; + } else if offset < para_len { + offset += 1; + } else { + para_idx += 1; + offset = 0; + } + continue; + } + + if is_word_char(ch) { + if offset < para_len { + offset += 1; + } else { + para_idx += 1; + offset = 0; + } + } else { + break; + } + } + + TextPositionWithAffinity::new_without_affinity(para_idx, offset) +} + +/// Move cursor up by one line. +pub fn move_cursor_up( + cursor: &TextPositionWithAffinity, + paragraphs: &[Paragraph], + _text_content: &TextContent, +) -> TextPositionWithAffinity { + // TODO: Implement proper line-based navigation using line metrics + if cursor.paragraph > 0 { + let prev_para = cursor.paragraph - 1; + let char_count = paragraph_char_count(¶graphs[prev_para]); + let new_offset = cursor.offset.min(char_count); + TextPositionWithAffinity::new_without_affinity(prev_para, new_offset) + } else { + TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) + } +} + +/// Move cursor down by one line. +pub fn move_cursor_down( + cursor: &TextPositionWithAffinity, + paragraphs: &[Paragraph], + _text_content: &TextContent, +) -> TextPositionWithAffinity { + // TODO: Implement proper line-based navigation using line metrics + if cursor.paragraph < paragraphs.len() - 1 { + let next_para = cursor.paragraph + 1; + let char_count = paragraph_char_count(¶graphs[next_para]); + let new_offset = cursor.offset.min(char_count); + TextPositionWithAffinity::new_without_affinity(next_para, new_offset) + } else { + let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); + TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) + } +} + +/// Move cursor to start of current line. +pub fn move_cursor_line_start( + cursor: &TextPositionWithAffinity, + _paragraphs: &[Paragraph], +) -> TextPositionWithAffinity { + // TODO: Implement proper line-start using line metrics + TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) +} + +/// Move cursor to end of current line. +pub fn move_cursor_line_end( + cursor: &TextPositionWithAffinity, + paragraphs: &[Paragraph], +) -> TextPositionWithAffinity { + // TODO: Implement proper line-end using line metrics + let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); + TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) +} + +pub fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +pub fn paragraph_text_char_at(para: &Paragraph, offset: usize) -> Option { + let mut remaining = offset; + for span in para.children() { + let span_len = span.text.chars().count(); + if remaining < span_len { + return span.text.chars().nth(remaining); + } + remaining -= span_len; + } + None +} + +pub fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> { + let children = para.children(); + let mut accumulated = 0; + for (span_idx, span) in children.iter().enumerate() { + let span_len = span.text.chars().count(); + if char_offset <= accumulated + span_len { + return Some((span_idx, char_offset - accumulated)); + } + accumulated += span_len; + } + if !children.is_empty() { + let last_idx = children.len() - 1; + let last_len = children[last_idx].text.chars().count(); + return Some((last_idx, last_len)); + } + None +} + +/// Insert text at a cursor position, splitting on newlines into multiple paragraphs. +/// Returns the final cursor position after insertion. +pub fn insert_text_with_newlines( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, + text: &str, +) -> Option { + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + let lines: Vec<&str> = normalized.split('\n').collect(); + if lines.is_empty() { + return None; + } + + let mut current_cursor = *cursor; + + if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, lines[0]) { + current_cursor = + TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); + } else { + return None; + } + + for line in lines.iter().skip(1) { + if !split_paragraph_at_cursor(text_content, ¤t_cursor) { + break; + } + current_cursor = + TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); + if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, line) { + current_cursor = TextPositionWithAffinity::new_without_affinity( + current_cursor.paragraph, + new_offset, + ); + } + } + + Some(current_cursor) +} + +/// Insert text at a cursor position. Returns the new character offset after insertion. +pub fn insert_text_at_cursor( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, + text: &str, +) -> Option { + let paragraphs = text_content.paragraphs_mut(); + if cursor.paragraph >= paragraphs.len() { + return None; + } + + let para = &mut paragraphs[cursor.paragraph]; + + let children = para.children_mut(); + if children.is_empty() { + return None; + } + + if children.len() == 1 && children[0].text.is_empty() { + children[0].set_text(text.to_string()); + return Some(text.chars().count()); + } + + let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?; + + let children = para.children_mut(); + let span = &mut children[span_idx]; + let mut new_text = span.text.clone(); + + let byte_offset = new_text + .char_indices() + .nth(offset_in_span) + .map(|(i, _)| i) + .unwrap_or(new_text.len()); + + new_text.insert_str(byte_offset, text); + span.set_text(new_text); + + Some(cursor.offset + text.chars().count()) +} + +/// Delete a range of text specified by a selection. +pub fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) { + let start = selection.start(); + let end = selection.end(); + + let paragraphs = text_content.paragraphs_mut(); + if start.paragraph >= paragraphs.len() { + return; + } + + if start.paragraph == end.paragraph { + delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset); + } else { + let start_para_len = paragraph_char_count(¶graphs[start.paragraph]); + delete_range_in_paragraph( + &mut paragraphs[start.paragraph], + start.offset, + start_para_len, + ); + + delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset); + + if end.paragraph < paragraphs.len() { + let end_para_children: Vec<_> = + paragraphs[end.paragraph].children_mut().drain(..).collect(); + paragraphs[start.paragraph] + .children_mut() + .extend(end_para_children); + } + + if end.paragraph < paragraphs.len() { + paragraphs.drain((start.paragraph + 1)..=end.paragraph); + } + + let children = paragraphs[start.paragraph].children_mut(); + let has_content = children.iter().any(|span| !span.text.is_empty()); + if has_content { + children.retain(|span| !span.text.is_empty()); + } else if children.len() > 1 { + children.truncate(1); + } + } +} + +/// Delete a range of characters within a single paragraph. +pub fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offset: usize) { + if start_offset >= end_offset { + return; + } + + let mut accumulated = 0; + let mut delete_start_span = None; + let mut delete_end_span = None; + + for (idx, span) in para.children().iter().enumerate() { + let span_len = span.text.chars().count(); + let span_end = accumulated + span_len; + + if delete_start_span.is_none() && start_offset < span_end { + delete_start_span = Some((idx, start_offset - accumulated)); + } + if end_offset <= span_end { + delete_end_span = Some((idx, end_offset - accumulated)); + break; + } + accumulated += span_len; + } + + let Some((start_span_idx, start_in_span)) = delete_start_span else { + return; + }; + let Some((end_span_idx, end_in_span)) = delete_end_span else { + return; + }; + + let children = para.children_mut(); + + if start_span_idx == end_span_idx { + let span = &mut children[start_span_idx]; + let text = span.text.clone(); + let chars: Vec = text.chars().collect(); + + let start_clamped = start_in_span.min(chars.len()); + let end_clamped = end_in_span.min(chars.len()); + + let new_text: String = chars[..start_clamped] + .iter() + .chain(chars[end_clamped..].iter()) + .collect(); + span.set_text(new_text); + } else { + let start_span = &mut children[start_span_idx]; + let text = start_span.text.clone(); + let start_char_count = text.chars().count(); + let start_clamped = start_in_span.min(start_char_count); + let new_text: String = text.chars().take(start_clamped).collect(); + start_span.set_text(new_text); + + let end_span = &mut children[end_span_idx]; + let text = end_span.text.clone(); + let end_char_count = text.chars().count(); + let end_clamped = end_in_span.min(end_char_count); + let new_text: String = text.chars().skip(end_clamped).collect(); + end_span.set_text(new_text); + + if end_span_idx > start_span_idx + 1 { + children.drain((start_span_idx + 1)..end_span_idx); + } + } + + let has_content = children.iter().any(|span| !span.text.is_empty()); + if has_content { + children.retain(|span| !span.text.is_empty()); + } else if !children.is_empty() { + children.truncate(1); + } +} + +/// Delete the character before the cursor. Returns the new cursor position. +pub fn delete_char_before( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, +) -> Option { + if cursor.offset > 0 { + let paragraphs = text_content.paragraphs_mut(); + let para = &mut paragraphs[cursor.paragraph]; + let delete_pos = cursor.offset - 1; + delete_range_in_paragraph(para, delete_pos, cursor.offset); + Some(TextPositionWithAffinity::new_without_affinity( + cursor.paragraph, + delete_pos, + )) + } else if cursor.paragraph > 0 { + let prev_para_idx = cursor.paragraph - 1; + let paragraphs = text_content.paragraphs_mut(); + let prev_para_len = paragraph_char_count(¶graphs[prev_para_idx]); + + let current_children: Vec<_> = paragraphs[cursor.paragraph] + .children_mut() + .drain(..) + .collect(); + paragraphs[prev_para_idx] + .children_mut() + .extend(current_children); + + paragraphs.remove(cursor.paragraph); + + Some(TextPositionWithAffinity::new_without_affinity( + prev_para_idx, + prev_para_len, + )) + } else { + None + } +} + +/// Delete the word before the cursor. Returns the new cursor position. +pub fn delete_word_before( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, +) -> Option { + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() { + return None; + } + + let end_paragraph = cursor.paragraph; + let end_offset = cursor + .offset + .min(paragraph_char_count(¶graphs[end_paragraph])); + + let mut start_paragraph = end_paragraph; + let mut start_offset = end_offset; + + let mut phase = 0u8; + loop { + let current = if start_offset > 0 { + let para = ¶graphs[start_paragraph]; + paragraph_text_char_at(para, start_offset - 1) + } else if start_paragraph > 0 { + Some('\n') + } else { + None + }; + + let Some(ch) = current else { + break; + }; + + if start_offset > 0 { + start_offset -= 1; + } else { + start_paragraph -= 1; + start_offset = paragraph_char_count(¶graphs[start_paragraph]); + } + + if phase == 0 { + if is_word_char(ch) { + phase = 1; + } + continue; + } + + if !is_word_char(ch) { + if start_offset < paragraph_char_count(¶graphs[start_paragraph]) { + start_offset += 1; + } else if start_paragraph < end_paragraph || start_offset < end_offset { + start_paragraph += 1; + start_offset = 0; + } + break; + } + } + + if start_paragraph == end_paragraph && start_offset == end_offset { + return None; + } + + let selection = TextSelection { + anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset), + focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset), + }; + + delete_selection_range(text_content, &selection); + + Some(TextPositionWithAffinity::new_without_affinity( + start_paragraph, + start_offset, + )) +} + +/// Delete the word after the cursor. +pub fn delete_word_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) { + let paragraphs = text_content.paragraphs(); + if paragraphs.is_empty() || cursor.paragraph >= paragraphs.len() { + return; + } + + let start_paragraph = cursor.paragraph; + let start_offset = cursor + .offset + .min(paragraph_char_count(¶graphs[start_paragraph])); + + let mut end_paragraph = start_paragraph; + let mut end_offset = start_offset; + + let mut phase = 0u8; + loop { + let para = ¶graphs[end_paragraph]; + let para_len = paragraph_char_count(para); + + let current = if end_offset < para_len { + paragraph_text_char_at(para, end_offset) + } else if end_paragraph < paragraphs.len() - 1 { + Some('\n') + } else { + None + }; + + let Some(ch) = current else { + break; + }; + + if phase == 0 { + if is_word_char(ch) { + phase = 1; + } else if end_offset < para_len { + end_offset += 1; + } else { + end_paragraph += 1; + end_offset = 0; + } + continue; + } + + if is_word_char(ch) { + if end_offset < para_len { + end_offset += 1; + } else { + end_paragraph += 1; + end_offset = 0; + } + } else { + break; + } + } + + if start_paragraph == end_paragraph && start_offset == end_offset { + return; + } + + let selection = TextSelection { + anchor: TextPositionWithAffinity::new_without_affinity(start_paragraph, start_offset), + focus: TextPositionWithAffinity::new_without_affinity(end_paragraph, end_offset), + }; + + delete_selection_range(text_content, &selection); +} + +/// Delete the character after the cursor. +pub fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) { + let paragraphs = text_content.paragraphs_mut(); + if cursor.paragraph >= paragraphs.len() { + return; + } + + let para_len = paragraph_char_count(¶graphs[cursor.paragraph]); + + if cursor.offset < para_len { + let para = &mut paragraphs[cursor.paragraph]; + delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1); + } else if cursor.paragraph < paragraphs.len() - 1 { + let next_para_idx = cursor.paragraph + 1; + let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect(); + paragraphs[cursor.paragraph] + .children_mut() + .extend(next_children); + + paragraphs.remove(next_para_idx); + } +} + +/// Split a paragraph at the cursor position. Returns true if split was successful. +pub fn split_paragraph_at_cursor( + text_content: &mut TextContent, + cursor: &TextPositionWithAffinity, +) -> bool { + let paragraphs = text_content.paragraphs_mut(); + if cursor.paragraph >= paragraphs.len() { + return false; + } + + let para = ¶graphs[cursor.paragraph]; + + let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else { + return false; + }; + + let mut new_para_children = Vec::new(); + let children = para.children(); + + let current_span = &children[span_idx]; + let span_text = current_span.text.clone(); + let chars: Vec = span_text.chars().collect(); + + if offset_in_span < chars.len() { + let after_text: String = chars[offset_in_span..].iter().collect(); + let mut new_span = current_span.clone(); + new_span.set_text(after_text); + new_para_children.push(new_span); + } + + for child in children.iter().skip(span_idx + 1) { + new_para_children.push(child.clone()); + } + + if new_para_children.is_empty() { + let mut empty_span = current_span.clone(); + empty_span.set_text(String::new()); + new_para_children.push(empty_span); + } + + let text_align = para.text_align(); + let text_direction = para.text_direction(); + let text_decoration = para.text_decoration(); + let text_transform = para.text_transform(); + let line_height = para.line_height(); + let letter_spacing = para.letter_spacing(); + + let para = &mut paragraphs[cursor.paragraph]; + let children = para.children_mut(); + + children.truncate(span_idx + 1); + + if !children.is_empty() { + let span = &mut children[span_idx]; + let text = span.text.clone(); + let new_text: String = text.chars().take(offset_in_span).collect(); + span.set_text(new_text); + } + + let new_para = crate::shapes::Paragraph::new( + text_align, + text_direction, + text_decoration, + text_transform, + line_height, + letter_spacing, + new_para_children, + ); + + paragraphs.insert(cursor.paragraph + 1, new_para); + + true +} diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 0886340851..25182c5050 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -2,10 +2,11 @@ use macros::{wasm_error, ToJs}; use crate::math::{Matrix, Point, Rect}; use crate::mem; -use crate::shapes::{Paragraph, Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; +use crate::shapes::{Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::state::TextSelection; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; +use crate::wasm::text::helpers as text_helpers; use crate::{with_state, with_state_mut, STATE}; use skia_safe::{textlayout::TextDirection, Color}; @@ -322,14 +323,16 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { let selection = state.text_editor_state.selection; if selection.is_selection() { - delete_selection_range(text_content, &selection); + text_helpers::delete_selection_range(text_content, &selection); let start = selection.start(); state.text_editor_state.selection.set_caret(start); } let cursor = state.text_editor_state.selection.focus; - if let Some(new_cursor) = insert_text_with_newlines(text_content, &cursor, &text) { + if let Some(new_cursor) = + text_helpers::insert_text_with_newlines(text_content, &cursor, &text) + { state.text_editor_state.selection.set_caret(new_cursor); } @@ -352,7 +355,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { } #[no_mangle] -pub extern "C" fn text_editor_delete_backward() { +pub extern "C" fn text_editor_delete_backward(word_boundary: bool) { with_state_mut!(state, { if !state.text_editor_state.is_active { return; @@ -373,13 +376,18 @@ pub extern "C" fn text_editor_delete_backward() { let selection = state.text_editor_state.selection; if selection.is_selection() { - delete_selection_range(text_content, &selection); + text_helpers::delete_selection_range(text_content, &selection); let start = selection.start(); - let clamped = clamp_cursor(start, text_content.paragraphs()); + let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); state.text_editor_state.selection.set_caret(clamped); + } else if word_boundary { + let cursor = selection.focus; + if let Some(new_cursor) = text_helpers::delete_word_before(text_content, &cursor) { + state.text_editor_state.selection.set_caret(new_cursor); + } } else { let cursor = selection.focus; - if let Some(new_cursor) = delete_char_before(text_content, &cursor) { + if let Some(new_cursor) = text_helpers::delete_char_before(text_content, &cursor) { state.text_editor_state.selection.set_caret(new_cursor); } } @@ -400,7 +408,7 @@ pub extern "C" fn text_editor_delete_backward() { } #[no_mangle] -pub extern "C" fn text_editor_delete_forward() { +pub extern "C" fn text_editor_delete_forward(word_boundary: bool) { with_state_mut!(state, { if !state.text_editor_state.is_active { return; @@ -421,14 +429,19 @@ pub extern "C" fn text_editor_delete_forward() { let selection = state.text_editor_state.selection; if selection.is_selection() { - delete_selection_range(text_content, &selection); + text_helpers::delete_selection_range(text_content, &selection); let start = selection.start(); - let clamped = clamp_cursor(start, text_content.paragraphs()); + let clamped = text_helpers::clamp_cursor(start, text_content.paragraphs()); + state.text_editor_state.selection.set_caret(clamped); + } else if word_boundary { + let cursor = selection.focus; + text_helpers::delete_word_after(text_content, &cursor); + let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); state.text_editor_state.selection.set_caret(clamped); } else { let cursor = selection.focus; - delete_char_after(text_content, &cursor); - let clamped = clamp_cursor(cursor, text_content.paragraphs()); + text_helpers::delete_char_after(text_content, &cursor); + let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs()); state.text_editor_state.selection.set_caret(clamped); } @@ -469,14 +482,14 @@ pub extern "C" fn text_editor_insert_paragraph() { let selection = state.text_editor_state.selection; if selection.is_selection() { - delete_selection_range(text_content, &selection); + text_helpers::delete_selection_range(text_content, &selection); let start = selection.start(); state.text_editor_state.selection.set_caret(start); } let cursor = state.text_editor_state.selection.focus; - if split_paragraph_at_cursor(text_content, &cursor) { + if text_helpers::split_paragraph_at_cursor(text_content, &cursor) { let new_cursor = TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0); state.text_editor_state.selection.set_caret(new_cursor); @@ -502,7 +515,11 @@ pub extern "C" fn text_editor_insert_paragraph() { // ============================================================================ #[no_mangle] -pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_selection: bool) { +pub extern "C" fn text_editor_move_cursor( + direction: CursorDirection, + word_boundary: bool, + extend_selection: bool, +) { with_state_mut!(state, { if !state.text_editor_state.is_active { return; @@ -529,7 +546,10 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel // Get the text direction of the span at the current cursor position let span_text_direction = if current.paragraph < paragraphs.len() { - get_span_text_direction_at_offset(¶graphs[current.paragraph], current.offset) + text_helpers::get_span_text_direction_at_offset( + ¶graphs[current.paragraph], + current.offset, + ) } else { TextDirection::LTR }; @@ -546,16 +566,22 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel }; let new_cursor = match adjusted_direction { - CursorDirection::Backward => move_cursor_backward(¤t, paragraphs), - CursorDirection::Forward => move_cursor_forward(¤t, paragraphs), + CursorDirection::Backward => { + text_helpers::move_cursor_backward(¤t, paragraphs, word_boundary) + } + CursorDirection::Forward => { + text_helpers::move_cursor_forward(¤t, paragraphs, word_boundary) + } CursorDirection::LineBefore => { - move_cursor_up(¤t, paragraphs, text_content, shape) + text_helpers::move_cursor_up(¤t, paragraphs, text_content) } CursorDirection::LineAfter => { - move_cursor_down(¤t, paragraphs, text_content, shape) + text_helpers::move_cursor_down(¤t, paragraphs, text_content) } - CursorDirection::LineStart => move_cursor_line_start(¤t, paragraphs), - CursorDirection::LineEnd => move_cursor_line_end(¤t, paragraphs), + CursorDirection::LineStart => { + text_helpers::move_cursor_line_start(¤t, paragraphs) + } + CursorDirection::LineEnd => text_helpers::move_cursor_line_end(¤t, paragraphs), }; if extend_selection { @@ -979,485 +1005,3 @@ fn get_selection_rects( rects } - -/// Get total character count in a paragraph. -fn paragraph_char_count(para: &Paragraph) -> usize { - para.children() - .iter() - .map(|span| span.text.chars().count()) - .sum() -} - -/// Clamp a cursor position to valid bounds within the text content. -fn clamp_cursor( - position: TextPositionWithAffinity, - paragraphs: &[Paragraph], -) -> TextPositionWithAffinity { - if paragraphs.is_empty() { - return TextPositionWithAffinity::new_without_affinity(0, 0); - } - - let para_idx = position.paragraph.min(paragraphs.len() - 1); - let para_len = paragraph_char_count(¶graphs[para_idx]); - let char_offset = position.offset.min(para_len); - - TextPositionWithAffinity::new_without_affinity(para_idx, char_offset) -} - -/// Move cursor left by one character. -fn move_cursor_backward( - cursor: &TextPositionWithAffinity, - paragraphs: &[Paragraph], -) -> TextPositionWithAffinity { - if cursor.offset > 0 { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset - 1) - } else if cursor.paragraph > 0 { - let prev_para = cursor.paragraph - 1; - let char_count = paragraph_char_count(¶graphs[prev_para]); - TextPositionWithAffinity::new_without_affinity(prev_para, char_count) - } else { - *cursor - } -} - -/// Move cursor right by one character. -fn move_cursor_forward( - cursor: &TextPositionWithAffinity, - paragraphs: &[Paragraph], -) -> TextPositionWithAffinity { - let para = ¶graphs[cursor.paragraph]; - let char_count = paragraph_char_count(para); - - if cursor.offset < char_count { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, cursor.offset + 1) - } else if cursor.paragraph < paragraphs.len() - 1 { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0) - } else { - *cursor - } -} - -/// Move cursor up by one line. -fn move_cursor_up( - cursor: &TextPositionWithAffinity, - paragraphs: &[Paragraph], - _text_content: &TextContent, - _shape: &Shape, -) -> TextPositionWithAffinity { - // TODO: Implement proper line-based navigation using line metrics - if cursor.paragraph > 0 { - let prev_para = cursor.paragraph - 1; - let char_count = paragraph_char_count(¶graphs[prev_para]); - let new_offset = cursor.offset.min(char_count); - TextPositionWithAffinity::new_without_affinity(prev_para, new_offset) - } else { - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) - } -} - -/// Move cursor down by one line. -fn move_cursor_down( - cursor: &TextPositionWithAffinity, - paragraphs: &[Paragraph], - _text_content: &TextContent, - _shape: &Shape, -) -> TextPositionWithAffinity { - // TODO: Implement proper line-based navigation using line metrics - if cursor.paragraph < paragraphs.len() - 1 { - let next_para = cursor.paragraph + 1; - let char_count = paragraph_char_count(¶graphs[next_para]); - let new_offset = cursor.offset.min(char_count); - TextPositionWithAffinity::new_without_affinity(next_para, new_offset) - } else { - let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) - } -} - -/// Move cursor to start of current line. -fn move_cursor_line_start( - cursor: &TextPositionWithAffinity, - _paragraphs: &[Paragraph], -) -> TextPositionWithAffinity { - // TODO: Implement proper line-start using line metrics - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, 0) -} - -/// Move cursor to end of current line. -fn move_cursor_line_end( - cursor: &TextPositionWithAffinity, - paragraphs: &[Paragraph], -) -> TextPositionWithAffinity { - // TODO: Implement proper line-end using line metrics - let char_count = paragraph_char_count(¶graphs[cursor.paragraph]); - TextPositionWithAffinity::new_without_affinity(cursor.paragraph, char_count) -} - -// ============================================================================ -// HELPERS: Text Modification -// ============================================================================ - -fn find_span_at_offset(para: &Paragraph, char_offset: usize) -> Option<(usize, usize)> { - let children = para.children(); - let mut accumulated = 0; - for (span_idx, span) in children.iter().enumerate() { - let span_len = span.text.chars().count(); - if char_offset <= accumulated + span_len { - return Some((span_idx, char_offset - accumulated)); - } - accumulated += span_len; - } - if !children.is_empty() { - let last_idx = children.len() - 1; - let last_len = children[last_idx].text.chars().count(); - return Some((last_idx, last_len)); - } - None -} - -/// Insert text at a cursor position, splitting on newlines into multiple paragraphs. -/// Returns the final cursor position after insertion. -fn insert_text_with_newlines( - text_content: &mut TextContent, - cursor: &TextPositionWithAffinity, - text: &str, -) -> Option { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - let lines: Vec<&str> = normalized.split('\n').collect(); - if lines.is_empty() { - return None; - } - - let mut current_cursor = *cursor; - - if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, lines[0]) { - current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph, new_offset); - } else { - return None; - } - - for line in lines.iter().skip(1) { - if !split_paragraph_at_cursor(text_content, ¤t_cursor) { - break; - } - current_cursor = - TextPositionWithAffinity::new_without_affinity(current_cursor.paragraph + 1, 0); - if let Some(new_offset) = insert_text_at_cursor(text_content, ¤t_cursor, line) { - current_cursor = TextPositionWithAffinity::new_without_affinity( - current_cursor.paragraph, - new_offset, - ); - } - } - - Some(current_cursor) -} - -/// Get the text direction of the span at a given offset in a paragraph. -fn get_span_text_direction_at_offset( - para: &Paragraph, - char_offset: usize, -) -> skia_safe::textlayout::TextDirection { - if let Some((span_idx, _)) = find_span_at_offset(para, char_offset) { - if let Some(span) = para.children().get(span_idx) { - return span.text_direction; - } - } - // Fallback to paragraph's text direction - para.text_direction() -} - -/// Insert text at a cursor position. Returns the new character offset after insertion. -fn insert_text_at_cursor( - text_content: &mut TextContent, - cursor: &TextPositionWithAffinity, - text: &str, -) -> Option { - let paragraphs = text_content.paragraphs_mut(); - if cursor.paragraph >= paragraphs.len() { - return None; - } - - let para = &mut paragraphs[cursor.paragraph]; - - let children = para.children_mut(); - if children.is_empty() { - return None; - } - - if children.len() == 1 && children[0].text.is_empty() { - children[0].set_text(text.to_string()); - return Some(text.chars().count()); - } - - let (span_idx, offset_in_span) = find_span_at_offset(para, cursor.offset)?; - - let children = para.children_mut(); - let span = &mut children[span_idx]; - let mut new_text = span.text.clone(); - - let byte_offset = new_text - .char_indices() - .nth(offset_in_span) - .map(|(i, _)| i) - .unwrap_or(new_text.len()); - - new_text.insert_str(byte_offset, text); - span.set_text(new_text); - - Some(cursor.offset + text.chars().count()) -} - -/// Delete a range of text specified by a selection. -fn delete_selection_range(text_content: &mut TextContent, selection: &TextSelection) { - let start = selection.start(); - let end = selection.end(); - - let paragraphs = text_content.paragraphs_mut(); - if start.paragraph >= paragraphs.len() { - return; - } - - if start.paragraph == end.paragraph { - delete_range_in_paragraph(&mut paragraphs[start.paragraph], start.offset, end.offset); - } else { - let start_para_len = paragraph_char_count(¶graphs[start.paragraph]); - delete_range_in_paragraph( - &mut paragraphs[start.paragraph], - start.offset, - start_para_len, - ); - - delete_range_in_paragraph(&mut paragraphs[end.paragraph], 0, end.offset); - - if end.paragraph < paragraphs.len() { - let end_para_children: Vec<_> = - paragraphs[end.paragraph].children_mut().drain(..).collect(); - paragraphs[start.paragraph] - .children_mut() - .extend(end_para_children); - } - - if end.paragraph < paragraphs.len() { - paragraphs.drain((start.paragraph + 1)..=end.paragraph); - } - - let children = paragraphs[start.paragraph].children_mut(); - let has_content = children.iter().any(|span| !span.text.is_empty()); - if has_content { - children.retain(|span| !span.text.is_empty()); - } else if children.len() > 1 { - children.truncate(1); - } - } -} - -/// Delete a range of characters within a single paragraph. -fn delete_range_in_paragraph(para: &mut Paragraph, start_offset: usize, end_offset: usize) { - if start_offset >= end_offset { - return; - } - - let mut accumulated = 0; - let mut delete_start_span = None; - let mut delete_end_span = None; - - for (idx, span) in para.children().iter().enumerate() { - let span_len = span.text.chars().count(); - let span_end = accumulated + span_len; - - if delete_start_span.is_none() && start_offset < span_end { - delete_start_span = Some((idx, start_offset - accumulated)); - } - if end_offset <= span_end { - delete_end_span = Some((idx, end_offset - accumulated)); - break; - } - accumulated += span_len; - } - - let Some((start_span_idx, start_in_span)) = delete_start_span else { - return; - }; - let Some((end_span_idx, end_in_span)) = delete_end_span else { - return; - }; - - let children = para.children_mut(); - - if start_span_idx == end_span_idx { - let span = &mut children[start_span_idx]; - let text = span.text.clone(); - let chars: Vec = text.chars().collect(); - - let start_clamped = start_in_span.min(chars.len()); - let end_clamped = end_in_span.min(chars.len()); - - let new_text: String = chars[..start_clamped] - .iter() - .chain(chars[end_clamped..].iter()) - .collect(); - span.set_text(new_text); - } else { - let start_span = &mut children[start_span_idx]; - let text = start_span.text.clone(); - let start_char_count = text.chars().count(); - let start_clamped = start_in_span.min(start_char_count); - let new_text: String = text.chars().take(start_clamped).collect(); - start_span.set_text(new_text); - - let end_span = &mut children[end_span_idx]; - let text = end_span.text.clone(); - let end_char_count = text.chars().count(); - let end_clamped = end_in_span.min(end_char_count); - let new_text: String = text.chars().skip(end_clamped).collect(); - end_span.set_text(new_text); - - if end_span_idx > start_span_idx + 1 { - children.drain((start_span_idx + 1)..end_span_idx); - } - } - - let has_content = children.iter().any(|span| !span.text.is_empty()); - if has_content { - children.retain(|span| !span.text.is_empty()); - } else if !children.is_empty() { - children.truncate(1); - } -} - -/// Delete the character before the cursor. Returns the new cursor position. -fn delete_char_before( - text_content: &mut TextContent, - cursor: &TextPositionWithAffinity, -) -> Option { - if cursor.offset > 0 { - let paragraphs = text_content.paragraphs_mut(); - let para = &mut paragraphs[cursor.paragraph]; - let delete_pos = cursor.offset - 1; - delete_range_in_paragraph(para, delete_pos, cursor.offset); - Some(TextPositionWithAffinity::new_without_affinity( - cursor.paragraph, - delete_pos, - )) - } else if cursor.paragraph > 0 { - let prev_para_idx = cursor.paragraph - 1; - let paragraphs = text_content.paragraphs_mut(); - let prev_para_len = paragraph_char_count(¶graphs[prev_para_idx]); - - let current_children: Vec<_> = paragraphs[cursor.paragraph] - .children_mut() - .drain(..) - .collect(); - paragraphs[prev_para_idx] - .children_mut() - .extend(current_children); - - paragraphs.remove(cursor.paragraph); - - Some(TextPositionWithAffinity::new_without_affinity( - prev_para_idx, - prev_para_len, - )) - } else { - None - } -} - -/// Delete the character after the cursor. -fn delete_char_after(text_content: &mut TextContent, cursor: &TextPositionWithAffinity) { - let paragraphs = text_content.paragraphs_mut(); - if cursor.paragraph >= paragraphs.len() { - return; - } - - let para_len = paragraph_char_count(¶graphs[cursor.paragraph]); - - if cursor.offset < para_len { - let para = &mut paragraphs[cursor.paragraph]; - delete_range_in_paragraph(para, cursor.offset, cursor.offset + 1); - } else if cursor.paragraph < paragraphs.len() - 1 { - let next_para_idx = cursor.paragraph + 1; - let next_children: Vec<_> = paragraphs[next_para_idx].children_mut().drain(..).collect(); - paragraphs[cursor.paragraph] - .children_mut() - .extend(next_children); - - paragraphs.remove(next_para_idx); - } -} - -/// Split a paragraph at the cursor position. Returns true if split was successful. -fn split_paragraph_at_cursor( - text_content: &mut TextContent, - cursor: &TextPositionWithAffinity, -) -> bool { - let paragraphs = text_content.paragraphs_mut(); - if cursor.paragraph >= paragraphs.len() { - return false; - } - - let para = ¶graphs[cursor.paragraph]; - - let Some((span_idx, offset_in_span)) = find_span_at_offset(para, cursor.offset) else { - return false; - }; - - let mut new_para_children = Vec::new(); - let children = para.children(); - - let current_span = &children[span_idx]; - let span_text = current_span.text.clone(); - let chars: Vec = span_text.chars().collect(); - - if offset_in_span < chars.len() { - let after_text: String = chars[offset_in_span..].iter().collect(); - let mut new_span = current_span.clone(); - new_span.set_text(after_text); - new_para_children.push(new_span); - } - - for child in children.iter().skip(span_idx + 1) { - new_para_children.push(child.clone()); - } - - if new_para_children.is_empty() { - let mut empty_span = current_span.clone(); - empty_span.set_text(String::new()); - new_para_children.push(empty_span); - } - - let text_align = para.text_align(); - let text_direction = para.text_direction(); - let text_decoration = para.text_decoration(); - let text_transform = para.text_transform(); - let line_height = para.line_height(); - let letter_spacing = para.letter_spacing(); - - let para = &mut paragraphs[cursor.paragraph]; - let children = para.children_mut(); - - children.truncate(span_idx + 1); - - if !children.is_empty() { - let span = &mut children[span_idx]; - let text = span.text.clone(); - let new_text: String = text.chars().take(offset_in_span).collect(); - span.set_text(new_text); - } - - let new_para = crate::shapes::Paragraph::new( - text_align, - text_direction, - text_decoration, - text_transform, - line_height, - letter_spacing, - new_para_children, - ); - - paragraphs.insert(cursor.paragraph + 1, new_para); - - true -}