Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-03-24 18:08:23 +01:00
commit d4bc1d37f2
47 changed files with 844 additions and 475 deletions

168
AGENTS.md
View File

@ -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
```
<type> <subject>
[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 <niwi@niwi.nz>
```
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.

View File

@ -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

View File

@ -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/<cmd-name>`.
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
```

View File

@ -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]

View File

@ -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)

View File

@ -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.

View File

@ -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(

View File

@ -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

View File

@ -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 %))

View File

@ -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]

View File

@ -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

View File

@ -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)]

View File

@ -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)

View File

@ -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)))

View File

@ -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))]))

View File

@ -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]

View File

@ -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))))

View File

@ -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)}]]))

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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))))

View File

@ -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))))]))
container)))]))

View File

@ -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))))

View File

@ -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))]))

View File

@ -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)

View File

@ -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}]

View File

@ -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]

View File

@ -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)]

View File

@ -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

View File

@ -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"

View File

@ -150,3 +150,17 @@ line_ending:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# 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: {}

View File

@ -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.)

View File

@ -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) => {

View File

@ -195,16 +195,19 @@ export class ExecuteCodeTaskHandler extends TaskHandler<ExecuteCodeTaskParams> {
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<ExecuteCodeTaskParams> {
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);

View File

@ -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

View File

@ -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);

View File

@ -1,4 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["CONTRIBUTING.md", "AGENTS.md"]
"instructions": ["AGENTS.md"]
}

View File

@ -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"
}
}

176
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -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.

View File

@ -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();
}

View File

@ -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;

View File

@ -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);

View File

@ -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::<String>() + chars.as_str(),
None => String::new(),
}
})
.collect::<Vec<_>>()
.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"
);
}
}

View File

@ -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);

View File

@ -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();
});