diff --git a/AGENTS.md b/AGENTS.md index 59c4ac0d26..bcb947da47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,139 +1,63 @@ -# IA Agent guide for Penpot monorepo +# AI Agent Guide -This document provides comprehensive context and guidelines for AI -agents working on this repository. +This document provides the core context and operating guidelines for AI agents +working in this repository. -CRITICAL: When you encounter a file reference (e.g., -@rules/general.md), use your Read tool to load it on a need-to-know -basis. They're relevant to the SPECIFIC task at hand. +## Before You Start +Before responding to any user request, you must: -## STOP - DO NOT PROCEED WITHOUT COMPLETING THESE STEPS +1. Read this file completely. +2. Identify which modules are affected by the task. +3. Load the `AGENTS.md` file **only** for each affected module (see the + architecture table below). Not all modules have an `AGENTS.md` — verify the + file exists before attempting to read it. +4. Do **not** load `AGENTS.md` files for unrelated modules. -Before responding to ANY user request, you MUST: +## Role: Senior Software Engineer -1. **READ** the CONTRIBUTING.md file -2. **READ** this file and has special focus on your ROLE. +You are a high-autonomy Senior Full-Stack 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 while maintaining a strong focus on maintainability and performance. +### Operational Guidelines -## ROLE: SENIOR SOFTWARE ENGINEER +1. Before writing code, describe your plan. If the task is complex, break it + down into atomic steps. +2. Be concise and autonomous. +3. Do **not** touch unrelated modules unless the task explicitly requires it. +4. Commit only when explicitly asked. Follow the commit format rules in + `CONTRIBUTING.md`. +5. When searching code, prefer `ripgrep` (`rg`) over `grep` — it respects + `.gitignore` by default. -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. +## Architecture Overview +Penpot is an open-source design tool composed of several modules: -### OPERATIONAL GUIDELINES +| Directory | Language | Purpose | Has `AGENTS.md` | +|-----------|----------|---------|:----------------:| +| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | Yes | +| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | Yes | +| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | Yes | +| `render-wasm/` | Rust -> WebAssembly | High-performance canvas renderer (Skia) | Yes | +| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | No | +| `mcp/` | TypeScript | Model Context Protocol integration | No | +| `plugins/` | TypeScript | Plugin runtime and example plugins | No | -1. Always begin by analyzing this document and understand the - architecture and read the additional context from AGENTS.md of the - affected modules. -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. -4. Commit only if it explicitly asked, and use the CONTRIBUTING.md - document to understand the commit format guidelines. -5. Do not touch unrelated modules if not proceed or not explicitly - asked (per example you probably do not need to touch and read - docker/ directory unless the task explicitly requires it) -6. When searching code, always use `ripgrep` (rg) instead of grep if - available, as it respects `.gitignore` by default. +Some submodules use `pnpm` workspaces. The root `package.json` and +`pnpm-lock.yaml` manage shared dependencies. Helper scripts live in `scripts/`. - -## ARCHITECTURE OVERVIEW - -Penpot is a full-stack design tool composed of several distinct -components separated in modules and subdirectories: - -| Component | Language | Role | IA Agent CONTEXT | -|-----------|----------|------|---------------- -| `frontend/` | ClojureScript + SCSS | Single-page React app (design editor) | @frontend/AGENTS.md | -| `backend/` | Clojure (JVM) | HTTP/RPC server, PostgreSQL, Redis | @backend/AGENTS.md | -| `common/` | Cljc (shared Clojure/ClojureScript) | Data types, geometry, schemas, utilities | @common/AGENTS.md | -| `exporter/` | ClojureScript (Node.js) | Headless Playwright-based export (SVG/PDF) | @exporter/AGENTS.md | -| `render-wasm/` | Rust → WebAssembly | High-performance canvas renderer using Skia | @render-wasm/AGENTS.md | -| `mcp/` | TypeScript | Model Context Protocol integration | @mcp/AGENTS.md | -| `plugins/` | TypeScript | Plugin runtime and example plugins | @plugins/AGENTS.md | - -Several of the mentionend submodules are internall managed with `pnpm` workspaces. - - -## COMMIT FORMAT - -We have very precise rules on how our git commit messages must be -formatted. - -The commit message format is: +### Module Dependency Graph ``` - - -[body] - -[footer] +frontend ──> common +backend ──> common +exporter ──> common +frontend ──> render-wasm (loads compiled WASM) ``` -Where type is: - -- :bug: `:bug:` a commit that fixes a bug -- :sparkles: `:sparkles:` a commit that adds an improvement -- :tada: `:tada:` a commit with a new feature -- :recycle: `:recycle:` a commit that introduces a refactor -- :lipstick: `:lipstick:` a commit with cosmetic changes -- :ambulance: `:ambulance:` a commit that fixes a critical bug -- :books: `:books:` a commit that improves or adds documentation -- :construction: `:construction:` a WIP commit -- :boom: `:boom:` a commit with breaking changes -- :wrench: `:wrench:` a commit for config updates -- :zap: `:zap:` a commit with performance improvements -- :whale: `:whale:` a commit for Docker-related stuff -- :paperclip: `:paperclip:` a commit with other non-relevant changes -- :arrow_up: `:arrow_up:` a commit with dependency updates -- :arrow_down: `:arrow_down:` a commit with dependency downgrades -- :fire: `:fire:` a commit that removes files or code -- :globe_with_meridians: `:globe_with_meridians:` a commit that adds or updates - translations - -The commit should contain a sign-off at the end of the patch/commit -description body. It can be automatically added by adding the `-s` -parameter to `git commit`. - -This is an example of what the line should look like: - -``` -Signed-off-by: Andrey Antukh -``` - -Please, use your real name (sorry, no pseudonyms or anonymous -contributions are allowed). - -CRITICAL: The commit Signed-off-by is mandatory and should match the commit author. - -Each commit should have: - -- A concise subject using the imperative mood. -- The subject should capitalize the first letter, omit the period - at the end, and be no longer than 65 characters. -- A blank line between the subject line and the body. -- An entry in the CHANGES.md file if applicable, referencing the - GitHub or Taiga issue/user story using these same rules. - -Examples of good commit messages: - -- `:bug: Fix unexpected error on launching modal` -- `:bug: Set proper error message on generic error` -- `:sparkles: Enable new modal for profile` -- `:zap: Improve performance of dashboard navigation` -- `:wrench: Update default backend configuration` -- `:books: Add more documentation for authentication process` -- `:ambulance: Fix critical bug on user registration process` -- `:tada: Add new approach for user registration` - -More info: - - - https://gist.github.com/parmentf/035de27d6ed1dce0b36a - - https://gist.github.com/rxaviers/7360908 - - +`common` is referenced as a local dependency (`{:local/root "../common"}`) by +both `frontend` and `backend`. Changes to `common` can therefore affect multiple +modules — test across consumers when modifying shared code. diff --git a/CHANGES.md b/CHANGES.md index 427c6aad56..710fa230b9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -48,7 +48,7 @@ - Fix tooltip activated when tab change [Taiga #13627](https://tree.taiga.io/project/penpot/issue/13627) -## 2.14.0 (Unreleased) +## 2.14.0 ### :boom: Breaking changes & Deprecations diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 278df26e52..b4ac2ac1dd 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -7,8 +7,8 @@ Redis for messaging/caching. ## General Guidelines -This is a golden rule for backend development standards. To ensure consistency -across the Penpot JVM stack, all contributions must adhere to these criteria: +To ensure consistency across the Penpot JVM stack, all contributions must adhere +to these criteria: ### 1. Testing & Validation @@ -16,14 +16,14 @@ across the Penpot JVM stack, all contributions must adhere to these criteria: tests in `test/backend_tests/` must be added or updated. * **Execution:** - * **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific task. - * **Regression:** Run `clojure -M:dev:test` for ensure the suite passes without regressions in related functional areas. + * **Isolated:** Run `clojure -M:dev:test --focus backend-tests.my-ns-test` for the specific test namespace. + * **Regression:** Run `clojure -M:dev:test` to ensure the suite passes without regressions in related functional areas. ### 2. Code Quality & Formatting * **Linting:** All code must pass `clj-kondo` checks (run `pnpm run lint:clj`) * **Formatting:** All the code must pass the formatting check (run `pnpm run - check-fmt`). Use the `pnpm run fmt` fix the formatting issues. Avoid "dirty" + check-fmt`). Use `pnpm run fmt` to fix formatting issues. Avoid "dirty" diffs caused by unrelated whitespace changes. * **Type Hinting:** Use explicit JVM type hints (e.g., `^String`, `^long`) in performance-critical paths to avoid reflection overhead. @@ -40,18 +40,18 @@ namespaces structure: - `app.db.*` – Database layer - `app.tasks.*` – Background job tasks - `app.main` – Integrant system setup and entrypoint -- `app.loggers` – Internal loggers (auditlog, mattermost, etc) (do not be confused with `app.common.loggin`) +- `app.loggers` – Internal loggers (auditlog, mattermost, etc.) (not to be confused with `app.common.logging`) ### RPC -The PRC methods are implement in a some kind of multimethod structure using -`app.util.serivices` namespace. The main RPC methods are collected under +The RPC methods are implemented using a multimethod-like structure via the +`app.util.services` namespace. The main RPC methods are collected under `app.rpc.commands` namespace and exposed under `/api/rpc/command/`. -The RPC method accepts POST and GET requests indistinctly and uses `Accept` -header for negotiate the response encoding (which can be transit, the defaut or -plain json). It also accepts transit (defaut) or json as input, which should be -indicated using `Content-Type` header. +The RPC method accepts POST and GET requests indistinctly and uses the `Accept` +header to negotiate the response encoding (which can be Transit — the default — +or plain JSON). It also accepts Transit (default) or JSON as input, which should +be indicated using the `Content-Type` header. The main convention is: use `get-` prefix on RPC name when we want READ operation. @@ -107,7 +107,7 @@ are config maps with `::ig/ref` for dependencies. Components implement (db/insert! conn :table row))) ``` -Almost all methods on `app.db` namespace accepts `pool`, `conn` or +Almost all methods in the `app.db` namespace accept `pool`, `conn`, or `cfg` as params. Migrations live in `src/app/migrations/` as numbered SQL files. They run automatically on startup. @@ -116,7 +116,7 @@ Migrations live in `src/app/migrations/` as numbered SQL files. They run automat ### Error Handling The exception helpers are defined on Common module, and are available under -`app.commin.exceptions` namespace. +`app.common.exceptions` namespace. Example of raising an exception: @@ -132,10 +132,11 @@ Common types: `:not-found`, `:validation`, `:authorization`, `:conflict`, `:inte ### Performance Macros (`app.common.data.macros`) -Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript: +Always prefer these macros over their `clojure.core` equivalents — they provide +optimized implementations: ```clojure -(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys +(dm/select-keys m [:a :b]) ;; faster than core/select-keys (dm/get-in obj [:a :b :c]) ;; faster than core/get-in (dm/str "a" "b" "c") ;; string concatenation ``` diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 4383ab794f..efe99c4a70 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -48,7 +48,7 @@ (def schema:props [:map {:title "ProfileProps"} [:plugins {:optional true} schema:plugin-registry] - [:mcp-status {:optional true} ::sm/boolean] + [:mcp-enabled {:optional true} ::sm/boolean] [:newsletter-updates {:optional true} ::sm/boolean] [:newsletter-news {:optional true} ::sm/boolean] [:onboarding-team-id {:optional true} ::sm/uuid] diff --git a/common/AGENTS.md b/common/AGENTS.md index 996a7f4953..2659b83939 100644 --- a/common/AGENTS.md +++ b/common/AGENTS.md @@ -1,14 +1,13 @@ # Penpot Common – Agent Instructions -A shared module with code written in Clojure, ClojureScript and -JavaScript. Contains multplatform code that can be used and executed -from frontend, backend or exporter modules. It uses clojure reader -conditionals for specify platform specific implementation. +A shared module with code written in Clojure, ClojureScript, and +JavaScript. Contains multiplatform code that can be used and executed +from the frontend, backend, or exporter modules. It uses Clojure reader +conditionals to specify platform-specific implementations. ## General Guidelines -This is a golden rule for common module development. To ensure -consistency across the penpot stack, all contributions must adhere to +To ensure consistency across the Penpot stack, all contributions must adhere to these criteria: ### 1. Testing & Validation @@ -16,11 +15,11 @@ these criteria: If code is added or modified in `src/`, corresponding tests in `test/common_tests/` must be added or updated. -* **Environment:** Tests should run in a JS (nodejs) and JVM + * **Environment:** Tests should run in both JS (Node.js) and JVM environments. * **Location:** Place tests in the `test/common_tests/` directory, following the namespace structure of the source code (e.g., `app.common.colors` -> `common-tests.colors-test`). -* **Execution:** The tests should be executed on both: JS (nodejs) and JVM environments +* **Execution:** Tests should be executed on both JS (Node.js) and JVM environments: * **Isolated:** * JS: To run a focused ClojureScript unit test: edit the `test/common_tests/runner.cljs` to narrow the test suite, then @@ -37,8 +36,8 @@ If code is added or modified in `src/`, corresponding tests in * **Formatting:** All code changes must pass the formatting check * Run `pnpm run check-fmt:clj` for CLJ/CLJS/CLJC * Run `pnpm run check-fmt:js` for JS - * Use the `pnpm run fmt` fix all the formatting issues (`pnpm run - fmt:clj` or `pnpm run fmt:js` for isolated formatting fix) + * Use `pnpm run fmt` to fix all formatting issues (`pnpm run + fmt:clj` or `pnpm run fmt:js` for isolated formatting fix). ## Code Conventions @@ -50,16 +49,16 @@ namespaces structure: - `app.common.types.*` – Shared data types for shapes, files, pages using Malli schemas - `app.common.schema` – Malli abstraction layer, exposes the most used functions from malli - `app.common.geom.*` – Geometry and shape transformation helpers -- `app.common.data` – Generic helpers used around all application -- `app.common.math` – Generic math helpers used around all aplication +- `app.common.data` – Generic helpers used across the entire application +- `app.common.math` – Generic math helpers used across the entire application - `app.common.json` – Generic JSON encoding/decoding helpers - `app.common.data.macros` – Performance macros used everywhere ### Reader Conditionals -We use reader conditionals to target for differentiate an -implementation depending on the target platform where code should run: +We use reader conditionals to differentiate implementations depending on the +target platform where the code runs: ```clojure #?(:clj (import java.util.UUID) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index b6f63794cc..b4ad811522 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -1,13 +1,12 @@ # Penpot Frontend – Agent Instructions -ClojureScript based frontend application that uses React, RxJS as main +ClojureScript-based frontend application that uses React and RxJS as its main architectural pieces. - ## General Guidelines -This is a golden rule for frontend development standards. To ensure consistency -across the penpot stack, all contributions must adhere to these criteria: +To ensure consistency across the Penpot stack, all contributions must adhere to +these criteria: ### 1. Testing & Validation @@ -22,7 +21,7 @@ If code is added or modified in `src/`, corresponding tests in running backend. Test are developed using cljs.test. * **Mocks & Stubs:** * Use proper mocks for any side-effecting functions (e.g., API calls, storage access). - * Avoid testing through the UI (DOM), we have e2e tests for that/ + * Avoid testing through the UI (DOM); we have e2e tests for that. * Use `with-redefs` or similar ClojureScript mocking utilities to isolate the logic under test. * **No Flakiness:** Tests must be deterministic. Do not use `setTimeout` or real network calls. Use synchronous mocks for asynchronous workflows where @@ -34,15 +33,15 @@ If code is added or modified in `src/`, corresponding tests in * **Isolated:** To run a focused ClojureScript unit test: edit the `test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm run test`. - * **Regression:** Run `pnpm run test` without modifications on the runner (preferred) + * **Regression:** To run `pnpm run test` without modifications on the runner (preferred) #### Integration Tests (Playwright) Integration tests are developed under `frontend/playwright` directory, we use -mocks for remove communication with backend. +mocks for remote communication with the backend. -You should not add, modify or run the integration tests unless it exlicitly asked for. +You should not add, modify or run the integration tests unless explicitly asked. ``` @@ -50,7 +49,7 @@ pnpm run test:e2e # Playwright e2e tests pnpm run test:e2e --grep "pattern" # Single e2e test by pattern ``` -Ensure everything installed before executing tests with `./scripts/setup` script. +Ensure everything is installed before executing tests with the `./scripts/setup` script. ### 2. Code Quality & Formatting @@ -68,8 +67,8 @@ Ensure everything installed before executing tests with `./scripts/setup` script ### 3. Implementation Rules -* **Logic vs. View:** If logic is embedded in an UI component, extract it into a - function in the same namespace if is only used locally or look for a helper +* **Logic vs. View:** If logic is embedded in a UI component, extract it into a + function in the same namespace if it is only used locally, or look for a helper namespace to make it unit-testable. @@ -113,7 +112,7 @@ State is a single atom managed by a Potok store. Events implement protocols ``` The state is located under `app.main.store` namespace where we have -the `emit!` function responsible of emiting events. +the `emit!` function responsible for emitting events. Example: @@ -128,15 +127,14 @@ Example: (st/emit! (my-event))) ``` -On `app.main.refs` we have reactive references which lookup into the main state -for just inner data or precalculated data. That references are very usefull but -should be used with care because, per example if we have complex operation, this -operation will be executed on each state change, and sometimes is better to have -simple references and use react `use-memo` for more granular memoization. +On `app.main.refs` we have reactive references which look up the main state +for inner data or precalculated data. These references are very useful but +should be used with care because, for example, if we have a complex operation, +this operation will be executed on each state change. Sometimes it is better to +have simple references and use React `use-memo` for more granular memoization. -Prefer helpers from `app.util.dom` instead of using direct dom calls, if no helper is -available, prefer adding a new helper for handling it and the use the -new helper. +Prefer helpers from `app.util.dom` instead of using direct DOM calls. If no +helper is available, prefer adding a new helper and then using it. ### UI Components (React & Rumext: mf/defc) @@ -175,19 +173,20 @@ lifecycle management. These are analogous to standard React hooks: ``` The `mf/use-state` in difference with React.useState, returns an atom-like -object, where you can use `swap!` or `reset!` for to perform an update and -`deref` for get the current value. +object, where you can use `swap!` or `reset!` to perform an update and +`deref` to get the current value. -You also has `mf/deref` hook (which does not follow the `use-` naming pattern) -and it's purpose is watch (subscribe to changes) on atom or derived atom (from -okulary) and get the current value. Is mainly used for subscribe to lenses -defined in `app.main.refs` or (private lenses defined in namespaces). +You also have the `mf/deref` hook (which does not follow the `use-` naming +pattern) and its purpose is to watch (subscribe to changes on) an atom or +derived atom (from okulary) and get the current value. It is mainly used to +subscribe to lenses defined in `app.main.refs` or private lenses defined in +namespaces. Rumext also comes with improved syntax macros as alternative to `mf/use-effect` and `mf/use-memo` functions. Examples: -Example for `mf/with-memo` macro: +Example for `mf/with-effect` macro: ```clj ;; Using functions @@ -221,7 +220,7 @@ Example for `mf/with-memo` macro: (filterv #(= team-id (:team-id %))))) ``` -Prefer using the macros for it syntax simplicity. +Prefer using the macros for their syntax simplicity. #### 4. Component Usage (Hiccup Syntax) @@ -282,22 +281,22 @@ CSS modules pattern): - If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss" as *; padding: px2rem(23);`. - Do **not** create new SCSS variables for one-off values. -- Use physical directions with logical ones to support RTL/LTR naturally. - - ❌ `margin-left`, `padding-right`, `left`, `right`. - - ✅ `margin-inline-start`, `padding-inline-end`, `inset-inline-start`. -- Always use the `use-typography` mixin from `ds/typography.scss`. - - ✅ `@include t.use-typography("title-small");` +- Use physical directions with logical ones to support RTL/LTR naturally: + - Avoid: `margin-left`, `padding-right`, `left`, `right`. + - Prefer: `margin-inline-start`, `padding-inline-end`, `inset-inline-start`. +- Always use the `use-typography` mixin from `ds/typography.scss`: + - Example: `@include t.use-typography("title-small");` - Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`. - Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or legacy color variables. -- Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like +- Use mixins only from `ds/mixins.scss`. Avoid legacy mixins like `@include flexCenter;`. Write standard CSS (flex/grid) instead. - 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 *;` (Use `as *` to expose variables directly). - Avoid deep selector nesting or high-specificity (IDs). Flatten selectors: - - ❌ `.card { .title { ... } }` - - ✅ `.card-title { ... }` + - Avoid: `.card { .title { ... } }` + - Prefer: `.card-title { ... }` - Leverage component-level CSS variables for state changes (hover/focus) instead of rewriting properties. @@ -324,5 +323,5 @@ Always prefer these macros over their `clojure.core` equivalents — they compil ### Configuration `src/app/config.clj` reads globally defined variables and exposes precomputed -configuration vars ready to be used from other parts of the application +configuration values ready to be used from other parts of the application. diff --git a/frontend/packages/draft-js/index.js b/frontend/packages/draft-js/index.js index 23bd20af01..84b7190537 100644 --- a/frontend/packages/draft-js/index.js +++ b/frontend/packages/draft-js/index.js @@ -366,12 +366,19 @@ export function getInlineStyle(state, blockKey, offset) { const NEWLINE_REGEX = /\r\n?|\n/g; function splitTextIntoTextBlocks(text) { + if (text == null) { + return []; + } return text.split(NEWLINE_REGEX); } export function insertText(state, text, attrs, inlineStyles) { const blocks = splitTextIntoTextBlocks(text); + if (blocks.length === 0) { + return state; + } + const character = CharacterMetadata.create({style: OrderedSet(inlineStyles)}); let blockArray = DraftPasteProcessor.processText( diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index fa2e04fe9d..64221eecb8 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -340,7 +340,10 @@ (rx/of (ntf/hide) (dcmt/retrieve-comment-threads file-id) (dcmt/fetch-profiles) - (df/fetch-fonts team-id))) + (df/fetch-fonts team-id)) + + (when (contains? cf/flags :mcp) + (rx/of (du/fetch-access-tokens)))) ;; Once the essential data is fetched, lets proceed to ;; fetch teh file bunldle @@ -377,7 +380,7 @@ (rx/map deref) (rx/mapcat (fn [value] (rx/of (mcp/update-mcp-connection value) - (mcp/disconnect-mcp)))))) + (mcp/user-disconnect-mcp)))))) (when (contains? cf/flags :mcp) (->> mbc/stream diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 8c1b8021a0..4c3e60f7d4 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -295,6 +295,22 @@ (def default-paste-from-blob (create-paste-from-blob false)) +(defn- clipboard-permission-error? + "Check if the given error is a clipboard permission error + (NotAllowedError DOMException)." + [cause] + (and (instance? js/DOMException cause) + (= (.-name cause) "NotAllowedError"))) + +(defn- on-clipboard-permission-error + [cause] + (if (clipboard-permission-error? cause) + (rx/of (ntf/show {:content (tr "errors.clipboard-permission-denied") + :type :toast + :level :warning + :timeout 5000})) + (rx/throw cause))) + (defn paste-from-clipboard "Perform a `paste` operation using the Clipboard API." [] @@ -303,7 +319,8 @@ (watch [_ _ _] (->> (clipboard/from-navigator default-options) (rx/mapcat default-paste-from-blob) - (rx/take 1))))) + (rx/take 1) + (rx/catch on-clipboard-permission-error))))) (defn paste-from-event "Perform a `paste` operation from user emmited event." @@ -483,11 +500,20 @@ (-> entry t/decode-str paste-transit-props)) (on-error [cause] - (let [data (ex-data cause)] - (if (:not-implemented data) - (rx/of (ntf/warn (tr "errors.clipboard-not-implemented"))) - (js/console.error "Clipboard error:" cause)) - (rx/empty)))] + (cond + (clipboard-permission-error? cause) + (rx/of (ntf/show {:content (tr "errors.clipboard-permission-denied") + :type :toast + :level :warning + :timeout 5000})) + + (:not-implemented (ex-data cause)) + (rx/of (ntf/warn (tr "errors.clipboard-not-implemented"))) + + :else + (do + (js/console.error "Clipboard error:" cause) + (rx/empty))))] (->> (clipboard/from-navigator default-options) (rx/mapcat #(.text %)) diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index a9198750c7..50b70fee43 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -17,9 +17,12 @@ [app.main.store :as st] [app.plugins.register :refer [mcp-plugin-id]] [app.util.i18n :refer [tr]] + [app.util.timers :as ts] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +(def retry-interval 10000) + (log/set-level! :info) (def ^:private default-manifest @@ -34,13 +37,53 @@ "comment:read" "comment:write" "content:write" "content:read"}}) +(defonce interval-sub (atom nil)) + (defn finalize-workspace? [event] (= (ptk/type event) :app.main.data.workspace/finalize-workspace)) -(defn disconnect-mcp +(defn set-mcp-active + [value] + (ptk/reify ::set-mcp-active + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:workspace-local :mcp :active] value)))) + +(defn start-reconnect-watcher! [] - (st/emit! (ptk/data-event ::disconnect))) + (st/emit! (set-mcp-active true)) + (when (nil? @interval-sub) + (reset! + interval-sub + (ts/interval + retry-interval + (fn [] + ;; Try to reconnect if active and not connected + (when-not (contains? #{"connecting" "connected"} + (-> @st/state :workspace-local :mcp :connection)) + (.log js/console "Reconnecting to MCP...") + (st/emit! (ptk/data-event ::connect)))))))) + +(defn stop-reconnect-watcher! + [] + (st/emit! (set-mcp-active false)) + (when @interval-sub + (rx/dispose! @interval-sub) + (reset! interval-sub nil))) + +;; This event will arrive when the user selects disconnect on the menu +;; or there is a broadcast message for disconnection +(defn user-disconnect-mcp + [] + (ptk/reify ::remote-disconnect-mcp + ptk/WatchEvent + (watch [_ _ _] + (rx/of (ptk/data-event ::disconnect))) + + ptk/EffectEvent + (effect [_ _ _] + (stop-reconnect-watcher!)))) (defn connect-mcp [] @@ -58,12 +101,13 @@ (ptk/reify ::manage-mcp-notification ptk/WatchEvent (watch [_ state _] - (let [mcp-connected? (true? (-> state :workspace-local :mcp :connection)) + (let [mcp-connected? (= "connected" (-> state :workspace-local :mcp :connection)) mcp-enabled? (true? (-> state :profile :props :mcp-enabled)) num-sessions (-> state :workspace-presence count) - multi-session? (> num-sessions 1)] + multi-session? (> num-sessions 1) + mcp-active? (-> state :workspace-local :mcp :active)] (if (and mcp-enabled? multi-session?) - (if mcp-connected? + (if (or mcp-connected? mcp-active?) (rx/of (ntf/hide)) (rx/of (ntf/dialog :content (tr "notifications.mcp.active-in-another-tab") :cancel {:label (tr "labels.dismiss") @@ -76,6 +120,7 @@ ::ev/origin "workspace-notification"}))}))) (rx/of (ntf/hide))))))) +;; This event will arrive when the mcp is enabled in the main menu (defn update-mcp-status [value] (ptk/reify ::update-mcp-status @@ -121,13 +166,10 @@ :getServerUrl #(str cf/mcp-ws-uri) :setMcpStatus (fn [status] - (let [mcp-connection (case status - "connected" true - "disconnected" false - "error" nil - "")] - (st/emit! (update-mcp-connection mcp-connection)) - (log/info :hint "MCP STATUS" :status status))) + (when (= status "connected") + (start-reconnect-watcher!)) + (st/emit! (update-mcp-connection status)) + (log/info :hint "MCP STATUS" :status status)) :on (fn [event cb] diff --git a/frontend/src/app/main/data/workspace/path/changes.cljs b/frontend/src/app/main/data/workspace/path/changes.cljs index c2bc293da3..8eb73b3663 100644 --- a/frontend/src/app/main/data/workspace/path/changes.cljs +++ b/frontend/src/app/main/data/workspace/path/changes.cljs @@ -69,7 +69,7 @@ content (if (and (not preserve-move-to) (= (-> content last :command) :move-to)) (path/content (take (dec (count content)) content)) - content)] + (path/content content))] (st/set-content state content))) ptk/WatchEvent diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index 0d11245453..af03561722 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -927,23 +927,25 @@ final-shape-fn (fn [shape] (merge shape final-geom))] (-> base (pcb/with-objects objects-with-old) - (pcb/update-shapes [id] final-shape-fn {:attrs attrs}) - (pcb/set-stack-undo? true)))) + (pcb/update-shapes [id] final-shape-fn {:attrs attrs})))) (defn- build-finalize-commit-changes "Builds the commit changes for text finalization (content + geometry undo). - For auto-width text, include geometry so undo restores e.g. width." - [it state id {:keys [new-shape? content-has-text? content original-content undo-group]}] + For auto-width text, include geometry so undo restores e.g. width. + Includes :name when update-name? so we can skip save-undo on the preceding + update-shapes for finalize without losing name undo." + [it state id {:keys [new-shape? content-has-text? content original-content + update-name? name]}] (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) shape* (get objects id) base (-> (pcb/empty-changes it page-id) + (pcb/with-objects objects) (pcb/set-text-content id content original-content) + (cond-> (and update-name? (some? name) (not= (:name shape*) name)) + (pcb/update-shapes [id] (fn [s] (assoc s :name name)) {:attrs [:name]})) (cond-> new-shape? (-> (pcb/set-undo-group id) - (pcb/set-stack-undo? true))) - (cond-> (and (not new-shape?) (some? undo-group)) - (-> (pcb/set-undo-group undo-group) (pcb/set-stack-undo? true)))) final-geom (select-keys shape* [:selrect :points :width :height]) geom-keys (if new-shape? [:selrect :points] [:selrect :points :width :height]) @@ -987,7 +989,13 @@ ;; New shapes: single undo on finalize only (no per-keystroke undo) effective-save-undo? (if new-shape? finalize? save-undo?) effective-stack-undo? (and new-shape? finalize?) - finalize-undo-group (when (and finalize? (not new-shape?)) (uuid/next))] + ;; No save-undo on first update when finalizing: either build-finalize + ;; holds undo (non-new), or we delete empty text and only delete-shapes + ;; should record undo. + finalize-save-undo-first? + (if (and finalize? (or (not new-shape?) (not content-has-text?))) + false + effective-save-undo?)] (rx/concat (rx/of @@ -1006,16 +1014,17 @@ (dissoc :prev-content)) (cond-> (and (not new-shape?) prev-content-has-text? - (not content-has-text?)) + (not content-has-text?) + (not finalize?)) (assoc :prev-content prev-content)) (cond-> (and update-name? (some? name)) (assoc :name name)) (cond-> (some? new-size) (gsh/transform-shape (ctm/change-size shape (:width new-size) (:height new-size)))))) - {:save-undo? effective-save-undo? + {:save-undo? finalize-save-undo-first? :stack-undo? effective-stack-undo? - :undo-group (or finalize-undo-group (when new-shape? id))}) + :undo-group (when new-shape? id)}) ;; When we don't update the shape (no new-size), still update WASM display (when-not (some? new-size) @@ -1024,28 +1033,35 @@ (when finalize? (rx/concat - (when (and (not content-has-text?) (some? id)) - (rx/of - (when has-prev-content? - (dwsh/update-shapes - [id] - (fn [shape] (assoc shape :content (:prev-content shape))) - {:save-undo? false})) - (dws/deselect-shape id) - (dwsh/delete-shapes #{id}))) - (rx/of - (dch/commit-changes - (build-finalize-commit-changes it state id - {:new-shape? new-shape? - :content-has-text? content-has-text? - :content content - :original-content original-content - :undo-group finalize-undo-group})) - (dwt/finish-transform) - (fn [state] - (-> state - (update :workspace-new-text-shapes disj id) - (update :workspace-text-session-geom (fnil dissoc {}) id)))))))) + (if (and (not content-has-text?) (some? id)) + (rx/concat + (if (and (some? original-content) (v2-content-has-text? original-content)) + (rx/of + (dwsh/update-shapes + [id] + (fn [s] (-> s (assoc :content original-content) (dissoc :prev-content))) + {:save-undo? false})) + (rx/empty)) + (rx/of (dws/deselect-shape id) + (dwsh/delete-shapes #{id}))) + (rx/empty)) + (rx/concat + (if content-has-text? + (rx/of + (dch/commit-changes + (build-finalize-commit-changes it state id + {:new-shape? new-shape? + :content-has-text? content-has-text? + :content content + :original-content original-content + :update-name? update-name? + :name name}))) + (rx/empty)) + (rx/of (dwt/finish-transform) + (fn [state] + (-> state + (update :workspace-new-text-shapes disj id) + (update :workspace-text-session-geom (fnil dissoc {}) id))))))))) (let [modifiers (get-in state [:workspace-text-modifier id]) new-shape? (contains? (:workspace-new-text-shapes state) id)] diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 29f7571625..6e40dbcbda 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -485,21 +485,32 @@ [:& shape-wrapper {:shape object}]]]])) (defn render-to-canvas - [objects canvas bounds scale object-id] - (try - (when (wasm.api/init-canvas-context canvas) - (wasm.api/initialize-viewport - objects scale bounds "#000000" 0 - (fn [] - (wasm.api/render-sync-shape object-id) - (dom/set-attribute! canvas "id" (dm/str "screenshot-" object-id))))) - (catch :default e - (js/console.error "Error initializing canvas context:" e) - false))) + [objects canvas bounds scale object-id on-render] + (let [width (.-width canvas) + height (.-height canvas) + os-canvas (js/OffscreenCanvas. width height)] + (try + (when (wasm.api/init-canvas-context os-canvas) + (wasm.api/initialize-viewport + objects scale bounds "#000000" 0 + (fn [] + (wasm.api/render-sync-shape object-id) + (ts/raf + (fn [] + (let [bitmap (.transferToImageBitmap os-canvas) + ctx2d (.getContext canvas "2d")] + (.clearRect ctx2d 0 0 width height) + (.drawImage ctx2d bitmap 0 0) + (dom/set-attribute! canvas "id" (dm/str "screenshot-" object-id)) + (wasm.api/clear-canvas) + (on-render))))))) + (catch :default e + (js/console.error "Error initializing canvas context:" e) + false)))) (mf/defc object-wasm {::mf/wrap [mf/memo]} - [{:keys [objects object-id skip-children scale] :as props}] + [{:keys [objects object-id skip-children scale on-render] :as props}] (let [object (get objects object-id) object (cond-> object (:hide-fill-on-export object) @@ -521,7 +532,7 @@ (p/fmap (fn [ready?] (when ready? - (render-to-canvas objects canvas bounds scale object-id)))))))) + (render-to-canvas objects canvas bounds scale object-id on-render)))))))) [:canvas {:ref canvas-ref :width (* scale width) diff --git a/frontend/src/app/main/ui/components/portal.cljs b/frontend/src/app/main/ui/components/portal.cljs index ff9f3558d4..381db4b66c 100644 --- a/frontend/src/app/main/ui/components/portal.cljs +++ b/frontend/src/app/main/ui/components/portal.cljs @@ -6,16 +6,12 @@ (ns app.main.ui.components.portal (:require - [app.util.dom :as dom] + [app.main.ui.hooks :as hooks] [rumext.v2 :as mf])) (mf/defc portal-on-document* [{:keys [children]}] - (let [container (mf/use-memo #(dom/create-element "div"))] - (mf/with-effect [] - (let [body (dom/get-body)] - (dom/append-child! body container) - #(dom/remove-child! body container))) + (let [container (hooks/use-portal-container)] (mf/portal (mf/html [:* children]) container))) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index d536429f50..e1216479f4 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -9,6 +9,7 @@ [app.main.style :as stl]) (:require [app.common.data :as d] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.timers :as ts] @@ -159,6 +160,8 @@ tooltip-ref (mf/use-ref nil) + container (hooks/use-portal-container) + id (d/nilv id internal-id) @@ -198,6 +201,14 @@ (reset! active-tooltip {:id tooltip-id :trigger trigger-el}) (reset! visible* true))))))) + on-show-focus + (mf/use-fn + (mf/deps on-show) + (fn [event] + (let [related (dom/get-related-target event)] + (when (some? related) + (on-show event))))) + on-hide (mf/use-fn (mf/deps tooltip-id) @@ -234,7 +245,7 @@ (mf/spread-props props {:on-mouse-enter on-show :on-mouse-leave on-hide - :on-focus on-show + :on-focus on-show-focus :on-blur on-hide :ref internal-trigger-ref :on-key-down handle-key-down @@ -244,17 +255,6 @@ content aria-label)})] - (mf/use-effect - (mf/deps tooltip-id) - (fn [] - (let [handle-visibility-change - (fn [] - (when (.-hidden js/document) - (on-hide)))] - (js/document.addEventListener "visibilitychange" handle-visibility-change) - ;; cleanup - #(js/document.removeEventListener "visibilitychange" handle-visibility-change)))) - (mf/use-effect (mf/deps visible placement offset) (fn [] @@ -295,4 +295,4 @@ [:div {:class (stl/css :tooltip-content)} content] [:div {:class (stl/css :tooltip-arrow) :id "tooltip-arrow"}]]]) - (.-body js/document)))])) + container))])) diff --git a/frontend/src/app/main/ui/hooks.cljs b/frontend/src/app/main/ui/hooks.cljs index b4ad8fe616..42560cd8fe 100644 --- a/frontend/src/app/main/ui/hooks.cljs +++ b/frontend/src/app/main/ui/hooks.cljs @@ -380,6 +380,18 @@ state)) +(defn use-portal-container + "Creates a dedicated div container for React portals. The container + is appended to document.body on mount and removed on cleanup, preventing + removeChild race conditions when multiple portals target the same body." + [] + (let [container (mf/use-memo #(dom/create-element "div"))] + (mf/with-effect [] + (let [body (dom/get-body)] + (dom/append-child! body container) + #(dom/remove-child! body container))) + container)) + (defn use-dynamic-grid-item-width ([] (use-dynamic-grid-item-width nil)) ([itemsize] diff --git a/frontend/src/app/main/ui/modal.cljs b/frontend/src/app/main/ui/modal.cljs index 9d260de69e..5df1cc3daa 100644 --- a/frontend/src/app/main/ui/modal.cljs +++ b/frontend/src/app/main/ui/modal.cljs @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.main.data.modal :as modal] [app.main.store :as st] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.keyboard :as k] [goog.events :as events] @@ -83,7 +84,8 @@ (mf/defc modal-container* {::mf/props :obj} [] - (when-let [modal (mf/deref ref:modal)] - (mf/portal - (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) - (dom/get-body)))) + (let [container (hooks/use-portal-container)] + (when-let [modal (mf/deref ref:modal)] + (mf/portal + (mf/html [:> modal-wrapper* {:data modal :key (dm/str (:id modal))}]) + container)))) diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 223e30ce50..135ed716e4 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -10,6 +10,7 @@ [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] + [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.common :as dcm] @@ -42,9 +43,13 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [beicon.v2.core :as rx] + [okulary.core :as l] [potok.v2.core :as ptk] [rumext.v2 :as mf])) +(def tokens-ref + (l/derived :access-tokens st/state)) + (mf/defc shortcuts* {::mf/private true} [{:keys [id]}] @@ -750,7 +755,7 @@ workspace-local (mf/deref refs/workspace-local) mcp-enabled? (true? (-> profile :props :mcp-enabled)) - mcp-connected? (true? (-> workspace-local :mcp :connection)) + mcp-connected? (= "connected" (-> workspace-local :mcp :connection)) on-nav-to-integrations (mf/use-fn @@ -769,7 +774,7 @@ (mf/use-fn (fn [] (if mcp-connected? - (st/emit! (mcp/disconnect-mcp) + (st/emit! (mcp/user-disconnect-mcp) (ptk/event ::ev/event {::ev/name "disconnect-mcp-plugin" ::ev/origin "workspace-menu"})) (st/emit! (mcp/connect-mcp) @@ -978,10 +983,21 @@ :class (stl/css :item-arrow)}]]) (when (contains? cf/flags :mcp) - (let [mcp-enabled? (true? (-> profile :props :mcp-enabled)) + (let [tokens (mf/deref tokens-ref) + expired? (some->> tokens + (some #(when (= (:type %) "mcp") %)) + :expires-at + (> (ct/now))) + + mcp-enabled? (true? (-> profile :props :mcp-enabled)) mcp-connection (-> workspace-local :mcp :connection) - mcp-connected? (true? mcp-connection) - mcp-error? (nil? mcp-connection)] + mcp-connected? (= mcp-connection "connected") + mcp-error? (= mcp-connection "error") + + active? (and mcp-enabled? mcp-connected?) + failed? (or (and mcp-enabled? mcp-error?) + (true? expired?))] + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :menu-item) :on-click on-menu-click :on-key-down (fn [event] @@ -993,8 +1009,8 @@ [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.option.mcp")] [:span {:class (stl/css-case :item-indicator true - :active (and mcp-enabled? mcp-connected?) - :failed (and mcp-enabled? mcp-error?))}] + :active active? + :failed failed?)}] [:> icon* {:icon-id i/arrow-right :class (stl/css :item-arrow)}]])) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs index 135df9ecb3..616cd17b98 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/editor.cljs @@ -221,12 +221,13 @@ handle-pasted-text (fn [text _ _] - (let [current-block-styles (ted/get-editor-current-block-data state) - inline-styles (ted/get-editor-current-inline-styles state) - style (merge current-block-styles inline-styles) - state (-> (ted/insert-text state text style) - (handle-change))] - (st/emit! (dwt/update-editor-state shape state))) + (when (seq text) + (let [current-block-styles (ted/get-editor-current-block-data state) + inline-styles (ted/get-editor-current-inline-styles state) + style (merge current-block-styles inline-styles) + state (-> (ted/insert-text state text style) + (handle-change))] + (st/emit! (dwt/update-editor-state shape state)))) "handled")] (mf/use-layout-effect on-mount) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs index f2f4df6423..281ed7e888 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs @@ -202,10 +202,10 @@ detach-value (mf/use-fn - (mf/deps on-detach index) + (mf/deps on-detach index color) (fn [_] (when on-detach - (on-detach index)))) + (on-detach index color)))) handle-select (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index 955e69f26c..6eb0cc349d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -82,7 +82,7 @@ on-color-detach (mf/use-fn (mf/deps index on-color-detach) - (fn [color] + (fn [_ color] (on-color-detach index color))) on-remove diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index df3c5070fe..d87398ec55 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -20,6 +20,7 @@ [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.hooks :as hooks] [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr]] @@ -520,7 +521,8 @@ dropdown-direction (deref dropdown-direction*) dropdown-direction-change* (mf/use-ref 0) top (+ (get-in mdata [:position :y]) 5) - left (+ (get-in mdata [:position :x]) 5)] + left (+ (get-in mdata [:position :x]) 5) + container (hooks/use-portal-container)] (mf/use-effect (mf/deps is-open?) @@ -559,4 +561,4 @@ :on-context-menu prevent-default} (when mdata [:& token-context-menu-tree (assoc mdata :width @width :on-delete-token on-delete-token)])]]) - (dom/get-body))))) + container)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs index f1ef466929..137551c260 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -19,6 +19,7 @@ [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.forms :as fc] + [app.main.ui.hooks :as hooks] [app.main.ui.workspace.tokens.management.forms.controls.combobox-navigation :refer [use-navigation]] [app.main.ui.workspace.tokens.management.forms.controls.floating-dropdown :refer [use-floating-dropdown]] [app.main.ui.workspace.tokens.management.forms.controls.token-parsing :as tp] @@ -96,6 +97,8 @@ icon-button-ref (mf/use-ref nil) ref (or ref internal-ref) + container (hooks/use-portal-container) + raw-tokens-by-type (mf/use-ctx muc/active-tokens-by-type) filtered-tokens-by-type @@ -328,4 +331,4 @@ :empty-to-end empty-to-end :wrapper-ref dropdown-ref :ref set-option-ref}]) - (dom/get-body))))])) \ No newline at end of file + container)))])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index f98e761203..a75966f9e4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -6,6 +6,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [okulary.core :as l] @@ -43,13 +44,15 @@ type (get mdata :type)] (when node (on-rename-node node type))))) - delete-node (mf/use-fn - (mf/deps mdata) - (fn [] - (let [node (get mdata :node) - type (get mdata :type)] - (when node - (on-delete-node node type)))))] + + container (hooks/use-portal-container) + delete-node (mf/use-fn + (mf/deps mdata) + (fn [] + (let [node (get mdata :node) + type (get mdata :type)] + (when node + (on-delete-node node type)))))] (mf/with-effect [is-open?] (when (and (not= 0 (mf/ref-val dropdown-direction-change*)) (= false is-open?)) @@ -92,4 +95,4 @@ :type "button" :on-click delete-node} (tr "labels.delete")]]])]]) - (dom/get-body))))) + container)))) diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs index 3d799e0b59..a8687c9719 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.cljs @@ -17,6 +17,7 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.typography.text :refer [text*]] + [app.main.ui.hooks :as hooks] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [cuerdas.core :as str] @@ -111,7 +112,9 @@ (let [rect (dom/get-bounding-rect node)] (swap! state* assoc :is-open? true - :rect rect))))))] + :rect rect)))))) + + container (hooks/use-portal-container)] [:div {:on-click on-open-dropdown :disabled (not can-edit?) @@ -140,4 +143,4 @@ [:& theme-options {:active-theme-paths active-theme-paths :themes themes :on-close on-close-dropdown}]]]) - (dom/get-body)))])) + container))])) diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 41f37476b0..ad338ca32b 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -294,15 +294,19 @@ :addToken {:enumerable false :schema (fn [args] - [:tuple (-> (cfo/make-token-schema - (-> (u/locate-tokens-lib file-id) (ctob/get-tokens id)) - (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) - ;; Don't allow plugins to set the id - (sm/dissoc-key :id) - ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) - ;; and set a converter that changes DTCG types to internal types (:decode/json). - ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width - (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))]) + (let [tokens-tree (-> (u/locate-tokens-lib file-id) + (ctob/get-tokens id) + ;; Convert to the adecuate format for schema + (ctob/tokens-tree))] + [:tuple (-> (cfo/make-token-schema + tokens-tree + (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) + ;; Don't allow plugins to set the id + (sm/dissoc-key :id) + ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) + ;; and set a converter that changes DTCG types to internal types (:decode/json). + ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width + (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])) :decode/options {:key-fn identity} :fn (fn [attrs] (let [tokens-lib (u/locate-tokens-lib file-id) diff --git a/frontend/src/app/render.cljs b/frontend/src/app/render.cljs index e134d5d95b..a7fef90ca9 100644 --- a/frontend/src/app/render.cljs +++ b/frontend/src/app/render.cljs @@ -7,6 +7,7 @@ (ns app.render "The main entry point for UI part needed by the exporter." (:require + [app.common.data :as d] [app.common.geom.shapes.bounds :as gsb] [app.common.logging :as log] [app.common.math :as mth] @@ -95,25 +96,34 @@ (mf/defc objects-svg {::mf/wrap-props false} [{:keys [object-ids embed skip-children wasm scale]}] - (when-let [objects (mf/deref ref:objects)] - (for [object-id object-ids] - (let [objects (render/adapt-objects-for-shape objects object-id)] - (if wasm - [:& render/object-wasm - {:objects objects - :key (str object-id) - :object-id object-id - :embed embed - :scale scale - :skip-children skip-children}] + (let [limit + (mf/use-state (if wasm (min 1 (count object-ids)) (count object-ids))) - [:& (mf/provider ctx/is-render?) {:value true} - [:& render/object-svg - {:objects objects - :key (str object-id) - :object-id object-id - :embed embed - :skip-children skip-children}]]))))) + cb-fn + (mf/use-fn + (fn [] + (swap! limit #(min (count object-ids) (inc %)))))] + (when-let [objects (mf/deref ref:objects)] + ;;Limit + (for [object-id (take @limit object-ids)] + (let [objects (render/adapt-objects-for-shape objects object-id)] + (if wasm + [:& render/object-wasm + {:objects objects + :key (str object-id) + :object-id object-id + :embed embed + :scale (d/parse-integer scale) + :skip-children skip-children + :on-render cb-fn}] + + [:& (mf/provider ctx/is-render?) {:value true} + [:& render/object-svg + {:objects objects + :key (str object-id) + :object-id object-id + :embed embed + :skip-children skip-children}]])))))) (defn- fetch-objects-bundle [& {:keys [file-id page-id share-id object-id] :as options}] diff --git a/frontend/src/app/render_wasm/api/fonts.cljs b/frontend/src/app/render_wasm/api/fonts.cljs index 8c73f85e2d..f50a7e9a14 100644 --- a/frontend/src/app/render_wasm/api/fonts.cljs +++ b/frontend/src/app/render_wasm/api/fonts.cljs @@ -124,29 +124,35 @@ fallback?) true)) -;; This variable will store the fonts that are currently being fetched -;; so we don't fetch more than once the same font -(def fetching (atom #{})) +;; Tracks fonts currently being fetched: {url -> fallback?} +;; When the same font is requested as both primary and fallback, +;; the fallback flag is upgraded to true so it gets registered +;; in WASM's fallback_fonts set. +(def fetching (atom {})) (defn- fetch-font [font-data font-url emoji? fallback?] - (when-not (contains? @fetching font-url) - (swap! fetching conj font-url) - {:key font-url - :callback - (fn [] - (->> (http/send! {:method :get - :uri font-url - :response-type :buffer}) - (rx/map (fn [{:keys [body]}] - (swap! fetching disj font-url) - (store-font-buffer font-data body emoji? fallback?))) - (rx/catch (fn [cause] - (swap! fetching disj font-url) - (log/error :hint "Could not fetch font" - :font-url font-url - :cause cause) - (rx/empty)))))})) + (if (contains? @fetching font-url) + (do (when fallback? (swap! fetching assoc font-url true)) + nil) + (do + (swap! fetching assoc font-url fallback?) + {:key font-url + :callback + (fn [] + (->> (http/send! {:method :get + :uri font-url + :response-type :buffer}) + (rx/map (fn [{:keys [body]}] + (let [fallback? (get @fetching font-url fallback?)] + (swap! fetching dissoc font-url) + (store-font-buffer font-data body emoji? fallback?)))) + (rx/catch (fn [cause] + (swap! fetching dissoc font-url) + (log/error :hint "Could not fetch font" + :font-url font-url + :cause cause) + (rx/empty)))))}))) (defn- google-font-ttf-url [font-id font-variant-id font-weight font-style] diff --git a/frontend/src/app/render_wasm/api/texts.cljs b/frontend/src/app/render_wasm/api/texts.cljs index 9598cb73bc..8dd8ee1016 100644 --- a/frontend/src/app/render_wasm/api/texts.cljs +++ b/frontend/src/app/render_wasm/api/texts.cljs @@ -166,7 +166,7 @@ (h/call wasm/internal-module "_set_shape_text_content"))) (def ^:private emoji-pattern - #"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDF00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDEFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]") + #"(?:\uD83C[\uDDE6-\uDDFF]\uD83C[\uDDE6-\uDDFF])|(?:\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDEFF])|(?:\uD83E[\uDD00-\uDDFF])|(?:\uD83D[\uDE80-\uDEFF]|\uD83E[\uDC00-\uDCFF])|(?:\uD83E[\uDE70-\uDFFF])|[\u2600-\u26FF\u2700-\u27BF\u2300-\u23FF\u2B00-\u2BFF]") (def ^:private unicode-ranges {:japanese #"[\u3040-\u30FF\u31F0-\u31FF\uFF66-\uFF9F]" @@ -215,9 +215,12 @@ :meroitic #"\uD802[\uDD80-\uDD9F]" ;; Arrows, Mathematical Operators, Misc Technical, Geometric Shapes, Misc Symbols, Dingbats, Supplemental Arrows, etc. :symbols #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]" - ;; Additional arrows, math, technical, geometric, and symbol blocks - :symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]" - :music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"}) + ;; Additional symbol blocks covered by Noto Sans Symbols 2: + ;; BMP: same as :symbols (arrows, math, misc symbols, dingbats, etc.) + ;; SMP: Mahjong/Domino/Playing Cards (U+1F000-1F0FF), Supplemental Arrows-C (U+1F800-1F8FF), + ;; Legacy Computing Symbols (U+1FB00-1FBFF) + :symbols-2 #"[\u2190-\u21FF\u2200-\u22FF\u2300-\u23FF\u25A0-\u25FF\u2600-\u26FF\u2700-\u27BF\u2B00-\u2BFF]|\uD83C[\uDC00-\uDCFF]|\uD83E[\uDC00-\uDCFF\uDF00-\uDFFF]" + :music #"[\u2669-\u267B]|\uD834[\uDD00-\uDD1F]"}) (defn contains-emoji? [text] (let [result (re-find emoji-pattern text)] diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index 0e9d15635d..aa46be4497 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -170,8 +170,17 @@ [^js node name] (let [name (str/camel name)] (loop [current node] - (if (or (nil? current) (obj/in? (.-dataset current) name)) + (cond + (nil? current) + nil + + (not= (.-nodeType current) js/Node.ELEMENT_NODE) + (recur (.-parentElement current)) + + (obj/in? (.-dataset current) name) current + + :else (recur (.-parentElement current)))))) (defn get-parent-with-selector diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 624b8a2acb..d60268fd41 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -1331,6 +1331,10 @@ msgstr "Character limit exceeded" msgid "errors.clipboard-not-implemented" msgstr "Your browser cannot do this operation" +#: src/app/main/data/workspace/clipboard.cljs +msgid "errors.clipboard-permission-denied" +msgstr "Clipboard access denied. Please allow clipboard permissions in your browser to paste content" + #: src/app/main/errors.cljs:235 msgid "errors.comment-error" msgstr "There was an error with the comment" diff --git a/mcp/.serena/project.yml b/mcp/.serena/project.yml index abb5cab52e..e5729836cd 100644 --- a/mcp/.serena/project.yml +++ b/mcp/.serena/project.yml @@ -150,3 +150,17 @@ line_ending: # list of regex patterns which, when matched, mark a memory entry as read‑only. # Extends the list from the global configuration, merging the two lists. read_only_memory_patterns: [] + +# list of regex patterns for memories to completely ignore. +# Matching memories will not appear in list_memories or activate_project output +# and cannot be accessed via read_memory or write_memory. +# To access ignored memory files, use the read_file tool on the raw file path. +# Extends the list from the global configuration, merging the two lists. +# Example: ["_archive/.*", "_episodes/.*"] +ignored_memory_patterns: [] + +# advanced configuration option allowing to configure language server-specific options. +# Maps the language key to the options. +# Have a look at the docstring of the constructors of the LS implementations within solidlsp (e.g., for C# or PHP) to see which options are available. +# No documentation on options means no options are available. +ls_specific_settings: {} diff --git a/mcp/README.md b/mcp/README.md index e3feeb80ff..eaf8b22081 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -50,13 +50,33 @@ Follow the steps below to enable the integration. ### Prerequisites -The project requires [Node.js](https://nodejs.org/) (tested with v22.x). -Following the installation of Node.js, the tools `corepack` and `npx` -should be available in your terminal. +The project requires [Node.js](https://nodejs.org/) (tested with v22.x). + +### 1. Starting the MCP Server and the Plugin Server + +#### Running a Released Version via npx + +The easiest way to launch the servers is to use `npx` to run the appropriate +version that matches your Penpot version. + +* If you are using the latest Penpot release, e.g. as served on [design.penpot.app](https://design.penpot.app), run: + ```shell + npx -y @penpot/mcp@">=0" + ``` +* If you are participating in the MCP beta-test, which uses [test-mcp.penpot.dev](https://test-mcp.penpot.dev), run: + ```shell + npx -y @penpot/mcp@"*" + ``` + +Once the servers are running, continue with step 2. + +#### Running the Source Version from the Repository + +The tools `corepack` and `npx` should be available in your terminal. On Windows, use the Git Bash terminal to ensure compatibility with the provided scripts. -### 0. Clone the Appropriate Branch of the Repository +##### Clone the Appropriate Branch of the Repository > [!IMPORTANT] > The branches are subject to change in the future. @@ -65,13 +85,13 @@ On Windows, use the Git Bash terminal to ensure compatibility with the provided Clone the Penpot repository, using the proper branch depending on the version of Penpot you want to use the MCP server with. - * For released versions of Penpot, use the `mcp-prod` branch: + * For the current Penpot release 2.14, use the `mcp-prod-2.14.0` branch: ```shell - git clone https://github.com/penpot/penpot.git --branch mcp-prod --depth 1 + git clone https://github.com/penpot/penpot.git --branch mcp-prod-2.14.0 --depth 1 ``` - * For the latest development version of Penpot, use the `develop` branch: + * For the latest development version of Penpot (including the MCP beta-test), use the `develop` branch: ```shell git clone https://github.com/penpot/penpot.git --branch develop --depth 1 @@ -83,7 +103,7 @@ Then change into the `mcp` directory: cd penpot/mcp ``` -### 1. Build & Launch the MCP Server and the Plugin Server +##### Build & Launch the MCP Server and the Plugin Server If it's your first execution, install the required dependencies. (If you are using the Penpot devenv, this step is not necessary, as dependencies are already installed.) diff --git a/mcp/packages/plugin/src/main.ts b/mcp/packages/plugin/src/main.ts index 8aad137ec3..45396f421d 100644 --- a/mcp/packages/plugin/src/main.ts +++ b/mcp/packages/plugin/src/main.ts @@ -105,8 +105,12 @@ function connectToMcpServer(baseUrl?: string, token?: string): void { updateConnectionStatus("connecting", "Connecting..."); ws.onopen = () => { - console.log("Connected to MCP server"); - updateConnectionStatus("connected", "Connected"); + setTimeout(() => { + if (ws) { + console.log("Connected to MCP server"); + updateConnectionStatus("connected", "Connected"); + } + }, 100); }; ws.onmessage = (event) => { diff --git a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts index 213333bab9..85ed5a32d1 100644 --- a/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts +++ b/mcp/packages/plugin/src/task-handlers/ExecuteCodeTaskHandler.ts @@ -195,16 +195,19 @@ export class ExecuteCodeTaskHandler extends TaskHandler { const context = this.context; const code = task.params.code; - // set the penpot.flags.naturalChildOrdering to true during code execution. - // NOTE: This significantly simplifies API usage (see ) - // TODO: Remove ts-ignore once Penpot types have been updated - let originalNaturalChildOrdering: any; + // set the flags naturalChildOrdering and throwValidationErrors to true during code execution. + // TODO: Remove all ts-ignore once Penpot types have been updated + let originalNaturalChildOrdering: any, originalThrowValidationErrors: any; // @ts-ignore if (penpot.flags) { // @ts-ignore originalNaturalChildOrdering = penpot.flags.naturalChildOrdering; // @ts-ignore penpot.flags.naturalChildOrdering = true; + // @ts-ignore + originalThrowValidationErrors = penpot.flags.throwValidationErrors; + // @ts-ignore + penpot.flags.throwValidationErrors = true; } else { // TODO: This can be removed once `flags` has been merged to PROD throw new Error( @@ -224,9 +227,11 @@ export class ExecuteCodeTaskHandler extends TaskHandler { return fn(...Object.values(ctx)); })(context); } finally { - // restore the original value of penpot.flags.naturalChildOrdering + // restore the original value of the flags // @ts-ignore penpot.flags.naturalChildOrdering = originalNaturalChildOrdering; + // @ts-ignore + penpot.flags.throwValidationErrors = originalThrowValidationErrors; } console.log("Code execution result:", result); diff --git a/mcp/packages/server/data/initial_instructions.md b/mcp/packages/server/data/initial_instructions.md index c85441a977..2d0be0ac55 100644 --- a/mcp/packages/server/data/initial_instructions.md +++ b/mcp/packages/server/data/initial_instructions.md @@ -138,13 +138,23 @@ Boards can have layout systems that automatically control the positioning and sp # Text Elements -The rendered content of a `Text` element is given by the `characters` property. - -To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size, -it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text. -The bounding box is sized automatically as long as the `growType` property is set to "auto-width" or "auto-height". -`resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-*" if you want automatic sizing! -The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box. +`Text` elements: + * The text to be rendered is given by the `characters` property. + * To change the size of the text, change the `fontSize` property; applying `resize()` does NOT change the font size, + it only changes the formal bounding box; if the text does not fit it, it will overflow; use `textBounds` for the actual bounding box of the rendered text. + * Property `bounds` is sized automatically (in one dimension) if the `growType` property is set to "auto-width" or "auto-height". + `resize` always sets `growType` to "fixed", so ALWAYS set it back to "auto-width" or "auto-height" if you want automatic sizing! + The auto-sizing is not immediate; sleep for a short time (100ms) if you want to read the updated bounding box. + * Method `getRange(start, end): TextRange` to reference a range of characters as a `TextRange` object, which can be styled separately from the rest of the text; `start` index inclusive, `end` exclusive + * Other Writable font properties: `fontId`, `fontFamily`, `fontWeight`, `fontVariant`, `fontStyle` + - To discover valid values, check available fonts in `penpot.fonts: FontContext` + - `FontContext` provides `Font` instances; each font has property `variants: FontVariant[]` + - Example: Determine available weights for a font using `penpot.fonts.findByName("Laila").variants.map(v => v.fontWeight)` + - To apply a `Font` to a `Text` instance and set all font properties at once: + - `font.applyToText(text: Text, variant?: FontVariant)` + - `applyToRange(range: TextRange, variant?: FontVariant)` + * Further writable properties: `align`, `verticalAlign`, `lineHeight`, `letterSpacing`, `textTransform`, `textDecoration` (see API info) + * Method `applyTypography(typography: LibraryTypography)` # The `penpot` and `penpotUtils` Objects, Exploring Designs diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 10dfb5eeb9..5147d361fd 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -68,6 +68,7 @@ export class PluginBridge { if (this.clientsByToken.has(userToken)) { this.logger.warn("Duplicate connection for given user token; rejecting new connection"); ws.close(1008, "Duplicate connection for given user token; close previous connection first."); + return; } this.clientsByToken.set(userToken, connection); diff --git a/opencode.json b/opencode.json index 0ab56b6192..6376bc70e5 100644 --- a/opencode.json +++ b/opencode.json @@ -1,4 +1,4 @@ { "$schema": "https://opencode.ai/config.json", - "instructions": ["CONTRIBUTING.md", "AGENTS.md"] + "instructions": ["AGENTS.md"] } diff --git a/package.json b/package.json index 3580392cf5..687d4def63 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,9 @@ "fmt": "./scripts/fmt" }, "devDependencies": { - "@github/copilot": "^1.0.2", + "@github/copilot": "^1.0.11", "@types/node": "^20.12.7", - "esbuild": "^0.25.9" + "esbuild": "^0.25.9", + "opencode-ai": "^1.3.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bec7b49e31..4d683348b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,14 +9,17 @@ importers: .: devDependencies: '@github/copilot': - specifier: ^1.0.2 - version: 1.0.2 + specifier: ^1.0.11 + version: 1.0.11 '@types/node': specifier: ^20.12.7 version: 20.19.37 esbuild: specifier: ^0.25.9 version: 0.25.12 + opencode-ai: + specifier: ^1.3.0 + version: 1.3.0 packages: @@ -176,44 +179,44 @@ packages: cpu: [x64] os: [win32] - '@github/copilot-darwin-arm64@1.0.2': - resolution: {integrity: sha512-dYoeaTidsphRXyMjvAgpjEbBV41ipICnXURrLFEiATcjC4IY6x2BqPOocrExBYW/Tz2VZvDw51iIZaf6GXrTmw==} + '@github/copilot-darwin-arm64@1.0.11': + resolution: {integrity: sha512-wdKimjtbsVeXqMqQSnGpGBPFEYHljxXNuWeH8EIJTNRgFpAsimcivsFgql3Twq4YOp0AxfsH36icG4IEen30mA==} cpu: [arm64] os: [darwin] hasBin: true - '@github/copilot-darwin-x64@1.0.2': - resolution: {integrity: sha512-8+Z9dYigEfXf0wHl9c2tgFn8Cr6v4RAY8xTgHMI9mZInjQyxVeBXCxbE2VgzUtDUD3a705Ka2d8ZOz05aYtGsg==} + '@github/copilot-darwin-x64@1.0.11': + resolution: {integrity: sha512-VeuPv8rzBVGBB8uDwMEhcHBpldoKaq26yZ5YQm+G9Ka5QIF+1DMah8ZNRMVsTeNKkb1ji9G8vcuCsaPbnG3fKg==} cpu: [x64] os: [darwin] hasBin: true - '@github/copilot-linux-arm64@1.0.2': - resolution: {integrity: sha512-ik0Y5aTXOFRPLFrNjZJdtfzkozYqYeJjVXGBAH3Pp1nFZRu/pxJnrnQ1HrqO/LEgQVbJzAjQmWEfMbXdQIxE4Q==} + '@github/copilot-linux-arm64@1.0.11': + resolution: {integrity: sha512-/d8p6RlFYKj1Va2hekFIcYNMHWagcEkaxgcllUNXSyQLnmEtXUkaWtz62VKGWE+n/UMkEwCB6vI2xEwPTlUNBQ==} cpu: [arm64] os: [linux] hasBin: true - '@github/copilot-linux-x64@1.0.2': - resolution: {integrity: sha512-mHSPZjH4nU9rwbfwLxYJ7CQ90jK/Qu1v2CmvBCUPfmuGdVwrpGPHB5FrB+f+b0NEXjmemDWstk2zG53F7ppHfw==} + '@github/copilot-linux-x64@1.0.11': + resolution: {integrity: sha512-UujTRO3xkPFC1CybchBbCnaTEAG6JrH0etIst07JvfekMWgvRxbiCHQPpDPSzBCPiBcGu0gba0/IT+vUCORuIw==} cpu: [x64] os: [linux] hasBin: true - '@github/copilot-win32-arm64@1.0.2': - resolution: {integrity: sha512-tLW2CY/vg0fYLp8EuiFhWIHBVzbFCDDpohxT/F/XyMAdTVSZLnopCcxQHv2BOu0CVGrYjlf7YOIwPfAKYml1FA==} + '@github/copilot-win32-arm64@1.0.11': + resolution: {integrity: sha512-EOW8HUM+EmnHEZEa+iUMl4pP1+2eZUk2XCbynYiMehwX9sidc4BxEHp2RuxADSzFPTieQEWzgjQmHWrtet8pQg==} cpu: [arm64] os: [win32] hasBin: true - '@github/copilot-win32-x64@1.0.2': - resolution: {integrity: sha512-cFlc3xMkKKFRIYR00EEJ2XlYAemeh5EZHsGA8Ir2G0AH+DOevJbomdP1yyCC5gaK/7IyPkHX3sGie5sER2yPvQ==} + '@github/copilot-win32-x64@1.0.11': + resolution: {integrity: sha512-fKGkSNamzs3h9AbmswNvPYJBORCb2Y8CbusijU3C7fT3ohvqnHJwKo5iHhJXLOKZNOpFZgq9YKha410u9sIs6Q==} cpu: [x64] os: [win32] hasBin: true - '@github/copilot@1.0.2': - resolution: {integrity: sha512-716SIZMYftldVcJay2uZOzsa9ROGGb2Mh2HnxbDxoisFsWNNgZlQXlV7A+PYoGsnAo2Zk/8e1i5SPTscGf2oww==} + '@github/copilot@1.0.11': + resolution: {integrity: sha512-cptVopko/tNKEXyBP174yBjHQBEwg6CqaKN2S0M3J+5LEB8u31bLL75ioOPd+5vubqBrA0liyTdcHeZ8UTRbmg==} hasBin: true '@types/node@20.19.37': @@ -224,6 +227,70 @@ packages: engines: {node: '>=18'} hasBin: true + opencode-ai@1.3.0: + resolution: {integrity: sha512-il/dC3B55m5mZV2u72emfPqkZBTzrlZwqGI4Ds5Ld6kt2LTUzBZtKB8sOfy7Bmw2qIel0hLZdoKc8wxLjaXQDw==} + hasBin: true + + opencode-darwin-arm64@1.3.0: + resolution: {integrity: sha512-OB+yl/BZkjQhnjjFc+KT57iqhPlXNq3E0oIcHHlGiG63L2LTY3zfi9OhzaoemL+or2CWnpCITUe91yTAddiSEQ==} + cpu: [arm64] + os: [darwin] + + opencode-darwin-x64-baseline@1.3.0: + resolution: {integrity: sha512-Th5yiWOSDeEcjnKWhR8b267Uf8r+jwLFhv30JK4x07Zdmu3Jjjr6TdMvjLgEOv3PWmHf/1yYz22Xachb+QST0A==} + cpu: [x64] + os: [darwin] + + opencode-darwin-x64@1.3.0: + resolution: {integrity: sha512-jivDUpmhzkT7WZp7pXVSb9fdnEVuhKBsnve/9fIkI/UFHxomiZ2NIaNRbHxG26PYT9a1IR4D5QvXBq623g2Mnw==} + cpu: [x64] + os: [darwin] + + opencode-linux-arm64-musl@1.3.0: + resolution: {integrity: sha512-EmXBHyRSzWCnD/KDpaSi8ldgjOa+1t5c5tRASyL/lnbinsrZekxub3lI+oxRvKJXESKdgq9EP4gkp6t2fqGsFw==} + cpu: [arm64] + os: [linux] + + opencode-linux-arm64@1.3.0: + resolution: {integrity: sha512-rWEEKo4oqgJ/zk670ywg6uhEPwbUIQCwYCeh+xJ3IlgPltQNiIjqUbzbRqAmEfI1Uj9DCdbZ2TUtHayRv8umKw==} + cpu: [arm64] + os: [linux] + + opencode-linux-x64-baseline-musl@1.3.0: + resolution: {integrity: sha512-sb7LyPlf+5/t4pQ3whcHPVlb7R7SRY0Bgjgy55amEs3xRuKnC3BfSoj8CAoY50M/yVAbOj0haoxu4LFixljwNw==} + cpu: [x64] + os: [linux] + + opencode-linux-x64-baseline@1.3.0: + resolution: {integrity: sha512-STZtcgGgeRlaFCmkk+mNm+01d02JCzCPvP9kWwNpRF6FBGTcFZ97MxEoGvk+7mEqMueImVQZOR21NiYN6anQhw==} + cpu: [x64] + os: [linux] + + opencode-linux-x64-musl@1.3.0: + resolution: {integrity: sha512-Jc/EbYgqmT2J2WLPm7EQWBYfSqetWTrI4Ipc4KFrSB/LbM/7lfXkjpemjQaYNlDTVkvPXaUPFJUpisH64xZ+4g==} + cpu: [x64] + os: [linux] + + opencode-linux-x64@1.3.0: + resolution: {integrity: sha512-U9aS0wl0uBDxXncqSYhYBDDQP2ZwiTiuJSLM6MgtFJTbUXuTZZCKmQ8p7C5/+Nxpl4sY5xK+ZaCJcS3k3WGN3g==} + cpu: [x64] + os: [linux] + + opencode-windows-arm64@1.3.0: + resolution: {integrity: sha512-3iWo9lOctaWQ+8QHRKszINPTLjLtb0ztzedlvdY5HAiot9MUK/G5MHeskutxQ7sMvTACiAp02ey+Ml/f/jyf7Q==} + cpu: [arm64] + os: [win32] + + opencode-windows-x64-baseline@1.3.0: + resolution: {integrity: sha512-pYuY+9LqPLB/GrlZQr67Cl8RlV6vcay4fW8L3TjabwJOinFMDX9OpNo+DkdKJW7YtPtHD78cXaNDEV8tv9Nx2A==} + cpu: [x64] + os: [win32] + + opencode-windows-x64@1.3.0: + resolution: {integrity: sha512-iFd/6GwfM3jlI2tOb3f12m5ddDY8Ug2HiUU1xmxWJvDnbDBdftlHrzD5twlbIHnKoGvohepX8iWk+A/UN2cXKQ==} + cpu: [x64] + os: [win32] + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -307,32 +374,32 @@ snapshots: '@esbuild/win32-x64@0.25.12': optional: true - '@github/copilot-darwin-arm64@1.0.2': + '@github/copilot-darwin-arm64@1.0.11': optional: true - '@github/copilot-darwin-x64@1.0.2': + '@github/copilot-darwin-x64@1.0.11': optional: true - '@github/copilot-linux-arm64@1.0.2': + '@github/copilot-linux-arm64@1.0.11': optional: true - '@github/copilot-linux-x64@1.0.2': + '@github/copilot-linux-x64@1.0.11': optional: true - '@github/copilot-win32-arm64@1.0.2': + '@github/copilot-win32-arm64@1.0.11': optional: true - '@github/copilot-win32-x64@1.0.2': + '@github/copilot-win32-x64@1.0.11': optional: true - '@github/copilot@1.0.2': + '@github/copilot@1.0.11': optionalDependencies: - '@github/copilot-darwin-arm64': 1.0.2 - '@github/copilot-darwin-x64': 1.0.2 - '@github/copilot-linux-arm64': 1.0.2 - '@github/copilot-linux-x64': 1.0.2 - '@github/copilot-win32-arm64': 1.0.2 - '@github/copilot-win32-x64': 1.0.2 + '@github/copilot-darwin-arm64': 1.0.11 + '@github/copilot-darwin-x64': 1.0.11 + '@github/copilot-linux-arm64': 1.0.11 + '@github/copilot-linux-x64': 1.0.11 + '@github/copilot-win32-arm64': 1.0.11 + '@github/copilot-win32-x64': 1.0.11 '@types/node@20.19.37': dependencies: @@ -367,4 +434,55 @@ snapshots: '@esbuild/win32-ia32': 0.25.12 '@esbuild/win32-x64': 0.25.12 + opencode-ai@1.3.0: + optionalDependencies: + opencode-darwin-arm64: 1.3.0 + opencode-darwin-x64: 1.3.0 + opencode-darwin-x64-baseline: 1.3.0 + opencode-linux-arm64: 1.3.0 + opencode-linux-arm64-musl: 1.3.0 + opencode-linux-x64: 1.3.0 + opencode-linux-x64-baseline: 1.3.0 + opencode-linux-x64-baseline-musl: 1.3.0 + opencode-linux-x64-musl: 1.3.0 + opencode-windows-arm64: 1.3.0 + opencode-windows-x64: 1.3.0 + opencode-windows-x64-baseline: 1.3.0 + + opencode-darwin-arm64@1.3.0: + optional: true + + opencode-darwin-x64-baseline@1.3.0: + optional: true + + opencode-darwin-x64@1.3.0: + optional: true + + opencode-linux-arm64-musl@1.3.0: + optional: true + + opencode-linux-arm64@1.3.0: + optional: true + + opencode-linux-x64-baseline-musl@1.3.0: + optional: true + + opencode-linux-x64-baseline@1.3.0: + optional: true + + opencode-linux-x64-musl@1.3.0: + optional: true + + opencode-linux-x64@1.3.0: + optional: true + + opencode-windows-arm64@1.3.0: + optional: true + + opencode-windows-x64-baseline@1.3.0: + optional: true + + opencode-windows-x64@1.3.0: + optional: true + undici-types@6.21.0: {} diff --git a/render-wasm/AGENTS.md b/render-wasm/AGENTS.md index dfe9c3def9..511b7da0c9 100644 --- a/render-wasm/AGENTS.md +++ b/render-wasm/AGENTS.md @@ -59,4 +59,4 @@ parent/child relationships are tracked separately. The WASM module is loaded by `app.render-wasm.*` namespaces in the frontend. ClojureScript calls exported Rust functions to push shape data, then calls `render_frame`. Do not change export function -signatures without updating the ClojureScript bridge. +signatures without updating the corresponding ClojureScript bridge. diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index beb1c1384b..75178d146b 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -1,14 +1,9 @@ use crate::shapes::{Shape, TextContent, Type, VerticalAlign}; use crate::state::{TextEditorState, TextSelection}; use skia_safe::textlayout::{RectHeightStyle, RectWidthStyle}; -use skia_safe::{BlendMode, Canvas, Matrix, Paint, Rect}; +use skia_safe::{BlendMode, Canvas, Paint, Rect}; -pub fn render_overlay( - canvas: &Canvas, - editor_state: &TextEditorState, - shape: &Shape, - transform: &Matrix, -) { +pub fn render_overlay(canvas: &Canvas, editor_state: &TextEditorState, shape: &Shape) { if !editor_state.is_active { return; } @@ -18,16 +13,12 @@ pub fn render_overlay( }; canvas.save(); - canvas.concat(transform); - if editor_state.selection.is_selection() { render_selection(canvas, editor_state, text_content, shape); } - if editor_state.cursor_visible { render_cursor(canvas, editor_state, text_content, shape); } - canvas.restore(); } diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index dedf9ab770..13115a9946 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1212,6 +1212,7 @@ impl Shape { matrix } + #[allow(dead_code)] pub fn get_concatenated_matrix(&self, shapes: ShapesPoolRef) -> Matrix { let mut matrix = Matrix::new_identity(); let mut current_id = self.id; diff --git a/render-wasm/src/shapes/modifiers/grid_layout.rs b/render-wasm/src/shapes/modifiers/grid_layout.rs index 7ef2cb447b..3599f1e595 100644 --- a/render-wasm/src/shapes/modifiers/grid_layout.rs +++ b/render-wasm/src/shapes/modifiers/grid_layout.rs @@ -750,7 +750,8 @@ pub fn reflow_grid_layout( let mut new_width = child_bounds.width(); if child.is_layout_horizontal_fill() { let margin_left = child.layout_item.map(|i| i.margin_left).unwrap_or(0.0); - new_width = cell.width - margin_left; + let margin_right = child.layout_item.map(|i| i.margin_right).unwrap_or(0.0); + new_width = cell.width - margin_left - margin_right; let min_width = child.layout_item.and_then(|i| i.min_w).unwrap_or(MIN_SIZE); let max_width = child.layout_item.and_then(|i| i.max_w).unwrap_or(MAX_SIZE); new_width = new_width.clamp(min_width, max_width); @@ -759,7 +760,8 @@ pub fn reflow_grid_layout( let mut new_height = child_bounds.height(); if child.is_layout_vertical_fill() { let margin_top = child.layout_item.map(|i| i.margin_top).unwrap_or(0.0); - new_height = cell.height - margin_top; + let margin_bottom = child.layout_item.map(|i| i.margin_bottom).unwrap_or(0.0); + new_height = cell.height - margin_top - margin_bottom; let min_height = child.layout_item.and_then(|i| i.min_h).unwrap_or(MIN_SIZE); let max_height = child.layout_item.and_then(|i| i.max_h).unwrap_or(MAX_SIZE); new_height = new_height.clamp(min_height, max_height); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 9ef4da3d07..96adb52b28 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -1002,6 +1002,49 @@ impl Paragraph { } } +/// Capitalize the first letter of each word, preserving all original whitespace. +/// Matches CSS `text-transform: capitalize` behavior: a "word" starts after +/// any non-letter character (whitespace, punctuation, digits, symbols). +fn capitalize_words(text: &str) -> String { + let mut result = String::with_capacity(text.len()); + let mut capitalize_next = true; + for c in text.chars() { + if c.is_alphabetic() { + if capitalize_next { + result.extend(c.to_uppercase()); + } else { + result.push(c); + } + capitalize_next = false; + } else { + result.push(c); + capitalize_next = true; + } + } + result +} + +/// Filter control characters below U+0020, preserving line breaks. +/// Browser-dependent: Firefox drops them, others replace with space. +fn process_ignored_chars(text: &str, browser: u8) -> String { + text.chars() + .filter_map(|c| { + if c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { + return Some(c); + } + if c < '\u{0020}' { + if browser == Browser::Firefox as u8 { + None + } else { + Some(' ') + } + } else { + Some(c) + } + }) + .collect() +} + #[derive(Debug, PartialEq, Clone)] pub struct TextSpan { pub text: String, @@ -1136,43 +1179,13 @@ impl TextSpan { format!("{}", self.font_family) } - fn process_ignored_chars(text: &str, browser: u8) -> String { - text.chars() - .filter_map(|c| { - // Preserve line breaks: \n (U+000A), \r (U+000D), and Unicode separators - if c == '\n' || c == '\r' || c == '\u{2028}' || c == '\u{2029}' { - return Some(c); - } - if c < '\u{0020}' { - if browser == Browser::Firefox as u8 { - None - } else { - Some(' ') - } - } else { - Some(c) - } - }) - .collect() - } - pub fn apply_text_transform(&self) -> String { let browser = crate::with_state!(state, { state.current_browser }); - let text = Self::process_ignored_chars(&self.text, browser); + let text = process_ignored_chars(&self.text, browser); match self.text_transform { Some(TextTransform::Uppercase) => text.to_uppercase(), Some(TextTransform::Lowercase) => text.to_lowercase(), - Some(TextTransform::Capitalize) => text - .split_whitespace() - .map(|word| { - let mut chars = word.chars(); - match chars.next() { - Some(first) => first.to_uppercase().collect::() + chars.as_str(), - None => String::new(), - } - }) - .collect::>() - .join(" "), + Some(TextTransform::Capitalize) => capitalize_words(&text), None => text, } } @@ -1464,3 +1477,95 @@ pub fn calculate_position_data( layout_info.position_data } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capitalize_basic_words() { + assert_eq!(capitalize_words("hello world"), "Hello World"); + } + + #[test] + fn capitalize_preserves_leading_whitespace() { + assert_eq!(capitalize_words(" hello"), " Hello"); + } + + #[test] + fn capitalize_preserves_trailing_whitespace() { + assert_eq!(capitalize_words("hello "), "Hello "); + } + + #[test] + fn capitalize_preserves_multiple_spaces() { + assert_eq!(capitalize_words("hello world"), "Hello World"); + } + + #[test] + fn capitalize_whitespace_only() { + assert_eq!(capitalize_words(" "), " "); + assert_eq!(capitalize_words(" "), " "); + } + + #[test] + fn capitalize_empty_string() { + assert_eq!(capitalize_words(""), ""); + } + + #[test] + fn capitalize_single_char() { + assert_eq!(capitalize_words("a"), "A"); + } + + #[test] + fn capitalize_already_uppercase() { + assert_eq!(capitalize_words("HELLO WORLD"), "HELLO WORLD"); + } + + #[test] + fn capitalize_preserves_tabs_and_newlines() { + assert_eq!(capitalize_words("hello\tworld"), "Hello\tWorld"); + assert_eq!(capitalize_words("hello\nworld"), "Hello\nWorld"); + } + + #[test] + fn capitalize_after_punctuation() { + assert_eq!(capitalize_words("(readonly)"), "(Readonly)"); + assert_eq!(capitalize_words("hello-world"), "Hello-World"); + assert_eq!(capitalize_words("one/two/three"), "One/Two/Three"); + } + + #[test] + fn capitalize_after_digits() { + assert_eq!(capitalize_words("item1name"), "Item1Name"); + } + + #[test] + fn process_ignored_chars_preserves_spaces() { + assert_eq!(process_ignored_chars("hello world", 0), "hello world"); + } + + #[test] + fn process_ignored_chars_preserves_line_breaks() { + assert_eq!(process_ignored_chars("hello\nworld", 0), "hello\nworld"); + assert_eq!(process_ignored_chars("hello\rworld", 0), "hello\rworld"); + } + + #[test] + fn process_ignored_chars_replaces_control_chars_chrome() { + // U+0001 (SOH) should become space in non-Firefox + assert_eq!( + process_ignored_chars("a\x01b", Browser::Chrome as u8), + "a b" + ); + } + + #[test] + fn process_ignored_chars_removes_control_chars_firefox() { + assert_eq!( + process_ignored_chars("a\x01b", Browser::Firefox as u8), + "ab" + ); + } +} diff --git a/render-wasm/src/wasm/layouts.rs b/render-wasm/src/wasm/layouts.rs index 904ce63cc5..81ea59240d 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -63,10 +63,11 @@ pub extern "C" fn set_layout_data( let h_sizing = RawSizing::from(h_sizing); let v_sizing = RawSizing::from(v_sizing); - let max_h = if has_max_h { Some(max_h) } else { None }; - let min_h = if has_min_h { Some(min_h) } else { None }; - let max_w = if has_max_w { Some(max_w) } else { None }; - let min_w = if has_min_w { Some(min_w) } else { None }; + let max_h = has_max_h.then(|| max_h.max(0.01)); + let min_h = has_min_h.then(|| min_h.clamp(0.01, max_h.unwrap_or(f32::INFINITY))); + let max_w = has_max_w.then(|| max_w.max(0.01)); + let min_w = has_min_w.then(|| min_w.clamp(0.01, max_w.unwrap_or(f32::INFINITY))); + let z_index = if z_index != 0 { Some(z_index) } else { None }; let raw_align_self = align::RawAlignSelf::from(align_self); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index eeac88ce43..1c6de52fe8 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -2,6 +2,8 @@ use macros::{wasm_error, ToJs}; use crate::math::{Matrix, Point, Rect}; use crate::mem; +use crate::render::text_editor as text_editor_render; +use crate::render::SurfaceId; use crate::shapes::{Shape, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; use crate::state::TextSelection; use crate::utils::uuid_from_u32_quartet; @@ -841,21 +843,13 @@ pub extern "C" fn text_editor_render_overlay() { return; }; - let transform = shape.get_concatenated_matrix(&state.shapes); - - use crate::render::text_editor as te_render; - use crate::render::SurfaceId; - let canvas = state.render_state.surfaces.canvas(SurfaceId::Target); - canvas.save(); let viewbox = state.render_state.viewbox; let zoom = viewbox.zoom * state.render_state.options.dpr(); canvas.scale((zoom, zoom)); canvas.translate((-viewbox.area.left, -viewbox.area.top)); - - te_render::render_overlay(canvas, &state.text_editor_state, shape, &transform); - + text_editor_render::render_overlay(canvas, &state.text_editor_state, shape); canvas.restore(); state.render_state.flush_and_submit(); });