Blend modes (#252)

* Add backend for selecting layer blend mode

* Change dropdown input to support callback on change

* Add debug messages

* Fix canvas update for blend-modes

* Finish up and polish blend modes implementations

* Add changes from code review

Co-authored-by: Keavon Chambers <keavon@keavon.com>
Co-authored-by: Dennis Kobert <dennis@kobert.dev>
This commit is contained in:
George Atkinson
2021-07-23 10:21:07 -07:00
committed by GitHub
co-authored by Keavon Chambers Dennis Kobert
parent 63e3b5c604
commit 8f9168bfe5
18 changed files with 348 additions and 69 deletions
Generated
+3
View File
@@ -1,5 +1,7 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3
[[package]] [[package]]
name = "aho-corasick" name = "aho-corasick"
version = "0.7.18" version = "0.7.18"
@@ -125,6 +127,7 @@ name = "graphite-wasm-wrapper"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"graphite-document-core",
"graphite-editor-core", "graphite-editor-core",
"log", "log",
"serde", "serde",
@@ -2,7 +2,7 @@
<LayoutCol :class="'document'"> <LayoutCol :class="'document'">
<LayoutRow :class="'options-bar'"> <LayoutRow :class="'options-bar'">
<div class="left side"> <div class="left side">
<DropdownInput :menuEntries="modeMenuEntries" :default="modeMenuEntries[0][0]" :drawIcon="true" /> <DropdownInput :menuEntries="documentModeEntries" v-model:selectedIndex="documentModeSelectionIndex" :drawIcon="true" />
<Separator :type="SeparatorType.Section" /> <Separator :type="SeparatorType.Section" />
@@ -104,11 +104,11 @@
</LayoutCol> </LayoutCol>
<LayoutCol :class="'viewport'"> <LayoutCol :class="'viewport'">
<LayoutRow :class="'bar-area'"> <LayoutRow :class="'bar-area'">
<CanvasRuler :origin="0" :majorMarkSpacing="75" :direction="RulerDirection.Horizontal" :class="'top-ruler'" /> <CanvasRuler :origin="0" :majorMarkSpacing="100" :direction="RulerDirection.Horizontal" :class="'top-ruler'" />
</LayoutRow> </LayoutRow>
<LayoutRow :class="'canvas-area'"> <LayoutRow :class="'canvas-area'">
<LayoutCol :class="'bar-area'"> <LayoutCol :class="'bar-area'">
<CanvasRuler :origin="0" :majorMarkSpacing="75" :direction="RulerDirection.Vertical" /> <CanvasRuler :origin="0" :majorMarkSpacing="100" :direction="RulerDirection.Vertical" />
</LayoutCol> </LayoutCol>
<LayoutCol :class="'canvas-area'"> <LayoutCol :class="'canvas-area'">
<div class="canvas" @mousedown="canvasMouseDown" @mouseup="canvasMouseUp" @mousemove="canvasMouseMove" ref="canvas"> <div class="canvas" @mousedown="canvasMouseDown" @mouseup="canvasMouseUp" @mousemove="canvasMouseMove" ref="canvas">
@@ -216,7 +216,7 @@ import OptionalInput from "@/components/widgets/inputs/OptionalInput.vue";
import ToolOptions from "@/components/widgets/options/ToolOptions.vue"; import ToolOptions from "@/components/widgets/options/ToolOptions.vue";
import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue"; import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
const modeMenuEntries: SectionsOfMenuListEntries = [ const documentModeEntries: SectionsOfMenuListEntries = [
[ [
{ label: "Design Mode", icon: "ViewportDesignMode" }, { label: "Design Mode", icon: "ViewportDesignMode" },
{ label: "Select Mode", icon: "ViewportSelectMode" }, { label: "Select Mode", icon: "ViewportSelectMode" },
@@ -334,7 +334,8 @@ export default defineComponent({
return { return {
viewportSvg: "", viewportSvg: "",
activeTool: "Select", activeTool: "Select",
modeMenuEntries, documentModeEntries,
documentModeSelectionIndex: 0,
viewModeIndex: 0, viewModeIndex: 0,
snappingEnabled: true, snappingEnabled: true,
gridEnabled: true, gridEnabled: true,
+106 -29
View File
@@ -1,7 +1,7 @@
<template> <template>
<LayoutCol :class="'layer-tree-panel'"> <LayoutCol :class="'layer-tree-panel'">
<LayoutRow :class="'options-bar'"> <LayoutRow :class="'options-bar'">
<DropdownInput :menuEntries="blendModeMenuEntries" :default="blendModeMenuEntries[0][0]" /> <DropdownInput :menuEntries="blendModeEntries" v-model:selectedIndex="blendModeSelectedIndex" @update:selectedIndex="blendModeChanged" :disabled="blendModeDropdownDisabled" />
<Separator :type="SeparatorType.Related" /> <Separator :type="SeparatorType.Related" />
@@ -15,18 +15,18 @@
</PopoverButton> </PopoverButton>
</LayoutRow> </LayoutRow>
<LayoutRow :class="'layer-tree scrollable-y'"> <LayoutRow :class="'layer-tree scrollable-y'">
<LayoutCol :class="'list'"> <LayoutCol :class="'list'" @click="deselectAllLayers">
<div class="layer-row" v-for="layer in layers" :key="layer.path"> <div class="layer-row" v-for="layer in layers" :key="layer.path">
<div class="layer-visibility"> <div class="layer-visibility">
<IconButton :icon="layer.visible ? 'EyeVisible' : 'EyeHidden'" @click="toggleLayerVisibility(layer.path)" :size="24" :title="layer.visible ? 'Visible' : 'Hidden'" /> <IconButton :icon="layer.visible ? 'EyeVisible' : 'EyeHidden'" @click.stop="toggleLayerVisibility(layer.path)" :size="24" :title="layer.visible ? 'Visible' : 'Hidden'" />
</div> </div>
<div <div
class="layer" class="layer"
:class="{ selected: layer.layer_data.selected }" :class="{ selected: layer.layer_data.selected }"
@click.shift.exact="handleShiftClick(layer)" @click.shift.exact.stop="handleShiftClick(layer)"
@click.ctrl.exact="handleControlClick(layer)" @click.ctrl.exact.stop="handleControlClick(layer)"
@click.alt.exact="handleControlClick(layer)" @click.alt.exact.stop="handleControlClick(layer)"
@click.exact="handleClick(layer)" @click.exact.stop="handleClick(layer)"
> >
<div class="layer-thumbnail" v-html="layer.thumbnail"></div> <div class="layer-thumbnail" v-html="layer.thumbnail"></div>
<div class="layer-type-icon"> <div class="layer-type-icon">
@@ -53,11 +53,12 @@
align-items: center; align-items: center;
.dropdown-input { .dropdown-input {
flex: 0 0 auto; max-width: 120px;
} }
.dropdown-input,
.number-input { .number-input {
flex: 1 1 100%; flex: 1 1 auto;
} }
} }
@@ -110,7 +111,7 @@
<script lang="ts"> <script lang="ts">
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { ResponseType, registerResponseHandler, Response, ExpandFolder, LayerPanelEntry } from "@/utilities/response-handler"; import { ResponseType, registerResponseHandler, Response, BlendMode, ExpandFolder, LayerPanelEntry } from "@/utilities/response-handler";
import LayoutRow from "@/components/layout/LayoutRow.vue"; import LayoutRow from "@/components/layout/LayoutRow.vue";
import LayoutCol from "@/components/layout/LayoutCol.vue"; import LayoutCol from "@/components/layout/LayoutCol.vue";
import Separator, { SeparatorType } from "@/components/widgets/separators/Separator.vue"; import Separator, { SeparatorType } from "@/components/widgets/separators/Separator.vue";
@@ -124,13 +125,43 @@ import { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/M
const wasm = import("@/../wasm/pkg"); const wasm = import("@/../wasm/pkg");
const blendModeMenuEntries: SectionsOfMenuListEntries = [ const blendModeEntries: SectionsOfMenuListEntries = [
[{ label: "Normal" }], [{ label: "Normal", value: BlendMode.Normal }],
[{ label: "Multiply" }, { label: "Darken" }, { label: "Color Burn" }, { label: "Linear Burn" }, { label: "Darker Color" }], [
[{ label: "Screen" }, { label: "Lighten" }, { label: "Color Dodge" }, { label: "Linear Dodge (Add)" }, { label: "Lighter Color" }], { label: "Multiply", value: BlendMode.Multiply },
[{ label: "Overlay" }, { label: "Soft Light" }, { label: "Hard Light" }, { label: "Vivid Light" }, { label: "Linear Light" }, { label: "Pin Light" }, { label: "Hard Mix" }], { label: "Darken", value: BlendMode.Darken },
[{ label: "Difference" }, { label: "Exclusion" }, { label: "Subtract" }, { label: "Divide" }], { label: "Color Burn", value: BlendMode.ColorBurn },
[{ label: "Hue" }, { label: "Saturation" }, { label: "Color" }, { label: "Luminosity" }], // { label: "Linear Burn", value: "" }, // Not supported by SVG
// { label: "Darker Color", value: "" }, // Not supported by SVG
],
[
{ label: "Screen", value: BlendMode.Screen },
{ label: "Lighten", value: BlendMode.Lighten },
{ label: "Color Dodge", value: BlendMode.ColorDodge },
// { label: "Linear Dodge (Add)", value: "" }, // Not supported by SVG
// { label: "Lighter Color", value: "" }, // Not supported by SVG
],
[
{ label: "Overlay", value: BlendMode.Overlay },
{ label: "Soft Light", value: BlendMode.SoftLight },
{ label: "Hard Light", value: BlendMode.HardLight },
// { label: "Vivid Light", value: "" }, // Not supported by SVG
// { label: "Linear Light", value: "" }, // Not supported by SVG
// { label: "Pin Light", value: "" }, // Not supported by SVG
// { label: "Hard Mix", value: "" }, // Not supported by SVG
],
[
{ label: "Difference", value: BlendMode.Difference },
{ label: "Exclusion", value: BlendMode.Exclusion },
// { label: "Subtract", value: "" }, // Not supported by SVG
// { label: "Divide", value: "" }, // Not supported by SVG
],
[
{ label: "Hue", value: BlendMode.Hue },
{ label: "Saturation", value: BlendMode.Saturation },
{ label: "Color", value: BlendMode.Color },
{ label: "Luminosity", value: BlendMode.Luminosity },
],
]; ];
export default defineComponent({ export default defineComponent({
@@ -140,9 +171,14 @@ export default defineComponent({
const { toggle_layer_visibility } = await wasm; const { toggle_layer_visibility } = await wasm;
toggle_layer_visibility(path); toggle_layer_visibility(path);
}, },
async setLayerBlendMode(blendMode: BlendMode) {
const { set_blend_mode_for_selected_layers } = await wasm;
set_blend_mode_for_selected_layers(blendMode);
},
async handleControlClick(clickedLayer: LayerPanelEntry) { async handleControlClick(clickedLayer: LayerPanelEntry) {
const index = this.layers.indexOf(clickedLayer); const index = this.layers.indexOf(clickedLayer);
clickedLayer.layer_data.selected = !clickedLayer.layer_data.selected; clickedLayer.layer_data.selected = !clickedLayer.layer_data.selected;
this.selectionRangeEndLayer = undefined; this.selectionRangeEndLayer = undefined;
this.selectionRangeStartLayer = this.selectionRangeStartLayer =
this.layers.slice(index).filter((layer) => layer.layer_data.selected)[0] || this.layers.slice(index).filter((layer) => layer.layer_data.selected)[0] ||
@@ -150,29 +186,41 @@ export default defineComponent({
.slice(0, index) .slice(0, index)
.reverse() .reverse()
.filter((layer) => layer.layer_data.selected)[0]; .filter((layer) => layer.layer_data.selected)[0];
this.updateSelection();
this.sendSelectedLayers();
}, },
async handleShiftClick(clickedLayer: LayerPanelEntry) { async handleShiftClick(clickedLayer: LayerPanelEntry) {
// The two paths of the range are stored in selectionRangeStartLayer and selectionRangeEndLayer // The two paths of the range are stored in selectionRangeStartLayer and selectionRangeEndLayer
// So for a new Shift+Click, select all layers between selectionRangeStartLayer and selectionRangeEndLayer(stored in prev Sft+C) // So for a new Shift+Click, select all layers between selectionRangeStartLayer and selectionRangeEndLayer (stored in prev Shift+Click)
this.clearSelection(); this.clearSelection();
this.selectionRangeEndLayer = clickedLayer; this.selectionRangeEndLayer = clickedLayer;
if (!this.selectionRangeStartLayer) this.selectionRangeStartLayer = clickedLayer; if (!this.selectionRangeStartLayer) this.selectionRangeStartLayer = clickedLayer;
this.fillSelectionRange(this.selectionRangeStartLayer, this.selectionRangeEndLayer, true); this.fillSelectionRange(this.selectionRangeStartLayer, this.selectionRangeEndLayer, true);
this.updateSelection();
},
this.sendSelectedLayers();
},
async handleClick(clickedLayer: LayerPanelEntry) { async handleClick(clickedLayer: LayerPanelEntry) {
this.selectionRangeStartLayer = clickedLayer; this.selectionRangeStartLayer = clickedLayer;
this.selectionRangeEndLayer = clickedLayer; this.selectionRangeEndLayer = clickedLayer;
this.clearSelection(); this.clearSelection();
clickedLayer.layer_data.selected = true; clickedLayer.layer_data.selected = true;
this.updateSelection();
this.sendSelectedLayers();
},
async deselectAllLayers() {
this.selectionRangeStartLayer = undefined;
this.selectionRangeEndLayer = undefined;
const { deselect_all_layers } = await wasm;
deselect_all_layers();
}, },
async fillSelectionRange(start: LayerPanelEntry, end: LayerPanelEntry, selected = true) { async fillSelectionRange(start: LayerPanelEntry, end: LayerPanelEntry, selected = true) {
const startIndex = this.layers.findIndex((layer) => layer.path.join() === start.path.join()); const startIndex = this.layers.findIndex((layer) => layer.path.join() === start.path.join());
const endIndex = this.layers.findIndex((layer) => layer.path.join() === end.path.join()); const endIndex = this.layers.findIndex((layer) => layer.path.join() === end.path.join());
const [min, max] = [startIndex, endIndex].sort(); const [min, max] = [startIndex, endIndex].sort();
if (min !== -1) { if (min !== -1) {
for (let i = min; i <= max; i += 1) { for (let i = min; i <= max; i += 1) {
this.layers[i].layer_data.selected = selected; this.layers[i].layer_data.selected = selected;
@@ -184,11 +232,12 @@ export default defineComponent({
layer.layer_data.selected = false; layer.layer_data.selected = false;
}); });
}, },
async updateSelection() { async sendSelectedLayers() {
const paths = this.layers.filter((layer) => layer.layer_data.selected).map((layer) => layer.path); const paths = this.layers.filter((layer) => layer.layer_data.selected).map((layer) => layer.path);
if (paths.length === 0) return;
const length = paths.reduce((acc, cur) => acc + cur.length, 0) + paths.length - 1; const length = paths.reduce((acc, cur) => acc + cur.length, 0) + paths.length - 1;
const output = new BigUint64Array(length); const output = new BigUint64Array(length);
let i = 0; let i = 0;
paths.forEach((path, index) => { paths.forEach((path, index) => {
output.set(path, i); output.set(path, i);
@@ -202,6 +251,30 @@ export default defineComponent({
const { select_layers } = await wasm; const { select_layers } = await wasm;
select_layers(output); select_layers(output);
}, },
setBlendModeForSelectedLayers() {
const selected = this.layers.filter((layer) => layer.layer_data.selected);
if (selected.length < 1) {
this.blendModeSelectedIndex = 0;
this.blendModeDropdownDisabled = true;
return;
}
this.blendModeDropdownDisabled = false;
const firstEncounteredBlendMode = selected[0].blend_mode;
const allBlendModesAlike = !selected.find((layer) => layer.blend_mode !== firstEncounteredBlendMode);
if (allBlendModesAlike) {
this.blendModeSelectedIndex = this.blendModeEntries.flat().findIndex((entry) => entry.value === firstEncounteredBlendMode);
} else {
// Display a dash when they are not all the same value
this.blendModeSelectedIndex = -1;
}
},
blendModeChanged() {
const blendMode = this.blendModeEntries.flat()[this.blendModeSelectedIndex].value as BlendMode;
if (blendMode) this.setLayerBlendMode(blendMode);
},
}, },
mounted() { mounted() {
registerResponseHandler(ResponseType.ExpandFolder, (responseData: Response) => { registerResponseHandler(ResponseType.ExpandFolder, (responseData: Response) => {
@@ -212,6 +285,8 @@ export default defineComponent({
if (responsePath.length > 0) console.error("Non root paths are currently not implemented"); if (responsePath.length > 0) console.error("Non root paths are currently not implemented");
this.layers = responseLayers; this.layers = responseLayers;
this.setBlendModeForSelectedLayers();
} }
}); });
registerResponseHandler(ResponseType.CollapseFolder, (responseData) => { registerResponseHandler(ResponseType.CollapseFolder, (responseData) => {
@@ -220,13 +295,15 @@ export default defineComponent({
}, },
data() { data() {
return { return {
blendModeMenuEntries, blendModeEntries,
blendModeSelectedIndex: 0,
blendModeDropdownDisabled: true,
layers: [] as Array<LayerPanelEntry>,
selectionRangeStartLayer: undefined as undefined | LayerPanelEntry,
selectionRangeEndLayer: undefined as undefined | LayerPanelEntry,
opacity: 100,
MenuDirection, MenuDirection,
SeparatorType, SeparatorType,
layers: [] as Array<LayerPanelEntry>,
selectionRangeStartLayer: undefined as LayerPanelEntry | undefined,
selectionRangeEndLayer: undefined as LayerPanelEntry | undefined,
opacity: 100,
}; };
}, },
components: { components: {
@@ -129,6 +129,7 @@ export type MenuListEntries = Array<MenuListEntry>;
export type SectionsOfMenuListEntries = Array<MenuListEntries>; export type SectionsOfMenuListEntries = Array<MenuListEntries>;
interface MenuListEntryData { interface MenuListEntryData {
value?: string;
label?: string; label?: string;
icon?: string; icon?: string;
// TODO: Add `checkbox` (which overrides any `icon`) // TODO: Add `checkbox` (which overrides any `icon`)
@@ -1,15 +1,16 @@
<template> <template>
<div class="dropdown-input"> <div class="dropdown-input">
<div class="dropdown-box" :style="{ minWidth: `${minWidth}px` }" @click="clickDropdownBox" data-hover-menu-spawner> <div class="dropdown-box" :class="{ disabled }" :style="{ minWidth: `${minWidth}px`, disabled: 'disabled' }" @click="clickDropdownBox" data-hover-menu-spawner>
<IconLabel :class="'dropdown-icon'" :icon="activeEntry.icon" v-if="activeEntry.icon" /> <IconLabel :class="'dropdown-icon'" :icon="activeEntry.icon" v-if="activeEntry.icon" />
<span>{{ activeEntry.label }}</span> <span>{{ activeEntry.label }}</span>
<IconLabel :class="'dropdown-arrow'" :icon="'DropdownArrow'" /> <IconLabel :class="'dropdown-arrow'" :icon="'DropdownArrow'" />
</div> </div>
<MenuList <MenuList
:menuEntries="menuEntries"
v-model:active-entry="activeEntry" v-model:active-entry="activeEntry"
:direction="MenuDirection.Bottom" @update:activeEntry="activeEntryChanged"
@width-changed="onWidthChanged" @width-changed="onWidthChanged"
:menuEntries="menuEntries"
:direction="MenuDirection.Bottom"
:drawIcon="drawIcon" :drawIcon="drawIcon"
:scrollable="true" :scrollable="true"
ref="menuList" ref="menuList"
@@ -66,6 +67,18 @@
&.open { &.open {
border-radius: 2px 2px 0 0; border-radius: 2px 2px 0 0;
} }
&.disabled {
background: var(--color-2-mildblack);
span {
color: var(--color-8-uppergray);
}
svg {
fill: var(--color-8-uppergray);
}
}
} }
.menu-list .floating-menu-container .floating-menu-content { .menu-list .floating-menu-container .floating-menu-content {
@@ -83,22 +96,36 @@ import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.
export default defineComponent({ export default defineComponent({
props: { props: {
menuEntries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true }, menuEntries: { type: Array as PropType<SectionsOfMenuListEntries>, required: true },
default: { type: Object as PropType<MenuListEntry>, required: true }, selectedIndex: { type: Number, required: true },
drawIcon: { type: Boolean, default: false }, drawIcon: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
}, },
data() { data() {
return { return {
activeEntry: this.default, activeEntry: this.menuEntries.flat()[this.selectedIndex],
MenuDirection, MenuDirection,
minWidth: 0, minWidth: 0,
}; };
}, },
methods: { watch: {
clickDropdownBox() { // Called only when `selectedIndex` is changed from outside this component (with v-model)
(this.$refs.menuList as typeof MenuList).setOpen(); selectedIndex(newSelectedIndex: number) {
const entries = this.menuEntries.flat();
if (newSelectedIndex >= 0 && newSelectedIndex < entries.length) {
this.activeEntry = entries[newSelectedIndex];
} else {
this.activeEntry = { label: "-" };
}
}, },
setActiveEntry(newActiveEntry: MenuListEntry) { },
this.activeEntry = newActiveEntry; methods: {
// Called only when `activeEntry` is changed from the child MenuList component via user input
activeEntryChanged(newActiveEntry: MenuListEntry) {
this.$emit("update:selectedIndex", this.menuEntries.flat().indexOf(newActiveEntry));
},
clickDropdownBox() {
if (!this.disabled) (this.$refs.menuList as typeof MenuList).setOpen();
}, },
onWidthChanged(newWidth: number) { onWidthChanged(newWidth: number) {
this.minWidth = newWidth; this.minWidth = newWidth;
@@ -201,9 +201,53 @@ function newSetCanvasRotation(input: any): SetCanvasRotation {
}; };
} }
export enum BlendMode {
Normal = "normal",
Multiply = "multiply",
Darken = "darken",
ColorBurn = "color-burn",
Screen = "screen",
Lighten = "lighten",
ColorDodge = "color-dodge",
Overlay = "overlay",
SoftLight = "soft-light",
HardLight = "hard-light",
Difference = "difference",
Exclusion = "exclusion",
Hue = "hue",
Saturation = "saturation",
Color = "color",
Luminosity = "luminosity",
}
function newBlendMode(input: string): BlendMode {
const blendMode = {
Normal: BlendMode.Normal,
Multiply: BlendMode.Multiply,
Darken: BlendMode.Darken,
ColorBurn: BlendMode.ColorBurn,
Screen: BlendMode.Screen,
Lighten: BlendMode.Lighten,
ColorDodge: BlendMode.ColorDodge,
Overlay: BlendMode.Overlay,
SoftLight: BlendMode.SoftLight,
HardLight: BlendMode.HardLight,
Difference: BlendMode.Difference,
Exclusion: BlendMode.Exclusion,
Hue: BlendMode.Hue,
Saturation: BlendMode.Saturation,
Color: BlendMode.Color,
Luminosity: BlendMode.Luminosity,
}[input];
if (!blendMode) throw new Error(`Invalid blend mode "${blendMode}"`);
return blendMode;
}
export interface LayerPanelEntry { export interface LayerPanelEntry {
name: string; name: string;
visible: boolean; visible: boolean;
blend_mode: BlendMode;
layer_type: LayerType; layer_type: LayerType;
path: BigUint64Array; path: BigUint64Array;
layer_data: LayerData; layer_data: LayerData;
@@ -213,6 +257,7 @@ function newLayerPanelEntry(input: any): LayerPanelEntry {
return { return {
name: input.name, name: input.name,
visible: input.visible, visible: input.visible,
blend_mode: newBlendMode(input.blend_mode),
layer_type: newLayerType(input.layer_type), layer_type: newLayerType(input.layer_type),
layer_data: newLayerData(input.layer_data), layer_data: newLayerData(input.layer_data),
path: new BigUint64Array(input.path.map((n: number) => BigInt(n))), path: new BigUint64Array(input.path.map((n: number) => BigInt(n))),
+1
View File
@@ -18,6 +18,7 @@ default = ["console_error_panic_hook"]
[dependencies] [dependencies]
console_error_panic_hook = { version = "0.1.6", optional = true } console_error_panic_hook = { version = "0.1.6", optional = true }
editor-core = { path = "../../../core/editor", package = "graphite-editor-core" } editor-core = { path = "../../../core/editor", package = "graphite-editor-core" }
document-core = { path = "../../../core/document", package = "graphite-document-core" }
log = "0.4" log = "0.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
wasm-bindgen = { version = "0.2.73", features = ["serde-serialize"] } wasm-bindgen = { version = "0.2.73", features = ["serde-serialize"] }
+28
View File
@@ -1,9 +1,11 @@
use crate::shims::Error; use crate::shims::Error;
use crate::wrappers::{translate_key, translate_tool, Color}; use crate::wrappers::{translate_key, translate_tool, Color};
use crate::EDITOR_STATE; use crate::EDITOR_STATE;
use document_core::layers::BlendMode;
use editor_core::input::input_preprocessor::ModifierKeys; use editor_core::input::input_preprocessor::ModifierKeys;
use editor_core::input::mouse::ScrollDelta; use editor_core::input::mouse::ScrollDelta;
use editor_core::message_prelude::*; use editor_core::message_prelude::*;
use editor_core::misc::EditorError;
use editor_core::tool::{tool_options::ToolOptions, tools, ToolType}; use editor_core::tool::{tool_options::ToolOptions, tools, ToolType};
use editor_core::{ use editor_core::{
input::mouse::{MouseState, ViewportPosition}, input::mouse::{MouseState, ViewportPosition},
@@ -211,6 +213,32 @@ pub fn reorder_selected_layers(delta: i32) -> Result<(), JsValue> {
.map_err(convert_error) .map_err(convert_error)
} }
/// Set the blend mode of the selected layers
#[wasm_bindgen]
pub fn set_blend_mode_for_selected_layers(blend_mode_svg_style_name: String) -> Result<(), JsValue> {
let blend_mode = match blend_mode_svg_style_name.as_str() {
"normal" => BlendMode::Normal,
"multiply" => BlendMode::Multiply,
"darken" => BlendMode::Darken,
"color-burn" => BlendMode::ColorBurn,
"screen" => BlendMode::Screen,
"lighten" => BlendMode::Lighten,
"color-dodge" => BlendMode::ColorDodge,
"overlay" => BlendMode::Overlay,
"soft-light" => BlendMode::SoftLight,
"hard-light" => BlendMode::HardLight,
"difference" => BlendMode::Difference,
"exclusion" => BlendMode::Exclusion,
"hue" => BlendMode::Hue,
"saturation" => BlendMode::Saturation,
"color" => BlendMode::Color,
"luminosity" => BlendMode::Luminosity,
_ => return Err(convert_error(EditorError::Misc("UnknownBlendMode".to_string())).into()),
};
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::SetBlendModeForSelectedLayers(blend_mode)).map_err(convert_error))
}
/// Export the document /// Export the document
#[wasm_bindgen] #[wasm_bindgen]
pub fn export_document() -> Result<(), JsValue> { pub fn export_document() -> Result<(), JsValue> {
+6 -6
View File
@@ -23,6 +23,12 @@ pub fn init() {
log::set_max_level(log::LevelFilter::Debug); log::set_max_level(log::LevelFilter::Debug);
} }
#[wasm_bindgen(module = "/../src/utilities/response-handler-binding.ts")]
extern "C" {
#[wasm_bindgen(catch)]
fn handleResponse(responseType: String, responseData: JsValue) -> Result<(), JsValue>;
}
fn handle_response(response: FrontendMessage) { fn handle_response(response: FrontendMessage) {
let response_type = response.to_discriminant().local_name(); let response_type = response.to_discriminant().local_name();
send_response(response_type, response); send_response(response_type, response);
@@ -32,9 +38,3 @@ fn send_response(response_type: String, response_data: FrontendMessage) {
let response_data = JsValue::from_serde(&response_data).expect("Failed to serialize response"); let response_data = JsValue::from_serde(&response_data).expect("Failed to serialize response");
let _ = handleResponse(response_type, response_data).map_err(|error| log::error!("javascript threw an error: {:?}", error)); let _ = handleResponse(response_type, response_data).map_err(|error| log::error!("javascript threw an error: {:?}", error));
} }
#[wasm_bindgen(module = "/../src/utilities/response-handler-binding.ts")]
extern "C" {
#[wasm_bindgen(catch)]
fn handleResponse(responseType: String, responseData: JsValue) -> Result<(), JsValue>;
}
+1 -1
View File
@@ -25,7 +25,7 @@ impl Color {
} }
macro_rules! match_string_to_enum { macro_rules! match_string_to_enum {
(match ($e:expr) {$($var:ident),* $(,)?}) => { (match ($e:expr) {$($var:ident),* $(,)?}) => {
match $e { match $e {
$( $(
stringify!($var) => Some($var), stringify!($var) => Some($var),
+13 -3
View File
@@ -221,7 +221,9 @@ impl Document {
/// Deletes the layer specified by `path`. /// Deletes the layer specified by `path`.
pub fn delete(&mut self, path: &[LayerId]) -> Result<(), DocumentError> { pub fn delete(&mut self, path: &[LayerId]) -> Result<(), DocumentError> {
let (path, id) = split_path(path)?; let (path, id) = split_path(path)?;
let _ = self.layer_mut(path).map(|x| x.cache_dirty = true); if let Ok(layer) = self.layer_mut(path) {
layer.cache_dirty = true;
}
self.document_folder_mut(path)?.as_folder_mut()?.remove_layer(id)?; self.document_folder_mut(path)?.as_folder_mut()?.remove_layer(id)?;
Ok(()) Ok(())
} }
@@ -393,13 +395,21 @@ impl Document {
Some(responses) Some(responses)
} }
Operation::ToggleVisibility { path } => { Operation::ToggleVisibility { path } => {
let _ = self.layer_mut(&path).map(|layer| { if let Ok(layer) = self.layer_mut(&path) {
layer.visible = !layer.visible; layer.visible = !layer.visible;
layer.cache_dirty = true; layer.cache_dirty = true;
}); }
let path = path.as_slice()[..path.len() - 1].to_vec(); let path = path.as_slice()[..path.len() - 1].to_vec();
Some(vec![DocumentResponse::DocumentChanged, DocumentResponse::FolderChanged { path }]) Some(vec![DocumentResponse::DocumentChanged, DocumentResponse::FolderChanged { path }])
} }
Operation::SetLayerBlendMode { path, blend_mode } => {
self.mark_as_dirty(path)?;
self.layer_mut(&path).unwrap().blend_mode = *blend_mode;
let path = path.as_slice()[..path.len() - 1].to_vec();
Some(vec![DocumentResponse::DocumentChanged, DocumentResponse::FolderChanged { path: path.clone() }])
}
Operation::FillLayer { path, color } => { Operation::FillLayer { path, color } => {
let layer = self.layer_mut(path).unwrap(); let layer = self.layer_mut(path).unwrap();
layer.style.set_fill(layers::style::Fill::new(*color)); layer.style.set_fill(layers::style::Fill::new(*color));
+61 -2
View File
@@ -24,12 +24,15 @@ use crate::LayerId;
pub use folder::Folder; pub use folder::Folder;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt::Write;
pub trait LayerData { pub trait LayerData {
fn render(&mut self, svg: &mut String, transform: glam::DAffine2, style: style::PathStyle); fn render(&mut self, svg: &mut String, transform: glam::DAffine2, style: style::PathStyle);
fn to_kurbo_path(&self, transform: glam::DAffine2, style: style::PathStyle) -> BezPath; fn to_kurbo_path(&self, transform: glam::DAffine2, style: style::PathStyle) -> BezPath;
fn intersects_quad(&self, quad: [DVec2; 4], path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>, style: style::PathStyle); fn intersects_quad(&self, quad: [DVec2; 4], path: &mut Vec<LayerId>, intersections: &mut Vec<Vec<LayerId>>, style: style::PathStyle);
} }
// TODO: Rename this `LayerDataType` to not be plural in a separate commit (together with `enum ToolOptions`)
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
pub enum LayerDataTypes { pub enum LayerDataTypes {
Folder(Folder), Folder(Folder),
@@ -40,6 +43,48 @@ pub enum LayerDataTypes {
Shape(Shape), Shape(Shape),
} }
#[derive(PartialEq, Copy, Clone, Debug, Serialize, Deserialize)]
pub enum BlendMode {
Normal,
Multiply,
Darken,
ColorBurn,
Screen,
Lighten,
ColorDodge,
Overlay,
SoftLight,
HardLight,
Difference,
Exclusion,
Hue,
Saturation,
Color,
Luminosity,
}
impl BlendMode {
fn to_svg_style_name(&self) -> &str {
match self {
BlendMode::Normal => "normal",
BlendMode::Multiply => "multiply",
BlendMode::Darken => "darken",
BlendMode::ColorBurn => "color-burn",
BlendMode::Screen => "screen",
BlendMode::Lighten => "lighten",
BlendMode::ColorDodge => "color-dodge",
BlendMode::Overlay => "overlay",
BlendMode::SoftLight => "soft-light",
BlendMode::HardLight => "hard-light",
BlendMode::Difference => "difference",
BlendMode::Exclusion => "exclusion",
BlendMode::Hue => "hue",
BlendMode::Saturation => "saturation",
BlendMode::Color => "color",
BlendMode::Luminosity => "luminosity",
}
}
}
macro_rules! call_render { macro_rules! call_render {
($self:ident.render($svg:ident, $transform:ident, $style:ident) { $($variant:ident),* }) => { ($self:ident.render($svg:ident, $transform:ident, $style:ident) { $($variant:ident),* }) => {
match $self { match $self {
@@ -56,7 +101,7 @@ macro_rules! call_kurbo_path {
} }
macro_rules! call_intersects_quad { macro_rules! call_intersects_quad {
($self:ident.intersects_quad($quad:ident, $path:ident, $intersections:ident, $style:ident) { $($variant:ident),* }) => { ($self:ident.intersects_quad($quad:ident, $path:ident, $intersections:ident, $style:ident) { $($variant:ident),* }) => {
match $self { match $self {
$(Self::$variant(x) => x.intersects_quad($quad, $path, $intersections, $style)),* $(Self::$variant(x) => x.intersects_quad($quad, $path, $intersections, $style)),*
} }
@@ -76,6 +121,7 @@ impl LayerDataTypes {
} }
} }
} }
pub fn to_kurbo_path(&self, transform: glam::DAffine2, style: style::PathStyle) -> BezPath { pub fn to_kurbo_path(&self, transform: glam::DAffine2, style: style::PathStyle) -> BezPath {
call_kurbo_path! { call_kurbo_path! {
self.to_kurbo_path(transform, style) { self.to_kurbo_path(transform, style) {
@@ -125,7 +171,9 @@ pub struct Layer {
pub transform: glam::DAffine2, pub transform: glam::DAffine2,
pub style: style::PathStyle, pub style: style::PathStyle,
pub cache: String, pub cache: String,
pub thumbnail_cache: String,
pub cache_dirty: bool, pub cache_dirty: bool,
pub blend_mode: BlendMode,
} }
impl Layer { impl Layer {
@@ -137,7 +185,9 @@ impl Layer {
transform: glam::DAffine2::from_cols_array(&transform), transform: glam::DAffine2::from_cols_array(&transform),
style, style,
cache: String::new(), cache: String::new(),
thumbnail_cache: String::new(),
cache_dirty: true, cache_dirty: true,
blend_mode: BlendMode::Normal,
} }
} }
@@ -146,8 +196,17 @@ impl Layer {
return ""; return "";
} }
if self.cache_dirty { if self.cache_dirty {
self.thumbnail_cache.clear();
self.data.render(&mut self.thumbnail_cache, self.transform, self.style);
self.cache.clear(); self.cache.clear();
self.data.render(&mut self.cache, self.transform, self.style); let _ = write!(
self.cache,
r#"<g style="mix-blend-mode: {}">{}</g>"#,
self.blend_mode.to_svg_style_name(),
self.thumbnail_cache.as_str()
);
self.cache_dirty = false; self.cache_dirty = false;
} }
self.cache.as_str() self.cache.as_str()
+3 -3
View File
@@ -1,10 +1,10 @@
use crate::color::Color; use crate::color::Color;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
const OPACITY_PERCISION: usize = 3; const OPACITY_PRECISION: usize = 3;
fn format_opacity(name: &str, opacity: f32) -> String { fn format_opacity(name: &str, opacity: f32) -> String {
if (opacity - 1.).abs() > 10f32.powi(-(OPACITY_PERCISION as i32)) { if (opacity - 1.).abs() > 10f32.powi(-(OPACITY_PRECISION as i32)) {
format!(r#" {}-opacity="{:.percision$}""#, name, opacity, percision = OPACITY_PERCISION) format!(r#" {}-opacity="{:.precision$}""#, name, opacity, precision = OPACITY_PRECISION)
} else { } else {
String::new() String::new()
} }
+5 -1
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
color::Color, color::Color,
layers::{style, Layer}, layers::{style, BlendMode, Layer},
LayerId, LayerId,
}; };
@@ -72,6 +72,10 @@ pub enum Operation {
ToggleVisibility { ToggleVisibility {
path: Vec<LayerId>, path: Vec<LayerId>,
}, },
SetLayerBlendMode {
path: Vec<LayerId>,
blend_mode: BlendMode,
},
FillLayer { FillLayer {
path: Vec<LayerId>, path: Vec<LayerId>,
color: Color, color: Color,
+17 -8
View File
@@ -38,11 +38,17 @@ fn layer_data<'a>(layer_data: &'a mut HashMap<Vec<LayerId>, LayerData>, path: &[
layer_data.get_mut(path).unwrap() layer_data.get_mut(path).unwrap()
} }
pub fn layer_panel_entry(layer_data: &mut LayerData, layer: &Layer, path: Vec<LayerId>) -> LayerPanelEntry { pub fn layer_panel_entry(layer_data: &mut LayerData, layer: &mut Layer, path: Vec<LayerId>) -> LayerPanelEntry {
let blend_mode = layer.blend_mode.clone();
let layer_type: LayerType = (&layer.data).into(); let layer_type: LayerType = (&layer.data).into();
let name = layer.name.clone().unwrap_or_else(|| format!("Unnamed {}", layer_type)); let name = layer.name.clone().unwrap_or_else(|| format!("Unnamed {}", layer_type));
let arr = layer.current_bounding_box().unwrap_or([DVec2::ZERO, DVec2::ZERO]); let arr = layer.current_bounding_box().unwrap_or([DVec2::ZERO, DVec2::ZERO]);
let arr = arr.iter().map(|x| (*x).into()).collect::<Vec<(f64, f64)>>(); let arr = arr.iter().map(|x| (*x).into()).collect::<Vec<(f64, f64)>>();
if layer.cache_dirty {
layer.render();
}
let thumbnail = if let [(x_min, y_min), (x_max, y_max)] = arr.as_slice() { let thumbnail = if let [(x_min, y_min), (x_max, y_max)] = arr.as_slice() {
format!( format!(
r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{} {} {} {}">{}</svg>"#, r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="{} {} {} {}">{}</svg>"#,
@@ -50,14 +56,16 @@ pub fn layer_panel_entry(layer_data: &mut LayerData, layer: &Layer, path: Vec<La
y_min, y_min,
x_max - x_min, x_max - x_min,
y_max - y_min, y_max - y_min,
layer.cache.clone() layer.thumbnail_cache.clone()
) )
} else { } else {
String::new() String::new()
}; };
LayerPanelEntry { LayerPanelEntry {
name, name,
visible: layer.visible, visible: layer.visible,
blend_mode,
layer_type, layer_type,
layer_data: *layer_data, layer_data: *layer_data,
path, path,
@@ -73,16 +81,17 @@ impl Document {
/// Returns a list of `LayerPanelEntry`s intended for display purposes. These don't contain /// Returns a list of `LayerPanelEntry`s intended for display purposes. These don't contain
/// any actual data, but rather metadata such as visibility and names of the layers. /// any actual data, but rather metadata such as visibility and names of the layers.
pub fn layer_panel(&mut self, path: &[LayerId]) -> Result<Vec<LayerPanelEntry>, EditorError> { pub fn layer_panel(&mut self, path: &[LayerId]) -> Result<Vec<LayerPanelEntry>, EditorError> {
let folder = self.document.document_folder(path)?; let folder = self.document.document_folder_mut(path)?;
let ids = folder.as_folder()?.layer_ids.clone();
let self_layer_data = &mut self.layer_data; let self_layer_data = &mut self.layer_data;
let entries = folder let entries = folder
.as_folder()? .as_folder_mut()?
.layers() .layers_mut()
.iter() .iter_mut()
.zip(folder.as_folder()?.layer_ids.iter()) .zip(ids)
.rev() .rev()
.map(|(layer, id)| { .map(|(layer, id)| {
let path = [path, &[*id]].concat(); let path = [path, &[id]].concat();
layer_panel_entry(layer_data(self_layer_data, &path), layer, path) layer_panel_entry(layer_data(self_layer_data, &path), layer, path)
}) })
.collect(); .collect();
@@ -3,6 +3,7 @@ use crate::{
consts::{MOUSE_ZOOM_RATE, VIEWPORT_SCROLL_RATE, VIEWPORT_ZOOM_SCALE_MAX, VIEWPORT_ZOOM_SCALE_MIN, WHEEL_ZOOM_RATE}, consts::{MOUSE_ZOOM_RATE, VIEWPORT_SCROLL_RATE, VIEWPORT_ZOOM_SCALE_MAX, VIEWPORT_ZOOM_SCALE_MIN, WHEEL_ZOOM_RATE},
input::{mouse::ViewportPosition, InputPreprocessor}, input::{mouse::ViewportPosition, InputPreprocessor},
}; };
use document_core::layers::BlendMode;
use document_core::layers::Layer; use document_core::layers::Layer;
use document_core::{DocumentResponse, LayerId, Operation as DocumentOperation}; use document_core::{DocumentResponse, LayerId, Operation as DocumentOperation};
use glam::{DAffine2, DVec2}; use glam::{DAffine2, DVec2};
@@ -24,6 +25,7 @@ pub enum DocumentMessage {
DeleteSelectedLayers, DeleteSelectedLayers,
DuplicateSelectedLayers, DuplicateSelectedLayers,
CopySelectedLayers, CopySelectedLayers,
SetBlendModeForSelectedLayers(BlendMode),
PasteLayers, PasteLayers,
AddFolder(Vec<LayerId>), AddFolder(Vec<LayerId>),
RenameLayer(Vec<LayerId>, String), RenameLayer(Vec<LayerId>, String),
@@ -342,6 +344,13 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
} }
.into(), .into(),
), ),
SetBlendModeForSelectedLayers(blend_mode) => {
let active_document = self.active_document();
for path in active_document.layer_data.iter().filter_map(|(path, data)| data.selected.then(|| path)) {
responses.push_back(DocumentOperation::SetLayerBlendMode { path: path.clone(), blend_mode }.into());
}
}
ToggleLayerVisibility(path) => { ToggleLayerVisibility(path) => {
responses.push_back(DocumentOperation::ToggleVisibility { path }.into()); responses.push_back(DocumentOperation::ToggleVisibility { path }.into());
} }
+5 -1
View File
@@ -1,5 +1,8 @@
use crate::document::LayerData; use crate::document::LayerData;
use document_core::{layers::LayerDataTypes, LayerId}; use document_core::{
layers::{BlendMode, LayerDataTypes},
LayerId,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt; use std::fmt;
@@ -7,6 +10,7 @@ use std::fmt;
pub struct LayerPanelEntry { pub struct LayerPanelEntry {
pub name: String, pub name: String,
pub visible: bool, pub visible: bool,
pub blend_mode: BlendMode,
pub layer_type: LayerType, pub layer_type: LayerType,
pub layer_data: LayerData, pub layer_data: LayerData,
pub path: Vec<LayerId>, pub path: Vec<LayerId>,
+1
View File
@@ -1,5 +1,6 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
// TODO: Rename this `ToolOption` to not be plural in a separate commit (together with `enum LayerDataTypes`)
#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)]
pub enum ToolOptions { pub enum ToolOptions {
Select { append_mode: SelectAppendMode }, Select { append_mode: SelectAppendMode },