Implement clipping masks, stroke align, and stroke paint order (#2644)

* refactor: opacity + blend_mode -> blend_style

* Add code for clipping

* Add alt-click masking

* Clip to all colors. Fill option

* Fix undo not working. Fix strokes not being white

* Allow clipped to be grouped or raster

* Switch to alpha mode in mask-type

* add plumbing to know if clipped in frontend and add fill slider

* Attempt at document upgrade code

* Fix fill slider

* Add clipped styling and Alt-click layer border

* Use mask attr judiciously by using clip when possible

* Fix breaking documents and upgrade code

* Fix fixes

* No-op toggle if last child of parent and don't show clip UI if last element

* Fix mouse styles by plumbing clippable to frontend

* Fix Clip detection by disallowed groups as clipPath according to SVG spec doesn't allow <g>

* Add opacity to clippers can_use_clip check

* Fix issue with clipping not working nicely with strokes by using masks

* Add vello code

* cleanup

* Add stroke alignment hacks to SVG renderer

* svg: Fix mask bounds in vector data

* vello: Implement mask hacks to support stroke alignment

* Move around alignment and doc upgrade code

* rename Line X -> X

* An attempt at fixing names not updating

* svg: add stroke order with svg

* vello: add stroke order with by calling one before the other explicitly

* fix merge

* fix svg renderer messing up transform det

* Code review; reorder and rename parameters (TODO: fix tools)

* Fixes to previous

* Formatting

* fix bug 3

* some moving around (not fixed)

* fix issue 1

* fix vello

* Final code review

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
mTvare
2025-06-19 19:11:01 -07:00
committed by Keavon Chambers
co-authored by Keavon Chambers
parent 8a3f133140
commit e238753a35
28 changed files with 1025 additions and 311 deletions
+88 -8
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { getContext, onMount, tick } from "svelte";
import { getContext, onMount, onDestroy, tick } from "svelte";
import type { Editor } from "@graphite/editor";
import { beginDraggingElement } from "@graphite/io-managers/drag";
@@ -55,6 +55,10 @@
let fakeHighlightOfNotYetSelectedLayerBeingDragged: undefined | bigint = undefined;
let dragInPanel = false;
// Interactive clipping
let layerToClipUponClick: LayerListingInfo | undefined = undefined;
let layerToClipAltKeyPressed = false;
// Layouts
let layersPanelControlBarLeftLayout = defaultWidgetLayout();
let layersPanelControlBarRightLayout = defaultWidgetLayout();
@@ -87,6 +91,16 @@
updateLayerInTree(targetId, targetLayer);
});
addEventListener("pointermove", clippingHover);
addEventListener("keydown", clippingKeyPress);
addEventListener("keyup", clippingKeyPress);
});
onDestroy(() => {
removeEventListener("pointermove", clippingHover);
removeEventListener("keydown", clippingKeyPress);
removeEventListener("keyup", clippingKeyPress);
});
type DocumentLayerStructure = {
@@ -208,12 +222,58 @@
// Get the state of the platform's accel key and its opposite platform's accel key
const [accel, oppositeAccel] = platformIsMac() ? [meta, ctrl] : [ctrl, meta];
// Alt-clicking to make a clipping mask
if (layerToClipAltKeyPressed && layerToClipUponClick && layerToClipUponClick.entry.clippable) clipLayer(layerToClipUponClick);
// Select the layer only if the accel and/or shift keys are pressed
if (!oppositeAccel && !alt) selectLayer(listing, accel, shift);
else if (!oppositeAccel && !alt) selectLayer(listing, accel, shift);
e.stopPropagation();
}
function clipLayer(listing: LayerListingInfo) {
editor.handle.clipLayer(listing.entry.id);
}
function clippingKeyPress(e: KeyboardEvent) {
layerToClipAltKeyPressed = e.altKey;
}
function clippingHover(e: PointerEvent) {
// Don't do anything if the user is dragging to rearrange layers
if (dragInPanel) return;
// Get the layer below the cursor
const target = (e.target instanceof HTMLElement && e.target.closest("[data-layer]")) || undefined;
if (!target) {
layerToClipUponClick = undefined;
return;
}
// Check if the cursor is near the border btween two layers
const DISTANCE = 6;
const distanceFromTop = e.clientY - target.getBoundingClientRect().top;
const distanceFromBottom = target.getBoundingClientRect().bottom - e.clientY;
const nearTop = distanceFromTop < DISTANCE;
const nearBottom = distanceFromBottom < DISTANCE;
// If we are not near the border, we don't want to clip
if (!nearTop && !nearBottom) {
layerToClipUponClick = undefined;
return;
}
// If we are near the border, we want to clip the layer above the border
const indexAttribute = target?.getAttribute("data-index") ?? undefined;
const index = indexAttribute ? Number(indexAttribute) : undefined;
const layer = index !== undefined && layers[nearTop ? index - 1 : index];
if (!layer) return;
// Update the state used to show the clipping action
layerToClipUponClick = layer;
layerToClipAltKeyPressed = e.altKey;
}
function selectLayer(listing: LayerListingInfo, accel: boolean, shift: boolean) {
// Don't select while we are entering text to rename the layer
if (listing.editingName) return;
@@ -433,7 +493,16 @@
<WidgetLayout layout={layersPanelControlBarRightLayout} />
</LayoutRow>
<LayoutRow class="list-area" scrollableY={true}>
<LayoutCol class="list" data-layer-panel bind:this={list} on:click={() => deselectAllLayers()} on:dragover={updateInsertLine} on:dragend={drop} on:drop={drop}>
<LayoutCol
class="list"
styles={{ cursor: layerToClipUponClick && layerToClipAltKeyPressed && layerToClipUponClick.entry.clippable ? "alias" : "auto" }}
data-layer-panel
bind:this={list}
on:click={() => deselectAllLayers()}
on:dragover={updateInsertLine}
on:dragend={drop}
on:drop={drop}
>
{#each layers as listing, index}
{@const selected = fakeHighlightOfNotYetSelectedLayerBeingDragged !== undefined ? fakeHighlightOfNotYetSelectedLayerBeingDragged === listing.entry.id : listing.entry.selected}
<LayoutRow
@@ -464,6 +533,11 @@
on:click={(e) => handleExpandArrowClickWithModifiers(e, listing.entry.id)}
tabindex="0"
></button>
{:else}
<div class="expand-arrow-none"></div>
{/if}
{#if listing.entry.clipped}
<IconLabel icon="Clipped" class="clipped-arrow" tooltip={"Clipping mask is active (Alt-click border to release)"} />
{/if}
<div class="thumbnail">
{#if $nodeGraph.thumbnails.has(listing.entry.id)}
@@ -589,6 +663,7 @@
.expand-arrow {
padding: 0;
margin: 0;
margin-right: 4px;
width: 16px;
height: 100%;
border: none;
@@ -625,10 +700,19 @@
}
}
.expand-arrow-none {
flex: 0 0 16px;
margin-right: 4px;
}
.clipped-arrow {
margin-left: 2px;
margin-right: 2px;
}
.thumbnail {
width: 36px;
height: 24px;
margin-left: 4px;
border-radius: 2px;
flex: 0 0 auto;
background-image: var(--color-transparent-checkered-background);
@@ -636,10 +720,6 @@
background-position: var(--color-transparent-checkered-background-position-mini);
background-repeat: var(--color-transparent-checkered-background-repeat);
&:first-child {
margin-left: 20px;
}
svg {
width: calc(100% - 4px);
height: calc(100% - 4px);