mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-21 12:28:12 +08:00
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:
@@ -8,13 +8,13 @@
|
||||
|
||||
import NodeCatalog from "@graphite/components/floating-menus/NodeCatalog.svelte";
|
||||
import BreadcrumbTrailButtons from "@graphite/components/widgets/buttons/BreadcrumbTrailButtons.svelte";
|
||||
import ColorButton from "@graphite/components/widgets/buttons/ColorButton.svelte";
|
||||
import IconButton from "@graphite/components/widgets/buttons/IconButton.svelte";
|
||||
import ImageButton from "@graphite/components/widgets/buttons/ImageButton.svelte";
|
||||
import ParameterExposeButton from "@graphite/components/widgets/buttons/ParameterExposeButton.svelte";
|
||||
import PopoverButton from "@graphite/components/widgets/buttons/PopoverButton.svelte";
|
||||
import TextButton from "@graphite/components/widgets/buttons/TextButton.svelte";
|
||||
import CheckboxInput from "@graphite/components/widgets/inputs/CheckboxInput.svelte";
|
||||
import ColorInput from "@graphite/components/widgets/inputs/ColorInput.svelte";
|
||||
import CurveInput from "@graphite/components/widgets/inputs/CurveInput.svelte";
|
||||
import DropdownInput from "@graphite/components/widgets/inputs/DropdownInput.svelte";
|
||||
import FontInput from "@graphite/components/widgets/inputs/FontInput.svelte";
|
||||
@@ -87,9 +87,9 @@
|
||||
{#if checkboxInput}
|
||||
<CheckboxInput {...exclude(checkboxInput)} on:checked={({ detail }) => widgetValueCommitAndUpdate(index, detail)} />
|
||||
{/if}
|
||||
{@const colorInput = narrowWidgetProps(component.props, "ColorButton")}
|
||||
{@const colorInput = narrowWidgetProps(component.props, "ColorInput")}
|
||||
{#if colorInput}
|
||||
<ColorButton {...exclude(colorInput)} on:value={({ detail }) => widgetValueUpdate(index, detail)} on:startHistoryTransaction={() => widgetValueCommit(index, colorInput.value)} />
|
||||
<ColorInput {...exclude(colorInput)} on:value={({ detail }) => widgetValueUpdate(index, detail)} on:startHistoryTransaction={() => widgetValueCommit(index, colorInput.value)} />
|
||||
{/if}
|
||||
{@const curvesInput = narrowWidgetProps(component.props, "CurveInput")}
|
||||
{#if curvesInput}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import type { FillChoice } from "@graphite/messages";
|
||||
import { Color, 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;
|
||||
|
||||
$: chosenGradient = value instanceof Gradient ? value.toLinearGradientCSS() : `linear-gradient(${value.toHexOptionalAlpha()}, ${value.toHexOptionalAlpha()})`;
|
||||
</script>
|
||||
|
||||
<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)}
|
||||
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;
|
||||
border-radius: 2px;
|
||||
background: var(--color-5-dullgray);
|
||||
|
||||
&:hover,
|
||||
&.open {
|
||||
&,
|
||||
> .text-label {
|
||||
background: rgba(var(--color-6-lowergray-rgb), 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
&,
|
||||
> .text-label {
|
||||
background: var(--color-4-dimgray);
|
||||
color: var(--color-8-uppergray);
|
||||
}
|
||||
}
|
||||
|
||||
> button {
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin-left: 2px;
|
||||
margin-top: 2px;
|
||||
width: calc(100% - 4px);
|
||||
height: calc(100% - 4px);
|
||||
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 {
|
||||
> 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 {
|
||||
> button::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(var(--color-4-dimgray-rgb), 0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> .text-label {
|
||||
background: rgba(var(--color-5-dullgray-rgb), 0.5);
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
height: 12px;
|
||||
border-radius: 6px 0 0 6px;
|
||||
padding-right: 2px;
|
||||
padding-left: 4px;
|
||||
margin: auto;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
> .floating-menu {
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,14 @@
|
||||
export let tooltip: string | undefined = undefined;
|
||||
|
||||
$: iconSizeClass = ((icon: IconName) => {
|
||||
return `size-${iconSizeOverride || ICONS[icon].size}`;
|
||||
const iconData = ICONS[icon];
|
||||
// eslint-disable-next-line no-console
|
||||
if (!iconData) {
|
||||
console.warn(`Icon "${icon}" does not exist.`);
|
||||
return "size-24";
|
||||
}
|
||||
if (iconData.size === undefined) return "";
|
||||
return `size-${iconSizeOverride || iconData.size}`;
|
||||
})(icon);
|
||||
$: extraClasses = Object.entries(classes)
|
||||
.flatMap(([className, stateName]) => (stateName ? [className] : []))
|
||||
@@ -20,7 +27,7 @@
|
||||
</script>
|
||||
|
||||
<LayoutRow class={`icon-label ${iconSizeClass} ${className} ${extraClasses}`.trim()} classes={{ disabled }} {tooltip}>
|
||||
{@html ICON_SVG_STRINGS[icon]}
|
||||
{@html ICON_SVG_STRINGS[icon] || "�"}
|
||||
</LayoutRow>
|
||||
|
||||
<style lang="scss" global>
|
||||
|
||||
Reference in New Issue
Block a user