mirror of
https://github.com/penpot/penpot.git
synced 2026-09-09 21:49:10 +00:00
Merge remote-tracking branch 'origin/staging-render' into develop
This commit is contained in:
commit
4c9775e182
20
.github/workflows/tests.yml
vendored
20
.github/workflows/tests.yml
vendored
@ -146,11 +146,18 @@ jobs:
|
|||||||
name: "Frontend Tests"
|
name: "Frontend Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
container: penpotapp/devenv:latest
|
container: penpotapp/devenv:latest
|
||||||
|
needs: test-render-wasm
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
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
|
- name: Unit Tests
|
||||||
working-directory: ./frontend
|
working-directory: ./frontend
|
||||||
run: |
|
run: |
|
||||||
@ -187,6 +194,19 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
./test
|
./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:
|
test-backend:
|
||||||
name: "Backend Tests"
|
name: "Backend Tests"
|
||||||
runs-on: penpot-runner-02
|
runs-on: penpot-runner-02
|
||||||
|
|||||||
321
AGENTS.md
321
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 |
|
| 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
|
orchestrates cross-component builds. `run-ci.sh` defines the CI
|
||||||
pipeline.
|
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
|
### Namespace Structure
|
||||||
|
|
||||||
The backend, frontend and exporter are developed using clojure and
|
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
|
- `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
|
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`
|
`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.
|
new helper.
|
||||||
|
|
||||||
|
|
||||||
### CSS (Modules Pattern)
|
### Integration Tests (Playwright)
|
||||||
|
|
||||||
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 are developed under `frontend/playwright` directory, we use
|
Integration tests are developed under `frontend/playwright` directory, we use
|
||||||
mocks for remove communication with backend.
|
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
|
(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:
|
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
|
The codebase contains various component patterns. When creating or refactoring
|
||||||
components, follow the Modern Syntax rules outlined below.
|
components, follow the Modern Syntax rules outlined below.
|
||||||
@ -417,7 +323,113 @@ Examples:
|
|||||||
- [ ] Does the component name end with *?
|
- [ ] 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: `<emoji-code> <subject>`
|
Format: `<emoji-code> <subject>`
|
||||||
|
|
||||||
@ -456,9 +468,28 @@ applicable.
|
|||||||
| 🌐 | `:globe_with_meridians:` | Translations |
|
| 🌐 | `: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
|
- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss
|
||||||
variables and get the already defined properties from `_sizes.scss`. The 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
|
- Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like
|
||||||
`@include flexCenter;`. Write standard CSS (flex/grid) instead.
|
`@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,
|
- 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
|
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
|
- Leverage component-level CSS variables for state changes (hover/focus) instead
|
||||||
of rewriting properties.
|
of rewriting properties.
|
||||||
|
|
||||||
### Checklist
|
##### Checklist
|
||||||
|
|
||||||
- [ ] No references to `common/refactor/`
|
- [ ] No references to `common/refactor/`
|
||||||
- [ ] All `@import` converted to `@use` (only if refactoring)
|
- [ ] All `@import` converted to `@use` (only if refactoring)
|
||||||
- [ ] Physical properties (left/right) using logical properties (inline-start/end).
|
- [ ] Physical properties (left/right) using logical properties (inline-start/end).
|
||||||
- [ ] Typography implemented via `use-typography()` mixin.
|
- [ ] Typography implemented via `use-typography()` mixin.
|
||||||
|
|||||||
@ -206,3 +206,6 @@ Signed-off-by: Andrey Antukh <niwi@niwi.nz>
|
|||||||
|
|
||||||
Please, use your real name (sorry, no pseudonyms or anonymous
|
Please, use your real name (sorry, no pseudonyms or anonymous
|
||||||
contributions are allowed).
|
contributions are allowed).
|
||||||
|
|
||||||
|
The commit Signed-off-by is mandatory and should match the commit author.
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
135
frontend/playwright/data/workspace/get-file-13272.json
Normal file
135
frontend/playwright/data/workspace/get-file-13272.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -72,7 +72,7 @@ test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => {
|
|||||||
fileId: "3a4d7ec7-c391-8146-8007-9a05c41da6b9",
|
fileId: "3a4d7ec7-c391-8146-8007-9a05c41da6b9",
|
||||||
pageId: "95b23c15-79f9-81ba-8007-99d81b5290dd",
|
pageId: "95b23c15-79f9-81ba-8007-99d81b5290dd",
|
||||||
});
|
});
|
||||||
0
|
|
||||||
await workspacePage.clickToggableLayer("Parent");
|
await workspacePage.clickToggableLayer("Parent");
|
||||||
await workspacePage.clickToggableLayer("Container");
|
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");
|
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");
|
||||||
|
});
|
||||||
|
|||||||
@ -628,12 +628,13 @@
|
|||||||
(let [prev-wasm-props (:prev-wasm-props state)
|
(let [prev-wasm-props (:prev-wasm-props state)
|
||||||
wasm-props (:wasm-props state)
|
wasm-props (:wasm-props state)
|
||||||
objects (dsh/lookup-page-objects 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)
|
(set-wasm-props! objects prev-wasm-props wasm-props)
|
||||||
(let [structure-entries (parse-structure-modifiers modif-tree)]
|
(let [structure-entries (parse-structure-modifiers modif-tree)]
|
||||||
(wasm.api/set-structure-modifiers structure-entries)
|
(wasm.api/set-structure-modifiers structure-entries)
|
||||||
(let [geometry-entries (parse-geometry-modifiers modif-tree)
|
(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)
|
(wasm.api/set-modifiers modifiers)
|
||||||
(let [ids (into [] xf:map-key geometry-entries)
|
(let [ids (into [] xf:map-key geometry-entries)
|
||||||
selrect (wasm.api/get-selection-rect ids)]
|
selrect (wasm.api/get-selection-rect ids)]
|
||||||
|
|||||||
@ -162,7 +162,7 @@
|
|||||||
(= key "Backspace")
|
(= key "Backspace")
|
||||||
(do
|
(do
|
||||||
(dom/prevent-default event)
|
(dom/prevent-default event)
|
||||||
(text-editor/text-editor-delete-backward)
|
(text-editor/text-editor-delete-backward ctrl?)
|
||||||
(sync-wasm-text-editor-content!)
|
(sync-wasm-text-editor-content!)
|
||||||
(wasm.api/request-render "text-delete-backward"))
|
(wasm.api/request-render "text-delete-backward"))
|
||||||
|
|
||||||
@ -170,7 +170,7 @@
|
|||||||
(= key "Delete")
|
(= key "Delete")
|
||||||
(do
|
(do
|
||||||
(dom/prevent-default event)
|
(dom/prevent-default event)
|
||||||
(text-editor/text-editor-delete-forward)
|
(text-editor/text-editor-delete-forward ctrl?)
|
||||||
(sync-wasm-text-editor-content!)
|
(sync-wasm-text-editor-content!)
|
||||||
(wasm.api/request-render "text-delete-forward"))
|
(wasm.api/request-render "text-delete-forward"))
|
||||||
|
|
||||||
@ -178,37 +178,37 @@
|
|||||||
(= key "ArrowLeft")
|
(= key "ArrowLeft")
|
||||||
(do
|
(do
|
||||||
(dom/prevent-default event)
|
(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"))
|
(wasm.api/request-render "text-cursor-move"))
|
||||||
|
|
||||||
(= key "ArrowRight")
|
(= key "ArrowRight")
|
||||||
(do
|
(do
|
||||||
(dom/prevent-default event)
|
(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"))
|
(wasm.api/request-render "text-cursor-move"))
|
||||||
|
|
||||||
(= key "ArrowUp")
|
(= key "ArrowUp")
|
||||||
(do
|
(do
|
||||||
(dom/prevent-default event)
|
(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"))
|
(wasm.api/request-render "text-cursor-move"))
|
||||||
|
|
||||||
(= key "ArrowDown")
|
(= key "ArrowDown")
|
||||||
(do
|
(do
|
||||||
(dom/prevent-default event)
|
(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"))
|
(wasm.api/request-render "text-cursor-move"))
|
||||||
|
|
||||||
(= key "Home")
|
(= key "Home")
|
||||||
(do
|
(do
|
||||||
(dom/prevent-default event)
|
(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"))
|
(wasm.api/request-render "text-cursor-move"))
|
||||||
|
|
||||||
(= key "End")
|
(= key "End")
|
||||||
(do
|
(do
|
||||||
(dom/prevent-default event)
|
(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"))
|
(wasm.api/request-render "text-cursor-move"))
|
||||||
|
|
||||||
;; Let contenteditable handle text input via on-input
|
;; Let contenteditable handle text input via on-input
|
||||||
|
|||||||
@ -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,
|
|
||||||
};
|
|
||||||
|
|
||||||
@ -78,22 +78,28 @@
|
|||||||
(h/call wasm/internal-module "_text_editor_insert_text")
|
(h/call wasm/internal-module "_text_editor_insert_text")
|
||||||
(mem/free))))
|
(mem/free))))
|
||||||
|
|
||||||
(defn text-editor-delete-backward []
|
(defn text-editor-delete-backward
|
||||||
(when wasm/context-initialized?
|
([]
|
||||||
(h/call wasm/internal-module "_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 []
|
(defn text-editor-delete-forward
|
||||||
(when wasm/context-initialized?
|
([]
|
||||||
(h/call wasm/internal-module "_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 []
|
(defn text-editor-insert-paragraph []
|
||||||
(when wasm/context-initialized?
|
(when wasm/context-initialized?
|
||||||
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
|
(h/call wasm/internal-module "_text_editor_insert_paragraph")))
|
||||||
|
|
||||||
(defn text-editor-move-cursor
|
(defn text-editor-move-cursor
|
||||||
[direction extend-selection]
|
[direction word-boundary extend-selection]
|
||||||
(when wasm/context-initialized?
|
(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
|
(defn text-editor-select-all
|
||||||
[]
|
[]
|
||||||
|
|||||||
4
opencode.json
Normal file
4
opencode.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.json",
|
||||||
|
"instructions": ["CONTRIBUTING.md", "AGENTS.md"]
|
||||||
|
}
|
||||||
@ -123,9 +123,9 @@ fn calculate_cursor_rect(
|
|||||||
.map(|span| span.text.chars().count())
|
.map(|span| span.text.chars().count())
|
||||||
.sum();
|
.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
|
// Empty paragraph - use default height
|
||||||
(0.0, laid_out_para.height())
|
(0.0, 0.0, laid_out_para.height())
|
||||||
} else if char_pos == 0 {
|
} else if char_pos == 0 {
|
||||||
let rects = laid_out_para.get_rects_for_range(
|
let rects = laid_out_para.get_rects_for_range(
|
||||||
0..1,
|
0..1,
|
||||||
@ -133,9 +133,10 @@ fn calculate_cursor_rect(
|
|||||||
RectWidthStyle::Tight,
|
RectWidthStyle::Tight,
|
||||||
);
|
);
|
||||||
if !rects.is_empty() {
|
if !rects.is_empty() {
|
||||||
(rects[0].rect.left(), rects[0].rect.height())
|
let r = &rects[0].rect;
|
||||||
|
(r.left(), r.top(), r.height())
|
||||||
} else {
|
} else {
|
||||||
(0.0, laid_out_para.height())
|
(0.0, 0.0, laid_out_para.height())
|
||||||
}
|
}
|
||||||
} else if char_pos >= para_char_count {
|
} else if char_pos >= para_char_count {
|
||||||
let rects = laid_out_para.get_rects_for_range(
|
let rects = laid_out_para.get_rects_for_range(
|
||||||
@ -144,9 +145,10 @@ fn calculate_cursor_rect(
|
|||||||
RectWidthStyle::Tight,
|
RectWidthStyle::Tight,
|
||||||
);
|
);
|
||||||
if !rects.is_empty() {
|
if !rects.is_empty() {
|
||||||
(rects[0].rect.right(), rects[0].rect.height())
|
let r = &rects[0].rect;
|
||||||
|
(r.right(), r.top(), r.height())
|
||||||
} else {
|
} else {
|
||||||
(laid_out_para.longest_line(), laid_out_para.height())
|
(laid_out_para.longest_line(), 0.0, laid_out_para.height())
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let rects = laid_out_para.get_rects_for_range(
|
let rects = laid_out_para.get_rects_for_range(
|
||||||
@ -155,17 +157,18 @@ fn calculate_cursor_rect(
|
|||||||
RectWidthStyle::Tight,
|
RectWidthStyle::Tight,
|
||||||
);
|
);
|
||||||
if !rects.is_empty() {
|
if !rects.is_empty() {
|
||||||
(rects[0].rect.left(), rects[0].rect.height())
|
let r = &rects[0].rect;
|
||||||
|
(r.left(), r.top(), r.height())
|
||||||
} else {
|
} else {
|
||||||
// Fallback: use glyph position
|
// Fallback: use glyph position
|
||||||
let pos = laid_out_para.get_glyph_position_at_coordinate((0.0, 0.0));
|
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(
|
return Some(Rect::from_xywh(
|
||||||
cursor_x,
|
cursor_x,
|
||||||
y_offset,
|
y_offset + cursor_y,
|
||||||
editor_state.theme.cursor_width,
|
editor_state.theme.cursor_width,
|
||||||
cursor_height,
|
cursor_height,
|
||||||
));
|
));
|
||||||
|
|||||||
@ -133,21 +133,21 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) {
|
|||||||
let width = bounds.width();
|
let width = bounds.width();
|
||||||
let height = bounds.height();
|
let height = bounds.height();
|
||||||
|
|
||||||
|
let target_width = bounds.width().round();
|
||||||
|
let target_height = bounds.height().round();
|
||||||
|
|
||||||
let scale_width = if width > 0.1 {
|
let scale_width = if width > 0.1 {
|
||||||
f32::max(0.01, bounds.width().round() / bounds.width())
|
f32::max(0.01, target_width / width)
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
};
|
};
|
||||||
let scale_height = if height > 0.1 {
|
let scale_height = if height > 0.1 {
|
||||||
f32::max(0.01, bounds.height().round() / bounds.height())
|
f32::max(0.01, target_height / height)
|
||||||
} else {
|
} else {
|
||||||
1.0
|
1.0
|
||||||
};
|
};
|
||||||
|
|
||||||
if f32::is_finite(scale_width)
|
if f32::is_finite(scale_width) && f32::is_finite(scale_height) {
|
||||||
&& f32::is_finite(scale_height)
|
|
||||||
&& (!math::is_close_to(scale_width, 1.0) || !math::is_close_to(scale_height, 1.0))
|
|
||||||
{
|
|
||||||
let mut round_transform = Matrix::scale((scale_width, scale_height));
|
let mut round_transform = Matrix::scale((scale_width, scale_height));
|
||||||
round_transform.post_concat(&tr);
|
round_transform.post_concat(&tr);
|
||||||
round_transform.pre_concat(&tr_inv);
|
round_transform.pre_concat(&tr_inv);
|
||||||
@ -373,7 +373,7 @@ pub fn propagate_modifiers(
|
|||||||
if math::identitish(&entry.transform) {
|
if math::identitish(&entry.transform) {
|
||||||
Modifier::Reflow(entry.id, false)
|
Modifier::Reflow(entry.id, false)
|
||||||
} else {
|
} else {
|
||||||
Modifier::Transform(*entry)
|
Modifier::Transform(*entry, pixel_precision)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@ -392,9 +392,9 @@ pub fn propagate_modifiers(
|
|||||||
while !entries.is_empty() {
|
while !entries.is_empty() {
|
||||||
while let Some(modifier) = entries.pop_front() {
|
while let Some(modifier) = entries.pop_front() {
|
||||||
match modifier {
|
match modifier {
|
||||||
Modifier::Transform(entry) => propagate_transform(
|
Modifier::Transform(entry, pixel) => propagate_transform(
|
||||||
entry,
|
entry,
|
||||||
pixel_precision,
|
pixel,
|
||||||
state,
|
state,
|
||||||
&mut entries,
|
&mut entries,
|
||||||
&mut bounds,
|
&mut bounds,
|
||||||
|
|||||||
@ -480,8 +480,12 @@ impl TextContent {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if matches {
|
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 =
|
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) {
|
if let Some(paragraph) = self.paragraphs().get(paragraph_index) {
|
||||||
// Computed position keeps the current position in terms
|
// Computed position keeps the current position in terms
|
||||||
// of number of characters of text. This is used to know
|
// of number of characters of text. This is used to know
|
||||||
|
|||||||
@ -7,16 +7,16 @@ use skia::Matrix;
|
|||||||
|
|
||||||
#[derive(PartialEq, Debug, Clone)]
|
#[derive(PartialEq, Debug, Clone)]
|
||||||
pub enum Modifier {
|
pub enum Modifier {
|
||||||
Transform(TransformEntry),
|
Transform(TransformEntry, bool),
|
||||||
Reflow(Uuid, bool),
|
Reflow(Uuid, bool),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Modifier {
|
impl Modifier {
|
||||||
pub fn transform_propagate(id: Uuid, transform: Matrix) -> Self {
|
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 {
|
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 {
|
pub fn reflow(id: Uuid, force_reflow: bool) -> Self {
|
||||||
Modifier::Reflow(id, force_reflow)
|
Modifier::Reflow(id, force_reflow)
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::shapes::{TextContent, TextPositionWithAffinity};
|
use crate::shapes::{TextContent, TextPositionWithAffinity};
|
||||||
use crate::uuid::Uuid;
|
use crate::uuid::Uuid;
|
||||||
|
use crate::wasm::text::helpers as text_helpers;
|
||||||
use skia_safe::{
|
use skia_safe::{
|
||||||
textlayout::{Affinity, PositionWithAffinity},
|
textlayout::{Affinity, PositionWithAffinity},
|
||||||
Color,
|
Color,
|
||||||
@ -233,11 +234,14 @@ impl TextEditorState {
|
|||||||
|
|
||||||
if offset == chars.len() {
|
if offset == chars.len() {
|
||||||
offset = offset.saturating_sub(1);
|
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;
|
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(
|
self.set_caret_from_position(&TextPositionWithAffinity::new_without_affinity(
|
||||||
position.paragraph,
|
position.paragraph,
|
||||||
position.offset.min(chars.len()),
|
position.offset.min(chars.len()),
|
||||||
@ -248,12 +252,12 @@ impl TextEditorState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut start = offset;
|
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;
|
start -= 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut end = offset + 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;
|
end += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,8 @@ use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_m
|
|||||||
|
|
||||||
use crate::error::Error;
|
use crate::error::Error;
|
||||||
|
|
||||||
|
pub mod helpers;
|
||||||
|
|
||||||
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
|
const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::<RawTextSpan>();
|
||||||
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
|
const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::<RawParagraphData>();
|
||||||
|
|
||||||
|
|||||||
753
render-wasm/src/wasm/text/helpers.rs
Normal file
753
render-wasm/src/wasm/text/helpers.rs
Normal file
@ -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<char> {
|
||||||
|
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<TextPositionWithAffinity> {
|
||||||
|
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<usize> {
|
||||||
|
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<char> = 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<TextPositionWithAffinity> {
|
||||||
|
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<TextPositionWithAffinity> {
|
||||||
|
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<char> = 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
|
||||||
|
}
|
||||||
@ -2,10 +2,11 @@ use macros::{wasm_error, ToJs};
|
|||||||
|
|
||||||
use crate::math::{Matrix, Point, Rect};
|
use crate::math::{Matrix, Point, Rect};
|
||||||
use crate::mem;
|
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::state::TextSelection;
|
||||||
use crate::utils::uuid_from_u32_quartet;
|
use crate::utils::uuid_from_u32_quartet;
|
||||||
use crate::utils::uuid_to_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 crate::{with_state, with_state_mut, STATE};
|
||||||
use skia_safe::{textlayout::TextDirection, Color};
|
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;
|
let selection = state.text_editor_state.selection;
|
||||||
|
|
||||||
if selection.is_selection() {
|
if selection.is_selection() {
|
||||||
delete_selection_range(text_content, &selection);
|
text_helpers::delete_selection_range(text_content, &selection);
|
||||||
let start = selection.start();
|
let start = selection.start();
|
||||||
state.text_editor_state.selection.set_caret(start);
|
state.text_editor_state.selection.set_caret(start);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cursor = state.text_editor_state.selection.focus;
|
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);
|
state.text_editor_state.selection.set_caret(new_cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,7 +355,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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, {
|
with_state_mut!(state, {
|
||||||
if !state.text_editor_state.is_active {
|
if !state.text_editor_state.is_active {
|
||||||
return;
|
return;
|
||||||
@ -373,13 +376,18 @@ pub extern "C" fn text_editor_delete_backward() {
|
|||||||
let selection = state.text_editor_state.selection;
|
let selection = state.text_editor_state.selection;
|
||||||
|
|
||||||
if selection.is_selection() {
|
if selection.is_selection() {
|
||||||
delete_selection_range(text_content, &selection);
|
text_helpers::delete_selection_range(text_content, &selection);
|
||||||
let start = selection.start();
|
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);
|
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 {
|
} else {
|
||||||
let cursor = selection.focus;
|
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);
|
state.text_editor_state.selection.set_caret(new_cursor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -400,7 +408,7 @@ pub extern "C" fn text_editor_delete_backward() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[no_mangle]
|
#[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, {
|
with_state_mut!(state, {
|
||||||
if !state.text_editor_state.is_active {
|
if !state.text_editor_state.is_active {
|
||||||
return;
|
return;
|
||||||
@ -421,14 +429,19 @@ pub extern "C" fn text_editor_delete_forward() {
|
|||||||
let selection = state.text_editor_state.selection;
|
let selection = state.text_editor_state.selection;
|
||||||
|
|
||||||
if selection.is_selection() {
|
if selection.is_selection() {
|
||||||
delete_selection_range(text_content, &selection);
|
text_helpers::delete_selection_range(text_content, &selection);
|
||||||
let start = selection.start();
|
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);
|
state.text_editor_state.selection.set_caret(clamped);
|
||||||
} else {
|
} else {
|
||||||
let cursor = selection.focus;
|
let cursor = selection.focus;
|
||||||
delete_char_after(text_content, &cursor);
|
text_helpers::delete_char_after(text_content, &cursor);
|
||||||
let clamped = clamp_cursor(cursor, text_content.paragraphs());
|
let clamped = text_helpers::clamp_cursor(cursor, text_content.paragraphs());
|
||||||
state.text_editor_state.selection.set_caret(clamped);
|
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;
|
let selection = state.text_editor_state.selection;
|
||||||
|
|
||||||
if selection.is_selection() {
|
if selection.is_selection() {
|
||||||
delete_selection_range(text_content, &selection);
|
text_helpers::delete_selection_range(text_content, &selection);
|
||||||
let start = selection.start();
|
let start = selection.start();
|
||||||
state.text_editor_state.selection.set_caret(start);
|
state.text_editor_state.selection.set_caret(start);
|
||||||
}
|
}
|
||||||
|
|
||||||
let cursor = state.text_editor_state.selection.focus;
|
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 =
|
let new_cursor =
|
||||||
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
|
TextPositionWithAffinity::new_without_affinity(cursor.paragraph + 1, 0);
|
||||||
state.text_editor_state.selection.set_caret(new_cursor);
|
state.text_editor_state.selection.set_caret(new_cursor);
|
||||||
@ -502,7 +515,11 @@ pub extern "C" fn text_editor_insert_paragraph() {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
#[no_mangle]
|
#[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, {
|
with_state_mut!(state, {
|
||||||
if !state.text_editor_state.is_active {
|
if !state.text_editor_state.is_active {
|
||||||
return;
|
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
|
// Get the text direction of the span at the current cursor position
|
||||||
let span_text_direction = if current.paragraph < paragraphs.len() {
|
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 {
|
} else {
|
||||||
TextDirection::LTR
|
TextDirection::LTR
|
||||||
};
|
};
|
||||||
@ -546,16 +566,22 @@ pub extern "C" fn text_editor_move_cursor(direction: CursorDirection, extend_sel
|
|||||||
};
|
};
|
||||||
|
|
||||||
let new_cursor = match adjusted_direction {
|
let new_cursor = match adjusted_direction {
|
||||||
CursorDirection::Backward => move_cursor_backward(¤t, paragraphs),
|
CursorDirection::Backward => {
|
||||||
CursorDirection::Forward => move_cursor_forward(¤t, paragraphs),
|
text_helpers::move_cursor_backward(¤t, paragraphs, word_boundary)
|
||||||
|
}
|
||||||
|
CursorDirection::Forward => {
|
||||||
|
text_helpers::move_cursor_forward(¤t, paragraphs, word_boundary)
|
||||||
|
}
|
||||||
CursorDirection::LineBefore => {
|
CursorDirection::LineBefore => {
|
||||||
move_cursor_up(¤t, paragraphs, text_content, shape)
|
text_helpers::move_cursor_up(¤t, paragraphs, text_content)
|
||||||
}
|
}
|
||||||
CursorDirection::LineAfter => {
|
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::LineStart => {
|
||||||
CursorDirection::LineEnd => move_cursor_line_end(¤t, paragraphs),
|
text_helpers::move_cursor_line_start(¤t, paragraphs)
|
||||||
|
}
|
||||||
|
CursorDirection::LineEnd => text_helpers::move_cursor_line_end(¤t, paragraphs),
|
||||||
};
|
};
|
||||||
|
|
||||||
if extend_selection {
|
if extend_selection {
|
||||||
@ -979,485 +1005,3 @@ fn get_selection_rects(
|
|||||||
|
|
||||||
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<TextPositionWithAffinity> {
|
|
||||||
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<usize> {
|
|
||||||
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<char> = 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<TextPositionWithAffinity> {
|
|
||||||
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<char> = 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
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user