- Add session ID tracking to RPC layer (backend and frontend)
- Send session ID header with RPC requests for request correlation
- Rename file-restore to file-restored for consistency
- Extract initialize-file function from initialize-workspace flow
- Improve file restoration initialization with wait-for-persistence
- Extract initialize-version event handler for version restoration
- Fix viewport key generation with file version numbers for proper re-renders
- Update layout item schema and constraints to use internal sizing state
- Add v-sizing state retrieval in layout-size-constraints component
- Refactor file-change notifications stream handling with rx/map
- Fix team-id lookup in restore-version-from-plugins
Improves request traceability across frontend/backend sessions and streamlines
the workspace initialization flow for file restoration scenarios.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
Paste clipboard contents in place of the currently selected shape,
inheriting its position, parent, and z-index. The replaced shape
is deleted in the same transaction for a single undo step.
Signed-off-by: eureka0928 <meobius123@gmail.com>
Introduce a purpose-agnostic three-step session-based upload API that
allows uploading large binary blobs (media files and .penpot imports)
without hitting multipart size limits.
Backend:
- Migration 0147: new `upload_session` table (profile_id, total_chunks,
created_at) with indexes on profile_id and created_at.
- Three new RPC commands in media.clj:
* `create-upload-session` – allocates a session row; enforces
`upload-sessions-per-profile` and `upload-chunks-per-session`
quota limits (configurable in config.clj, defaults 5 / 20).
* `upload-chunk` – stores each slice as a storage object;
validates chunk index bounds and profile ownership.
* `assemble-file-media-object` – reassembles chunks via the shared
`assemble-chunks!` helper and creates the final media object.
- `assemble-chunks!` is a public helper in media.clj shared by both
`assemble-file-media-object` and `import-binfile`.
- `import-binfile` (binfile.clj): accepts an optional `upload-id` param;
when provided, materialises the temp file from chunks instead of
expecting an inline multipart body, removing the 200 MiB body limit
on .penpot imports. Schema updated with an `:and` validator requiring
either `:file` or `:upload-id`.
- quotes.clj: new `upload-sessions-per-profile` quota check.
- Background GC task (`tasks/upload_session_gc.clj`): deletes stalled
(never-completed) sessions older than 1 hour; scheduled daily at
midnight via the cron system in main.clj.
- backend/AGENTS.md: document the background-task wiring pattern.
Frontend:
- New `app.main.data.uploads` namespace: generic `upload-blob-chunked`
helper drives steps 1–2 (create session + upload all chunks with a
concurrency cap of 2) and emits `{:session-id uuid}` for callers.
- `config.cljs`: expose `upload-chunk-size` (default 25 MiB, overridable
via `penpotUploadChunkSize` global).
- `workspace/media.cljs`: blobs ≥ chunk-size go through the chunked path
(`upload-blob-chunked` → `assemble-file-media-object`); smaller blobs
use the existing direct `upload-file-media-object` path.
`handle-media-error` simplified; `on-error` callback removed.
- `worker/import.cljs`: new `import-blob-via-upload` helper replaces the
inline multipart approach for both binfile-v1 and binfile-v3 imports.
- `repo.cljs`: `:upload-chunk` derived as a `::multipart-upload`;
`form-data?` removed from `import-binfile` (JSON params only).
Tests:
- Backend (rpc_media_test.clj): happy path, idempotency, permission
isolation, invalid media type, missing chunks, session-not-found,
chunk-index out-of-range, and quota-limit scenarios.
- Frontend (uploads_test.cljs): session creation and chunk-count
correctness for `upload-blob-chunked`.
- Frontend (workspace_media_test.cljs): direct-upload path for small
blobs, chunked path for large blobs, and chunk-count correctness for
`process-blobs`.
- `helpers/http.cljs`: shared fetch-mock helpers (`install-fetch-mock!`,
`make-json-response`, `make-transit-response`, `url->cmd`).
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Allow viewers to select locked elements in canvas
* ✨ Add ability to lock guides to prevent accidental movement
---------
Signed-off-by: Dexterity104 <hatanokanjiro@gmail.com>
Co-authored-by: Andrey Antukh <niwi@niwi.nz>
* ✨ Add clear artboard guides option to context menu
Adds a "Clear artboard guides" option to the right-click context menu
when one or more frames with guides are selected. Closes#6987
* ♻️ Address review feedback from niwinz
- Replace deprecated dm/assert! with assert
- Replace (map :id) with d/xf:map-id
Signed-off-by: eureka928 <meobius123@gmail.com>
In `preview-next-point`, `st/get-path` was called without extra keys,
which returns the full Shape record. That value was then passed directly
to `path/next-node` as its `content` argument.
`path/next-node` delegates to `impl/path-data`, which only accepts a
`PathData` instance, `nil`, or a sequential collection of segments. A
Shape record matches none of those cases, so `path-data` threw
"unexpected data" every time the user moved the mouse while drawing a
path.
The fix is to call `(st/get-path state :content)` so that only the
`:content` field (a `PathData` instance) is extracted and forwarded to
`path/next-node`.
* ✨ Add per-group add button for typographies
Add a "+" button to each typography group header, allowing users to
create new typographies directly inside a group instead of only at
the top level. The button only appears for local, editable files.
Closes#5275
* 📚 Add changelog entry for typography group add button
* 🐛 Fix typography group title button layout wrapping
* ♻️ Address review feedback for typography group add button
Signed-off-by: eureka928 <meobius123@gmail.com>
* 🐛 Fix nil path content crash by exposing safe public API
Move nil-safety for path segment helpers to the public API layer
(app.common.types.path) rather than the low-level segment namespace.
Add nil-safe wrappers for get-handlers, opposite-index, get-handler-point,
get-handler, handler->node, point-indices, handler-indices, next-node,
append-segment, points->content, closest-point, make-corner-point,
make-curve-point, split-segments, remove-nodes, merge-nodes, join-nodes,
and separate-nodes. Update all frontend callers to use path/ instead of
path.segment/ for these functions, removing the path.segment require
from helpers, drawing, edition, tools, curve, editor and debug.
Replace ad-hoc nil checks with impl/path-data coercion in all public
wrapper functions in app.common.types.path. The path-data helper
already handles nil by returning an empty PathData instance, which
provides uniform nil-safety across all content-accepting functions.
Update the path-get-points-nil-safe test to expect empty collection
instead of nil, matching the new coercion behavior.
* ♻️ Clean up path segment dead code and add missing tests
Remove dead code from segment.cljc: opposite-handler (duplicate of
calculate-opposite-handler) and path-closest-point-accuracy (unused
constant). Make update-handler and calculate-extremities private as
they are only used internally within segment.cljc.
Add missing tests for path/handler-indices, path/closest-point,
path/make-curve-point and path/merge-nodes. Update extremities tests
to use the local reference implementation instead of the now-private
calculate-extremities. Remove tests for deleted/privatized functions.
Add empty-content guard in path/closest-point wrapper to prevent
ArityException when reducing over zero segments.
The get-frame-ids function could enter infinite recursion when:
1. There's a circular reference in the frame hierarchy
2. A shape's frame-id points to itself (corrupt data)
The fix uses the cached version (get-frame-ids-cached) in recursive calls
and adds a guard to prevent self-referencing.
* ✨ Capture selection state before changes are applied
Save current selection IDs in commit-changes so undo entries
can track what was selected before each action.
* ✨ Save and restore selection state in undo/redo
Extend undo entry with selected-before and selected-after fields.
On undo, restore selection to what it was before the action.
On redo, restore selection to what it was after the action.
Handles single entries, stacked entries, accumulated transactions,
and undo groups.
Fixes#6007
* ♻️ Wire selected-before through workspace undo stream
Pass the captured selection state from commit data into
the undo entry so it is stored alongside changes.
* 🐛 Fix unmatched delimiter in changes.cljs
* 🐛 Pass selected-before through commit event to undo entry
selected-before was captured in commit-changes but dropped by the
commit function since it was missing from the destructuring and the
commit map. This caused restore-selection to receive nil on undo.
---------
Signed-off-by: eureka928 <meobius123@gmail.com>
Co-authored-by: Mihai <noreply@github.com>
Cache in-progress frame traversals before following parent frame links so thumbnail updates stop recursing forever on cyclic or transiently inconsistent shape graphs.
Add a regression test that covers cyclic frame-id chains and keeps the expected frame/component extraction behavior intact.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* 🐛 Fix crash in apply-text-modifier with nil selrect or modifier
Guard apply-text-modifier against nil text-modifier and nil selrect
to prevent the 'invalid arguments (on pointer constructor)' error
thrown by gpt/point when called with an invalid map.
- In text-wrapper: only call apply-text-modifier when text-modifier is
not nil (avoids unnecessary processing)
- In apply-text-modifier: handle nil text-modifier by returning shape
unchanged; guard selrect access before calling gpt/point
* 📚 Add tests for apply-text-modifier in workspace texts
Add exhaustive unit tests covering all paths of apply-text-modifier:
- nil modifier returns shape unchanged (identity)
- modifier with no recognised keys leaves shape unchanged
- :width / :height modifiers resize shape correctly
- nil :width / :height keys are skipped
- both dimensions applied simultaneously
- :position-data is set and nil-guarded
- position-data coordinates translated by delta on resize
- shape with nil selrect + nil modifier does not throw
- position-data-only modifier on shape without selrect is safe
- selrect origin preserved when no dimension changes
- result always carries required shape keys
* 🐛 Fix zero-dimension selrect crash in change-dimensions-modifiers
When a text shape is decoded from the server via map->Rect (which
bypasses make-rect's 0.01 minimum enforcement), its selrect can have
width or height of exactly 0. change-dimensions-modifiers and
change-size were dividing by these values, producing Infinity scale
factors that propagated through the transform pipeline until
calculate-selrect / center->rect returned nil, causing gpt/point to
throw 'invalid arguments (on pointer constructor)'.
Fix: before computing scale factors, guard sr-width / sr-height (and
old-width / old-height in change-size) against zero/negative and
non-finite values. When degenerate, fall back to the shape's own
top-level :width/:height so the denominator and proportion-lock base
remain consistent.
Also simplify apply-text-modifier's delta calculation now that the
transform pipeline is guaranteed to produce a valid selrect, and
update the test suite to test the exact degenerate-selrect scenario
that triggered the original crash.
Signed-off-by: Andrey Antukh <niwi@niwi.nz>
* ♻️ Simplify change-dimensions-modifiers internal logic
- Remove the intermediate 'size' map ({:width sr-width :height sr-height})
that was built only to be assoc'd and immediately destructured back into
width/height; compute both values directly instead.
- Replace the double-negated condition 'if-not (and (not ignore-lock?) …)'
with a clear positive 'locked?' binding, and flatten the three-branch
if-not/if tree into two independent if expressions keyed on 'attr'.
- Call safe-size-rect once and reuse its result for both the fallback
sizes and the scale computation, eliminating a redundant call.
- Access :transform and :transform-inverse via direct map lookup rather
than destructuring in the function signature, consistent with how the
rest of the let-block reads shape keys.
- Clean up change-size to use the same destructuring style as the updated
function ({sr-width :width sr-height :height}).
- Fix typo in comment: 'havig' -> 'having'.
* ✨ Add tests for change-size and change-dimensions-modifiers
Cover the main behavioural contract of both functions:
change-size:
- Scales both axes to the requested target dimensions.
- Sets the resize origin to the shape's top-left point.
- Nil width/height each fall back to the current dimension (scale 1 on
that axis); both nil produces an identity resize that is optimised away.
- Propagates the shape's transform and transform-inverse matrices into the
resulting GeometricOperation.
change-dimensions-modifiers:
- Changing :width without proportion-lock only scales the x-axis (y
scale stays 1), and vice-versa for :height.
- With proportion-lock enabled, changing :width adjusts height via the
inverse proportion, and changing :height adjusts width via the
proportion.
- ignore-lock? true bypasses proportion-lock regardless of shape state.
- Values below 0.01 are clamped to 0.01 before computing the scale.
- End-to-end: applying the returned modifiers via gsh/transform-shape
yields the expected selrect dimensions.
* ✨ Harden safe-size-rect with additional fallbacks
The previous implementation could still return an invalid rect in several
edge cases. The new version tries four sources in order, accepting each
only if it passes a dedicated safe-size-rect? predicate:
1. :selrect – used when width and height are finite, positive
and within [-max-safe-int, max-safe-int].
2. points->rect – computed from the shape corner points; subject to
the same predicate.
3. Top-level shape fields (:x :y :width :height) – present on all rect,
frame, image, and component shape types.
4. grc/empty-rect – a 0,0 0.01×0.01 unit rect used as last resort so
callers always receive a usable, non-crashing value.
The out-of-range check (> max-safe-int) is new: it rejects coordinates
that pass d/num? (finite) but exceed the platform integer boundary defined
in app.common.schema, which previously slipped through undetected.
Tests cover all four fallback paths, including the NaN, zero-dimension,
and max-safe-int overflow cases.
* ⚡ Optimise safe-size-rect for ClojureScript performance
- Replace (when (some? rect) ...) with (and ^boolean (some? rect) ...)
to keep the entire predicate as a single boolean expression without
introducing an implicit conditional branch.
- Replace keyword access (:width rect) / (:height rect) with
dm/get-prop calls, consistent with the hot-path style used throughout
the rest of the namespace.
- Add ^boolean type hints to every sub-expression of the and chain in
safe-size-rect? (d/num?, pos?, <=) so the ClojureScript compiler emits
raw JS boolean operations instead of boxing the results through
cljs.core/truth_.
- Replace (when (safe-size-rect? ...) value) in safe-size-rect with
(and ^boolean (safe-size-rect? ...) value), avoiding an extra
conditional and keeping the or fallback chain free of allocated
intermediate objects.
* ✨ Use safe-size-rect in apply-text-modifier delta-move computation
safe-size-rect was already used inside change-dimensions-modifiers to
guard the resize scale computation. However, apply-text-modifier in
texts.cljs was still reading (:selrect shape) and (:selrect new-shape)
directly to build the delta-move vector via gpt/point.
gpt/point raises "invalid arguments (on pointer constructor)" when
given a nil value or a map with non-finite :x/:y, which can happen when
a shape's selrect is missing or degenerate (e.g. decoded from the server
via map->Rect, bypassing make-rect's 0.01 floor).
Changes:
- Promote safe-size-rect from defn- to defn in app.common.types.modifiers
so it can be reused by consumers outside the namespace.
- Replace the two raw (:selrect …) accesses in the delta-move computation
with (ctm/safe-size-rect …), which always returns a valid, finite rect
through the established four-step fallback chain.
- Add two frontend tests covering the delta-move path with a fully
degenerate (zero-dimension) selrect, ensuring neither a bare
position-data modifier nor a combined width+position-data modifier
throws.
* ♻️ Ensure all test shapes are proper Shape records in modifiers-test
All shapes in safe-size-rect-fallbacks tests now start from a proper
Shape record built by cts/setup-shape (via make-shape) instead of plain
hash-maps. Each test that mutates geometry fields (selrect, points,
width, height) does so via assoc on the already-initialised record,
which preserves the correct type while isolating the field under test.
A (cts/shape? shape) assertion is added to each fallback test to make
the type guarantee explicit and guard against regressions.
The unused shape-with-selrect helper (which built a bare map) is
removed.
* 🔥 Remove dead code and tighten visibility in app.common.types.modifiers
Dead functions removed (zero callers across the entire codebase):
- modifiers->transform-old: superseded by modifiers->transform; only
ever appeared in a commented-out dev/bench.cljs entry.
- change-recursive-property: no callers anywhere.
- move-parent-modifiers, resize-parent-modifiers: convenience wrappers
for the parent-geometry builder functions; never called.
- remove-children-modifiers, add-children-modifiers,
scale-content-modifiers: single-op convenience builders; never called.
- select-structure: projection helper; only referenced by
select-child-geometry-modifiers which is itself dead.
- select-child-geometry-modifiers: no callers anywhere.
Functions narrowed from defn to defn- (used only within this namespace):
- valid-vector?: assertion helper called only by move/resize builders.
- increase-order: called only by add-modifiers.
- transform-move!, transform-resize!, transform-rotate!, transform!:
steps of the modifiers->transform pipeline.
- modifiers->transform1: immediate helper for modifiers->transform; the
doc-string describing it as 'multiplatform' was also removed since it
is an implementation detail.
- transform-text-node, transform-paragraph-node: leaf helpers for
scale-text-content.
- update-text-content, scale-text-content, apply-scale-content: internal
scale-content pipeline; all called only by apply-modifier.
- remove-children-set: called only by apply-modifier.
- select-structure: demoted to defn- rather than deleted because it is
still called by select-child-structre-modifiers, which has external
callers.
* ✨ Add more tests for modifiers
---------
Signed-off-by: Andrey Antukh <niwi@niwi.nz>