mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-25 18:18:12 +08:00
Move gradient picking into the color picker (#1778)
* Gradient picker * Fix up color picker layout CSS problems * Begin hooking up SpectrumInput for gradient in the ColorPicker * Working gradient picking on the frontend only * Plumb FillColorChoice into the backend * Hook everything else up, just with a weird bug remaining * Fix some svelty reactivity issues * Add and remove stops * Cleanup * Rename type * Fill node document format upgrading * Fix lint * Polish the color picker UX and fix a bug --------- Co-authored-by: 0hypercube <0hypercube@gmail.com>
This commit is contained in:
co-authored by
0hypercube
parent
449729f1e1
commit
a9a4b5cd19
@@ -1,33 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import type { Color } from "@graphite/wasm-communication/messages";
|
||||
import type { FillChoice } from "@graphite/wasm-communication/messages";
|
||||
import { Color, Gradient } from "@graphite/wasm-communication/messages";
|
||||
|
||||
import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher<{ value: Color; startHistoryTransaction: undefined }>();
|
||||
const dispatch = createEventDispatcher<{ value: FillChoice; startHistoryTransaction: undefined }>();
|
||||
|
||||
let open = false;
|
||||
|
||||
export let value: Color;
|
||||
export let value: FillChoice;
|
||||
export let disabled = false;
|
||||
export let allowNone = false;
|
||||
// export let allowTransparency = false; // TODO: Implement
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
$: chosenGradient = value instanceof Gradient ? value.toLinearGradientCSS() : `linear-gradient(${value.toHexOptionalAlpha()}, ${value.toHexOptionalAlpha()})`;
|
||||
</script>
|
||||
|
||||
<LayoutCol class="color-button" classes={{ disabled, none: value.none, open }} {tooltip}>
|
||||
<button {disabled} style:--chosen-color={value.toHexOptionalAlpha()} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner></button>
|
||||
{#if disabled && !value.none}
|
||||
<LayoutCol class="color-button" classes={{ disabled, none: value instanceof Color ? value.none : false, open }} {tooltip}>
|
||||
<button {disabled} style:--chosen-gradient={chosenGradient} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner></button>
|
||||
{#if disabled && value instanceof Color && !value.none}
|
||||
<TextLabel>sRGB</TextLabel>
|
||||
{/if}
|
||||
<ColorPicker
|
||||
{open}
|
||||
on:open={({ detail }) => (open = detail)}
|
||||
color={value}
|
||||
on:color={({ detail }) => {
|
||||
colorOrGradient={value}
|
||||
on:colorOrGradient={({ detail }) => {
|
||||
value = detail;
|
||||
dispatch("value", detail);
|
||||
}}
|
||||
@@ -71,9 +74,14 @@
|
||||
margin-top: 2px;
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
background: linear-gradient(var(--chosen-color), var(--chosen-color)), var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
background-image: var(--chosen-gradient), var(--color-transparent-checkered-background);
|
||||
background-size:
|
||||
100% 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
0 0,
|
||||
var(--color-transparent-checkered-background-position);
|
||||
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
}
|
||||
|
||||
&.none {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
import MenuList from "@graphite/components/floating-menus/MenuList.svelte";
|
||||
import ConditionalWrapper from "@graphite/components/layout/ConditionalWrapper.svelte";
|
||||
import IconLabel from "@graphite/components/widgets/labels/IconLabel.svelte";
|
||||
import Separator from "@graphite/components/widgets/labels/Separator.svelte";
|
||||
import TextLabel from "@graphite/components/widgets/labels/TextLabel.svelte";
|
||||
|
||||
let self: MenuList;
|
||||
@@ -74,9 +73,6 @@
|
||||
<IconLabel icon={hoverIcon} />
|
||||
{/if}
|
||||
{/if}
|
||||
{#if icon && label}
|
||||
<Separator type={flush ? "Unrelated" : "Related"} />
|
||||
{/if}
|
||||
{#if label}
|
||||
<TextLabel>{label}</TextLabel>
|
||||
{/if}
|
||||
@@ -161,22 +157,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
.widget-span.row > & + .text-button,
|
||||
.layout-row > & + .text-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.widget-span.column > & + .text-button,
|
||||
.layout-column > & + .text-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.icon-label {
|
||||
fill: var(--button-text-color);
|
||||
|
||||
+ .text-label {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-label {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Custom styling for when multiple TextButton widgets are used next to one another in a row or column
|
||||
.widget-span.row > & + .text-button,
|
||||
.layout-row > & + .text-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.widget-span.column > & + .text-button,
|
||||
.layout-column > & + .text-button {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -128,8 +128,7 @@
|
||||
}
|
||||
|
||||
.dropdown-icon {
|
||||
margin: 4px;
|
||||
margin-left: 8px;
|
||||
margin: 4px 8px;
|
||||
flex: 0 0 auto;
|
||||
|
||||
& + .dropdown-label {
|
||||
|
||||
@@ -266,6 +266,9 @@
|
||||
// Only drag the number with left click (and when it's valid to do so)
|
||||
if (e.button !== BUTTON_LEFT || mode !== "Increment" || value === undefined || disabled || editing) return;
|
||||
|
||||
// Remove the text entry cursor from any other selected text field
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||
|
||||
// Don't drag the text value from is input element
|
||||
e.preventDefault();
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onDestroy } from "svelte";
|
||||
|
||||
import { Color, type Gradient } from "@graphite/wasm-communication/messages";
|
||||
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
|
||||
|
||||
const dispatch = createEventDispatcher<{ activeMarkerIndexChange: number | undefined; gradient: Gradient }>();
|
||||
|
||||
export let gradient: Gradient;
|
||||
export let activeMarkerIndex = 0 as number | undefined;
|
||||
// export let disabled = false;
|
||||
// export let tooltip: string | undefined = undefined;
|
||||
|
||||
let markerTrack: LayoutRow | undefined;
|
||||
|
||||
function markerPointerDown(e: PointerEvent, index: number) {
|
||||
// Left-click to select and begin potentially dragging
|
||||
if (e.button === 0) {
|
||||
activeMarkerIndex = index;
|
||||
dispatch("activeMarkerIndexChange", index);
|
||||
addEvents();
|
||||
return;
|
||||
}
|
||||
|
||||
// Right-click to delete
|
||||
if (e.button === 2) {
|
||||
deleteStopByIndex(index);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function markerPosition(e: MouseEvent): number | undefined {
|
||||
const markerTrackRect = markerTrack?.div()?.getBoundingClientRect();
|
||||
if (!markerTrackRect) return;
|
||||
|
||||
const ratio = (e.clientX - markerTrackRect.left) / markerTrackRect.width;
|
||||
|
||||
return Math.max(0, Math.min(1, ratio));
|
||||
}
|
||||
|
||||
function insertStop(e: MouseEvent) {
|
||||
let position = markerPosition(e);
|
||||
if (position === undefined) return;
|
||||
|
||||
let before = gradient.stops.findLast((value) => value.position < position);
|
||||
let after = gradient.stops.find((value) => value.position > position);
|
||||
|
||||
let color = Color.fromCSS("black") as Color;
|
||||
if (before && after) {
|
||||
let t = (position - before.position) / (after.position - before.position);
|
||||
color = before.color.lerp(after.color, t);
|
||||
} else if (before) {
|
||||
color = before.color;
|
||||
} else if (after) {
|
||||
color = after.color;
|
||||
}
|
||||
|
||||
let index = gradient.stops.findIndex((value) => value.position > position);
|
||||
if (index === -1) index = gradient.stops.length;
|
||||
|
||||
gradient.stops.splice(index, 0, { position, color });
|
||||
activeMarkerIndex = index;
|
||||
|
||||
dispatch("activeMarkerIndexChange", index);
|
||||
dispatch("gradient", gradient);
|
||||
}
|
||||
|
||||
function deleteStop(e: KeyboardEvent) {
|
||||
if (e.key.toLowerCase() !== "delete" && e.key.toLowerCase() !== "backspace") return;
|
||||
if (activeMarkerIndex === undefined) return;
|
||||
deleteStopByIndex(activeMarkerIndex);
|
||||
}
|
||||
|
||||
function deleteStopByIndex(index: number) {
|
||||
if (gradient.stops.length <= 2) return;
|
||||
|
||||
gradient.stops.splice(index, 1);
|
||||
if (gradient.stops.length === 0) {
|
||||
activeMarkerIndex = undefined;
|
||||
} else {
|
||||
activeMarkerIndex = Math.max(0, Math.min(gradient.stops.length - 1, index));
|
||||
}
|
||||
|
||||
dispatch("activeMarkerIndexChange", activeMarkerIndex);
|
||||
dispatch("gradient", gradient);
|
||||
}
|
||||
|
||||
function moveMarker(e: PointerEvent, index: number) {
|
||||
// Just in case the mouseup event is lost
|
||||
if (e.buttons === 0) removeEvents();
|
||||
|
||||
let position = markerPosition(e);
|
||||
if (position === undefined) return;
|
||||
setPosition(index, position);
|
||||
}
|
||||
|
||||
export function setPosition(index: number, position: number) {
|
||||
const active = gradient.stops[index];
|
||||
active.position = position;
|
||||
gradient.stops.sort((a, b) => a.position - b.position);
|
||||
if (gradient.stops.indexOf(active) !== activeMarkerIndex) {
|
||||
activeMarkerIndex = gradient.stops.indexOf(active);
|
||||
dispatch("activeMarkerIndexChange", gradient.stops.indexOf(active));
|
||||
}
|
||||
dispatch("gradient", gradient);
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (activeMarkerIndex !== undefined) {
|
||||
moveMarker(e, activeMarkerIndex);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp() {
|
||||
removeEvents();
|
||||
}
|
||||
|
||||
function addEvents() {
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
function removeEvents() {
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", deleteStop);
|
||||
onDestroy(() => {
|
||||
removeEvents();
|
||||
document.removeEventListener("keydown", deleteStop);
|
||||
});
|
||||
|
||||
// # Backend -> Frontend
|
||||
// Populate(gradient, { position, color }[], active) // The only way indexes get changed. Frontend drops marker if it's being dragged.
|
||||
// UpdateGradient(gradient)
|
||||
// UpdateMarkers({ index, position, color }[])
|
||||
|
||||
// # Frontend -> Backend
|
||||
// SendNewActive(index)
|
||||
// SendPositions({ index, position }[])
|
||||
// AddMarker(position)
|
||||
// RemoveMarkers(index[])
|
||||
// ResetMarkerToDefault(index)
|
||||
|
||||
// // We need a way to encode constraints on some markers, like locking them in place or preventing reordering
|
||||
// // We need a way to encode the allowability of adding new markers between certain markers, or preventing the deletion of certain markers
|
||||
// // We need the ability to multi-select markers and move them all at once
|
||||
</script>
|
||||
|
||||
<LayoutCol
|
||||
class="spectrum-input"
|
||||
styles={{
|
||||
"--gradient-start": gradient.firstColor()?.toHexOptionalAlpha() || "black",
|
||||
"--gradient-end": gradient.lastColor()?.toHexOptionalAlpha() || "black",
|
||||
"--gradient-stops": gradient.toLinearGradientCSS(),
|
||||
}}
|
||||
>
|
||||
<LayoutRow class="gradient-strip" on:click={insertStop}></LayoutRow>
|
||||
<LayoutRow class="marker-track" bind:this={markerTrack}>
|
||||
{#each gradient.stops as marker, index}
|
||||
<svg
|
||||
style:--marker-position={marker.position}
|
||||
style:--marker-color={marker.color.toRgbCSS()}
|
||||
class="marker"
|
||||
class:active={index === activeMarkerIndex}
|
||||
data-gradient-marker
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 12 12"
|
||||
>
|
||||
<path
|
||||
on:pointerdown={(e) => markerPointerDown(e, index)}
|
||||
d="M10,11.5H2c-0.8,0-1.5-0.7-1.5-1.5V6.8c0-0.4,0.2-0.8,0.4-1.1L6,0.7l5.1,5.1c0.3,0.3,0.4,0.7,0.4,1.1V10C11.5,10.8,10.8,11.5,10,11.5z"
|
||||
/>
|
||||
<path
|
||||
on:pointerdown={(e) => markerPointerDown(e, index)}
|
||||
d="M6,1.4L1.3,6.1C1.1,6.3,1,6.6,1,6.8V10c0,0.6,0.4,1,1,1h8c0.6,0,1-0.4,1-1V6.8c0-0.3-0.1-0.5-0.3-0.7L6,1.4M6,0l5.4,5.4C11.8,5.8,12,6.3,12,6.8V10c0,1.1-0.9,2-2,2H2c-1.1,0-2-0.9-2-2V6.8c0-0.5,0.2-1,0.6-1.4L6,0z"
|
||||
/>
|
||||
</svg>
|
||||
{/each}
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
<style lang="scss" global>
|
||||
.spectrum-input {
|
||||
--marker-half-width: 6px;
|
||||
|
||||
.gradient-strip {
|
||||
flex: 0 0 auto;
|
||||
height: 16px;
|
||||
background-image:
|
||||
var(--gradient-stops),
|
||||
// Solid start/end colors on either side so the gradient begins at the center of a marker
|
||||
linear-gradient(var(--gradient-start), var(--gradient-start)),
|
||||
linear-gradient(var(--gradient-end), var(--gradient-end)),
|
||||
var(--color-transparent-checkered-background);
|
||||
background-size:
|
||||
calc(100% - 2 * var(--marker-half-width)) 100%,
|
||||
// TODO: Find a solution that avoids visual artifacts where these end colors meet the gradient that appear when viewing with a non-integer zoom or display scaling factor
|
||||
var(--marker-half-width) 100%,
|
||||
var(--marker-half-width) 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
var(--marker-half-width) 0,
|
||||
left 0,
|
||||
right 0,
|
||||
var(--color-transparent-checkered-background-position);
|
||||
background-repeat: no-repeat, no-repeat, no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.marker-track {
|
||||
margin-top: calc(24px - 16px - 12px);
|
||||
margin-left: var(--marker-half-width);
|
||||
width: calc(100% - 2 * var(--marker-half-width));
|
||||
position: relative;
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
transform: translateX(-50%);
|
||||
left: calc(var(--marker-position) * 100%);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
// Inner fill
|
||||
path:first-child {
|
||||
fill: var(--marker-color);
|
||||
}
|
||||
|
||||
// Outer border
|
||||
path:last-child {
|
||||
fill: var(--color-5-dullgray);
|
||||
}
|
||||
|
||||
&:not(.active) path:first-child:hover + path:last-child,
|
||||
&:not(.active) path:last-child:hover {
|
||||
fill: var(--color-6-lowergray);
|
||||
}
|
||||
|
||||
// Outer border when active
|
||||
&.active path:last-child {
|
||||
fill: var(--color-e-nearwhite);
|
||||
}
|
||||
|
||||
&.active path:first-child:hover + path:last-child,
|
||||
&.active path:last-child:hover {
|
||||
fill: var(--color-f-white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
import { getContext } from "svelte";
|
||||
|
||||
import type { Editor } from "@graphite/wasm-communication/editor";
|
||||
import type { Color } from "@graphite/wasm-communication/messages";
|
||||
import { Color } from "@graphite/wasm-communication/messages";
|
||||
|
||||
import ColorPicker from "@graphite/components/floating-menus/ColorPicker.svelte";
|
||||
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
|
||||
@@ -38,11 +38,23 @@
|
||||
<LayoutCol class="working-colors-button">
|
||||
<LayoutRow class="primary swatch">
|
||||
<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
|
||||
<ColorPicker open={primaryOpen} on:open={({ detail }) => (primaryOpen = detail)} color={primary} on:color={({ detail }) => primaryColorChanged(detail)} direction="Right" />
|
||||
<ColorPicker
|
||||
open={primaryOpen}
|
||||
on:open={({ detail }) => (primaryOpen = detail)}
|
||||
colorOrGradient={primary}
|
||||
on:colorOrGradient={({ detail }) => detail instanceof Color && primaryColorChanged(detail)}
|
||||
direction="Right"
|
||||
/>
|
||||
</LayoutRow>
|
||||
<LayoutRow class="secondary swatch">
|
||||
<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={secondary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0" />
|
||||
<ColorPicker open={secondaryOpen} on:open={({ detail }) => (secondaryOpen = detail)} color={secondary} on:color={({ detail }) => secondaryColorChanged(detail)} direction="Right" />
|
||||
<ColorPicker
|
||||
open={secondaryOpen}
|
||||
on:open={({ detail }) => (secondaryOpen = detail)}
|
||||
colorOrGradient={secondary}
|
||||
on:colorOrGradient={({ detail }) => detail instanceof Color && secondaryColorChanged(detail)}
|
||||
direction="Right"
|
||||
/>
|
||||
</LayoutRow>
|
||||
</LayoutCol>
|
||||
|
||||
@@ -67,8 +79,13 @@
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
background: linear-gradient(var(--swatch-color), var(--swatch-color)), var(--color-transparent-checkered-background);
|
||||
background-size: var(--color-transparent-checkered-background-size);
|
||||
background-position: var(--color-transparent-checkered-background-position);
|
||||
background-size:
|
||||
100% 100%,
|
||||
var(--color-transparent-checkered-background-size);
|
||||
background-position:
|
||||
0 0,
|
||||
var(--color-transparent-checkered-background-position);
|
||||
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
|
||||
overflow: hidden;
|
||||
|
||||
&:hover,
|
||||
|
||||
Reference in New Issue
Block a user