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 18:21:07 +01:00
committed by GitHub
parent 63e3b5c604
commit 8f9168bfe5
18 changed files with 348 additions and 69 deletions

3
Cargo.lock generated
View File

@@ -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",

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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`)

View File

@@ -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;

View File

@@ -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))),

View File

@@ -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"] }

View File

@@ -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> {

View File

@@ -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>;
}

View File

@@ -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),

View File

@@ -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));

View File

@@ -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()

View File

@@ -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()
}

View File

@@ -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,

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()
}
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();

View File

@@ -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());
}

View File

@@ -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>,

View File

@@ -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 },