Polish and add aborting to several input widgets: no Esc closing parent menus; color picker axis align; repeat on arrow buttons (#2276)

* Remove color input outline; reduce antialiasing compositing artifacts in color widgets

* Rename ColorButton to ColorInput

* Add features and aborting to several other widgets

- Prevent Esc from closing parent floating menus when aborting
- Fix missing icon regression
- Gutter resizing abort
- Color picker aborts, Shift axis alignment, improve click/drag behavior for gradient spectrum
- Scrollbar abort, repeat when held, fix directional arrows when viewport is zoomed
- Number input abort, repeat when held

* Move ColorInput into the inputs folder

* Fix tiny logo
This commit is contained in:
Keavon Chambers
2025-02-10 05:46:41 -08:00
committed by GitHub
parent 0037f5158c
commit ec8c8d6485
38 changed files with 1031 additions and 452 deletions
@@ -0,0 +1,141 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import type { FillChoice } from "@graphite/messages";
import { Color, contrastingOutlineFactor, Gradient } from "@graphite/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: FillChoice; startHistoryTransaction: undefined }>();
let open = false;
export let value: FillChoice;
export let disabled = false;
export let allowNone = false;
// export let allowTransparency = false; // TODO: Implement
export let tooltip: string | undefined = undefined;
$: outlineFactor = contrastingOutlineFactor(value, ["--color-1-nearblack", "--color-3-darkgray"], 0.01);
$: outlined = outlineFactor > 0.0001;
$: chosenGradient = value instanceof Gradient ? value.toLinearGradientCSS() : `linear-gradient(${value.toHexOptionalAlpha()}, ${value.toHexOptionalAlpha()})`;
$: none = value instanceof Color ? value.none : false;
$: transparency = value instanceof Gradient ? value.stops.some((stop) => stop.color.alpha < 1) : value.alpha < 1;
</script>
<LayoutCol class="color-button" classes={{ open, disabled, none, transparency, outlined }} {tooltip}>
<button {disabled} style:--chosen-gradient={chosenGradient} style:--outline-amount={outlineFactor} on:click={() => (open = true)} tabindex="0" data-floating-menu-spawner>
{#if disabled && value instanceof Color && !value.none}
<TextLabel>sRGB</TextLabel>
{/if}
</button>
<ColorPicker
{open}
on:open={({ detail }) => (open = detail)}
colorOrGradient={value}
on:colorOrGradient={({ detail }) => {
value = detail;
dispatch("value", detail);
}}
on:startHistoryTransaction={() => {
// This event is sent to the backend so it knows to start a transaction for the history system. See discussion for some explanation:
// <https://github.com/GraphiteEditor/Graphite/pull/1584#discussion_r1477592483>
dispatch("startHistoryTransaction");
}}
{allowNone}
/>
</LayoutCol>
<style lang="scss" global>
.color-button {
position: relative;
min-width: 80px;
> button {
border: none;
border-radius: 2px;
padding: 0;
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
&::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: var(--chosen-gradient);
}
.text-label {
background: var(--color-5-dullgray);
font-size: 10px;
line-height: 12px;
height: 12px;
border-radius: 0 0 0 2px;
padding-right: 4px;
padding-left: 4px;
position: absolute;
right: 0;
top: 0;
}
}
&.outlined > button::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
box-shadow: inset 0 0 0 1px rgba(var(--color-5-dullgray-rgb), var(--outline-amount));
}
&.transparency > button {
background-image: var(--color-transparent-checkered-background);
background-size: var(--color-transparent-checkered-background-size);
background-position: var(--color-transparent-checkered-background-position);
background-repeat: var(--color-transparent-checkered-background-repeat);
}
&:not(.disabled).none > button {
background: var(--color-none);
background-repeat: var(--color-none-repeat);
background-position: var(--color-none-position);
background-size: var(--color-none-size-24px);
background-image: var(--color-none-image-24px);
}
&.disabled.none > button::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: var(--color-4-dimgray);
}
&:not(.disabled):hover > button .text-label,
&:not(.disabled).open > button .text-label {
background: var(--color-6-lowergray);
color: var(--color-f-white);
}
&.disabled > button .text-label {
background: var(--color-4-dimgray);
color: var(--color-8-uppergray);
}
> .floating-menu {
left: 50%;
bottom: 0;
}
}
</style>
@@ -3,6 +3,7 @@
import { platformIsMac } from "@graphite/utility-functions/platform";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
const dispatch = createEventDispatcher<{
@@ -65,6 +66,12 @@
export function element(): HTMLInputElement | HTMLTextAreaElement | undefined {
return inputOrTextarea;
}
function cancel() {
dispatch("textChangeCanceled");
if (inputOrTextarea) preventEscapeClosingParentFloatingMenu(inputOrTextarea);
}
</script>
<!-- This is a base component, extended by others like NumberInput and TextInput. It should not be used directly. -->
@@ -83,7 +90,7 @@
on:blur={() => dispatch("textChanged")}
on:change={() => dispatch("textChanged")}
on:keydown={(e) => e.key === "Enter" && dispatch("textChanged")}
on:keydown={(e) => e.key === "Escape" && dispatch("textChangeCanceled")}
on:keydown={(e) => e.key === "Escape" && cancel()}
on:pointerdown
on:contextmenu={(e) => hideContextMenu && e.preventDefault()}
data-input-element
@@ -102,7 +109,7 @@
on:blur={() => dispatch("textChanged")}
on:change={() => dispatch("textChanged")}
on:keydown={(e) => (macKeyboardLayout ? e.metaKey : e.ctrlKey) && e.key === "Enter" && dispatch("textChanged")}
on:keydown={(e) => e.key === "Escape" && dispatch("textChangeCanceled")}
on:keydown={(e) => e.key === "Escape" && cancel()}
on:pointerdown
on:contextmenu={(e) => hideContextMenu && e.preventDefault()}
/>
@@ -1,9 +1,11 @@
<script lang="ts">
import { createEventDispatcher, onMount, onDestroy } from "svelte";
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS } from "@graphite/io-managers/input";
import { type NumberInputMode, type NumberInputIncrementBehavior } from "@graphite/messages";
import { evaluateMathExpression } from "@graphite-frontend/wasm/pkg/graphite_wasm.js";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import FieldInput from "@graphite/components/widgets/inputs/FieldInput.svelte";
const BUTTONS_LEFT = 0b0000_0001;
@@ -59,8 +61,10 @@
let self: FieldInput | undefined;
let inputRangeElement: HTMLInputElement | undefined;
let text = displayText(value, unit);
let isDragging = false;
let editing = false;
let isDragging = false;
let pressingArrow = false;
let repeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
// Stays in sync with a binding to the actual input range slider element.
let rangeSliderValue = value !== undefined ? value : 0;
// Value used to render the position of the fake slider when applicable, and length of the progress colored region to the slider's left.
@@ -230,8 +234,8 @@
// INCREMENT MODE: ARROW BUTTONS
// =============================
function onIncrement(direction: "Decrease" | "Increase") {
if (value === undefined) return;
function onIncrementPointerDown(e: PointerEvent, direction: "Decrease" | "Increase") {
if (value === undefined || e.button !== BUTTON_LEFT) return;
const actions: Record<NumberInputIncrementBehavior, () => void> = {
Add: () => {
@@ -250,7 +254,41 @@
},
None: () => {},
};
actions[incrementBehavior]();
const sendAction = () => {
if (!pressingArrow) return;
actions[incrementBehavior]();
if (afterInitialDelay) repeatTimeout = setTimeout(sendAction, PRESS_REPEAT_INTERVAL_MS);
afterInitialDelay = true;
};
pressingArrow = true;
initialValueBeforeDragging = value;
let afterInitialDelay = false;
sendAction();
repeatTimeout = setTimeout(sendAction, PRESS_REPEAT_DELAY_MS);
addEventListener("keydown", incrementPressAbort);
}
function onIncrementPointerUp() {
pressingArrow = false;
clearTimeout(repeatTimeout);
}
function onIncrementMouseDown(e: MouseEvent) {
if (e.button === BUTTON_RIGHT) incrementPressAbort();
}
function incrementPressAbort() {
const element = self?.element() || undefined;
if (element) preventEscapeClosingParentFloatingMenu(element);
pressingArrow = false;
clearTimeout(repeatTimeout);
updateValue(initialValueBeforeDragging);
removeEventListener("keydown", onIncrementPointerUp);
}
// =======================================
@@ -399,8 +437,8 @@
if (rangeSliderClickDragState === "Aborted") {
// If we've just aborted the drag by right clicking, but the user hasn't yet released the left mouse button, Firefox treats
// some subsequent interactions with the slider (like that right mouse button release, or maybe mouse movement in some cases)
// as input changes to the slider position. Thus, until we leave the "Aborted" state by releasing all mouse buttons,
// we have to set the slider position back to currently intended value to fight against Firefox's attempts to let the user move it.
// as input changes to the slider position. Thus, until we leave the "Aborted" state by releasing all mouse buttons, we have
// to set the slider position back to the currently intended value to fight against Firefox's attempts to let the user move it.
updateValue(rangeSliderValueAsRendered);
// Now we exit early because we're ignoring further user input until the user releases all mouse buttons, which gets us back to the "Ready" state.
@@ -511,10 +549,7 @@
// Logic for aborting from pressing Escape.
if (e instanceof KeyboardEvent) {
// Detect if the user pressed Escape and abort the slider drag.
if (e.key === "Escape") {
// Call the abort helper function.
sliderAbort();
}
if (e.key === "Escape") sliderAbort(true);
}
// Logic for aborting from a right click.
@@ -522,7 +557,7 @@
// This handler's "pointermove" event will be fired upon right click even if the cursor didn't move, which is why it's okay to check in this event handler.
if (e instanceof PointerEvent && e.buttons & BUTTONS_RIGHT) {
// Call the abort helper function
sliderAbort();
sliderAbort(false);
}
// Recovery from the window losing focus while dragging the slider.
@@ -543,9 +578,11 @@
// During this momentary step, the slider hasn't moved yet but we want to allow aborting from this limbo state.
function sliderAbortFromMousedown(e: MouseEvent | KeyboardEvent) {
// Logic for aborting from a right click or pressing Escape.
if ((e instanceof KeyboardEvent && e.key === "Escape") || (e instanceof MouseEvent && e.button === BUTTON_RIGHT)) {
const abortWithEscape = e instanceof KeyboardEvent && e.key === "Escape";
const abortWithRightClick = e instanceof MouseEvent && e.button === BUTTON_RIGHT;
if (abortWithEscape || abortWithRightClick) {
// Call the abort helper function
sliderAbort();
sliderAbort(abortWithEscape);
// Clean up these event listeners because they were for getting us into this function and now we're done with them.
removeEventListener("mousedown", sliderAbortFromMousedown);
@@ -554,7 +591,10 @@
}
// Helper function that performs the state management and cleanup for aborting the slider drag.
function sliderAbort() {
function sliderAbort(abortWithEscape: boolean) {
const element = self?.element() || undefined;
if (abortWithEscape && element) preventEscapeClosingParentFloatingMenu(element);
// End the user's drag by instantaneously disabling and re-enabling the range input element
if (inputRangeElement) inputRangeElement.disabled = true;
setTimeout(() => {
@@ -580,9 +620,7 @@
// dragging the slider, hitting Escape, then releasing the mouse button. This results in being transferred by `onSliderInput()` to the
// "Deciding" state when we should remain in the "Ready" state as set here. (For debugging, this can be visualized in CSS by
// recoloring the fake slider handle, which is shown in the "Deciding" state.)
setTimeout(() => {
rangeSliderClickDragState = "Ready";
}, 0);
setTimeout(() => (rangeSliderClickDragState = "Ready"), 0);
// Clean up the event listener that was used to call this function.
removeEventListener("pointerup", sliderResetAbort);
@@ -617,8 +655,22 @@
>
{#if value !== undefined}
{#if mode === "Increment" && incrementBehavior !== "None"}
<button class="arrow left" on:click={() => onIncrement("Decrease")} tabindex="-1" />
<button class="arrow right" on:click={() => onIncrement("Increase")} tabindex="-1" />
<button
class="arrow left"
on:pointerdown={(e) => onIncrementPointerDown(e, "Decrease")}
on:mousedown={onIncrementMouseDown}
on:pointerup={onIncrementPointerUp}
on:pointerleave={onIncrementPointerUp}
tabindex="-1"
></button>
<button
class="arrow right"
on:pointerdown={(e) => onIncrementPointerDown(e, "Increase")}
on:mousedown={onIncrementMouseDown}
on:pointerup={onIncrementPointerUp}
on:pointerleave={onIncrementPointerUp}
tabindex="-1"
></button>
{/if}
{#if mode === "Range"}
<input
@@ -684,6 +736,8 @@
border: none;
border-radius: 2px;
background: rgba(var(--color-1-nearblack-rgb), 0.5);
// An outline can appear when pressing the arrow button with left click then hitting Escape, so this stops that from showing
outline: none;
&:hover {
background: var(--color-4-dimgray);
@@ -3,38 +3,46 @@
</script>
<script lang="ts">
import { createEventDispatcher, onMount, onDestroy } from "svelte";
import { createEventDispatcher } from "svelte";
// Linear Interpolation
const lerp = (x: number, y: number, a: number): number => x * (1 - a) + y * a;
import { PRESS_REPEAT_DELAY_MS, PRESS_REPEAT_INTERVAL_MS, PRESS_REPEAT_INTERVAL_RAPID_MS } from "@graphite/io-managers/input";
// Convert the position of the handle (0-1) to the position on the track (0-1).
// This includes the 1/2 handle length gap of the possible handle positionson each side so the end of the handle doesn't go off the track.
const handleToTrack = (handleLen: number, handlePos: number): number => lerp(handleLen / 2, 1 - handleLen / 2, handlePos);
const ARROW_CLICK_DISTANCE = 0.05;
const ARROW_REPEAT_DISTANCE = 0.01;
const pointerPosition = (direction: ScrollbarDirection, e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX);
// Convert the position of the thumb (0-1) to the position on the track (0-1).
// This includes the 1/2 thumb length gap of the possible thumb position each side so the end of the thumb doesn't go off the track.
const lerp = (a: number, b: number, t: number): number => a * (1 - t) + b * t;
const thumbToTrack = (thumbLength: number, thumbPosition: number): number => lerp(thumbLength / 2, 1 - thumbLength / 2, thumbPosition);
const dispatch = createEventDispatcher<{ handlePosition: number; pressTrack: number; pointerup: undefined }>();
const pointerPosition = (e: PointerEvent): number => (direction === "Vertical" ? e.clientY : e.clientX);
const clamp01 = (value: number): number => Math.min(Math.max(value, 0), 1);
const dispatch = createEventDispatcher<{ trackShift: number; thumbPosition: number; thumbDragStart: undefined; thumbDragEnd: undefined; thumbDragAbort: undefined }>();
export let direction: ScrollbarDirection = "Vertical";
export let handlePosition = 0.5;
export let handleLength = 0.5;
export let thumbPosition = 0.5;
export let thumbLength = 0.5;
let scrollTrack: HTMLDivElement | undefined;
let dragging = false;
let pointerPos = 0;
let pressingTrack = false;
let pressingArrow = false;
let repeatTimeout: ReturnType<typeof setTimeout> | undefined = undefined;
let pointerPositionLastFrame = 0;
let thumbTop: string | undefined = undefined;
let thumbBottom: string | undefined = undefined;
let thumbLeft: string | undefined = undefined;
let thumbRight: string | undefined = undefined;
$: start = handleToTrack(handleLength, handlePosition) - handleLength / 2;
$: end = 1 - handleToTrack(handleLength, handlePosition) - handleLength / 2;
$: start = thumbToTrack(thumbLength, thumbPosition) - thumbLength / 2;
$: end = 1 - thumbToTrack(thumbLength, thumbPosition) - thumbLength / 2;
$: [thumbTop, thumbBottom, thumbLeft, thumbRight] = direction === "Vertical" ? [`${start * 100}%`, `${end * 100}%`, "0%", "0%"] : ["0%", "0%", `${start * 100}%`, `${end * 100}%`];
function trackLength(): number | undefined {
if (scrollTrack === undefined) return undefined;
return direction === "Vertical" ? scrollTrack.clientHeight - handleLength : scrollTrack.clientWidth;
return direction === "Vertical" ? scrollTrack.clientHeight - thumbLength : scrollTrack.clientWidth;
}
function trackOffset(): number | undefined {
@@ -42,72 +50,168 @@
return direction === "Vertical" ? scrollTrack.getBoundingClientRect().top : scrollTrack.getBoundingClientRect().left;
}
function clampHandlePosition(newPos: number) {
const clampedPosition = Math.min(Math.max(newPos, 0), 1);
dispatch("handlePosition", clampedPosition);
function dragThumb(e: PointerEvent) {
if (dragging) return;
dragging = true;
dispatch("thumbDragStart");
pointerPositionLastFrame = pointerPosition(e);
addEvents();
}
function updateHandlePosition(e: PointerEvent) {
function pressArrow(direction: number) {
const sendMove = () => {
if (!pressingArrow) return;
const distance = afterInitialDelay ? ARROW_REPEAT_DISTANCE : ARROW_CLICK_DISTANCE;
dispatch("trackShift", -direction * distance);
if (afterInitialDelay) repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_INTERVAL_RAPID_MS);
afterInitialDelay = true;
};
pressingArrow = true;
dispatch("thumbDragStart");
let afterInitialDelay = false;
sendMove();
repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_DELAY_MS);
addEvents();
}
function pressTrack(e: PointerEvent) {
if (dragging) return;
const length = trackLength();
if (length === undefined) return;
const offset = trackOffset();
if (length === undefined || offset === undefined) return;
const position = pointerPosition(direction, e);
const sendMove = () => {
if (!pressingTrack) return;
clampHandlePosition(handlePosition + (position - pointerPos) / (length * (1 - handleLength)));
pointerPos = position;
const oldPointer = thumbToTrack(thumbLength, thumbPosition) * length + offset;
const newPointer = pointerPosition(e);
// Check if the thumb has reached the cursor position
const proposedThumbPosition = (newPointer - offset) / length;
if (proposedThumbPosition >= start && proposedThumbPosition <= 1 - end) {
// End pressing the track
pressingTrack = false;
clearTimeout(repeatTimeout);
// Begin dragging the thumb
dragging = true;
pointerPositionLastFrame = newPointer;
return;
}
const move = newPointer - oldPointer < 0 ? 1 : -1;
dispatch("trackShift", move);
if (afterInitialDelay) repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_INTERVAL_MS);
afterInitialDelay = true;
};
dispatch("thumbDragStart");
pressingTrack = true;
let afterInitialDelay = false;
sendMove();
repeatTimeout = setTimeout(sendMove, PRESS_REPEAT_DELAY_MS);
addEvents();
}
function grabHandle(e: PointerEvent) {
if (!dragging) {
dragging = true;
pointerPos = pointerPosition(direction, e);
function abortInteraction() {
if (pressingTrack || pressingArrow) {
pressingTrack = false;
pressingArrow = false;
clearTimeout(repeatTimeout);
dispatch("thumbDragAbort");
}
if (dragging) {
dragging = false;
dispatch("thumbDragAbort");
}
}
function grabArea(e: PointerEvent) {
if (!dragging) {
const length = trackLength();
const offset = trackOffset();
if (length === undefined || offset === undefined) return;
function onPointerUp() {
if (dragging) dispatch("thumbDragEnd");
const oldPointer = handleToTrack(handleLength, handlePosition) * length + offset;
const pointerPos = pointerPosition(direction, e);
dispatch("pressTrack", pointerPos - oldPointer);
}
}
function pointerUp() {
dragging = false;
pressingTrack = false;
pressingArrow = false;
clearTimeout(repeatTimeout);
removeEvents();
}
function pointerMove(e: PointerEvent) {
if (dragging) updateHandlePosition(e);
function onPointerMove(e: PointerEvent) {
if (pressingTrack) {
return;
}
if (pressingArrow) {
const target = e.target || undefined;
if (!target || !(target instanceof Element)) return;
if (!target?.closest?.("[data-scrollbar-arrow]")) {
pressingArrow = false;
clearTimeout(repeatTimeout);
removeEvents();
}
return;
}
if (dragging) {
const length = trackLength();
if (length === undefined) return;
const positionPositionThisFrame = pointerPosition(e);
const dragDelta = positionPositionThisFrame - pointerPositionLastFrame;
const movement = dragDelta / (length * (1 - thumbLength));
const newThumbPosition = clamp01(thumbPosition + movement);
dispatch("thumbPosition", newThumbPosition);
pointerPositionLastFrame = positionPositionThisFrame;
return;
}
removeEvents();
}
function changePosition(difference: number) {
const length = trackLength();
if (length === undefined) return;
clampHandlePosition(handlePosition + difference / length);
function onMouseDown(e: MouseEvent) {
const BUTTONS_RIGHT = 0b0000_0010;
if (e.buttons & BUTTONS_RIGHT) abortInteraction();
}
onMount(() => {
window.addEventListener("pointerup", pointerUp);
window.addEventListener("pointermove", pointerMove);
});
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") abortInteraction();
}
onDestroy(() => {
window.removeEventListener("pointerup", pointerUp);
window.removeEventListener("pointermove", pointerMove);
});
function addEvents() {
window.addEventListener("pointerup", onPointerUp);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("mousedown", onMouseDown);
window.addEventListener("keydown", onKeyDown);
}
function removeEvents() {
window.removeEventListener("pointerup", onPointerUp);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("keydown", onKeyDown);
}
</script>
<div class={`scrollbar-input ${direction.toLowerCase()}`} on:pointerup={() => dispatch("pointerup")}>
<button class="arrow decrease" on:pointerdown={() => changePosition(-50)} tabindex="-1" />
<div class="scroll-track" bind:this={scrollTrack} on:pointerdown={grabArea}>
<div class="scroll-thumb" on:pointerdown={grabHandle} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight} />
<div class={`scrollbar-input ${direction.toLowerCase()}`}>
<button class="arrow decrease" on:pointerdown={() => pressArrow(-1)} tabindex="-1" data-scrollbar-arrow></button>
<div class="scroll-track" on:pointerdown={pressTrack} bind:this={scrollTrack}>
<div class="scroll-thumb" on:pointerdown={dragThumb} class:dragging style:top={thumbTop} style:bottom={thumbBottom} style:left={thumbLeft} style:right={thumbRight} />
</div>
<button class="arrow increase" on:click={() => changePosition(50)} tabindex="-1" />
<button class="arrow increase" on:pointerdown={() => pressArrow(1)} tabindex="-1" data-scrollbar-arrow></button>
</div>
<style lang="scss" global>
@@ -115,20 +219,66 @@
display: flex;
flex: 1 1 100%;
&.vertical {
flex-direction: column;
}
&.horizontal {
flex-direction: row;
}
.arrow {
--arrow-color: var(--color-5-dullgray);
flex: 0 0 auto;
background: none;
border: none;
border-style: solid;
width: 0;
height: 0;
margin: 0;
padding: 0;
width: 16px;
height: 16px;
&:hover {
--arrow-color: var(--color-6-lowergray);
}
&:hover:active {
--arrow-color: var(--color-c-brightgray);
}
&::after {
content: "";
display: block;
border-style: solid;
}
}
&.vertical .arrow.decrease::after {
margin: 4px 3px;
border-width: 0 5px 8px 5px;
border-color: transparent transparent var(--arrow-color) transparent;
}
&.vertical .arrow.increase::after {
margin: 4px 3px;
border-width: 8px 5px 0 5px;
border-color: var(--arrow-color) transparent transparent transparent;
}
&.horizontal .arrow.decrease::after {
margin: 3px 4px;
border-width: 5px 8px 5px 0;
border-color: transparent var(--arrow-color) transparent transparent;
}
&.horizontal .arrow.increase::after {
margin: 3px 4px;
border-width: 5px 0 5px 8px;
border-color: transparent transparent transparent var(--arrow-color);
}
.scroll-track {
flex: 1 1 100%;
position: relative;
flex: 1 1 100%;
.scroll-thumb {
position: absolute;
@@ -140,70 +290,6 @@
background: var(--color-6-lowergray);
}
}
.scroll-click-area {
position: absolute;
}
}
&.vertical {
flex-direction: column;
.arrow.decrease {
margin: 4px 3px;
border-width: 0 5px 8px 5px;
border-color: transparent transparent var(--color-5-dullgray) transparent;
&:hover {
border-color: transparent transparent var(--color-6-lowergray) transparent;
}
&:active {
border-color: transparent transparent var(--color-c-brightgray) transparent;
}
}
.arrow.increase {
margin: 4px 3px;
border-width: 8px 5px 0 5px;
border-color: var(--color-5-dullgray) transparent transparent transparent;
&:hover {
border-color: var(--color-6-lowergray) transparent transparent transparent;
}
&:active {
border-color: var(--color-c-brightgray) transparent transparent transparent;
}
}
}
&.horizontal {
flex-direction: row;
.arrow.decrease {
margin: 3px 4px;
border-width: 5px 8px 5px 0;
border-color: transparent var(--color-5-dullgray) transparent transparent;
&:hover {
border-color: transparent var(--color-6-lowergray) transparent transparent;
}
&:active {
border-color: transparent var(--color-c-brightgray) transparent transparent;
}
}
.arrow.increase {
margin: 3px 4px;
border-width: 5px 0 5px 8px;
border-color: transparent transparent transparent var(--color-5-dullgray);
&:hover {
border-color: transparent transparent transparent var(--color-6-lowergray);
}
&:active {
border-color: transparent transparent transparent var(--color-c-brightgray);
}
}
}
}
</style>
@@ -3,21 +3,27 @@
import { Color, type Gradient } from "@graphite/messages";
import { preventEscapeClosingParentFloatingMenu } from "@graphite/components/layout/FloatingMenu.svelte";
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
import LayoutRow from "@graphite/components/layout/LayoutRow.svelte";
const dispatch = createEventDispatcher<{ activeMarkerIndexChange: number | undefined; gradient: Gradient }>();
const BUTTON_LEFT = 0;
const BUTTON_RIGHT = 2;
const dispatch = createEventDispatcher<{ activeMarkerIndexChange: number | undefined; gradient: Gradient; dragging: boolean }>();
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;
let markerTrack: LayoutRow | undefined = undefined;
let positionRestore: number | undefined = undefined;
let deletionRestore: boolean | undefined = undefined;
function markerPointerDown(e: PointerEvent, index: number) {
// Left-click to select and begin potentially dragging
if (e.button === 0) {
if (e.button === BUTTON_LEFT) {
activeMarkerIndex = index;
dispatch("activeMarkerIndexChange", index);
addEvents();
@@ -25,7 +31,7 @@
}
// Right-click to delete
if (e.button === 2) {
if (e.button === BUTTON_RIGHT && deletionRestore === undefined) {
deleteStopByIndex(index);
return;
}
@@ -41,6 +47,8 @@
}
function insertStop(e: MouseEvent) {
if (e.button !== BUTTON_LEFT) return;
let position = markerPosition(e);
if (position === undefined) return;
@@ -62,14 +70,20 @@
gradient.stops.splice(index, 0, { position, color });
activeMarkerIndex = index;
deletionRestore = true;
dispatch("activeMarkerIndexChange", index);
dispatch("gradient", gradient);
addEvents();
}
function deleteStop(e: KeyboardEvent) {
if (e.key.toLowerCase() !== "delete" && e.key.toLowerCase() !== "backspace") return;
if (e.key !== "Delete" && e.key !== "Backspace") return;
if (activeMarkerIndex === undefined) return;
if (positionRestore !== undefined) stopDrag();
deleteStopByIndex(activeMarkerIndex);
}
@@ -82,6 +96,7 @@
} else {
activeMarkerIndex = Math.max(0, Math.min(gradient.stops.length - 1, index));
}
deletionRestore = undefined;
dispatch("activeMarkerIndexChange", activeMarkerIndex);
dispatch("gradient", gradient);
@@ -89,10 +104,18 @@
function moveMarker(e: PointerEvent, index: number) {
// Just in case the mouseup event is lost
if (e.buttons === 0) removeEvents();
if (e.buttons === 0) stopDrag();
let position = markerPosition(e);
if (position === undefined) return;
if (positionRestore === undefined) positionRestore = position;
if (deletionRestore === undefined) {
deletionRestore = false;
dispatch("dragging", true);
}
setPosition(index, position);
}
@@ -107,24 +130,61 @@
dispatch("gradient", gradient);
}
function onPointerMove(e: PointerEvent) {
if (activeMarkerIndex !== undefined) {
moveMarker(e, activeMarkerIndex);
function abortDrag() {
if (activeMarkerIndex === undefined) return;
if (deletionRestore) {
deleteStopByIndex(activeMarkerIndex);
} else if (positionRestore !== undefined) {
setPosition(activeMarkerIndex, positionRestore);
}
stopDrag();
}
function stopDrag() {
removeEvents();
positionRestore = undefined;
deletionRestore = undefined;
dispatch("dragging", false);
}
function onPointerMove(e: PointerEvent) {
if (activeMarkerIndex !== undefined) moveMarker(e, activeMarkerIndex);
}
function onPointerUp() {
removeEvents();
stopDrag();
}
function onMouseDown(e: MouseEvent) {
const BUTTONS_RIGHT = 0b0000_0010;
if (e.buttons & BUTTONS_RIGHT) abortDrag();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
const element = markerTrack?.div();
if (element) preventEscapeClosingParentFloatingMenu(element);
abortDrag();
}
}
function addEvents() {
document.addEventListener("pointermove", onPointerMove);
document.addEventListener("pointerup", onPointerUp);
document.addEventListener("mousedown", onMouseDown);
document.addEventListener("keydown", onKeyDown);
}
function removeEvents() {
document.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerup", onPointerUp);
document.removeEventListener("mousedown", onMouseDown);
document.removeEventListener("keydown", onKeyDown);
}
document.addEventListener("keydown", deleteStop);
@@ -158,24 +218,22 @@
"--gradient-stops": gradient.toLinearGradientCSS(),
}}
>
<LayoutRow class="gradient-strip" on:click={insertStop}></LayoutRow>
<LayoutRow class="gradient-strip" on:pointerdown={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}
style:--marker-position={marker.position}
style:--marker-color={marker.color.toRgbCSS()}
on:pointerdown={(e) => markerPointerDown(e, index)}
data-gradient-marker
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 12 12"
>
<path class="inner-fill" 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="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)}
class="outer-border"
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>
@@ -216,6 +274,7 @@
margin-left: var(--marker-half-width);
width: calc(100% - 2 * var(--marker-half-width));
position: relative;
pointer-events: none;
.marker {
position: absolute;
@@ -223,30 +282,40 @@
left: calc(var(--marker-position) * 100%);
width: 12px;
height: 12px;
pointer-events: auto;
overflow: visible;
padding-top: 12px;
margin-top: -12px;
// Inner fill
path:first-child {
.inner-fill {
fill: var(--marker-color);
}
// Outer border
path:last-child {
.outer-border {
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);
&:not(.active) {
.inner-fill:hover + .outer-border,
.outer-border:hover {
fill: var(--color-6-lowergray);
}
}
// Outer border when active
&.active path:last-child {
fill: var(--color-e-nearwhite);
}
&.active {
.inner-fill {
filter: drop-shadow(0 0 1px var(--color-2-mildblack)) drop-shadow(0 0 1px var(--color-2-mildblack));
}
&.active path:first-child:hover + path:last-child,
&.active path:last-child:hover {
fill: var(--color-f-white);
// Outer border when active
.outer-border {
fill: var(--color-e-nearwhite);
}
.inner-fill:hover + .outer-border,
.outer-border:hover {
fill: var(--color-f-white);
}
}
}
}
@@ -37,7 +37,7 @@
<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" />
<button on:click={clickPrimarySwatch} class:open={primaryOpen} style:--swatch-color={primary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button>
<ColorPicker
open={primaryOpen}
on:open={({ detail }) => (primaryOpen = detail)}
@@ -47,7 +47,7 @@
/>
</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" />
<button on:click={clickSecondarySwatch} class:open={secondaryOpen} style:--swatch-color={secondary.toRgbaCSS()} data-floating-menu-spawner="no-hover-transfer" tabindex="0"></button>
<ColorPicker
open={secondaryOpen}
on:open={({ detail }) => (secondaryOpen = detail)}
@@ -70,27 +70,55 @@
> button {
--swatch-color: #ffffff;
--ring-color: var(--color-5-dullgray);
width: 100%;
height: 100%;
border-radius: 50%;
border: 2px var(--color-5-dullgray) solid;
box-shadow: 0 0 0 2px var(--color-3-darkgray);
margin: 0;
padding: 0;
border: none;
outline: none;
border-radius: 50%;
box-sizing: border-box;
background: linear-gradient(var(--swatch-color), var(--swatch-color)), 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);
overflow: hidden;
position: relative;
// Color of the panel background, used to extend outside the ring and appear to cut out a crescent from the lower circle (by covering it up with the panel background color)
box-shadow: 0 0 0 2px var(--color-3-darkgray);
background: var(--color-3-darkgray);
// Main color and checked transparency pattern (inset by 1px to begin inside/below the ring to avoid antialiasing artifacts)
&::before {
content: "";
position: absolute;
top: 1px;
bottom: 1px;
left: 1px;
right: 1px;
border-radius: 50%;
background: linear-gradient(var(--swatch-color), var(--swatch-color)), 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-plus-one);
background-repeat: no-repeat, var(--color-transparent-checkered-background-repeat);
}
// Gray ring outline
&::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 50%;
box-shadow: inset 0 0 0 2px var(--ring-color);
}
&:hover,
&.open {
border-color: var(--color-6-lowergray);
--ring-color: var(--color-6-lowergray);
}
}