Compare commits

..

14 Commits

Author SHA1 Message Date
alonso.torres
da4920a079 WIPPP 2026-01-19 10:37:59 +01:00
alonso.torres
6fd8ef5574 WIP 2026-01-15 09:35:27 +01:00
alonso.torres
cb29100f93 WIP 2026-01-15 09:35:27 +01:00
Elena Torro
a3119bef5e 🔧 Show message and button to reload the page when WebGL context is lost 2026-01-14 11:10:03 +01:00
Alejandro Alonso
c60d74df62 🐛 Fix nested frames border clipping 2026-01-14 11:10:03 +01:00
Alejandro Alonso
d593e299e3 🐛 Fix mask erros on save/restore optimizations 2026-01-14 11:10:03 +01:00
Alejandro Alonso
4a8e02987f 🐛 Fix mask erros on save/restore optimizations 2026-01-14 11:10:03 +01:00
Alejandro Alonso
ee766e85a0 🎉 Wasm render dirty surfaces 2026-01-14 11:10:03 +01:00
Alejandro Alonso
35e3b7f19a 🎉 Root ids refactor 2026-01-14 11:10:03 +01:00
Alejandro Alonso
1810df232b 🎉 Ignore frames and groups when they have no visual extra information 2026-01-14 11:10:03 +01:00
Alejandro Alonso
3e99ad036c 🎉 Avoid unnecesary saves and restores 2026-01-14 11:10:03 +01:00
Alejandro Alonso
042a3a4080 🐛 Fix wasm playgrounds 2026-01-14 11:10:03 +01:00
Belén Albeza
f0687fd1f7 🎉 Make workspace loader to wait for first render 2026-01-14 11:10:03 +01:00
Aitor Moreno
2c9159288f 🐛 Fix previous styles lost when changing selected text 2026-01-14 11:10:01 +01:00
58 changed files with 2921 additions and 1730 deletions

View File

@@ -29,7 +29,7 @@
- Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956)
- Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959)
- Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865)
- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835)
## 2.12.1

View File

