mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
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:
committed by
Keavon Chambers
parent
1da0678dbb
commit
d5c9821a02
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -1,5 +1,7 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 3
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "0.7.18"
|
||||
@@ -125,6 +127,7 @@ name = "graphite-wasm-wrapper"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"graphite-document-core",
|
||||
"graphite-editor-core",
|
||||
"log",
|
||||
"serde",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<LayoutCol :class="'document'">
|
||||
<LayoutRow :class="'options-bar'">
|
||||
<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" />
|
||||
|
||||
@@ -104,11 +104,11 @@
|
||||
</LayoutCol>
|
||||
<LayoutCol :class="'viewport'">
|
||||
<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 :class="'canvas-area'">
|
||||
<LayoutCol :class="'bar-area'">
|
||||
<CanvasRuler :origin="0" :majorMarkSpacing="75" :direction="RulerDirection.Vertical" />
|
||||
<CanvasRuler :origin="0" :majorMarkSpacing="100" :direction="RulerDirection.Vertical" />
|
||||
</LayoutCol>
|
||||
<LayoutCol :class="'canvas-area'">
|
||||
<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 { SectionsOfMenuListEntries } from "@/components/widgets/floating-menus/MenuList.vue";
|
||||
|
||||
const modeMenuEntries: SectionsOfMenuListEntries = [
|
||||
const documentModeEntries: SectionsOfMenuListEntries = [
|
||||
[
|
||||
{ label: "Design Mode", icon: "ViewportDesignMode" },
|
||||
{ label: "Select Mode", icon: "ViewportSelectMode" },
|
||||
@@ -334,7 +334,8 @@ export default defineComponent({
|
||||
return {
|
||||
viewportSvg: "",
|
||||
activeTool: "Select",
|
||||
modeMenuEntries,
|
||||
documentModeEntries,
|
||||
documentModeSelectionIndex: 0,
|
||||
viewModeIndex: 0,
|
||||
snappingEnabled: true,
|
||||
gridEnabled: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<LayoutCol :class="'layer-tree-panel'">
|
||||
<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" />
|
||||
|
||||
@@ -15,18 +15,18 @@
|
||||
</PopoverButton>
|
||||
</LayoutRow>
|
||||
<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-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
|
||||
class="layer"
|
||||
:class="{ selected: layer.layer_data.selected }"
|
||||
@click.shift.exact="handleShiftClick(layer)"
|
||||
@click.ctrl.exact="handleControlClick(layer)"
|
||||
@click.alt.exact="handleControlClick(layer)"
|
||||
@click.exact="handleClick(layer)"
|
||||
@click.shift.exact.stop="handleShiftClick(layer)"
|
||||
@click.ctrl.exact.stop="handleControlClick(layer)"
|
||||
@click.alt.exact.stop="handleControlClick(layer)"
|
||||
@click.exact.stop="handleClick(layer)"
|
||||
>
|
||||
<div class="layer-thumbnail" v-html="layer.thumbnail"></div>
|
||||
<div class="layer-type-icon">
|
||||
@@ -53,11 +53,12 @@
|
||||
align-items: center;
|
||||
|
||||
.dropdown-input {
|
||||
flex: 0 0 auto;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.dropdown-input,
|
||||
.number-input {
|
||||
flex: 1 1 100%;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,7 +111,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
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 LayoutCol from "@/components/layout/LayoutCol.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 blendModeMenuEntries: SectionsOfMenuListEntries = [
|
||||
[{ label: "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: "Overlay" }, { label: "Soft Light" }, { label: "Hard Light" }, { label: "Vivid Light" }, { label: "Linear Light" }, { label: "Pin Light" }, { label: "Hard Mix" }],
|
||||
[{ label: "Difference" }, { label: "Exclusion" }, { label: "Subtract" }, { label: "Divide" }],
|
||||
[{ label: "Hue" }, { label: "Saturation" }, { label: "Color" }, { label: "Luminosity" }],
|
||||
const blendModeEntries: SectionsOfMenuListEntries = [
|
||||
[{ label: "Normal", value: BlendMode.Normal }],
|
||||
[
|
||||
{ label: "Multiply", value: BlendMode.Multiply },
|
||||
{ label: "Darken", value: BlendMode.Darken },
|
||||
{ label: "Color Burn", value: BlendMode.ColorBurn },
|
||||
// { 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({
|
||||
@@ -140,9 +171,14 @@ export default defineComponent({
|
||||
const { toggle_layer_visibility } = await wasm;
|
||||
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) {
|
||||
const index = this.layers.indexOf(clickedLayer);
|
||||
clickedLayer.layer_data.selected = !clickedLayer.layer_data.selected;
|
||||
|
||||
this.selectionRangeEndLayer = undefined;
|
||||
this.selectionRangeStartLayer =
|
||||
this.layers.slice(index).filter((layer) => layer.layer_data.selected)[0] ||
|
||||
@@ -150,29 +186,41 @@ export default defineComponent({
|
||||
.slice(0, index)
|
||||
.reverse()
|
||||
.filter((layer) => layer.layer_data.selected)[0];
|
||||
this.updateSelection();
|
||||
|
||||
this.sendSelectedLayers();
|
||||
},
|
||||
async handleShiftClick(clickedLayer: LayerPanelEntry) {
|
||||
// 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.selectionRangeEndLayer = clickedLayer;
|
||||
if (!this.selectionRangeStartLayer) this.selectionRangeStartLayer = clickedLayer;
|
||||
this.fillSelectionRange(this.selectionRangeStartLayer, this.selectionRangeEndLayer, true);
|
||||
this.updateSelection();
|
||||
},
|
||||
|
||||
this.sendSelectedLayers();
|
||||
},
|
||||
async handleClick(clickedLayer: LayerPanelEntry) {
|
||||
this.selectionRangeStartLayer = clickedLayer;
|
||||
this.selectionRangeEndLayer = clickedLayer;
|
||||
|
||||
this.clearSelection();
|
||||
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) {
|
||||
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 [min, max] = [startIndex, endIndex].sort();
|
||||
|
||||
if (min !== -1) {
|
||||
for (let i = min; i <= max; i += 1) {
|
||||
this.layers[i].layer_data.selected = selected;
|
||||
@@ -184,11 +232,12 @@ export default defineComponent({
|
||||
layer.layer_data.selected = false;
|
||||
});
|
||||
},
|
||||
async updateSelection() {
|
||||
async sendSelectedLayers() {
|
||||
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 output = new BigUint64Array(length);
|
||||
|
||||
let i = 0;
|
||||
paths.forEach((path, index) => {
|
||||
output.set(path, i);
|
||||
@@ -202,6 +251,30 @@ export default defineComponent({
|
||||
const { select_layers } = await wasm;
|
||||
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() {
|
||||
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");
|
||||
|
||||
this.layers = responseLayers;
|
||||
|
||||
this.setBlendModeForSelectedLayers();
|
||||
}
|
||||
});
|
||||
registerResponseHandler(ResponseType.CollapseFolder, (responseData) => {
|
||||
@@ -220,13 +295,15 @@ export default defineComponent({
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
blendModeMenuEntries,
|
||||
blendModeEntries,
|
||||
blendModeSelectedIndex: 0,
|
||||
blendModeDropdownDisabled: true,
|
||||
layers: [] as Array<LayerPanelEntry>,
|
||||
selectionRangeStartLayer: undefined as undefined | LayerPanelEntry,
|
||||
selectionRangeEndLayer: undefined as undefined | LayerPanelEntry,
|
||||
opacity: 100,
|
||||
MenuDirection,
|
||||
SeparatorType,
|
||||
layers: [] as Array<LayerPanelEntry>,
|
||||
selectionRangeStartLayer: undefined as LayerPanelEntry | undefined,
|
||||
selectionRangeEndLayer: undefined as LayerPanelEntry | undefined,
|
||||
opacity: 100,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
|
||||
@@ -129,6 +129,7 @@ export type MenuListEntries = Array<MenuListEntry>;
|
||||
export type SectionsOfMenuListEntries = Array<MenuListEntries>;
|
||||
|
||||
interface MenuListEntryData {
|
||||
value?: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
// TODO: Add `checkbox` (which overrides any `icon`)
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<template>
|
||||
<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" />
|
||||
<span>{{ activeEntry.label }}</span>
|
||||
<IconLabel :class="'dropdown-arrow'" :icon="'DropdownArrow'" />
|
||||
</div>
|
||||
<MenuList
|
||||
:menuEntries="menuEntries"
|
||||
v-model:active-entry="activeEntry"
|
||||
:direction="MenuDirection.Bottom"
|
||||
@update:activeEntry="activeEntryChanged"
|
||||
@width-changed="onWidthChanged"
|
||||
:menuEntries="menuEntries"
|
||||
:direction="MenuDirection.Bottom"
|
||||
:drawIcon="drawIcon"
|
||||
:scrollable="true"
|
||||
ref="menuList"
|
||||
@@ -66,6 +67,18 @@
|
||||
&.open {
|
||||
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 {
|
||||
@@ -83,22 +96,36 @@ import { MenuDirection } from "@/components/widgets/floating-menus/FloatingMenu.
|
||||
export default defineComponent({
|
||||
props: {
|
||||
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 },
|
||||
disabled: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
activeEntry: this.default,
|
||||
activeEntry: this.menuEntries.flat()[this.selectedIndex],
|
||||
MenuDirection,
|
||||
minWidth: 0,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
clickDropdownBox() {
|
||||
(this.$refs.menuList as typeof MenuList).setOpen();
|
||||
watch: {
|
||||
// Called only when `selectedIndex` is changed from outside this component (with v-model)
|
||||
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) {
|
||||
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 {
|
||||
name: string;
|
||||
visible: boolean;
|
||||
blend_mode: BlendMode;
|
||||
layer_type: LayerType;
|
||||
path: BigUint64Array;
|
||||
layer_data: LayerData;
|
||||
@@ -213,6 +257,7 @@ function newLayerPanelEntry(input: any): LayerPanelEntry {
|
||||
return {
|
||||
name: input.name,
|
||||
visible: input.visible,
|
||||
blend_mode: newBlendMode(input.blend_mode),
|
||||
layer_type: newLayerType(input.layer_type),
|
||||
layer_data: newLayerData(input.layer_data),
|
||||
path: new BigUint64Array(input.path.map((n: number) => BigInt(n))),
|
||||
|
||||
@@ -18,6 +18,7 @@ default = ["console_error_panic_hook"]
|
||||
[dependencies]
|
||||
console_error_panic_hook = { version = "0.1.6", optional = true }
|
||||
editor-core = { path = "../../../core/editor", package = "graphite-editor-core" }
|
||||
document-core = { path = "../../../core/document", package = "graphite-document-core" }
|
||||
log = "0.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
wasm-bindgen = { version = "0.2.73", features = ["serde-serialize"] }
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
use crate::shims::Error;
|
||||
use crate::wrappers::{translate_key, translate_tool, Color};
|
||||
use crate::EDITOR_STATE;
|
||||
use document_core::layers::BlendMode;
|
||||
use editor_core::input::input_preprocessor::ModifierKeys;
|
||||
use editor_core::input::mouse::ScrollDelta;
|
||||
use editor_core::message_prelude::*;
|
||||
use editor_core::misc::EditorError;
|
||||
use editor_core::tool::{tool_options::ToolOptions, tools, ToolType};
|
||||
use editor_core::{
|
||||
input::mouse::{MouseState, ViewportPosition},
|
||||
@@ -211,6 +213,32 @@ pub fn reorder_selected_layers(delta: i32) -> Result<(), JsValue> {
|
||||
.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
|
||||
#[wasm_bindgen]
|
||||
pub fn export_document() -> Result<(), JsValue> {
|
||||
|
||||
@@ -23,6 +23,12 @@ pub fn init() {
|
||||
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) {
|
||||
let response_type = response.to_discriminant().local_name();
|
||||
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 _ = 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>;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ impl Color {
|
||||
}
|
||||
|
||||
macro_rules! match_string_to_enum {
|
||||
(match ($e:expr) {$($var:ident),* $(,)?}) => {
|
||||
(match ($e:expr) {$($var:ident),* $(,)?}) => {
|
||||
match $e {
|
||||
$(
|
||||
stringify!($var) => Some($var),
|
||||
|
||||
@@ -221,7 +221,9 @@ impl Document {
|
||||
/// Deletes the layer specified by `path`.
|
||||
pub fn delete(&mut self, path: &[LayerId]) -> Result<(), DocumentError> {
|
||||
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)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -393,13 +395,21 @@ impl Document {
|
||||
Some(responses)
|
||||
}
|
||||
Operation::ToggleVisibility { path } => {
|
||||
let _ = self.layer_mut(&path).map(|layer| {
|
||||
if let Ok(layer) = self.layer_mut(&path) {
|
||||
layer.visible = !layer.visible;
|
||||
layer.cache_dirty = true;
|
||||
});
|
||||
}
|
||||
let path = path.as_slice()[..path.len() - 1].to_vec();
|
||||
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 } => {
|
||||
let layer = self.layer_mut(path).unwrap();
|
||||
layer.style.set_fill(layers::style::Fill::new(*color));
|
||||
|
||||
@@ -24,12 +24,15 @@ use crate::LayerId;
|
||||
pub use folder::Folder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
pub trait LayerData {
|
||||
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 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)]
|
||||
pub enum LayerDataTypes {
|
||||
Folder(Folder),
|
||||
@@ -40,6 +43,48 @@ pub enum LayerDataTypes {
|
||||
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 {
|
||||
($self:ident.render($svg:ident, $transform:ident, $style:ident) { $($variant:ident),* }) => {
|
||||
match $self {
|
||||
@@ -56,7 +101,7 @@ macro_rules! call_kurbo_path {
|
||||
}
|
||||
|
||||
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 {
|
||||
$(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 {
|
||||
call_kurbo_path! {
|
||||
self.to_kurbo_path(transform, style) {
|
||||
@@ -125,7 +171,9 @@ pub struct Layer {
|
||||
pub transform: glam::DAffine2,
|
||||
pub style: style::PathStyle,
|
||||
pub cache: String,
|
||||
pub thumbnail_cache: String,
|
||||
pub cache_dirty: bool,
|
||||
pub blend_mode: BlendMode,
|
||||
}
|
||||
|
||||
impl Layer {
|
||||
@@ -137,7 +185,9 @@ impl Layer {
|
||||
transform: glam::DAffine2::from_cols_array(&transform),
|
||||
style,
|
||||
cache: String::new(),
|
||||
thumbnail_cache: String::new(),
|
||||
cache_dirty: true,
|
||||
blend_mode: BlendMode::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +196,17 @@ impl Layer {
|
||||
return "";
|
||||
}
|
||||
if self.cache_dirty {
|
||||
self.thumbnail_cache.clear();
|
||||
self.data.render(&mut self.thumbnail_cache, self.transform, self.style);
|
||||
|
||||
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.as_str()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use crate::color::Color;
|
||||
use serde::{Deserialize, Serialize};
|
||||
const OPACITY_PERCISION: usize = 3;
|
||||
const OPACITY_PRECISION: usize = 3;
|
||||
|
||||
fn format_opacity(name: &str, opacity: f32) -> String {
|
||||
if (opacity - 1.).abs() > 10f32.powi(-(OPACITY_PERCISION as i32)) {
|
||||
format!(r#" {}-opacity="{:.percision$}""#, name, opacity, percision = OPACITY_PERCISION)
|
||||
if (opacity - 1.).abs() > 10f32.powi(-(OPACITY_PRECISION as i32)) {
|
||||
format!(r#" {}-opacity="{:.precision$}""#, name, opacity, precision = OPACITY_PRECISION)
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
color::Color,
|
||||
layers::{style, Layer},
|
||||
layers::{style, BlendMode, Layer},
|
||||
LayerId,
|
||||
};
|
||||
|
||||
@@ -72,6 +72,10 @@ pub enum Operation {
|
||||
ToggleVisibility {
|
||||
path: Vec<LayerId>,
|
||||
},
|
||||
SetLayerBlendMode {
|
||||
path: Vec<LayerId>,
|
||||
blend_mode: BlendMode,
|
||||
},
|
||||
FillLayer {
|
||||
path: Vec<LayerId>,
|
||||
color: Color,
|
||||
|
||||
@@ -38,11 +38,17 @@ fn layer_data<'a>(layer_data: &'a mut HashMap<Vec<LayerId>, LayerData>, path: &[
|
||||
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 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 = 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() {
|
||||
format!(
|
||||
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,
|
||||
x_max - x_min,
|
||||
y_max - y_min,
|
||||
layer.cache.clone()
|
||||
layer.thumbnail_cache.clone()
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
LayerPanelEntry {
|
||||
name,
|
||||
visible: layer.visible,
|
||||
blend_mode,
|
||||
layer_type,
|
||||
layer_data: *layer_data,
|
||||
path,
|
||||
@@ -73,16 +81,17 @@ impl Document {
|
||||
/// 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.
|
||||
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 entries = folder
|
||||
.as_folder()?
|
||||
.layers()
|
||||
.iter()
|
||||
.zip(folder.as_folder()?.layer_ids.iter())
|
||||
.as_folder_mut()?
|
||||
.layers_mut()
|
||||
.iter_mut()
|
||||
.zip(ids)
|
||||
.rev()
|
||||
.map(|(layer, id)| {
|
||||
let path = [path, &[*id]].concat();
|
||||
let path = [path, &[id]].concat();
|
||||
layer_panel_entry(layer_data(self_layer_data, &path), layer, path)
|
||||
})
|
||||
.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},
|
||||
input::{mouse::ViewportPosition, InputPreprocessor},
|
||||
};
|
||||
use document_core::layers::BlendMode;
|
||||
use document_core::layers::Layer;
|
||||
use document_core::{DocumentResponse, LayerId, Operation as DocumentOperation};
|
||||
use glam::{DAffine2, DVec2};
|
||||
@@ -24,6 +25,7 @@ pub enum DocumentMessage {
|
||||
DeleteSelectedLayers,
|
||||
DuplicateSelectedLayers,
|
||||
CopySelectedLayers,
|
||||
SetBlendModeForSelectedLayers(BlendMode),
|
||||
PasteLayers,
|
||||
AddFolder(Vec<LayerId>),
|
||||
RenameLayer(Vec<LayerId>, String),
|
||||
@@ -342,6 +344,13 @@ impl MessageHandler<DocumentMessage, &InputPreprocessor> for DocumentMessageHand
|
||||
}
|
||||
.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) => {
|
||||
responses.push_back(DocumentOperation::ToggleVisibility { path }.into());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::document::LayerData;
|
||||
use document_core::{layers::LayerDataTypes, LayerId};
|
||||
use document_core::{
|
||||
layers::{BlendMode, LayerDataTypes},
|
||||
LayerId,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
|
||||
@@ -7,6 +10,7 @@ use std::fmt;
|
||||
pub struct LayerPanelEntry {
|
||||
pub name: String,
|
||||
pub visible: bool,
|
||||
pub blend_mode: BlendMode,
|
||||
pub layer_type: LayerType,
|
||||
pub layer_data: LayerData,
|
||||
pub path: Vec<LayerId>,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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)]
|
||||
pub enum ToolOptions {
|
||||
Select { append_mode: SelectAppendMode },
|
||||
|
||||
Reference in New Issue
Block a user