mirror of
https://github.com/penpot/penpot.git
synced 2026-04-25 11:18:36 +00:00
Merge remote-tracking branch 'origin/staging' into develop
This commit is contained in:
commit
d4bc1d37f2
168
AGENTS.md
168
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
|
||||
|
||||
```
|
||||
<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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 %))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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))]))
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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)}]]))
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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)))]))
|
||||
|
||||
@ -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))))
|
||||
|
||||
@ -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))]))
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}]
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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: {}
|
||||
|
||||
@ -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.)
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"instructions": ["CONTRIBUTING.md", "AGENTS.md"]
|
||||
"instructions": ["AGENTS.md"]
|
||||
}
|
||||
|
||||
@ -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
176
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user