@@ -223,7 +223,7 @@ http {
add_header X-Cache-Status $upstream_cache_status;
}
location ~* \.(jpg|png|svg|ttf|woff|woff2|gif)$ {
location ~* \.(jpg|png|svg|ttf|woff|woff2)$ {
add_header Cache-Control "public, max-age=604800" always; # 7 days
}

View File

@@ -130,6 +130,12 @@ services:
environment:
<< : [*penpot-flags, *penpot-public-uri, *penpot-http-body-size, *penpot-secret-key]
## The PREPL host. Mainly used for external programatic access to penpot backend
## (example: admin). By default it will listen on `localhost` but if you are going to use
## the `admin`, you will need to uncomment this and set the host to `0.0.0.0`.
# PENPOT_PREPL_HOST: 0.0.0.0
## Database connection parameters. Don't touch them unless you are using custom
## postgresql connection parameters.
@@ -145,8 +151,8 @@ services:
## Default configuration for assets storage: using filesystem based with all files
## stored in a docker volume.
PENPOT_OBJECTS_STORAGE_BACKEND: fs
PENPOT_OBJECTS_STORAGE_FS_DIRECTORY: /opt/data/assets
PENPOT_ASSETS_STORAGE_BACKEND: assets-fs
PENPOT_STORAGE_ASSETS_FS_DIRECTORY: /opt/data/assets
## Also can be configured to to use a S3 compatible storage.

View File

@@ -144,7 +144,7 @@ http {
location / {
include /etc/nginx/overrides/location.d/*.conf;
location ~* \.(js|css|jpg|png|svg|gif|ttf|woff|woff2|wasm)$ {
location ~* \.(js|css|jpg|png|svg|ttf|woff|woff2|wasm)$ {
add_header Cache-Control "public, max-age=604800" always; # 7 days
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -19,12 +19,6 @@ desc: Begin with the Penpot user guide! Get quickstarts, shortcuts, and tutorial
<p>Create and manage your teams</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/projects-files">
<h2>Projects and Files →</h2>
<p>Organize your work with projects and files</p>
</a>
</li>
<li>
<a href="/user-guide/account-teams/comments/">
<h2>Comments →</h2>

View File

@@ -1,107 +0,0 @@
---
title: Projects and Files
order: 3
desc: Learn how to organize your work in Penpot. Create, manage and organize projects and files, work with drafts, and handle deleted items.
---
<h1 id="projects-files">Projects and Files</h1>
<p class="main-paragraph">Projects and files are the core organizational structure in Penpot. Projects work like folders that contain multiple design files, helping you organize your work efficiently. Files are your actual design documents where you create boards, pages, and all your design elements.</p>
<p class="main-paragraph">Understanding how to manage projects and files will help you keep your workspace organized and make it easier to collaborate with your team.</p>
<h2 id="projects-management">Projects</h2>
<p>Projects are containers that help you organize and group related design files together. Think of them as folders in a file system. You can create as many projects as you need to organize your work by client, product, feature, or any other structure that fits your workflow.</p>
<p>If you're working with others, projects should be created inside a team so that team members can collaborate on the files within them. Projects created in your personal space ("Your Penpot") remain private to you.</p>
<figure>
<img src="/img/files-projects/01-projects.webp" alt="Projects view in dashboard" />
</figure>
<h3 id="create-project">Create a project</h3>
<p>To create a new project, use the <strong>+ New project</strong> button in the dashboard. You can also use the keyboard shortcut <kbd>+</kbd> when you're on the dashboard. A dialog will appear where you can enter the project name. Once created, the project will appear in your projects list.</p>
<p>When you create a project, you can immediately start adding files to it, or create files first and move them into the project later.</p>
<h3 id="edit-project">Edit a project</h3>
<p>To edit a project's name, right-click on the project in the sidebar or click the three-dot menu next to the project name. Select <strong>Edit</strong> or <strong>Rename</strong> to change the project name. You can also update the project's profile picture from the same menu.</p>
<h3 id="pin-project">Pin a project</h3>
<p>Projects can be pinned to the sidebar for quick access. Right-click on a project and select <strong>Pin</strong> to keep it visible in the sidebar even when you have many projects. Pinned projects appear at the top of your projects list for easy access.</p>
<p>To unpin a project, right-click on it and select <strong>Unpin</strong>. The project will remain in your list but won't be pinned to the sidebar anymore.</p>
<figure>
<img src="/img/files-projects/04-pin-project.webp" alt="Pin project option" />
</figure>
<h3 id="move-project">Move a project</h3>
<p>Projects can be moved between teams. To move a project, right-click on it and select <strong>Move to</strong> from the context menu. A dialog will appear showing all available teams where you can move the project. Select the destination team and confirm the move.</p>
<figure>
<img src="/img/files-projects/06-move-project.webp" alt="Move project to another team" />
</figure>
<p>When you move a project to another team, all files within the project are moved along with it. Team members of the destination team will gain access to the project and its files according to their permissions.</p>
<p class="advice">Moving a project to another team changes its ownership and access permissions. Make sure the destination team has the appropriate members and permissions for the work contained in the project.</p>
<h3 id="delete-project">Delete a project</h3>
<p>To delete a project, right-click on it and select <strong>Delete</strong> from the menu. You'll be asked to confirm the deletion. Keep in mind that deleting a project will also delete all files within it. Make sure you have backed up any important files before deleting a project.</p>
<p class="advice">Deleted projects and their files are moved to the trash area where they can be restored or permanently deleted.</p>
<h2 id="files-management">Files</h2>
<p>Files are your design documents in Penpot. Each file contains pages, boards, and all the design elements you create. Files can be created within a project or in the drafts section, and you can move them between projects as needed.</p>
<h3 id="create-file">Create a file</h3>
<p>To create a new file, you have several options:</p>
<ul>
<li>Click the <strong>+</strong> button in a project to create a file inside that project</li>
<li>Use the keyboard shortcut <kbd>+</kbd> when you have a project selected</li>
<li>Create a file directly in the drafts section if you're not ready to organize it into a project yet</li>
</ul>
<figure>
<img src="/img/files-projects/05-create-file.webp" alt="Create a new file" />
</figure>
<p>When creating a file, you'll be asked to give it a name. The file will open in the workspace where you can start designing immediately.</p>
<h3 id="edit-file">Edit a file</h3>
<p>To rename a file, right-click on the file card in the dashboard and select <strong>Rename</strong>, or click on the three-dot menu on the file card. Enter the new name and confirm the change. You can also access file settings and other options from the file's context menu.</p>
<h3 id="move-file">Move a file</h3>
<p>Files can be moved between projects, from drafts to a project, or even to projects in other teams. To move a file, right-click on the file card and select <strong>Move to</strong> from the context menu. A dialog will appear showing all available projects across your teams where you can choose the destination. Select the project where you want to move the file and confirm.</p>
<p>You can also drag and drop files between projects in the dashboard for a quick way to reorganize your files within the same team.</p>
<p>When moving a file to a project in another team, the file becomes accessible to members of that team according to their permissions. Moving a file doesn't affect its content or any shared libraries it might be using. Only its location in your project structure changes.</p>
<p class="advice">When moving files between teams, be aware that this changes who has access to the file. Make sure the destination team has the appropriate members and permissions for the work contained in the file.</p>
<h3 id="duplicate-file">Duplicate a file</h3>
<p>To create a copy of an existing file, right-click on the file card and select <strong>Duplicate</strong>. The duplicated file will be created in the same location (project or drafts) with the same name plus "Copy" added to it. You can then rename or move it as needed.</p>
<p>Duplicating a file creates a complete copy including all pages, boards, and design elements. This is useful when you want to create variations of a design or use a file as a starting point for a new project.</p>
<h3 id="delete-file">Delete a file</h3>
<p>To delete a file, right-click on the file card and select <strong>Delete</strong>. You'll be asked to confirm the deletion. The file will be moved to the trash area where it can be restored or permanently deleted later.</p>
<p class="advice">Deleting a file doesn't immediately remove it permanently. You can recover deleted files from the trash area within a certain time period.</p>
<h2 id="drafts">Drafts</h2>
<p>The drafts section is a fixed, non-deletable space in your dashboard where you can create and store files that aren't part of any specific project yet. This is useful for quick sketches, experimental designs, or files you're not ready to organize into projects.</p>
<figure>
<img src="/img/files-projects/02-drafts.webp" alt="Drafts section" />
</figure>
<p>Drafts appear in a dedicated section in the dashboard sidebar, separate from your projects. All team members can see and access files in the drafts section, depending on their permissions.</p>
<p>You can create files directly in drafts, or move existing files from projects into drafts if you want to temporarily remove them from a project's organization. Files in drafts work exactly like files in projects - they have the same functionality and features.</p>
<p>When you're ready to organize a file from drafts, you can move it into an appropriate project using the move option in the file's context menu.</p>
<h2 id="trash-area">Trash area</h2>
<p>When you delete projects or files, they are not removed permanently. Instead, they are moved to a trash area, a dedicated space for deleted content. This allows you to recover mistakenly deleted content or permanently remove items when you're sure you don't need them anymore.</p>
<p>The trash applies to both files and projects. Items in the trash remain there for a certain period depending on your Penpot subscription plan before being automatically deleted permanently.</p>
<h3 id="access-trash">Access the trash</h3>
<p>A <strong>Trash</strong> section is accessible from the dashboard navigation. When you access it, you'll see all your deleted files and projects, each clearly labeled so you can easily identify what you want to restore or permanently delete.</p>
<figure>
<img src="/img/files-projects/03-trash.webp" alt="Trash area" />
</figure>
<h3 id="trash-permissions">Trash permissions</h3>
<p>Access to the trash and the actions you can perform depend on your role in the team:</p>
<ul>
<li><strong>Owner, Admin, and Editor:</strong> Can view the trash, restore deleted items, and permanently delete items from the trash.</li>
<li><strong>Viewer:</strong> Cannot access the trash or manage deleted content.</li>
</ul>
<h3 id="restore-items">Restore items</h3>
<p>To restore a deleted file or project, access the trash area and find the item you want to recover. Select the item and choose <strong>Restore</strong>. The item will be restored to its original location (the project it belonged to, or the drafts section if it wasn't in a project).</p>
<h3 id="permanently-delete">Permanently delete items</h3>
<p>If you're sure you don't need an item anymore, you can permanently delete it from the trash. Select the item and choose <strong>Permanently delete</strong>. This action cannot be undone, so make sure you really want to remove the item permanently.</p>
<p class="advice">Items in the trash are automatically deleted after a certain period depending on your subscription plan. If you want to keep something, restore it before the auto-deletion period expires.</p>

View File

@@ -455,43 +455,6 @@ ExtraBold Italic
<p>A <strong>Typography composite token</strong> can be applied to a full text layer to set all typography properties at once. This lets you manage complete text styles using a single token instead of combining multiple individual ones.</p>
<p>When applying a Typography composite token to a layer, any previously applied <em>Typography composite token</em> or <em>style</em> will be detached. The same happens in reverse. Only one of them can be active at a time.</p>
<h3 id="design-tokens-shadow">Shadow</h3>
<p>Shadow tokens are composite entities that encapsulate the properties of one or more shadows into a single token definition. This token can contain a single shadow or an array of multiple shadows that can be reordered.</p>
<p>Shadow tokens support both <strong>Drop Shadow</strong> and <strong>Inner Shadow</strong> types. When creating or editing a shadow token, you can select the type of shadow you want to use. The default selection is Drop Shadow.</p>
<figure>
<img src="/img/design-tokens/37-tokens-shadow-individual.webp" alt="Shadow token creation with individual values" />
</figure>
<h4 id="design-tokens-shadow-properties">Shadow properties</h4>
<p>Each shadow within a shadow token contains a set of properties that define how the shadow appears:</p>
<ul>
<li><strong>Color:</strong> The color of the shadow. Accepts the same values as <a href="#design-tokens-color">color tokens</a> (Hex, RGB, RGBA, ARGB, HSL, HSLA), and you can reference existing color tokens. The color picker is available when defining the value.</li>
<li><strong>X offset:</strong> The horizontal offset of the shadow. Can be unit or unitless, and accepts negative values. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Y offset:</strong> The vertical offset of the shadow. Can be unit or unitless, and accepts negative values. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Blur:</strong> The blur radius of the shadow. Can be unit or unitless. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Spread:</strong> The spread radius of the shadow. Can be unit or unitless. You can use a number or reference a <a href="#design-tokens-number">number</a> or <a href="#design-tokens-dimensions">dimension</a> token.</li>
<li><strong>Type:</strong> Whether the shadow is a drop shadow or an inner shadow. Selected via a dropdown menu, with Drop Shadow as the default.</li>
</ul>
<p>Each property within a shadow token can reference existing tokens or be assigned hardcoded values. Shadows can also reference other shadow tokens (the type of shadow must match when using references).</p>
<p class="advice">Not all properties are mandatory to save a shadow token. Some can be empty (and will be computed as 0). Only the color property is mandatory. In an array of shadows, if any shadow does not have the color set, the form cannot be saved.</p>
<h4 id="design-tokens-shadow-create">Creating shadow tokens</h4>
<p>To create a shadow token, click on the <strong>+</strong> next to <strong>Shadow</strong> in the Tokens panel. Shadow tokens can be created in two ways:</p>
<ul>
<li><strong>Individual values:</strong> You can create one shadow or multiple shadows with individual property values. Click the <strong>+</strong> button to add more shadows to the array. New shadows are added at the top of the list.</li>
<li><strong>Single reference:</strong> You can reference another existing shadow token. When using a single reference, you cannot add more than one shadow. The resolved value will display the shadow or list of shadows that the referenced token contains.</li>
</ul>
<figure>
<img src="/img/design-tokens/38-tokens-shadow-reference.webp" alt="Shadow token creation with reference" />
</figure>
<p>When creating a shadow with individual values, the color value starts empty, but the other inputs have default values (X: 4, Y: 4, Blur: 4, Spread: 0). You can reorder shadows by hovering over a shadow form and using the reorder button to drag it to a different position.</p>
<p>You can also reference another existing shadow token instead of defining each property manually. When doing so, Penpot resolves all shadow properties from the referenced token.</p>
<h4 id="design-tokens-shadow-apply">Applying shadow tokens</h4>
<p>Shadow tokens can be applied to any layer type. Clicking on a shadow token will apply it to the selected layer. Right-clicking on a shadow token shows the context menu with the <strong>Shadow</strong> option to apply it.</p>
<p class="advice">Text elements in CSS do not support inner shadows, but Penpot does, since it uses the filter property internally instead of the box-shadow property.</p>
<p>When applying a shadow token, any existing shadow on the layer will be overridden (whether it's a raw shadow or an applied token shadow). If the token contains an array of shadows, each shadow will be added in the same order as in the creation form.</p>
<p class="advice">In Penpot, an element can have multiple shadows, but only one token of the same type can be applied. This means that applying a second shadow token would override the first one, regardless of how many shadows the shape currently has.</p>

View File

@@ -58,7 +58,8 @@
:share-id share-id
:object-id (mapv :id objects)
:route "objects"
:skip-children skip-children}
:skip-children skip-children
:wasm "true"}
uri (-> (cf/get :public-uri)
(assoc :path "/render.html")
(assoc :query (u/map->query-string params)))]

View File

@@ -33,7 +33,9 @@
:page-id page-id
:share-id share-id
:object-id object-id
:route "objects"}]
:route "objects"
;;:wasm "true"
}]
(-> base-uri
(assoc :path "/render.html")
(assoc :query (u/map->query-string params)))))

View File

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 389 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, addShapeSolidStrokeFill,

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, set_parent

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, set_parent, draw_star,

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, set_parent, allocBytes,

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, addShapeSolidFill, assignCanvas, hexToU32ARGB, getRandomInt, getRandomColor,
getRandomFloat, useShape, setShapeChildren, setupInteraction, addShapeSolidStrokeFill

View File

@@ -23,7 +23,7 @@
<body>
<canvas id="canvas"></canvas>
<script type="module">
import initWasmModule from '/js/render_wasm.js';
import initWasmModule from '/js/render-wasm.js';
import {
init, assignCanvas, setupInteraction, useShape, setShapeChildren, addTextShape, hexToU32ARGB,getRandomInt, getRandomColor, getRandomFloat, addShapeSolidFill, addShapeSolidStrokeFill
} from './js/lib.js';
@@ -102,4 +102,4 @@
});
</script>
</body>
</html>
</html>

View File

@@ -45,7 +45,9 @@
[app.main.ui.shapes.svg-raw :as svg-raw]
[app.main.ui.shapes.text :as text]
[app.main.ui.shapes.text.fontfaces :as ff]
[app.render-wasm.api :as wasm.api]
[app.util.dom :as dom]
[app.util.globals :as g]
[app.util.http :as http]
[app.util.strings :as ust]
[app.util.thumbnails :as th]
@@ -53,6 +55,7 @@
[beicon.v2.core :as rx]
[clojure.set :as set]
[cuerdas.core :as str]
[promesa.core :as p]
[rumext.v2 :as mf]))
(def ^:const viewbox-decimal-precision 3)
@@ -171,6 +174,8 @@
;; Don't wrap svg elements inside a <g> otherwise some can break
[:> svg-raw-wrapper {:shape shape :frame frame}]))))))
(set! wasm.api/shape-wrapper-factory shape-wrapper-factory)
(defn format-viewbox
"Format a viewbox given a rectangle"
[{:keys [x y width height] :or {x 0 y 0 width 100 height 100}}]
@@ -480,6 +485,48 @@
[:& ff/fontfaces-style {:fonts fonts}]
[:& shape-wrapper {:shape object}]]]]))
(mf/defc object-wasm
{::mf/wrap [mf/memo]}
[{:keys [objects object-id embed skip-children]
:or {embed false}
:as props}]
(let [object (get objects object-id)
object (cond-> object
(:hide-fill-on-export object)
(assoc :fills [])
skip-children
(assoc :shapes []))
{:keys [width height] :as bounds}
(gsb/get-object-bounds objects object {:ignore-margin? false})
vbox (format-viewbox bounds)
zoom 1
canvas-ref (mf/use-ref nil)]
(mf/use-effect
(fn []
(let [canvas (mf/ref-val canvas-ref)]
(->> @wasm.api/module
(p/fmap
(fn [ready?]
(when ready?
(try
(when (wasm.api/init-canvas-context canvas)
(wasm.api/initialize-viewport
objects zoom vbox "transparent"
(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)))))))))
[:canvas {:ref canvas-ref
:width width
:height height
:style {:background "red"}}]))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; SPRITES (DEBUG)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -10,6 +10,7 @@ $z-index-200: 200;
$z-index-300: 300;
$z-index-400: 400;
$z-index-500: 500;
$z-index-600: 600;
:global(:root) {
--z-index-auto: #{$z-index-auto}; // Index for elements such as workspace, rulers ...
@@ -18,4 +19,5 @@ $z-index-500: 500;
--z-index-set: #{$z-index-300}; // Index for configuration elements like modals, color picker, grid edition elements
--z-index-dropdown: #{$z-index-400}; // Index for dropdown like elements, selects, menus, dropdowns
--z-index-notifications: #{$z-index-500}; // Index for notification
--z-index-loaders: #{$z-index-600}; // Index for loaders
}

View File

@@ -52,8 +52,7 @@
(let [form (mf/use-ctx context)
disabled? (or (and (some? form)
(or (not (:valid @form))
(seq (:reference-errors @form))
(seq (:extra-errors @form))))
(seq (:external-errors @form))))
(true? disabled))
handle-key-down-save
(mf/use-fn

View File

@@ -31,7 +31,6 @@
[app.main.ui.releases.v2-10]
[app.main.ui.releases.v2-11]
[app.main.ui.releases.v2-12]
[app.main.ui.releases.v2-13]
[app.main.ui.releases.v2-2]
[app.main.ui.releases.v2-3]
[app.main.ui.releases.v2-4]
@@ -104,4 +103,4 @@
(defmethod rc/render-release-notes "0.0"
[params]
(rc/render-release-notes (assoc params :version "2.13")))
(rc/render-release-notes (assoc params :version "2.12")))

View File

@@ -1,118 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.releases.v2-13
(:require-macros [app.main.style :as stl])
(:require
[app.common.data.macros :as dm]
[app.main.ui.releases.common :as c]
[rumext.v2 :as mf]))
(defmethod c/render-release-notes "2.13"
[{:keys [slide klass next finish navigate version]}]
(mf/html
(case slide
:start
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.13-slide-0.jpg"
:class (stl/css :start-image)
:border "0"
:alt "Penpot 2.13 is here!"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Whats new in Penpot?"]
[:div {:class (stl/css :version-tag)}
(dm/str "Version " version)]]
[:div {:class (stl/css :features-block)}
[:span {:class (stl/css :feature-title)}
"The first release of the year, and were just getting started 🚀"]
[:p {:class (stl/css :feature-content)}
"This is our first release of the year, and it sets the tone for whats coming next. Were kicking off an exciting year where well take Penpot to a whole new level, with improved performance, stronger design system foundations, long-requested features, and new capabilities that unlock better workflows for teams."]
[:p {:class (stl/css :feature-content)}
"This release brings two highlights the community has been asking for, along with solid improvements under the hood to keep everything fast and smooth."]
[:p {:class (stl/css :feature-content)}
"Lets dive in!"]]
[:div {:class (stl/css :navigation)}
[:button {:class (stl/css :next-btn)
:on-click next} "Continue"]]]]]]
0
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.13-trash.gif"
:class (stl/css :start-image)
:border "0"
:alt "The Trash"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"The Trash"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"Deleting a file no longer means its gone forever. Introducing The Trash, a dedicated space in the dashboard where deleted files and projects live before being permanently removed."]
[:p {:class (stl/css :feature-content)}
"From here, you can recover content deleted by mistake or clean things up for good when youre sure you dont need them anymore. The Trash works for both files and projects, and items are automatically removed after a period of time depending on your Penpot plan."]
[:p {:class (stl/css :feature-content)}
"Highly requested, long overdue, and now officially here."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 3}]
[:button {:on-click next
:class (stl/css :next-btn)} "Continue"]]]]]]
1
[:div {:class (stl/css-case :modal-overlay true)}
[:div.animated {:class klass}
[:div {:class (stl/css :modal-container)}
[:img {:src "images/features/2.13-shadow-tokens.gif"
:class (stl/css :start-image)
:border "0"
:alt "Shadow tokens: Reusable shadows, at last!"}]
[:div {:class (stl/css :modal-content)}
[:div {:class (stl/css :modal-header)}
[:h1 {:class (stl/css :modal-title)}
"Shadow tokens: Reusable shadows, at last!"]]
[:div {:class (stl/css :feature)}
[:p {:class (stl/css :feature-content)}
"With Shadow tokens, were introducing our second composite token, right after Typography tokens. This is a big step forward for design systems in Penpot."]
[:p {:class (stl/css :feature-content)}
"Until now, shadows couldnt be defined as reusable styles the way colors could before color tokens existed. Shadow tokens change that. You can now create reusable, consistent shadows, made of one or multiple layers, fully tokenized and ready to scale across your designs."]
[:p {:class (stl/css :feature-content)}
"Each shadow can reference existing tokens or use custom values, supports both Drop Shadow and Inner Shadow, and even allows shadow tokens to reference other shadow tokens. A brand-new capability, unlocked."]]
[:div {:class (stl/css :navigation)}
[:& c/navigation-bullets
{:slide slide
:navigate navigate
:total 2}]
[:button {:on-click finish
:class (stl/css :next-btn)} "Let's go"]]]]]])))

View File

@@ -1,102 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "refactor/common-refactor.scss" as deprecated;
.modal-overlay {
@extend .modal-overlay-base;
}
.modal-container {
display: grid;
grid-template-columns: deprecated.$s-324 1fr;
height: deprecated.$s-500;
width: deprecated.$s-888;
border-radius: deprecated.$br-8;
background-color: var(--modal-background-color);
border: deprecated.$s-2 solid var(--modal-border-color);
}
.start-image {
width: deprecated.$s-324;
border-radius: deprecated.$br-8 0 0 deprecated.$br-8;
}
.modal-content {
padding: deprecated.$s-40;
display: grid;
grid-template-rows: auto 1fr deprecated.$s-32;
gap: deprecated.$s-24;
a {
color: var(--button-primary-background-color-rest);
}
}
.modal-header {
display: grid;
gap: deprecated.$s-8;
}
.version-tag {
@include deprecated.flexCenter;
@include deprecated.headlineSmallTypography;
height: deprecated.$s-32;
width: deprecated.$s-96;
background-color: var(--communication-tag-background-color);
color: var(--communication-tag-foreground-color);
border-radius: deprecated.$br-8;
}
.modal-title {
@include deprecated.headlineLargeTypography;
color: var(--modal-title-foreground-color);
}
.features-block {
display: flex;
flex-direction: column;
gap: deprecated.$s-16;
width: deprecated.$s-440;
}
.feature {
display: flex;
flex-direction: column;
gap: deprecated.$s-8;
}
.feature-title {
@include deprecated.bodyLargeTypography;
color: var(--modal-title-foreground-color);
}
.feature-content {
@include deprecated.bodyMediumTypography;
margin: 0;
color: var(--modal-text-foreground-color);
}
.feature-list {
@include deprecated.bodyMediumTypography;
color: var(--modal-text-foreground-color);
list-style: disc;
display: grid;
gap: deprecated.$s-8;
}
.navigation {
width: 100%;
display: grid;
grid-template-areas: "bullets button";
}
.next-btn {
@extend .button-primary;
width: deprecated.$s-100;
justify-self: flex-end;
grid-area: button;
}

View File

@@ -308,6 +308,16 @@
[:div {:class (stl/css :sign-info)}
[:button {:on-click on-click} (tr "labels.retry")]]]))
(mf/defc webgl-context-lost*
[]
(let [on-reload (mf/use-fn #(js/location.reload))]
[:> error-container* {}
[:div {:class (stl/css :main-message)} (tr "labels.webgl-context-lost.main-message")]
[:div {:class (stl/css :desc-message)} (tr "labels.webgl-context-lost.desc-message")]
[:div {:class (stl/css :buttons-container)}
[:> button* {:variant "primary" :on-click on-reload}
(tr "labels.reload-page")]]]))
(defn- generate-report
[data]
(try
@@ -437,6 +447,7 @@
(rx/of default)
(rx/throw cause)))))))
(mf/defc exception-section*
{::mf/private true}
[{:keys [data route] :as props}]
@@ -469,6 +480,9 @@
:service-unavailable
[:> service-unavailable*]
:webgl-context-lost
[:> webgl-context-lost*]
[:> internal-error* props])))
(mf/defc context-wrapper*

View File

@@ -217,6 +217,10 @@
design-tokens? (features/use-feature "design-tokens/v1")
wasm-renderer-enabled? (features/use-feature "render-wasm/v1")
first-frame-rendered? (mf/use-state false)
background-color (:background-color wglobal)]
(mf/with-effect []
@@ -241,6 +245,17 @@
(when (and file-loaded? (not page-id))
(st/emit! (dcm/go-to-workspace :file-id file-id ::rt/replace true))))
(mf/with-effect [file-id page-id]
(reset! first-frame-rendered? false))
(mf/with-effect []
(let [handle-wasm-render
(fn [_]
(reset! first-frame-rendered? true))
listener-key (events/listen globals/document "penpot:wasm:render" handle-wasm-render)]
(fn []
(events/unlistenByKey listener-key))))
[:> (mf/provider ctx/current-project-id) {:value project-id}
[:> (mf/provider ctx/current-file-id) {:value file-id}
[:> (mf/provider ctx/current-page-id) {:value page-id}
@@ -249,15 +264,18 @@
[:> modal-container*]
[:section {:class (stl/css :workspace)
:style {:background-color background-color
:touch-action "none"}}
:touch-action "none"
:position "relative"}}
[:> context-menu*]
(if (and file-loaded? page-id)
(when (and file-loaded? page-id)
[:> workspace-inner*
{:page-id page-id
:file-id file-id
:file file
:wglobal wglobal
:layout layout}]
:layout layout}])
(when (or (not (and file-loaded? page-id))
(and wasm-renderer-enabled? (not @first-frame-rendered?)))
[:> workspace-loader*])]]]]]]))
(mf/defc workspace-page*

View File

@@ -20,7 +20,13 @@
}
.workspace-loader {
grid-area: viewport;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: var(--z-index-loaders);
background-color: var(--color-background-primary);
}
.workspace-content {

View File

@@ -273,4 +273,4 @@
{:label (tr "workspace.tokens.import-menu-folder-option") :value :folder}]
:on-click handle-import-action
:text-render render-button-text
:default :file}]]]))
:default :zip}]]]))

View File

@@ -332,7 +332,6 @@
message (tr "workspace.tokens.resolved-value" (or resolved-value value))]
(swap! form update :errors dissoc :value)
(swap! form update :extra-errors dissoc :value)
(swap! form update :reference-errors dissoc :reference)
(if (= input-value (str resolved-value))
(reset! hint* {})
(reset! hint* {:message message :type "hint"})))))))]

View File

@@ -101,6 +101,13 @@
active-tab* (mf/use-state #(if (cft/is-reference? token) :reference :composite))
active-tab (deref active-tab*)
on-toggle-tab
(mf/use-fn
(mf/deps)
(fn [new-tab]
(let [new-tab (keyword new-tab)]
(reset! active-tab* new-tab))))
token
(mf/with-memo [token]
(or token {:type token-type}))
@@ -137,17 +144,6 @@
(fm/use-form :schema schema
:initial initial)
on-toggle-tab
(mf/use-fn
(mf/deps form)
(fn [new-tab]
(let [new-tab (keyword new-tab)]
(if (= new-tab :reference)
(swap! form assoc-in [:reference-errors :reference]
{:message "Need valid reference"})
(swap! form update :reference-errors dissoc :reference))
(reset! active-tab* new-tab))))
warning-name-change?
(not= (get-in @form [:data :name])
(:name initial))

View File

@@ -63,7 +63,7 @@
(mf/defc object-svg
{::mf/wrap-props false}
[{:keys [object-id embed skip-children]}]
[{:keys [object-id embed skip-children wasm]}]
(let [objects (mf/deref ref:objects)]
;; Set the globa CSS to assign the page size, needed for PDF
@@ -77,27 +77,42 @@
(mth/ceil height) "px")}))))
(when objects
[:& (mf/provider ctx/is-render?) {:value true}
[:& render/object-svg
{:objects objects
:object-id object-id
:embed embed
:skip-children skip-children}]])))
(if wasm
[:& render/object-wasm
{:objects objects
:object-id object-id
:embed embed
:skip-children skip-children}]
(mf/defc objects-svg
{::mf/wrap-props false}
[{:keys [object-ids embed skip-children]}]
(when-let [objects (mf/deref ref:objects)]
(for [object-id object-ids]
(let [objects (render/adapt-objects-for-shape objects object-id)]
[:& (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}]]))))
(mf/defc objects-svg
{::mf/wrap-props false}
[{:keys [object-ids embed skip-children wasm]}]
(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
:skip-children skip-children}]
[:& (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}]
(ptk/reify ::fetch-objects-bundle
@@ -136,7 +151,7 @@
(defn- render-objects
[params]
(try
(let [{:keys [file-id page-id embed share-id object-id skip-children] :as params}
(let [{:keys [file-id page-id embed share-id object-id skip-children wasm] :as params}
(coerce-render-objects-params params)]
(st/emit! (fetch-objects-bundle :file-id file-id :page-id page-id :share-id share-id :object-id object-id))
(if (uuid? object-id)
@@ -147,7 +162,8 @@
:share-id share-id
:object-id object-id
:embed embed
:skip-children skip-children}])
:skip-children skip-children
:wasm wasm}])
(mf/html
[:& objects-svg
@@ -156,7 +172,8 @@
:share-id share-id
:object-ids (into #{} object-id)
:embed embed
:skip-children skip-children}])))
:skip-children skip-children
:wasm wasm}])))
(catch :default cause
(when-let [explain (-> cause ex-data ::sm/explain)]
(js/console.log "Unexpected error")
@@ -307,6 +324,3 @@
(defn ^:dev/after-load after-load
[]
(reinit))

View File

@@ -10,6 +10,7 @@
["react-dom/server" :as rds]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.exceptions :as ex]
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.math :as mth]
@@ -21,9 +22,7 @@
[app.common.types.text :as txt]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.render-wasm :as drw]
[app.main.refs :as refs]
[app.main.render :as render]
[app.main.store :as st]
[app.main.ui.shapes.text]
[app.main.worker :as mw]
@@ -74,6 +73,9 @@
(def noop-fn
(constantly nil))
;;
(def shape-wrapper-factory nil)
;; Based on app.main.render/object-svg
(mf/defc object-svg
{::mf/props :obj}
@@ -81,7 +83,7 @@
(let [objects (mf/deref refs/workspace-page-objects)
shape-wrapper
(mf/with-memo [shape]
(render/shape-wrapper-factory objects))]
(shape-wrapper-factory objects))]
[:svg {:version "1.1"
:xmlns "http://www.w3.org/2000/svg"
@@ -915,84 +917,85 @@
(defn set-object
[shape]
(perf/begin-measure "set-object")
(let [shape (svg-filters/apply-svg-derived shape)
id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
(when shape
(let [shape (svg-filters/apply-svg-derived shape)
id (dm/get-prop shape :id)
type (dm/get-prop shape :type)
parent-id (get shape :parent-id)
masked (get shape :masked-group)
selrect (get shape :selrect)
constraint-h (get shape :constraints-h)
constraint-v (get shape :constraints-v)
clip-content (if (= type :frame)
(not (get shape :show-content))
false)
rotation (get shape :rotation)
transform (get shape :transform)
parent-id (get shape :parent-id)
masked (get shape :masked-group)
selrect (get shape :selrect)
constraint-h (get shape :constraints-h)
constraint-v (get shape :constraints-v)
clip-content (if (= type :frame)
(not (get shape :show-content))
false)
rotation (get shape :rotation)
transform (get shape :transform)
fills (get shape :fills)
strokes (if (= type :group)
[] (get shape :strokes))
children (get shape :shapes)
blend-mode (get shape :blend-mode)
opacity (get shape :opacity)
hidden (get shape :hidden)
content (let [content (get shape :content)]
(if (= type :text)
(ensure-text-content content)
content))
bool-type (get shape :bool-type)
grow-type (get shape :grow-type)
blur (get shape :blur)
svg-attrs (get shape :svg-attrs)
shadows (get shape :shadow)
corners (map #(get shape %) [:r1 :r2 :r3 :r4])]
fills (get shape :fills)
strokes (if (= type :group)
[] (get shape :strokes))
children (get shape :shapes)
blend-mode (get shape :blend-mode)
opacity (get shape :opacity)
hidden (get shape :hidden)
content (let [content (get shape :content)]
(if (= type :text)
(ensure-text-content content)
content))
bool-type (get shape :bool-type)
grow-type (get shape :grow-type)
blur (get shape :blur)
svg-attrs (get shape :svg-attrs)
shadows (get shape :shadow)
corners (map #(get shape %) [:r1 :r2 :r3 :r4])]
(use-shape id)
(set-parent-id parent-id)
(set-shape-type type)
(set-shape-clip-content clip-content)
(set-shape-constraints constraint-h constraint-v)
(use-shape id)
(set-parent-id parent-id)
(set-shape-type type)
(set-shape-clip-content clip-content)
(set-shape-constraints constraint-h constraint-v)
(set-shape-rotation rotation)
(set-shape-transform transform)
(set-shape-blend-mode blend-mode)
(set-shape-opacity opacity)
(set-shape-hidden hidden)
(set-shape-children children)
(set-shape-corners corners)
(set-shape-blur blur)
(when (= type :group)
(set-masked (boolean masked)))
(when (= type :bool)
(set-shape-bool-type bool-type))
(when (and (some? content)
(or (= type :path)
(= type :bool)))
(set-shape-path-content content))
(when (some? svg-attrs)
(set-shape-svg-attrs svg-attrs))
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(set-shape-shadows shadows)
(when (= type :text)
(set-shape-grow-type grow-type))
(set-shape-rotation rotation)
(set-shape-transform transform)
(set-shape-blend-mode blend-mode)
(set-shape-opacity opacity)
(set-shape-hidden hidden)
(set-shape-children children)
(set-shape-corners corners)
(set-shape-blur blur)
(when (= type :group)
(set-masked (boolean masked)))
(when (= type :bool)
(set-shape-bool-type bool-type))
(when (and (some? content)
(or (= type :path)
(= type :bool)))
(set-shape-path-content content))
(when (some? svg-attrs)
(set-shape-svg-attrs svg-attrs))
(when (and (some? content) (= type :svg-raw))
(set-shape-svg-raw-content (get-static-markup shape)))
(set-shape-shadows shadows)
(when (= type :text)
(set-shape-grow-type grow-type))
(set-shape-layout shape)
(set-shape-selrect selrect)
(set-shape-layout shape)
(set-shape-selrect selrect)
(let [pending_thumbnails (into [] (concat
(set-shape-text-content id content)
(set-shape-text-images id content true)
(set-shape-fills id fills true)
(set-shape-strokes id strokes true)))
pending_full (into [] (concat
(set-shape-text-images id content false)
(set-shape-fills id fills false)
(set-shape-strokes id strokes false)))]
(perf/end-measure "set-object")
{:thumbnails pending_thumbnails
:full pending_full})))
(let [pending_thumbnails (into [] (concat
(set-shape-text-content id content)
(set-shape-text-images id content true)
(set-shape-fills id fills true)
(set-shape-strokes id strokes true)))
pending_full (into [] (concat
(set-shape-text-images id content false)
(set-shape-fills id fills false)
(set-shape-strokes id strokes false)))]
(perf/end-measure "set-object")
{:thumbnails pending_thumbnails
:full pending_full}))))
(defn update-text-layouts
[shapes]
@@ -1055,8 +1058,9 @@
(perf/end-measure "set-objects")
(process-pending shapes thumbnails full noop-fn
(fn []
(when render-callback (render-callback))
(render-finish)
(if render-callback
(render-callback)
(render-finish))
(ug/dispatch! (ug/event "penpot:wasm:set-objects")))))))
(defn clear-focus-mode
@@ -1236,7 +1240,8 @@
(dom/prevent-default event)
(reset! wasm/context-lost? true)
(log/warn :hint "WebGL context lost")
(st/emit! (drw/context-lost)))
(ex/raise :type :webgl-context-lost
:hint "WebGL context lost"))
(defn init-canvas-context
[canvas]
@@ -1447,6 +1452,35 @@
result)))
(defn render-shape-pixels
[shape-id]
(let [buffer (uuid/get-u32 shape-id)
offset
(h/call wasm/internal-module "_render_shape_pixels"
(aget buffer 0)
(aget buffer 1)
(aget buffer 2)
(aget buffer 3))
offset-32
(mem/->offset-32 offset)
heap (mem/get-heap-u8)
heapu32 (mem/get-heap-u32)
length (aget heapu32 (mem/->offset-32 offset))
width (aget heapu32 (+ (mem/->offset-32 offset) 1))
height (aget heapu32 (+ (mem/->offset-32 offset) 2))
result
(dr/read-image-bytes heap (+ offset 12) length)
]
(mem/free)
result
))
(defn init-wasm-module
[module]
(let [default-fn (unchecked-get module "default")

View File

@@ -45,6 +45,12 @@
:center (gpt/point cx cy)
:transform (gmt/matrix a b c d e f)}))
(defn read-image-bytes
[heap offset length]
(.slice ^js heap offset (+ offset length))
)
(defn read-position-data-entry
[heapu32 heapf32 offset]
(let [paragraph (aget heapu32 (+ offset 0))

View File

@@ -6,6 +6,8 @@
(ns debug
(:require
[app.render-wasm.wasm :as wasm]
[app.render-wasm.api :as wasm.api]
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.repair :as cfr]
@@ -456,3 +458,46 @@
(defn ^:export network-averages
[]
(.log js/console (clj->js @http/network-averages)))
(defn ^:export export-image
[]
#_(let [texture (.createTexture ^js wasm/gl-context)]
(.bindTexture ^js wasm/gl-context (.-TEXTURE_2D ^js wasm/gl-context) texture)
(.texImage2D
^js wasm/gl-context
(.-TEXTURE_2D ^js wasm/gl-context)
0
(.-RGBA ^js wasm/gl-context)
800
600
0
(.-RGBA ^js wasm/gl-context)
(.-UNSIGNED_BYTE ^js wasm/gl-context)
nil)
;;(.log js/console )
(let [texture-id (wasm.api/get-texture-id-for-gl-object texture)
objects (dsh/lookup-page-objects @st/state)
shape-id (->> (get-selected @st/state) first)]
(wasm.api/render-to-texture shape-id texture-id))
)
(let [objects (dsh/lookup-page-objects @st/state)
shape-id (->> (get-selected @st/state) first)
bytes (wasm.api/render-shape-pixels shape-id)
_ (.log js/console (clj->js bytes))
blob (js/Blob. #js [bytes] #js {:type "image/png"})
url (.createObjectURL js/URL blob)
a (.createElement js/document "a")]
(set! (.-href a) url)
(set! (.-download a) "export.png")
(.click a)
(.revokeObjectURL js/URL url)
(.log js/console bytes)
))

View File

@@ -242,7 +242,6 @@ export class SelectionController extends EventTarget {
continue;
}
let styleValue = element.style.getPropertyValue(styleName);
if (styleName === "font-family") {
styleValue = sanitizeFontFamily(styleValue);
}
@@ -277,22 +276,29 @@ export class SelectionController extends EventTarget {
this.#applyDefaultStylesToCurrentStyle();
const root = startNode.parentElement.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(root);
// FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing"
// the cascade.
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const paragraph = textNode.parentElement.parentElement;
if (startNode === endNode) {
const paragraph = startNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
const textSpan = startNode.parentElement;
this.#applyStylesFromElementToCurrentStyle(textSpan);
} else {
// FIXME: I don't like this approximation. Having to iterate nodes twice
// is bad for performance. I think we need another way of "computing"
// the cascade.
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const paragraph = textNode.parentElement.parentElement;
this.#applyStylesFromElementToCurrentStyle(paragraph);
}
for (const textNode of this.#textNodeIterator.iterateFrom(
startNode,
endNode,
)) {
const textSpan = textNode.parentElement;
this.#mergeStylesFromElementToCurrentStyle(textSpan);
}
}
return this;
}

View File

@@ -2521,6 +2521,9 @@ msgstr "Release notes"
msgid "labels.reload-file"
msgstr "Reload file"
msgid "labels.reload-page"
msgstr "Reload page"
#: src/app/main/ui/workspace/libraries.cljs, src/app/main/ui/dashboard/team.cljs
#, unused
msgid "labels.remove"
@@ -8475,6 +8478,12 @@ msgstr "Recent"
msgid "labels.deleted"
msgstr "Deleted"
msgid "labels.webgl-context-lost.main-message"
msgstr "Oops! The canvas context was lost"
msgid "labels.webgl-context-lost.desc-message"
msgstr "WebGL has stopped working. Please reload the page to reset it"
msgid "dashboard.restore-all-deleted-button"
msgstr "Restore All"

View File

@@ -2502,6 +2502,9 @@ msgstr "Notas de versión"
msgid "labels.reload-file"
msgstr "Recargar archivo"
msgid "labels.reload-page"
msgstr "Recargar página"
#: src/app/main/ui/workspace/libraries.cljs, src/app/main/ui/dashboard/team.cljs
#, unused
msgid "labels.remove"
@@ -8328,6 +8331,12 @@ msgstr "Recientes"
msgid "labels.deleted"
msgstr "Eliminados"
msgid "labels.webgl-context-lost.main-message"
msgstr "Ups! Se ha perdido el contexto del canvas"
msgid "labels.webgl-context-lost.desc-message"
msgstr "WebGL ha dejado de funcionar. Por favor, recarga la página para restaurarlo"
msgid "dashboard.restore-all-deleted-button"
msgstr "Restaurar todo"

View File

@@ -738,6 +738,28 @@ pub extern "C" fn end_temp_objects() {
}
}
#[no_mangle]
pub extern "C" fn render_shape_pixels(a: u32, b: u32, c: u32, d: u32) -> *mut u8 {
let id = uuid_from_u32_quartet(a, b, c, d);
with_state_mut!(state, {
let (data, width, height) = state.render_shape_pixels(&id, performance::get_time())
.expect("Cannot render into texture");
let len = data.len() as u32;
println!(">{len} {width} {height}");
let mut buf = Vec::with_capacity(4 + data.len());
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&width.to_le_bytes());
buf.extend_from_slice(&height.to_le_bytes());
buf.extend_from_slice(&data);
println!("{len:?}");
mem::write_bytes(buf)
})
}
fn main() {
#[cfg(target_arch = "wasm32")]
init_gl!();

View File

@@ -10,7 +10,6 @@ mod shadows;
mod strokes;
mod surfaces;
pub mod text;
mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect};
@@ -18,6 +17,7 @@ use std::borrow::Cow;
use std::collections::HashSet;
use gpu_state::GpuState;
use options::RenderOptions;
pub use surfaces::{SurfaceId, Surfaces};
@@ -41,6 +41,7 @@ const NODE_BATCH_THRESHOLD: i32 = 3;
type ClipStack = Vec<(Rect, Option<Corners>, Matrix)>;
#[derive(Debug)]
pub struct NodeRenderState {
pub id: Uuid,
// We use this bool to keep that we've traversed all the children inside this node.
@@ -53,6 +54,25 @@ pub struct NodeRenderState {
mask: bool,
}
/// Get simplified children of a container, flattening nested flattened containers
fn get_simplified_children<'a>(tree: ShapesPoolRef<'a>, shape: &'a Shape) -> Vec<Uuid> {
let mut result = Vec::new();
for child_id in shape.children_ids_iter(false) {
if let Some(child) = tree.get(child_id) {
if child.can_flatten() {
// Child is flattened: recursively get its simplified children
result.extend(get_simplified_children(tree, child));
} else {
// Child is not flattened: add it directly
result.push(*child_id);
}
}
}
result
}
impl NodeRenderState {
pub fn is_root(&self) -> bool {
self.id.is_nil()
@@ -276,6 +296,10 @@ pub(crate) struct RenderState {
/// where we must render shapes without inheriting ancestor layer blurs. Toggle it through
/// `with_nested_blurs_suppressed` to ensure it's always restored.
pub ignore_nested_blurs: bool,
/// Cached root_ids and root_ids_map to avoid recalculating them every frame
/// These are invalidated when the tree structure changes
cached_root_ids: Option<Vec<Uuid>>,
cached_root_ids_map: Option<std::collections::HashMap<Uuid, usize>>,
}
pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize {
@@ -348,6 +372,8 @@ impl RenderState {
focus_mode: FocusMode::new(),
touched_ids: HashSet::default(),
ignore_nested_blurs: false,
cached_root_ids: None,
cached_root_ids_map: None,
}
}
@@ -398,12 +424,7 @@ impl RenderState {
}
fn frame_clip_layer_blur(shape: &Shape) -> Option<Blur> {
match shape.shape_type {
Type::Frame(_) if shape.clip() => shape.blur.filter(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.
}),
_ => None,
}
shape.frame_clip_layer_blur()
}
/// Runs `f` with `ignore_nested_blurs` temporarily forced to `true`.
@@ -516,43 +537,64 @@ impl RenderState {
);
}
pub fn apply_drawing_to_render_canvas(&mut self, shape: Option<&Shape>) {
pub fn apply_drawing_to_render_canvas(&mut self, shape: Option<&Shape>, target: SurfaceId) {
performance::begin_measure!("apply_drawing_to_render_canvas");
let paint = skia::Paint::default();
self.surfaces
.draw_into(SurfaceId::TextDropShadows, SurfaceId::Current, Some(&paint));
// Only draw surfaces that have content (dirty flag optimization)
if self.surfaces.is_dirty(SurfaceId::TextDropShadows) {
self.surfaces
.draw_into(SurfaceId::TextDropShadows, target, Some(&paint));
}
self.surfaces
.draw_into(SurfaceId::Fills, SurfaceId::Current, Some(&paint));
if self.surfaces.is_dirty(SurfaceId::Fills) {
self.surfaces
.draw_into(SurfaceId::Fills, target, Some(&paint));
}
let mut render_overlay_below_strokes = false;
if let Some(shape) = shape {
render_overlay_below_strokes = shape.has_fills();
}
if render_overlay_below_strokes {
if render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) {
self.surfaces
.draw_into(SurfaceId::InnerShadows, SurfaceId::Current, Some(&paint));
.draw_into(SurfaceId::InnerShadows, target, Some(&paint));
}
self.surfaces
.draw_into(SurfaceId::Strokes, SurfaceId::Current, Some(&paint));
if !render_overlay_below_strokes {
if self.surfaces.is_dirty(SurfaceId::Strokes) {
self.surfaces
.draw_into(SurfaceId::InnerShadows, SurfaceId::Current, Some(&paint));
.draw_into(SurfaceId::Strokes, target, Some(&paint));
}
let surface_ids = SurfaceId::Strokes as u32
| SurfaceId::Fills as u32
| SurfaceId::InnerShadows as u32
| SurfaceId::TextDropShadows as u32;
if !render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) {
self.surfaces
.draw_into(SurfaceId::InnerShadows, target, Some(&paint));
}
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().clear(skia::Color::TRANSPARENT);
});
// Build mask of dirty surfaces that need clearing
let mut dirty_surfaces_to_clear = 0u32;
if self.surfaces.is_dirty(SurfaceId::Strokes) {
dirty_surfaces_to_clear |= SurfaceId::Strokes as u32;
}
if self.surfaces.is_dirty(SurfaceId::Fills) {
dirty_surfaces_to_clear |= SurfaceId::Fills as u32;
}
if self.surfaces.is_dirty(SurfaceId::InnerShadows) {
dirty_surfaces_to_clear |= SurfaceId::InnerShadows as u32;
}
if self.surfaces.is_dirty(SurfaceId::TextDropShadows) {
dirty_surfaces_to_clear |= SurfaceId::TextDropShadows as u32;
}
if dirty_surfaces_to_clear != 0 {
self.surfaces.apply_mut(dirty_surfaces_to_clear, |s| {
s.canvas().clear(skia::Color::TRANSPARENT);
});
// Clear dirty flags for surfaces we just cleared
self.surfaces.clear_dirty(dirty_surfaces_to_clear);
}
}
pub fn clear_focus_mode(&mut self) {
@@ -601,13 +643,22 @@ impl RenderState {
offset: Option<(f32, f32)>,
parent_shadows: Option<Vec<skia_safe::Paint>>,
) {
println!(" >Shape:{:?}", shape.id);
let surface_ids = fills_surface_id as u32
| strokes_surface_id as u32
| innershadows_surface_id as u32
| text_drop_shadows_surface_id as u32;
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().save();
});
// Only save canvas state if we have clipping or transforms
// For simple shapes without clipping, skip expensive save/restore
let needs_save =
clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity();
if needs_save {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().save();
});
}
let antialias = shape.should_use_antialias(self.get_scale());
@@ -683,16 +734,18 @@ impl RenderState {
matrix.pre_concat(&svg_transform);
}
self.surfaces.canvas(fills_surface_id).concat(&matrix);
self.surfaces
.canvas_and_mark_dirty(fills_surface_id)
.concat(&matrix);
if let Some(svg) = shape.svg.as_ref() {
svg.render(self.surfaces.canvas(fills_surface_id))
svg.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
} else {
let font_manager = skia::FontMgr::from(self.fonts().font_provider().clone());
let dom_result = skia::svg::Dom::from_str(&sr.content, font_manager);
match dom_result {
Ok(dom) => {
dom.render(self.surfaces.canvas(fills_surface_id));
dom.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id));
shape.to_mut().set_svg(dom);
}
Err(e) => {
@@ -906,11 +959,15 @@ impl RenderState {
}
if apply_to_current_surface {
self.apply_drawing_to_render_canvas(Some(&shape));
self.apply_drawing_to_render_canvas(Some(&shape), SurfaceId::Current);
}
// Only restore if we saved (optimization for simple shapes)
if needs_save {
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().restore();
});
}
self.surfaces.apply_mut(surface_ids, |s| {
s.canvas().restore();
});
}
pub fn update_render_context(&mut self, tile: tiles::Tile) {
@@ -1031,7 +1088,8 @@ impl RenderState {
// reorder by distance to the center.
self.current_tile = None;
self.render_in_progress = true;
self.apply_drawing_to_render_canvas(None);
self.apply_drawing_to_render_canvas(None, SurfaceId::Current);
if sync_render {
self.render_shape_tree_sync(base_object, tree, timestamp)?;
@@ -1086,6 +1144,48 @@ impl RenderState {
Ok(())
}
pub fn render_shape_pixels(
&mut self,
id: &Uuid,
tree: ShapesPoolRef,
timestamp: i32,
) -> Result<(Vec<u8>, i32, i32), String> {
// let width = 800;
// let height = 600;
if tree.len() != 0 {
// self.render_shape_tree_partial(Some(id), tree, timestamp, false)?;
let shape = tree.get(id).unwrap();
let scale = 1.0;
self.surfaces.update_render_context(shape.extrect(tree, scale), scale);
self.pending_nodes.push(NodeRenderState {
id: *id,
visited_children: false,
clip_bounds: None,
visited_mask: false,
mask: false,
});
self.render_shape_tree_partial_uncached(tree, timestamp, false, true)?;
} else {
println!("Empty tree");
}
self.surfaces.flush_and_submit(&mut self.gpu_state, SurfaceId::Export);
let image = self.surfaces.snapshot(SurfaceId::Export);
let data = image.encode(
&mut self.gpu_state.context,
skia::EncodedImageFormat::PNG,
100
).expect("PNG encode failed");
let skia::ISize { width, height } = image.dimensions();
Ok((data.as_bytes().to_vec(), width, height))
}
#[inline]
pub fn should_stop_rendering(&self, iteration: i32, timestamp: i32) -> bool {
iteration % NODE_BATCH_THRESHOLD == 0
@@ -1117,18 +1217,6 @@ impl RenderState {
self.nested_fills.push(Vec::new());
}
let mut paint = skia::Paint::default();
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
}
}
// When we're rendering the mask shape we need to set a special blend mode
// called 'destination-in' that keeps the drawn content within the mask.
// @see https://skia.org/docs/user/api/skblendmode_overview/
@@ -1141,16 +1229,40 @@ impl RenderState {
.save_layer(&mask_rec);
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::Current)
.save_layer(&layer_rec);
// Only create save_layer if actually needed
// For simple shapes with default opacity and blend mode, skip expensive save_layer
// Groups with masks need a layer to properly handle the mask rendering
let needs_layer = element.needs_layer();
if needs_layer {
let mut paint = skia::Paint::default();
paint.set_blend_mode(element.blend_mode().into());
paint.set_alpha_f(element.opacity());
if let Some(frame_blur) = Self::frame_clip_layer_blur(element) {
let scale = self.get_scale();
let sigma = frame_blur.value * scale;
if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) {
paint.set_image_filter(filter);
}
}
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
self.surfaces
.canvas(SurfaceId::Current)
.save_layer(&layer_rec);
}
self.focus_mode.enter(&element.id);
}
#[inline]
pub fn render_shape_exit(&mut self, element: &Shape, visited_mask: bool) {
pub fn render_shape_exit(
&mut self,
element: &Shape,
visited_mask: bool,
clip_bounds: Option<ClipStack>,
) {
if visited_mask {
// Because masked groups needs two rendering passes (first drawing
// the content and then drawing the mask), we need to do an
@@ -1204,9 +1316,10 @@ impl RenderState {
element_strokes.to_mut().clear_fills();
element_strokes.to_mut().clear_shadows();
element_strokes.to_mut().clip_content = false;
println!(">in clip render");
self.render_shape(
&element_strokes,
None,
clip_bounds,
SurfaceId::Fills,
SurfaceId::Strokes,
SurfaceId::InnerShadows,
@@ -1217,7 +1330,14 @@ impl RenderState {
);
}
self.surfaces.canvas(SurfaceId::Current).restore();
// Only restore if we created a layer (optimization for simple shapes)
// Groups with masks need restore to properly handle the mask rendering
let needs_layer = element.needs_layer();
if needs_layer {
self.surfaces.canvas(SurfaceId::Current).restore();
}
self.focus_mode.exit(&element.id);
}
@@ -1366,6 +1486,7 @@ impl RenderState {
}
state.with_nested_blurs_suppressed(|state| {
println!(">render drop black shadow");
state.render_shape(
&plain_shape,
clip_bounds,
@@ -1433,11 +1554,18 @@ impl RenderState {
tree: ShapesPoolRef,
timestamp: i32,
allow_stop: bool,
export: bool,
) -> Result<(bool, bool), String> {
let mut iteration = 0;
let mut is_empty = true;
let mut target_surface = SurfaceId::Current;
if export {
target_surface = SurfaceId::Export;
}
while let Some(node_render_state) = self.pending_nodes.pop() {
println!("Node: {node_render_state:?}");
let node_id = node_render_state.id;
let visited_children = node_render_state.visited_children;
let visited_mask = node_render_state.visited_mask;
@@ -1457,18 +1585,48 @@ impl RenderState {
}
if visited_children {
self.render_shape_exit(element, visited_mask);
// Skip render_shape_exit for flattened containers
if !element.can_flatten() {
self.render_shape_exit(element, visited_mask, clip_bounds);
}
continue;
}
if !node_render_state.is_root() {
let transformed_element: Cow<Shape> = Cow::Borrowed(element);
let scale = self.get_scale();
let extrect = transformed_element.extrect(tree, scale);
let is_visible = extrect.intersects(self.render_area)
&& !transformed_element.hidden
&& !transformed_element.visually_insignificant(scale, tree);
// Aggressive early exit: check hidden first (fastest check)
if transformed_element.hidden {
continue;
}
// For frames and groups, we must use extrect because they can have nested content
// that extends beyond their selrect. Using selrect for early exit would incorrectly
// skip frames/groups that have nested content in the current tile.
let is_container = matches!(
transformed_element.shape_type,
crate::shapes::Type::Frame(_) | crate::shapes::Type::Group(_)
);
let scale = self.get_scale();
let has_effects = transformed_element.has_effects_that_extend_bounds();
let is_visible = export || if is_container {
// Containers (frames/groups) must always use extrect to include nested content
let extrect = transformed_element.extrect(tree, scale);
extrect.intersects(self.render_area)
&& !transformed_element.visually_insignificant(scale, tree)
} else if !has_effects {
// Simple shape: selrect check is sufficient, skip expensive extrect
let selrect = transformed_element.selrect();
selrect.intersects(self.render_area)
&& !transformed_element.visually_insignificant(scale, tree)
} else {
// Shape with effects: need extrect for accurate bounds
let extrect = transformed_element.extrect(tree, scale);
extrect.intersects(self.render_area)
&& !transformed_element.visually_insignificant(scale, tree)
};
if self.options.is_debug_visible() {
let shape_extrect_bounds =
@@ -1477,11 +1635,17 @@ impl RenderState {
}
if !is_visible {
println!(">NOT VISIBLE");
continue;
}
}
self.render_shape_enter(element, mask);
// Skip render_shape_enter/exit for flattened containers
// If a container was flattened, it doesn't affect children visually, so we skip
// the expensive enter/exit operations and process children directly
if !element.can_flatten() {
self.render_shape_enter(element, mask);
}
if !node_render_state.is_root() && self.focus_mode.is_active() {
let scale: f32 = self.get_scale();
@@ -1515,6 +1679,8 @@ impl RenderState {
_ => None,
};
let element_extrect = element.extrect(tree, scale);
for shadow in element.drop_shadows_visible() {
let paint = skia::Paint::default();
let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint);
@@ -1526,7 +1692,7 @@ impl RenderState {
// First pass: Render shadow in black to establish alpha mask
self.render_drop_black_shadow(
element,
&element.extrect(tree, scale),
&element_extrect,
shadow,
clip_bounds.clone(),
scale,
@@ -1546,9 +1712,10 @@ impl RenderState {
.get_nested_shadow_clip_bounds(element, shadow);
if !matches!(shadow_shape.shape_type, Type::Text(_)) {
let shadow_extrect = shadow_shape.extrect(tree, scale);
self.render_drop_black_shadow(
shadow_shape,
&shadow_shape.extrect(tree, scale),
&shadow_extrect,
shadow,
clip_bounds,
scale,
@@ -1585,6 +1752,7 @@ impl RenderState {
new_shadow_paint.set_blend_mode(skia::BlendMode::SrcOver);
self.with_nested_blurs_suppressed(|state| {
println!(">render_shape in if focus");
state.render_shape(
shadow_shape,
clip_bounds,
@@ -1619,7 +1787,7 @@ impl RenderState {
if let Some(clips) = clip_bounds.as_ref() {
let antialias = element.should_use_antialias(scale);
self.surfaces.canvas(SurfaceId::Current).save();
self.surfaces.canvas(target_surface).save();
for (bounds, corners, transform) in clips.iter() {
let mut total_matrix = Matrix::new_identity();
total_matrix.pre_scale((scale, scale), None);
@@ -1627,18 +1795,18 @@ impl RenderState {
total_matrix.pre_concat(transform);
self.surfaces
.canvas(SurfaceId::Current)
.canvas(target_surface)
.concat(&total_matrix);
if let Some(corners) = corners {
let rrect = RRect::new_rect_radii(*bounds, corners);
self.surfaces.canvas(SurfaceId::Current).clip_rrect(
self.surfaces.canvas(target_surface).clip_rrect(
rrect,
skia::ClipOp::Intersect,
antialias,
);
} else {
self.surfaces.canvas(SurfaceId::Current).clip_rect(
self.surfaces.canvas(target_surface).clip_rect(
*bounds,
skia::ClipOp::Intersect,
antialias,
@@ -1646,23 +1814,24 @@ impl RenderState {
}
self.surfaces
.canvas(SurfaceId::Current)
.canvas(target_surface)
.concat(&total_matrix.invert().unwrap_or_default());
}
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
.draw_into(SurfaceId::DropShadows, target_surface, None);
self.surfaces.canvas(SurfaceId::Current).restore();
self.surfaces.canvas(target_surface).restore();
} else {
self.surfaces
.draw_into(SurfaceId::DropShadows, SurfaceId::Current, None);
.draw_into(SurfaceId::DropShadows, target_surface, None);
}
self.surfaces
.canvas(SurfaceId::DropShadows)
.clear(skia::Color::TRANSPARENT);
println!(">>>> render_shape segundo focus");
self.render_shape(
element,
clip_bounds.clone(),
@@ -1679,17 +1848,22 @@ impl RenderState {
.canvas(SurfaceId::DropShadows)
.clear(skia::Color::TRANSPARENT);
} else if visited_children {
self.apply_drawing_to_render_canvas(Some(element));
println!(">>>>apply_drawing_to_render_canvas");
self.apply_drawing_to_render_canvas(Some(element), target_surface);
}
match element.shape_type {
Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => {
self.nested_blurs.push(None);
// Skip nested state updates for flattened containers
// Flattened containers don't affect children, so we don't need to track their state
if !element.can_flatten() {
match element.shape_type {
Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => {
self.nested_blurs.push(None);
}
Type::Frame(_) | Type::Group(_) => {
self.nested_blurs.push(element.blur);
}
_ => {}
}
Type::Frame(_) | Type::Group(_) => {
self.nested_blurs.push(element.blur);
}
_ => {}
}
// Set the node as visited_children before processing children
@@ -1702,26 +1876,38 @@ impl RenderState {
});
if element.is_recursive() {
println!("!adding children");
let children_clip_bounds =
node_render_state.get_children_clip_bounds(element, None);
let mut children_ids: Vec<_> = element.children_ids_iter(false).collect();
let children_ids: Vec<_> = if element.can_flatten() {
// Container was flattened: get simplified children (which skip this level)
get_simplified_children(tree, element)
} else {
// Container not flattened: use original children
element.children_ids_iter(false).copied().collect()
};
// Z-index ordering on Layouts
if element.has_layout() {
let children_ids = if element.has_layout() {
let mut ids = children_ids;
if element.is_flex() && !element.is_flex_reverse() {
children_ids.reverse();
ids.reverse();
}
children_ids.sort_by(|id1, id2| {
ids.sort_by(|id1, id2| {
let z1 = tree.get(id1).map(|s| s.z_index()).unwrap_or(0);
let z2 = tree.get(id2).map(|s| s.z_index()).unwrap_or(0);
z2.cmp(&z1)
});
}
ids
} else {
children_ids
};
for child_id in children_ids.iter() {
self.pending_nodes.push(NodeRenderState {
id: **child_id,
id: *child_id,
visited_children: false,
clip_bounds: children_clip_bounds.clone(),
visited_mask: false,
@@ -1732,6 +1918,7 @@ impl RenderState {
// We try to avoid doing too many calls to get_time
if allow_stop && self.should_stop_rendering(iteration, timestamp) {
println!("STOP");
return Ok((is_empty, true));
}
iteration += 1;
@@ -1771,7 +1958,7 @@ impl RenderState {
} else {
performance::begin_measure!("render_shape_tree::uncached");
let (is_empty, early_return) =
self.render_shape_tree_partial_uncached(tree, timestamp, allow_stop)?;
self.render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?;
if early_return {
return Ok(());
@@ -1803,14 +1990,39 @@ impl RenderState {
.canvas(SurfaceId::Current)
.clear(self.background_color);
let root_ids = {
if let Some(shape_id) = base_object {
// Get or compute root_ids and root_ids_map (cached to avoid recalculation every frame)
let root_ids_map = {
let root_ids = if let Some(shape_id) = base_object {
vec![*shape_id]
} else {
let Some(root) = tree.get(&Uuid::nil()) else {
return Err(String::from("Root shape not found"));
};
root.children_ids(false)
};
// Check if cache is valid (same root_ids)
let cache_valid = self
.cached_root_ids
.as_ref()
.map(|cached| cached.as_slice() == root_ids.as_slice())
.unwrap_or(false);
if cache_valid {
// Use cached map
self.cached_root_ids_map.as_ref().unwrap().clone()
} else {
// Recompute and cache
let root_ids_map: std::collections::HashMap<Uuid, usize> = root_ids
.iter()
.enumerate()
.map(|(i, id)| (*id, i))
.collect();
self.cached_root_ids = Some(root_ids.clone());
self.cached_root_ids_map = Some(root_ids_map.clone());
root_ids_map
}
};
@@ -1821,12 +2033,6 @@ impl RenderState {
if !self.surfaces.has_cached_tile_surface(next_tile) {
if let Some(ids) = self.tiles.get_shapes_at(next_tile) {
let root_ids_map: std::collections::HashMap<Uuid, usize> = root_ids
.iter()
.enumerate()
.map(|(i, id)| (*id, i))
.collect();
// We only need first level shapes
let mut valid_ids: Vec<Uuid> = ids
.iter()

View File

@@ -18,7 +18,7 @@ fn draw_image_fill(
}
let size = image.unwrap().dimensions();
let canvas = render_state.surfaces.canvas(surface_id);
let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id);
let container = &shape.selrect;
let path_transform = shape.to_path_transform();

View File

@@ -41,7 +41,7 @@ where
F: FnOnce(&mut RenderState, SurfaceId),
{
if let Some((image, scale)) = render_into_filter_surface(render_state, bounds, draw_fn) {
let canvas = render_state.surfaces.canvas(target_surface);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
// If we scaled down, we need to scale the source rect and adjust the destination
if scale < 1.0 {

View File

@@ -104,4 +104,38 @@ impl GpuState {
)
.unwrap()
}
#[allow(dead_code)]
pub fn create_surface_from_texture(
&mut self,
width: i32,
height: i32,
texture_id: u32
) -> skia::Surface {
let texture_info = TextureInfo {
target: gl::TEXTURE_2D,
id: texture_id,
format: gl::RGBA8,
protected: skia::gpu::Protected::No,
};
let backend_texture = unsafe{
gpu::backend_textures::make_gl(
(width, height),
gpu::Mipmapped::No,
texture_info,
String::from("export_texture"))
};
gpu::surfaces::wrap_backend_texture(
&mut self.context,
&backend_texture,
gpu::SurfaceOrigin::BottomLeft,
None,
skia::ColorType::RGBA8888,
None,
None,
).unwrap()
}
}

View File

@@ -135,7 +135,7 @@ pub fn render_text_shadows(
let canvas = render_state
.surfaces
.canvas(surface_id.unwrap_or(SurfaceId::TextDropShadows));
.canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::TextDropShadows));
for shadow in shadows {
let shadow_layer = SaveLayerRec::default().paint(shadow);

View File

@@ -387,7 +387,7 @@ fn draw_image_stroke_in_container(
}
let size = image.unwrap().dimensions();
let canvas = render_state.surfaces.canvas(surface_id);
let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id);
let container = &shape.selrect;
let path_transform = shape.to_path_transform();
let svg_attrs = shape.svg_attrs.as_ref();
@@ -606,7 +606,7 @@ fn render_internal(
let scale = render_state.get_scale();
let target_surface = surface_id.unwrap_or(SurfaceId::Strokes);
let canvas = render_state.surfaces.canvas(target_surface);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
let selrect = shape.selrect;
let path_transform = shape.to_path_transform();
let svg_attrs = shape.svg_attrs.as_ref();
@@ -688,7 +688,7 @@ pub fn render_text_paths(
let scale = render_state.get_scale();
let canvas = render_state
.surfaces
.canvas(surface_id.unwrap_or(SurfaceId::Strokes));
.canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::Strokes));
let selrect = &shape.selrect;
let svg_attrs = shape.svg_attrs.as_ref();
let mut paint: skia_safe::Handle<_> =

View File

@@ -17,17 +17,18 @@ const TILE_SIZE_MULTIPLIER: i32 = 2;
#[repr(u32)]
#[derive(Debug, PartialEq, Clone, Copy)]
pub enum SurfaceId {
Target = 0b00_0000_0001,
Filter = 0b00_0000_0010,
Cache = 0b00_0000_0100,
Current = 0b00_0000_1000,
Fills = 0b00_0001_0000,
Strokes = 0b00_0010_0000,
DropShadows = 0b00_0100_0000,
InnerShadows = 0b00_1000_0000,
TextDropShadows = 0b01_0000_0000,
UI = 0b10_0000_0000,
Debug = 0b10_0000_0001,
Target = 0b000_0000_0001,
Filter = 0b000_0000_0010,
Cache = 0b000_0000_0100,
Current = 0b000_0000_1000,
Fills = 0b000_0001_0000,
Strokes = 0b000_0010_0000,
DropShadows = 0b000_0100_0000,
InnerShadows = 0b000_1000_0000,
TextDropShadows = 0b001_0000_0000,
UI = 0b010_0000_0000,
Debug = 0b010_0000_0001,
Export = 0b100_0000_0001,
}
pub struct Surfaces {
@@ -52,9 +53,13 @@ pub struct Surfaces {
// for drawing debug info.
debug: skia::Surface,
// for drawing tiles.
export: skia::Surface,
tiles: TileTextureCache,
sampling_options: skia::SamplingOptions,
margins: skia::ISize,
// Tracks which surfaces have content (dirty flag bitmask)
dirty_surfaces: u32,
}
#[allow(dead_code)]
@@ -88,6 +93,7 @@ impl Surfaces {
let ui = gpu_state.create_surface_with_dimensions("ui".to_string(), width, height);
let debug = gpu_state.create_surface_with_dimensions("debug".to_string(), width, height);
let export = gpu_state.create_target_surface(width, height);
let tiles = TileTextureCache::new();
Surfaces {
@@ -102,9 +108,11 @@ impl Surfaces {
shape_strokes,
ui,
debug,
export,
tiles,
sampling_options,
margins,
dirty_surfaces: 0,
}
}
@@ -147,10 +155,51 @@ impl Surfaces {
None
}
/// Returns a mutable reference to the canvas and automatically marks
/// render surfaces as dirty when accessed. This tracks which surfaces
/// have content for optimization purposes.
pub fn canvas_and_mark_dirty(&mut self, id: SurfaceId) -> &skia::Canvas {
// Automatically mark render surfaces as dirty when accessed
// This tracks which surfaces have content for optimization
match id {
SurfaceId::Fills
| SurfaceId::Strokes
| SurfaceId::InnerShadows
| SurfaceId::TextDropShadows => {
self.mark_dirty(id);
}
_ => {}
}
self.canvas(id)
}
/// Returns a mutable reference to the canvas without any side effects.
/// Use this when you only need to read or manipulate the canvas state
/// without marking the surface as dirty.
pub fn canvas(&mut self, id: SurfaceId) -> &skia::Canvas {
self.get_mut(id).canvas()
}
/// Marks a surface as having content (dirty)
pub fn mark_dirty(&mut self, id: SurfaceId) {
self.dirty_surfaces |= id as u32;
}
/// Checks if a surface has content
pub fn is_dirty(&self, id: SurfaceId) -> bool {
(self.dirty_surfaces & id as u32) != 0
}
/// Clears the dirty flag for a surface or set of surfaces
pub fn clear_dirty(&mut self, ids: u32) {
self.dirty_surfaces &= !ids;
}
/// Clears all dirty flags
pub fn clear_all_dirty(&mut self) {
self.dirty_surfaces = 0;
}
pub fn flush_and_submit(&mut self, gpu_state: &mut GpuState, id: SurfaceId) {
let surface = self.get_mut(id);
gpu_state.context.flush_and_submit_surface(surface, None);
@@ -159,9 +208,12 @@ impl Surfaces {
pub fn draw_into(&mut self, from: SurfaceId, to: SurfaceId, paint: Option<&skia::Paint>) {
let sampling_options = self.sampling_options;
self.get_mut(from)
.clone()
.draw(self.canvas(to), (0.0, 0.0), sampling_options, paint);
self.get_mut(from).clone().draw(
self.canvas_and_mark_dirty(to),
(0.0, 0.0),
sampling_options,
paint,
);
}
pub fn apply_mut(&mut self, ids: u32, mut f: impl FnMut(&mut skia::Surface)) {
@@ -196,6 +248,9 @@ impl Surfaces {
if ids & SurfaceId::Debug as u32 != 0 {
f(self.get_mut(SurfaceId::Debug));
}
if ids & SurfaceId::Export as u32 != 0 {
f(self.get_mut(SurfaceId::Export));
}
performance::begin_measure!("apply_mut::flags");
}
@@ -212,22 +267,40 @@ impl Surfaces {
pub fn update_render_context(&mut self, render_area: skia::Rect, scale: f32) {
let translation = self.get_render_context_translation(render_area, scale);
self.apply_mut(
SurfaceId::Fills as u32
| SurfaceId::Strokes as u32
| SurfaceId::InnerShadows as u32
| SurfaceId::TextDropShadows as u32,
|s| {
let canvas = s.canvas();
canvas.reset_matrix();
canvas.scale((scale, scale));
canvas.translate(translation);
},
);
// When context changes (zoom/pan/tile), clear all render surfaces first
// to remove any residual content from previous tiles, then mark as dirty
// so they get redrawn with new transformations
let surface_ids = SurfaceId::Fills as u32
| SurfaceId::Strokes as u32
| SurfaceId::InnerShadows as u32
| SurfaceId::TextDropShadows as u32
// | SurfaceId::Export as u32
;
// Clear surfaces before updating transformations to remove residual content
self.apply_mut(surface_ids, |s| {
s.canvas().clear(skia::Color::TRANSPARENT);
});
// Mark all render surfaces as dirty so they get redrawn
self.mark_dirty(SurfaceId::Fills);
self.mark_dirty(SurfaceId::Strokes);
self.mark_dirty(SurfaceId::InnerShadows);
self.mark_dirty(SurfaceId::TextDropShadows);
self.mark_dirty(SurfaceId::Export);
// Update transformations
self.apply_mut(surface_ids, |s| {
let canvas = s.canvas();
canvas.reset_matrix();
canvas.scale((scale, scale));
canvas.translate(translation);
});
}
#[inline]
fn get_mut(&mut self, id: SurfaceId) -> &mut skia::Surface {
pub fn get_mut(&mut self, id: SurfaceId) -> &mut skia::Surface {
match id {
SurfaceId::Target => &mut self.target,
SurfaceId::Filter => &mut self.filter,
@@ -240,6 +313,7 @@ impl Surfaces {
SurfaceId::Strokes => &mut self.shape_strokes,
SurfaceId::Debug => &mut self.debug,
SurfaceId::UI => &mut self.ui,
SurfaceId::Export => &mut self.export
}
}
@@ -264,19 +338,21 @@ impl Surfaces {
pub fn draw_rect_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) {
if let Some(corners) = shape.shape_type.corners() {
let rrect = RRect::new_rect_radii(shape.selrect, &corners);
self.canvas(id).draw_rrect(rrect, paint);
self.canvas_and_mark_dirty(id).draw_rrect(rrect, paint);
} else {
self.canvas(id).draw_rect(shape.selrect, paint);
self.canvas_and_mark_dirty(id)
.draw_rect(shape.selrect, paint);
}
}
pub fn draw_circle_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) {
self.canvas(id).draw_oval(shape.selrect, paint);
self.canvas_and_mark_dirty(id)
.draw_oval(shape.selrect, paint);
}
pub fn draw_path_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) {
if let Some(path) = shape.get_skia_path() {
self.canvas(id).draw_path(&path, paint);
self.canvas_and_mark_dirty(id).draw_path(&path, paint);
}
}
@@ -304,6 +380,9 @@ impl Surfaces {
self.canvas(SurfaceId::UI)
.clear(skia::Color::TRANSPARENT)
.reset_matrix();
// Clear all dirty flags after reset
self.clear_all_dirty();
}
pub fn cache_current_tile_texture(

View File

@@ -192,7 +192,7 @@ pub fn render(
}
}
let canvas = render_state.surfaces.canvas(target_surface);
let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface);
render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur);
return;
}
@@ -371,7 +371,7 @@ pub fn render_as_path(
) {
let canvas = render_state
.surfaces
.canvas(surface_id.unwrap_or(SurfaceId::Fills));
.canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::Fills));
for (path, paint) in paths {
// Note: path can be empty
@@ -397,7 +397,7 @@ pub fn render_position_data(
let rect = Rect::from_xywh(pd.x, pd.y, pd.width, pd.height);
render_state
.surfaces
.canvas(surface_id)
.canvas_and_mark_dirty(surface_id)
.draw_rect(rect, &paint);
}
}

View File

@@ -920,8 +920,13 @@ impl Shape {
}
Type::Group(_) | Type::Frame(_) if !self.clip_content => {
// For frames and groups, we must always calculate extrect for all children
// to ensure accurate bounds that include nested content across all tiles.
// Using selrect for children can cause frames to be incorrectly omitted from
// tiles where they have nested content.
for child_id in self.children_ids_iter(false) {
if let Some(child_shape) = shapes_pool.get(child_id) {
// Always calculate full extrect for children to ensure accurate bounds
let child_extrect = child_shape.calculate_extrect(shapes_pool, scale);
rect.join(child_extrect);
}
@@ -1419,6 +1424,97 @@ impl Shape {
!self.fills.is_empty()
}
/// Determines if this frame or group can be flattened (doesn't affect children visually)
/// A container can be flattened if it has no visual effects that affect its children
/// and doesn't render its own content (no fills/strokes)
pub fn can_flatten(&self) -> bool {
// Only frames and groups can be flattened
if !matches!(self.shape_type, Type::Frame(_) | Type::Group(_)) {
return false;
}
// Cannot flatten if it has visual effects that affect children:
if self.clip_content {
return false;
}
if !self.transform.is_identity() {
return false;
}
if self.opacity != 1.0 {
return false;
}
if self.blend_mode() != BlendMode::default() {
return false;
}
if self.blur.is_some() {
return false;
}
if !self.shadows.is_empty() {
return false;
}
if let Type::Group(group) = &self.shape_type {
if group.masked {
return false;
}
}
if self.hidden {
return false;
}
// If the container itself has fills/strokes, it renders something visible
// We cannot flatten containers that render their own background/border
// because they need to be rendered even if they don't affect children
if self.has_fills() || self.has_visible_strokes() {
return false;
}
true
}
/// Checks if this shape needs a layer for rendering due to visual effects
/// (opacity < 1.0, non-default blend mode, or frame clip layer blur)
pub fn needs_layer(&self) -> bool {
self.opacity() < 1.0
|| self.blend_mode().0 != skia::BlendMode::SrcOver
|| self.has_frame_clip_layer_blur()
|| (matches!(self.shape_type, Type::Group(g) if g.masked))
}
/// Checks if this frame has clip layer blur (affects children)
/// A frame has clip layer blur if it clips content and has layer blur
pub fn has_frame_clip_layer_blur(&self) -> bool {
self.frame_clip_layer_blur().is_some()
}
/// Returns the frame clip layer blur if this frame has one
/// A frame has clip layer blur if it clips content and has layer blur
pub fn frame_clip_layer_blur(&self) -> Option<Blur> {
use crate::shapes::BlurType;
match self.shape_type {
Type::Frame(_) if self.clip_content => self.blur.filter(|blur| {
!blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0
}),
_ => None,
}
}
/// Checks if this shape has visual effects that might extend its bounds beyond selrect
/// Shapes with these effects require expensive extrect calculation for accurate visibility checks
pub fn has_effects_that_extend_bounds(&self) -> bool {
!self.shadows.is_empty()
|| self.blur.is_some()
|| !self.strokes.is_empty()
|| matches!(self.shape_type, Type::Group(_) | Type::Frame(_))
}
pub fn count_visible_inner_strokes(&self) -> usize {
self.visible_strokes()
.filter(|s| s.kind == StrokeKind::Inner)

View File

@@ -99,6 +99,11 @@ impl<'a> State<'a> {
Ok(())
}
pub fn render_shape_pixels(&mut self, id: &Uuid, timestamp: i32) -> Result<(Vec<u8>, i32, i32), String> {
self.render_state
.render_shape_pixels(id, &self.shapes, timestamp)
}
pub fn start_render_loop(&mut self, timestamp: i32) -> Result<(), String> {
self.render_state
.start_render_loop(None, &self.shapes, timestamp, false)?;