mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-03 07:38:12 +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:
co-authored by
Keavon Chambers
Dennis Kobert
parent
63e3b5c604
commit
8f9168bfe5
Generated
+3
@@ -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,
|
||||||
|
|||||||
@@ -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))),
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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 },
|
||||||
|
|||||||
Reference in New Issue
Block a user