mirror of
https://github.com/enso-org/enso.git
synced 2024-12-23 09:22:41 +03:00
Enso color picker (#9825)
New color picker, designed for Enso: https://github.com/enso-org/enso/assets/1047859/c3eff168-6807-4825-b17b-053e3cd8b04c - Colors never clash: OKLCH lightness and chroma are fixed. - Easily match colors: Colors of other nodes in the current method are expanded to slices of the color wheel. Closes #9613.
This commit is contained in:
parent
36dcbf1a07
commit
01a2ca458b
@ -71,7 +71,6 @@
|
||||
"rimraf": "^5.0.5",
|
||||
"semver": "^7.5.4",
|
||||
"sucrase": "^3.34.0",
|
||||
"verte-vue3": "^1.1.1",
|
||||
"vue": "^3.4.19",
|
||||
"ws": "^8.13.0",
|
||||
"y-codemirror.next": "^0.3.2",
|
||||
|
@ -79,6 +79,14 @@ export class Resumable<T> {
|
||||
this.current = this.iterator.next()
|
||||
}
|
||||
|
||||
peek() {
|
||||
return this.current.done ? undefined : this.current.value
|
||||
}
|
||||
|
||||
advance() {
|
||||
this.current = this.iterator.next()
|
||||
}
|
||||
|
||||
/** The given function peeks at the current value. If the function returns `true`, the current value will be advanced
|
||||
* and the function called again; if it returns `false`, the peeked value remains current and `advanceWhile` returns.
|
||||
*/
|
||||
|
@ -1,15 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import ColorRing from '@/components/ColorRing.vue'
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import SmallPlusButton from '@/components/SmallPlusButton.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const nodeColor = defineModel<string | undefined>('nodeColor')
|
||||
const props = defineProps<{
|
||||
isRecordingEnabledGlobally: boolean
|
||||
isRecordingOverridden: boolean
|
||||
isDocsVisible: boolean
|
||||
isVisualizationVisible: boolean
|
||||
isFullMenuVisible: boolean
|
||||
visibleNodeColors: Set<string>
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
'update:isRecordingOverridden': [isRecordingOverridden: boolean]
|
||||
@ -20,13 +24,18 @@ const emit = defineEmits<{
|
||||
openFullMenu: []
|
||||
delete: []
|
||||
createNodes: [options: NodeCreationOptions[]]
|
||||
toggleColorPicker: []
|
||||
}>()
|
||||
|
||||
const showColorPicker = ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="CircularMenu" @pointerdown.stop @pointerup.stop @click.stop>
|
||||
<div class="circle" :class="`${props.isFullMenuVisible ? 'full' : 'partial'}`">
|
||||
<div
|
||||
v-if="!showColorPicker"
|
||||
class="circle menu"
|
||||
:class="`${props.isFullMenuVisible ? 'full' : 'partial'}`"
|
||||
>
|
||||
<div v-if="!isFullMenuVisible" class="More" @pointerdown.stop="emit('openFullMenu')"></div>
|
||||
<SvgIcon
|
||||
v-if="isFullMenuVisible"
|
||||
@ -40,7 +49,7 @@ const emit = defineEmits<{
|
||||
name="paint_palette"
|
||||
class="icon-container button slot3"
|
||||
:alt="`Choose color`"
|
||||
@click.stop="emit('toggleColorPicker')"
|
||||
@click.stop="showColorPicker = true"
|
||||
/>
|
||||
<SvgIcon
|
||||
v-if="isFullMenuVisible"
|
||||
@ -72,6 +81,13 @@ const emit = defineEmits<{
|
||||
@update:modelValue="emit('update:isRecordingOverridden', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="showColorPicker" class="circle">
|
||||
<ColorRing
|
||||
v-model="nodeColor"
|
||||
:matchableColors="visibleNodeColors"
|
||||
@close="showColorPicker = false"
|
||||
/>
|
||||
</div>
|
||||
<SmallPlusButton
|
||||
v-if="!isVisualizationVisible"
|
||||
class="below-slot5"
|
||||
@ -85,15 +101,24 @@ const emit = defineEmits<{
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
/* This is a variable so that it can be referenced in computations,
|
||||
but currently it can't be changed due to many hard-coded values below. */
|
||||
--outer-diameter: 104px;
|
||||
--full-ring-path: path(
|
||||
evenodd,
|
||||
'M0,52 A52,52 0,1,1 104,52 A52,52 0,1,1 0, 52 z m52,20 A20,20 0,1,1 52,32 20,20 0,1,1 52,72 z'
|
||||
);
|
||||
}
|
||||
|
||||
.circle {
|
||||
position: relative;
|
||||
left: -36px;
|
||||
top: -36px;
|
||||
width: 114px;
|
||||
height: 114px;
|
||||
width: var(--outer-diameter);
|
||||
height: var(--outer-diameter);
|
||||
}
|
||||
|
||||
.circle.menu {
|
||||
> * {
|
||||
pointer-events: all;
|
||||
}
|
||||
@ -118,10 +143,7 @@ const emit = defineEmits<{
|
||||
}
|
||||
&.full {
|
||||
&:before {
|
||||
clip-path: path(
|
||||
evenodd,
|
||||
'M0,52 A52,52 0,1,1 104,52 A52,52 0,1,1 0, 52 z m52,20 A20,20 0,1,1 52,32 20,20 0,1,1 52,72 z'
|
||||
);
|
||||
clip-path: var(--full-ring-path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -153,6 +175,10 @@ const emit = defineEmits<{
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.ColorRing .gradient) {
|
||||
clip-path: var(--full-ring-path);
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: inline-flex;
|
||||
background: none;
|
||||
@ -220,7 +246,7 @@ const emit = defineEmits<{
|
||||
|
||||
.below-slot5 {
|
||||
position: absolute;
|
||||
top: calc(108px - 36px);
|
||||
top: calc(var(--outer-diameter) - 32px);
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
|
@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { convertToRgb } from '@/util/colors'
|
||||
// @ts-ignore
|
||||
import Verte from 'verte-vue3'
|
||||
import 'verte-vue3/dist/verte.css'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{ show: boolean; color: string }>()
|
||||
const emit = defineEmits<{ 'update:color': [string] }>()
|
||||
|
||||
/** Comparing RGB colors is complicated, because the string representation always has some minor differences.
|
||||
* In this particular case, we remove spaces to match the format used by `verte-vue3`. */
|
||||
const normalizedColor = computed(() => {
|
||||
return convertToRgb(props.color)?.replaceAll(/\s/g, '') ?? ''
|
||||
})
|
||||
|
||||
const updateColor = (c: string) => {
|
||||
if (props.show && normalizedColor.value !== c) emit('update:color', c)
|
||||
}
|
||||
|
||||
/** Looks weird, but it is a fix for verte’s bug: https://github.com/baianat/verte/issues/52. */
|
||||
const key = ref(0)
|
||||
watch(
|
||||
() => props.show,
|
||||
() => nextTick(() => key.value++),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Verte
|
||||
v-show="props.show"
|
||||
:key="key"
|
||||
:modelValue="convertToRgb(props.color)"
|
||||
picker="square"
|
||||
model="rgb"
|
||||
display="widget"
|
||||
:draggable="false"
|
||||
:enableAlpha="false"
|
||||
@update:modelValue="updateColor"
|
||||
@pointerdown.stop
|
||||
@pointerup.stop
|
||||
@click.stop
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style></style>
|
193
app/gui2/src/components/ColorRing.vue
Normal file
193
app/gui2/src/components/ColorRing.vue
Normal file
@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
cssAngularColorStop,
|
||||
gradientPoints,
|
||||
rangesForInputs,
|
||||
} from '@/components/ColorRing/gradient'
|
||||
import { injectInteractionHandler } from '@/providers/interactionHandler'
|
||||
import { targetIsOutside } from '@/util/autoBlur'
|
||||
import { cssSupported, ensoColor, formatCssColor, parseCssColor } from '@/util/colors'
|
||||
import { Rect } from '@/util/data/rect'
|
||||
import { Vec2 } from '@/util/data/vec2'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* Hue picker
|
||||
*
|
||||
* # Angles
|
||||
*
|
||||
* All angles are measured in turns, starting from the 12-o'clock position, normalized to the range 0-1, unless
|
||||
* otherwise specified.
|
||||
* - This is the axis used by CSS gradients (adjustment is necessary when working with trigonometric functions, which
|
||||
* start from the positive x-axis).
|
||||
* - Turns allow constants to be expressed as simple numbers, and can be easily converted to the units used by external
|
||||
* APIs (radians for math, degrees for culori).
|
||||
*/
|
||||
|
||||
// If the browser doesn't support OKLCH gradient interpolation, the gradient will be specified by computing the number
|
||||
// of points specified here in OKLCH, converting to sRGB if the browser doesn't support OKLCH colors at all, and
|
||||
// interpolating in sRGB. This number has been found to be enough to look close to the intended colors, without
|
||||
// excessive gradient complexity (which may affect performance).
|
||||
const NONNATIVE_OKLCH_INTERPOLATION_STEPS = 12
|
||||
|
||||
const FIXED_RANGE_WIDTH = 1 / 16
|
||||
|
||||
const selectedColor = defineModel<string | undefined>()
|
||||
const props = defineProps<{
|
||||
matchableColors: Set<string>
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const browserSupportsOklchInterpolation = cssSupported(
|
||||
'background-image: conic-gradient(in oklch increasing hue, red, blue)',
|
||||
)
|
||||
|
||||
const svgElement = ref<HTMLElement>()
|
||||
|
||||
const interaction = injectInteractionHandler()
|
||||
|
||||
onMounted(() => {
|
||||
interaction.setCurrent({
|
||||
cancel: () => emit('close'),
|
||||
pointerdown: (e: PointerEvent) => {
|
||||
if (targetIsOutside(e, svgElement.value)) emit('close')
|
||||
return false
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const mouseSelectedAngle = ref<number>()
|
||||
|
||||
const triangleAngle = computed(() => {
|
||||
if (mouseSelectedAngle.value) return mouseSelectedAngle.value
|
||||
if (selectedColor.value) {
|
||||
const color = parseCssColor(selectedColor.value)
|
||||
if (color?.h) return color.h / 360
|
||||
}
|
||||
return undefined
|
||||
})
|
||||
|
||||
function cssColor(hue: number) {
|
||||
return formatCssColor(ensoColor(hue))
|
||||
}
|
||||
|
||||
// === Events ===
|
||||
|
||||
function eventAngle(event: MouseEvent) {
|
||||
if (!svgElement.value) return 0
|
||||
const origin = Rect.FromDomRect(svgElement.value.getBoundingClientRect()).center()
|
||||
const offset = Vec2.FromXY(event).sub(origin)
|
||||
return Math.atan2(offset.y, offset.x) / (2 * Math.PI) + 0.25
|
||||
}
|
||||
|
||||
function ringHover(event: MouseEvent) {
|
||||
mouseSelectedAngle.value = eventAngle(event)
|
||||
}
|
||||
function ringClick(event: MouseEvent) {
|
||||
mouseSelectedAngle.value = eventAngle(event)
|
||||
if (triangleHue.value != null) selectedColor.value = cssColor(triangleHue.value)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// === Gradient colors ===
|
||||
|
||||
const fixedRanges = computed(() => {
|
||||
const inputHues = new Set<number>()
|
||||
for (const rawColor of props.matchableColors) {
|
||||
if (rawColor === selectedColor.value) continue
|
||||
const color = parseCssColor(rawColor)
|
||||
const hueDeg = color?.h
|
||||
if (hueDeg == null) continue
|
||||
const hue = hueDeg / 360
|
||||
inputHues.add(hue < 0 ? hue + 1 : hue)
|
||||
}
|
||||
return rangesForInputs(inputHues, FIXED_RANGE_WIDTH / 2)
|
||||
})
|
||||
|
||||
const triangleHue = computed(() => {
|
||||
const target = triangleAngle.value
|
||||
if (target == null) return undefined
|
||||
for (const range of fixedRanges.value) {
|
||||
if (target < range.start) break
|
||||
if (target <= range.end) return range.hue
|
||||
}
|
||||
return target
|
||||
})
|
||||
|
||||
// === CSS ===
|
||||
|
||||
const cssGradient = computed(() => {
|
||||
const points = gradientPoints(
|
||||
fixedRanges.value,
|
||||
browserSupportsOklchInterpolation ? 2 : NONNATIVE_OKLCH_INTERPOLATION_STEPS,
|
||||
)
|
||||
const angularColorStopList = Array.from(points, cssAngularColorStop)
|
||||
const colorStops = angularColorStopList.join(',')
|
||||
return browserSupportsOklchInterpolation ?
|
||||
`conic-gradient(in oklch increasing hue,${colorStops})`
|
||||
: `conic-gradient(${colorStops})`
|
||||
})
|
||||
const cssTriangleAngle = computed(() =>
|
||||
triangleAngle.value != null ? `${triangleAngle.value}turn` : undefined,
|
||||
)
|
||||
const cssTriangleColor = computed(() =>
|
||||
triangleHue.value != null ? cssColor(triangleHue.value) : undefined,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ColorRing">
|
||||
<svg v-if="cssTriangleAngle != null" class="svg" viewBox="-2 -2 4 4">
|
||||
<polygon class="triangle" points="0,-1 -0.4,-1.35 0.4,-1.35" />
|
||||
</svg>
|
||||
<div
|
||||
ref="svgElement"
|
||||
class="gradient"
|
||||
@pointerleave="mouseSelectedAngle = undefined"
|
||||
@pointermove="ringHover"
|
||||
@click.stop="ringClick"
|
||||
@pointerdown.stop
|
||||
@pointerup.stop
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ColorRing {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.svg {
|
||||
position: absolute;
|
||||
margin: -50%;
|
||||
}
|
||||
|
||||
.gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: auto;
|
||||
margin-top: auto;
|
||||
background: v-bind('cssGradient');
|
||||
cursor: crosshair;
|
||||
border-radius: var(--radius-full);
|
||||
animation: grow 0.1s forwards;
|
||||
}
|
||||
@keyframes grow {
|
||||
from {
|
||||
transform: scale(0);
|
||||
}
|
||||
to {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.triangle {
|
||||
transform: rotate(v-bind('cssTriangleAngle'));
|
||||
fill: v-bind('cssTriangleColor');
|
||||
}
|
||||
</style>
|
100
app/gui2/src/components/ColorRing/__tests__/gradient.test.ts
Normal file
100
app/gui2/src/components/ColorRing/__tests__/gradient.test.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { fc, test as fcTest } from '@fast-check/vitest'
|
||||
import { expect } from 'vitest'
|
||||
import type { FixedRange, GradientPoint } from '../gradient'
|
||||
import { gradientPoints, normalizeHue, rangesForInputs } from '../gradient'
|
||||
|
||||
/** Check value ranges and internal consistency. */
|
||||
function validateRange({ start, end }: FixedRange) {
|
||||
expect(start).toBeGreaterThanOrEqual(0)
|
||||
expect(start).toBeLessThan(1)
|
||||
expect(end).toBeGreaterThan(0)
|
||||
expect(end).toBeLessThanOrEqual(1)
|
||||
expect(end).toBeGreaterThan(start)
|
||||
}
|
||||
|
||||
/** Check value ranges and internal consistency. */
|
||||
function validateGradientPoint({ hue, angle, angle2 }: GradientPoint) {
|
||||
expect(hue).toBeGreaterThanOrEqual(0)
|
||||
expect(hue).toBeLessThanOrEqual(1)
|
||||
expect(angle).toBeGreaterThanOrEqual(0)
|
||||
expect(angle).toBeLessThanOrEqual(1)
|
||||
if (angle2 != null) {
|
||||
expect(angle2).toBeGreaterThanOrEqual(0)
|
||||
expect(angle2).toBeLessThanOrEqual(1)
|
||||
expect(angle).toBeLessThanOrEqual(angle2)
|
||||
} else {
|
||||
expect(hue).toBe(angle)
|
||||
}
|
||||
}
|
||||
|
||||
interface AngularStop {
|
||||
hue: number
|
||||
angle: number
|
||||
}
|
||||
function angularStops(points: Iterable<GradientPoint>) {
|
||||
const stops = new Array<AngularStop>()
|
||||
for (const { hue, angle, angle2 } of points) {
|
||||
stops.push({ hue, angle })
|
||||
if (angle2 != null) stops.push({ hue, angle: angle2 })
|
||||
}
|
||||
return stops
|
||||
}
|
||||
|
||||
function stopSpans(stops: Iterable<AngularStop>, radius: number) {
|
||||
const spans = new Array<{ start: number; end: number; hue: number }>()
|
||||
let prev: AngularStop | undefined = undefined
|
||||
for (const stop of stops) {
|
||||
if (prev && stop.angle !== prev.angle) {
|
||||
expect(stop.angle).toBeGreaterThanOrEqual(prev.angle)
|
||||
expect(stop.hue).toBeGreaterThanOrEqual(prev.hue)
|
||||
if (stop.hue === prev.hue) {
|
||||
spans.push({ start: prev.angle, end: stop.angle, hue: stop.hue })
|
||||
} else {
|
||||
expect(stop.hue).toBe(stop.angle)
|
||||
expect(prev.hue).toBe(prev.angle)
|
||||
}
|
||||
}
|
||||
prev = stop
|
||||
}
|
||||
const first = spans[0]
|
||||
const last = spans[spans.length - 1]
|
||||
if (spans.length >= 2 && first && last && normalizeHue(first.hue) === normalizeHue(last.hue)) {
|
||||
expect(first.start).toBe(0)
|
||||
expect(last.end).toBe(1)
|
||||
spans.pop()
|
||||
first.start = last.end
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
function testGradients({ hues, radius }: { hues: number[]; radius: number }) {
|
||||
const approximate = (n: number) => normalizeHue(Math.round(n * 2 ** 20) / 2 ** 20)
|
||||
const approximateHues = new Set(hues.map(approximate))
|
||||
const ranges = rangesForInputs(approximateHues, radius)
|
||||
ranges.forEach(validateRange)
|
||||
const points = gradientPoints(ranges, 2)
|
||||
points.forEach(validateGradientPoint)
|
||||
const stops = angularStops(points)
|
||||
expect(stops[0]?.angle).toBe(0)
|
||||
expect(stops[stops.length - 1]?.angle).toBe(1)
|
||||
const spans = stopSpans(stops, radius)
|
||||
for (const span of spans) {
|
||||
expect(approximateHues).toContain(approximate(span.hue))
|
||||
if (span.start < span.end) {
|
||||
expect(span.hue === 0 ? 1 : span.hue).toBeGreaterThan(span.start)
|
||||
expect(span.hue).toBeLessThanOrEqual(span.end)
|
||||
expect(span.end - span.start).toBeLessThan(radius * 2 + 0.0000001)
|
||||
} else {
|
||||
expect(span.hue > span.start || span.hue < span.end)
|
||||
expect(1 - span.start + span.end).toBeLessThan(radius * 2 + 0.0000001)
|
||||
}
|
||||
}
|
||||
expect(spans.length).toEqual(approximateHues.size)
|
||||
}
|
||||
|
||||
fcTest.prop({
|
||||
hues: fc.array(fc.float({ min: 0, max: 1, noNaN: true, maxExcluded: true })),
|
||||
/* This parameter comes from configuration, so we don't need to test unrealistically small or large values that may
|
||||
have their own edge cases. */
|
||||
radius: fc.float({ min: Math.fround(0.01), max: 0.25, noNaN: true }),
|
||||
})('CSS gradients', testGradients)
|
115
app/gui2/src/components/ColorRing/gradient.ts
Normal file
115
app/gui2/src/components/ColorRing/gradient.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { ensoColor, formatCssColor } from '@/util/colors'
|
||||
import { Resumable } from 'shared/util/data/iterable'
|
||||
|
||||
export interface FixedRange {
|
||||
start: number
|
||||
end: number
|
||||
hue: number
|
||||
meetsPreviousRange: boolean
|
||||
meetsNextRange: boolean
|
||||
}
|
||||
|
||||
/** Returns inputs sorted, deduplicated, with values near the end wrapped. */
|
||||
function normalizeRangeInputs(inputs: Iterable<number>, radius: number) {
|
||||
const sortedInputs = [...inputs].sort((a, b) => a - b)
|
||||
const normalizedInputs = new Set<number>()
|
||||
const firstInput = sortedInputs[0]
|
||||
const lastInput = sortedInputs[sortedInputs.length - 1]
|
||||
if (lastInput != null && lastInput + radius > 1) normalizedInputs.add(lastInput - 1)
|
||||
sortedInputs.forEach((value) => normalizedInputs.add(value))
|
||||
if (firstInput != null && firstInput < radius) normalizedInputs.add(firstInput + 1)
|
||||
return normalizedInputs
|
||||
}
|
||||
|
||||
export function normalizeHue(value: number) {
|
||||
return ((value % 1) + 1) % 1
|
||||
}
|
||||
|
||||
export function seminormalizeHue(value: number) {
|
||||
return value === 1 ? 1 : normalizeHue(value)
|
||||
}
|
||||
|
||||
export function rangesForInputs(inputs: Iterable<number>, radius: number): FixedRange[] {
|
||||
if (radius === 0) return []
|
||||
const ranges = new Array<FixedRange & { rawHue: number }>()
|
||||
const normalizedInputs = normalizeRangeInputs(inputs, radius)
|
||||
for (const hue of normalizedInputs) {
|
||||
const preferredStart = Math.max(hue - radius, 0)
|
||||
const preferredEnd = Math.min(hue + radius, 1)
|
||||
const prev = ranges[ranges.length - 1]
|
||||
if (prev && preferredStart < prev.end) {
|
||||
let midpoint = (prev.rawHue + hue) / 2
|
||||
if (midpoint >= 1) continue
|
||||
let meetsPreviousRange = true
|
||||
if (midpoint <= 0) {
|
||||
ranges.pop()
|
||||
midpoint = 0
|
||||
meetsPreviousRange = false
|
||||
} else {
|
||||
prev.end = midpoint
|
||||
prev.meetsNextRange = true
|
||||
}
|
||||
ranges.push({
|
||||
start: midpoint,
|
||||
end: preferredEnd,
|
||||
rawHue: hue,
|
||||
hue: seminormalizeHue(hue),
|
||||
meetsPreviousRange,
|
||||
meetsNextRange: false,
|
||||
})
|
||||
} else {
|
||||
const meetsPreviousRange = prev !== undefined && preferredStart < prev.end
|
||||
if (meetsPreviousRange) prev.meetsNextRange = true
|
||||
ranges.push({
|
||||
start: preferredStart,
|
||||
end: preferredEnd,
|
||||
rawHue: hue,
|
||||
hue: seminormalizeHue(hue),
|
||||
meetsPreviousRange,
|
||||
meetsNextRange: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
const first = ranges[0]
|
||||
const last = ranges[ranges.length - 1]
|
||||
if (ranges.length >= 2 && first?.start === 0 && last?.end === 1) {
|
||||
first.meetsPreviousRange = true
|
||||
last.meetsNextRange = true
|
||||
}
|
||||
return ranges
|
||||
}
|
||||
|
||||
export interface GradientPoint {
|
||||
hue: number
|
||||
angle: number
|
||||
angle2?: number
|
||||
}
|
||||
export function cssAngularColorStop({ hue, angle, angle2 }: GradientPoint) {
|
||||
return [
|
||||
formatCssColor(ensoColor(hue)),
|
||||
`${angle}turn`,
|
||||
...(angle2 != null ? [`${angle}turn`] : []),
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
export function gradientPoints(
|
||||
inputRanges: Iterable<FixedRange>,
|
||||
minStops: number,
|
||||
): GradientPoint[] {
|
||||
const points = new Array<GradientPoint>()
|
||||
const interpolationPoint = (angle: number) => ({ hue: angle, angle })
|
||||
const fixedRangeIter = new Resumable(inputRanges)
|
||||
for (let i = 0; i < minStops; i++) {
|
||||
const angle = i / (minStops - 1)
|
||||
fixedRangeIter.advanceWhile((range) => range.end < angle)
|
||||
const nextFixedRange = fixedRangeIter.peek()
|
||||
if (!nextFixedRange || nextFixedRange.start > angle) points.push(interpolationPoint(angle))
|
||||
}
|
||||
for (const { start, end, hue, meetsPreviousRange, meetsNextRange } of inputRanges) {
|
||||
if (!meetsPreviousRange) points.push(interpolationPoint(start))
|
||||
points.push({ hue, angle: start, angle2: end })
|
||||
if (!meetsNextRange) points.push(interpolationPoint(end))
|
||||
}
|
||||
points.sort((a, b) => a.angle - b.angle)
|
||||
return points
|
||||
}
|
@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { codeEditorBindings, graphBindings, interactionBindings } from '@/bindings'
|
||||
import CodeEditor from '@/components/CodeEditor.vue'
|
||||
import ColorPicker from '@/components/ColorPicker.vue'
|
||||
import ComponentBrowser from '@/components/ComponentBrowser.vue'
|
||||
import { type Usage } from '@/components/ComponentBrowser/input'
|
||||
import { usePlacement } from '@/components/ComponentBrowser/placement'
|
||||
@ -22,6 +21,7 @@ import { useNavigatorStorage } from '@/composables/navigatorStorage'
|
||||
import type { PlacementStrategy } from '@/composables/nodeCreation'
|
||||
import { useStackNavigator } from '@/composables/stackNavigator'
|
||||
import { provideGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { provideNodeColors } from '@/providers/graphNodeColors'
|
||||
import { provideNodeCreation } from '@/providers/graphNodeCreation'
|
||||
import { provideGraphSelection } from '@/providers/graphSelection'
|
||||
import { provideInteractionHandler } from '@/providers/interactionHandler'
|
||||
@ -240,17 +240,14 @@ const graphBindingsHandler = graphBindings.handler({
|
||||
stackNavigator.exitNode()
|
||||
},
|
||||
changeColorSelectedNodes() {
|
||||
toggleColorPicker()
|
||||
showColorPicker.value = true
|
||||
},
|
||||
})
|
||||
|
||||
const { handleClick } = useDoubleClick(
|
||||
(e: MouseEvent) => {
|
||||
graphBindingsHandler(e)
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
showColorPicker.value = false
|
||||
clearFocus()
|
||||
},
|
||||
() => {
|
||||
stackNavigator.exitNode()
|
||||
@ -499,49 +496,18 @@ async function handleFileDrop(event: DragEvent) {
|
||||
|
||||
// === Color Picker ===
|
||||
|
||||
/** A small offset to keep the color picker slightly away from the nodes. */
|
||||
const COLOR_PICKER_X_OFFSET_PX = -300
|
||||
const showColorPicker = ref(false)
|
||||
const colorPickerSelectedColor = ref('')
|
||||
|
||||
function overrideSelectedNodesColor(color: string) {
|
||||
;[...nodeSelection.selected].map((id) => graphStore.overrideNodeColor(id, color))
|
||||
}
|
||||
|
||||
/** Toggle displaying of the color picker. It will change colors of selected nodes. */
|
||||
function toggleColorPicker() {
|
||||
if (nodeSelection.selected.size === 0) {
|
||||
showColorPicker.value = false
|
||||
return
|
||||
}
|
||||
showColorPicker.value = !showColorPicker.value
|
||||
if (showColorPicker.value) {
|
||||
const oneOfSelected = set.first(nodeSelection.selected)
|
||||
const color = graphStore.db.getNodeColorStyle(oneOfSelected)
|
||||
if (color.startsWith('var') && viewportNode.value != null) {
|
||||
// Some colors are defined in CSS variables, we need to get the actual color.
|
||||
const variableName = color.slice(4, -1)
|
||||
colorPickerSelectedColor.value = getComputedStyle(viewportNode.value).getPropertyValue(
|
||||
variableName,
|
||||
)
|
||||
} else {
|
||||
colorPickerSelectedColor.value = color
|
||||
}
|
||||
}
|
||||
}
|
||||
const colorPickerPos = computed(() => {
|
||||
const nodeRects = [...nodeSelection.selected].map(
|
||||
(id) => graphStore.nodeRects.get(id) ?? Rect.Zero,
|
||||
)
|
||||
const boundingRect = Rect.Bounding(...nodeRects)
|
||||
return new Vec2(boundingRect.left + COLOR_PICKER_X_OFFSET_PX, boundingRect.center().y)
|
||||
})
|
||||
const colorPickerStyle = computed(() =>
|
||||
colorPickerPos.value != null ?
|
||||
{ transform: `translate(${colorPickerPos.value.x}px, ${colorPickerPos.value.y}px)` }
|
||||
: {},
|
||||
provideNodeColors((variable) =>
|
||||
viewportNode.value ? getComputedStyle(viewportNode.value).getPropertyValue(variable) : '',
|
||||
)
|
||||
|
||||
const showColorPicker = ref(false)
|
||||
|
||||
function setSelectedNodesColor(color: string) {
|
||||
graphStore.transact(() =>
|
||||
nodeSelection.selected.forEach((id) => graphStore.overrideNodeColor(id, color)),
|
||||
)
|
||||
}
|
||||
|
||||
const groupColors = computed(() => {
|
||||
const styles: { [key: string]: string } = {}
|
||||
for (let group of suggestionDb.groups) {
|
||||
@ -569,15 +535,7 @@ const groupColors = computed(() => {
|
||||
@nodeOutputPortDoubleClick="handleNodeOutputPortDoubleClick"
|
||||
@nodeDoubleClick="(id) => stackNavigator.enterNode(id)"
|
||||
@createNodes="createNodesFromSource"
|
||||
@toggleColorPicker="toggleColorPicker"
|
||||
/>
|
||||
|
||||
<ColorPicker
|
||||
class="colorPicker"
|
||||
:style="colorPickerStyle"
|
||||
:show="showColorPicker"
|
||||
:color="colorPickerSelectedColor"
|
||||
@update:color="overrideSelectedNodesColor"
|
||||
@setNodeColor="setSelectedNodesColor"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
@ -598,6 +556,7 @@ const groupColors = computed(() => {
|
||||
/>
|
||||
<TopBar
|
||||
v-model:recordMode="projectStore.recordMode"
|
||||
v-model:showColorPicker="showColorPicker"
|
||||
:breadcrumbs="stackNavigator.breadcrumbLabels.value"
|
||||
:allowNavigationLeft="stackNavigator.allowNavigationLeft.value"
|
||||
:allowNavigationRight="stackNavigator.allowNavigationRight.value"
|
||||
@ -612,7 +571,7 @@ const groupColors = computed(() => {
|
||||
@zoomOut="graphNavigator.stepZoom(-1)"
|
||||
@toggleCodeEditor="toggleCodeEditor"
|
||||
@collapseNodes="collapseNodes"
|
||||
@toggleColorPicker="toggleColorPicker"
|
||||
@setNodeColor="setSelectedNodesColor"
|
||||
@removeNodes="deleteSelected"
|
||||
/>
|
||||
<PlusButton @pointerdown.stop @click.stop="addNodeAuto()" @pointerup.stop />
|
||||
@ -646,8 +605,4 @@ const groupColors = computed(() => {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.colorPicker {
|
||||
position: absolute;
|
||||
}
|
||||
</style>
|
||||
|
@ -9,16 +9,17 @@ import GraphNodeMessage, {
|
||||
} from '@/components/GraphEditor/GraphNodeMessage.vue'
|
||||
import GraphNodeSelection from '@/components/GraphEditor/GraphNodeSelection.vue'
|
||||
import GraphVisualization from '@/components/GraphEditor/GraphVisualization.vue'
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import NodeWidgetTree, {
|
||||
GRAB_HANDLE_X_MARGIN,
|
||||
ICON_WIDTH,
|
||||
} from '@/components/GraphEditor/NodeWidgetTree.vue'
|
||||
import type { NodeCreationOptions } from '@/components/GraphEditor/nodeCreation'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import { useApproach } from '@/composables/animation'
|
||||
import { useDoubleClick } from '@/composables/doubleClick'
|
||||
import { usePointer, useResizeObserver } from '@/composables/events'
|
||||
import { injectGraphNavigator } from '@/providers/graphNavigator'
|
||||
import { injectNodeColors } from '@/providers/graphNodeColors'
|
||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||
import { useGraphStore, type Node } from '@/stores/graph'
|
||||
import { asNodeId } from '@/stores/graph/graphDatabase'
|
||||
@ -58,7 +59,7 @@ const emit = defineEmits<{
|
||||
outputPortDoubleClick: [event: PointerEvent, portId: AstId]
|
||||
doubleClick: []
|
||||
createNodes: [options: NodeCreationOptions[]]
|
||||
toggleColorPicker: []
|
||||
setNodeColor: [color: string]
|
||||
'update:edited': [cursorPosition: number]
|
||||
'update:rect': [rect: Rect]
|
||||
'update:visualizationId': [id: Opt<VisualizationIdentifier>]
|
||||
@ -453,6 +454,8 @@ const documentation = computed<string | undefined>({
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const { getNodeColor, visibleNodeColors } = injectNodeColors()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -508,6 +511,8 @@ const documentation = computed<string | undefined>({
|
||||
:isRecordingEnabledGlobally="projectStore.isRecordingEnabled"
|
||||
:isVisualizationVisible="isVisualizationVisible"
|
||||
:isFullMenuVisible="menuVisible && menuFull"
|
||||
:nodeColor="getNodeColor(nodeId)"
|
||||
:visibleNodeColors="visibleNodeColors"
|
||||
@update:isVisualizationVisible="emit('update:visualizationVisible', $event)"
|
||||
@startEditing="startEditingNode"
|
||||
@startEditingComment="editingComment = true"
|
||||
@ -516,7 +521,7 @@ const documentation = computed<string | undefined>({
|
||||
@createNodes="emit('createNodes', $event)"
|
||||
@pointerenter="menuHovered = true"
|
||||
@pointerleave="menuHovered = false"
|
||||
@toggleColorPicker="emit('toggleColorPicker')"
|
||||
@update:nodeColor="emit('setNodeColor', $event)"
|
||||
/>
|
||||
<GraphVisualization
|
||||
v-if="isVisualizationVisible"
|
||||
|
@ -21,7 +21,7 @@ const emit = defineEmits<{
|
||||
nodeOutputPortDoubleClick: [portId: AstId]
|
||||
nodeDoubleClick: [nodeId: NodeId]
|
||||
createNodes: [source: NodeId, options: NodeCreationOptions[]]
|
||||
toggleColorPicker: []
|
||||
setNodeColor: [color: string]
|
||||
}>()
|
||||
|
||||
const projectStore = useProjectStore()
|
||||
@ -63,7 +63,7 @@ const uploadingFiles = computed<[FileName, File][]>(() => {
|
||||
@outputPortDoubleClick="(_event, port) => emit('nodeOutputPortDoubleClick', port)"
|
||||
@doubleClick="emit('nodeDoubleClick', id)"
|
||||
@createNodes="emit('createNodes', id, $event)"
|
||||
@toggleColorPicker="emit('toggleColorPicker')"
|
||||
@setNodeColor="emit('setNodeColor', $event)"
|
||||
@update:edited="graphStore.setEditedNode(id, $event)"
|
||||
@update:rect="graphStore.updateNodeRect(id, $event)"
|
||||
@update:visualizationId="
|
||||
|
@ -1,10 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import ColorRing from '@/components/ColorRing.vue'
|
||||
import SvgIcon from '@/components/SvgIcon.vue'
|
||||
import ToggleIcon from '@/components/ToggleIcon.vue'
|
||||
import { injectNodeColors } from '@/providers/graphNodeColors'
|
||||
import { injectGraphSelection } from '@/providers/graphSelection'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const _props = defineProps<{
|
||||
selectedComponents: number
|
||||
const showColorPicker = defineModel<boolean>('showColorPicker', { required: true })
|
||||
const _props = defineProps<{ selectedComponents: number }>()
|
||||
const emit = defineEmits<{
|
||||
collapseNodes: []
|
||||
setNodeColor: [color: string]
|
||||
removeNodes: []
|
||||
}>()
|
||||
const emit = defineEmits<{ collapseNodes: []; toggleColorPicker: []; removeNodes: [] }>()
|
||||
|
||||
const { getNodeColor, visibleNodeColors } = injectNodeColors()
|
||||
const selection = injectGraphSelection(true)
|
||||
const selectionColor = computed(() => {
|
||||
if (!selection) return undefined
|
||||
let color: string | undefined = undefined
|
||||
for (const node of selection.selected) {
|
||||
const nodeColor = getNodeColor(node)
|
||||
if (nodeColor) {
|
||||
if (color !== undefined && color !== nodeColor) return undefined
|
||||
else color = nodeColor
|
||||
}
|
||||
}
|
||||
return color
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@ -19,12 +42,16 @@ const emit = defineEmits<{ collapseNodes: []; toggleColorPicker: []; removeNodes
|
||||
alt="Group components"
|
||||
@click.stop="emit('collapseNodes')"
|
||||
/>
|
||||
<SvgIcon
|
||||
name="paint_palette"
|
||||
draggable="false"
|
||||
class="icon button"
|
||||
alt="Change components' colors"
|
||||
@click.stop="emit('toggleColorPicker')"
|
||||
<ToggleIcon
|
||||
v-model="showColorPicker"
|
||||
:alt="`${showColorPicker ? 'Hide' : 'Show'} the component color chooser`"
|
||||
icon="paint_palette"
|
||||
class="toggle button"
|
||||
:class="{
|
||||
// Any `pointerdown` event outside the color picker will close it. Ignore clicks that occur while the color
|
||||
// picker is open, so that it isn't toggled back open.
|
||||
disableInput: showColorPicker,
|
||||
}"
|
||||
/>
|
||||
<SvgIcon
|
||||
name="trash"
|
||||
@ -33,6 +60,14 @@ const emit = defineEmits<{ collapseNodes: []; toggleColorPicker: []; removeNodes
|
||||
alt="Delete components"
|
||||
@click.stop="emit('removeNodes')"
|
||||
/>
|
||||
<div v-if="showColorPicker" class="colorPickerContainer">
|
||||
<ColorRing
|
||||
:modelValue="selectionColor"
|
||||
:matchableColors="visibleNodeColors"
|
||||
@close="showColorPicker = false"
|
||||
@update:modelValue="emit('setNodeColor', $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -50,4 +85,30 @@ const emit = defineEmits<{ collapseNodes: []; toggleColorPicker: []; removeNodes
|
||||
padding-top: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.colorPickerContainer {
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
left: 0;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
display: flex;
|
||||
border-radius: var(--radius-default);
|
||||
background: var(--color-frame-bg);
|
||||
backdrop-filter: var(--blur-app-bg);
|
||||
place-items: center;
|
||||
padding: 36px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.toggledOn {
|
||||
opacity: unset;
|
||||
}
|
||||
|
||||
.disableInput {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
@ -7,6 +7,7 @@ import SelectionMenu from '@/components/SelectionMenu.vue'
|
||||
import { injectGuiConfig } from '@/providers/guiConfig'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const showColorPicker = defineModel<boolean>('showColorPicker', { required: true })
|
||||
const props = defineProps<{
|
||||
breadcrumbs: BreadcrumbItem[]
|
||||
recordMode: boolean
|
||||
@ -26,7 +27,7 @@ const emit = defineEmits<{
|
||||
zoomOut: []
|
||||
toggleCodeEditor: []
|
||||
collapseNodes: []
|
||||
toggleColorPicker: []
|
||||
setNodeColor: [color: string]
|
||||
removeNodes: []
|
||||
}>()
|
||||
|
||||
@ -60,10 +61,11 @@ const barStyle = computed(() => {
|
||||
<Transition name="selection-menu">
|
||||
<SelectionMenu
|
||||
v-if="componentsSelected > 1"
|
||||
v-model:showColorPicker="showColorPicker"
|
||||
:selectedComponents="componentsSelected"
|
||||
@collapseNodes="emit('collapseNodes')"
|
||||
@toggleColorPicker="emit('toggleColorPicker')"
|
||||
@removeNodes="emit('removeNodes')"
|
||||
@setNodeColor="emit('setNodeColor', $event)"
|
||||
/>
|
||||
</Transition>
|
||||
<ExtendedMenu
|
||||
|
30
app/gui2/src/composables/nodeColors.ts
Normal file
30
app/gui2/src/composables/nodeColors.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { useGraphStore, type NodeId } from '@/stores/graph'
|
||||
import { computed } from 'vue'
|
||||
|
||||
export function useNodeColors(getCssValue: (variable: string) => string) {
|
||||
const graphStore = useGraphStore()
|
||||
|
||||
function getNodeColor(node: NodeId) {
|
||||
const color = graphStore.db.getNodeColorStyle(node)
|
||||
if (color.startsWith('var')) {
|
||||
// Some colors are defined in CSS variables, we need to get the actual color.
|
||||
const variableName = color.slice(4, -1)
|
||||
const value = getCssValue(variableName)
|
||||
if (value === '') return undefined
|
||||
return value
|
||||
} else {
|
||||
return color
|
||||
}
|
||||
}
|
||||
|
||||
const visibleNodeColors = computed(() => {
|
||||
const colors = new Set<string>()
|
||||
for (const node of graphStore.db.nodeIds()) {
|
||||
const color = getNodeColor(node)
|
||||
if (color) colors.add(color)
|
||||
}
|
||||
return colors
|
||||
})
|
||||
|
||||
return { getNodeColor, visibleNodeColors }
|
||||
}
|
5
app/gui2/src/providers/graphNodeColors.ts
Normal file
5
app/gui2/src/providers/graphNodeColors.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { useNodeColors } from '@/composables/nodeColors'
|
||||
import { createContextStore } from '@/providers'
|
||||
|
||||
export { injectFn as injectNodeColors, provideFn as provideNodeColors }
|
||||
const { provideFn, injectFn } = createContextStore('node colors', useNodeColors)
|
@ -263,6 +263,10 @@ export class GraphDb {
|
||||
return this.bindings.identifierToBindingId.hasKey(ident)
|
||||
}
|
||||
|
||||
nodeIds(): IterableIterator<NodeId> {
|
||||
return this.nodeIdToNode.keys()
|
||||
}
|
||||
|
||||
isKnownFunctionCall(id: AstId): boolean {
|
||||
return this.getMethodCallInfo(id) != null
|
||||
}
|
||||
|
@ -41,7 +41,7 @@ export function registerAutoBlurHandler() {
|
||||
)
|
||||
}
|
||||
|
||||
/** Returns true if the target of the event is in the DOM subtree of the given `area` element. */
|
||||
/** Returns true if the target of the event is outside the DOM subtree of the given `area` element. */
|
||||
export function targetIsOutside(e: Event, area: Opt<Element>): boolean {
|
||||
return !!area && e.target instanceof Element && !area.contains(e.target)
|
||||
}
|
||||
|
@ -1,38 +1,44 @@
|
||||
import { converter, formatCss, formatRgb, modeOklch, modeRgb, useMode, type Oklch } from 'culori/fn'
|
||||
import { v3 as hashString } from 'murmurhash'
|
||||
export { type Oklch }
|
||||
|
||||
useMode(modeOklch)
|
||||
useMode(modeRgb)
|
||||
|
||||
const rgbConverter = converter('rgb')
|
||||
const oklch = converter('oklch')
|
||||
|
||||
export function convertToRgb(color: string): string | undefined {
|
||||
const colorRgb = rgbConverter(color)
|
||||
return colorRgb ? formatRgb(colorRgb) : undefined
|
||||
export function cssSupported(css: string): boolean {
|
||||
return typeof CSS !== 'undefined' && 'supports' in CSS && CSS.supports(css)
|
||||
}
|
||||
|
||||
// Check if the browser supports `oklch` colorspace. If it does not, we fallback to good-old sRGB.
|
||||
const supportsOklch: boolean =
|
||||
typeof CSS !== 'undefined' && 'supports' in CSS && CSS.supports('color: oklch(0 0 0)')
|
||||
/** Whether the browser supports `oklch` colorspace. */
|
||||
export const browserSupportsOklch: boolean = cssSupported('color: oklch(0 0 0)')
|
||||
|
||||
/* Generate a sRGB color value from the provided string. */
|
||||
/* Generate a CSS color value from the provided string. */
|
||||
export function colorFromString(s: string) {
|
||||
const hash: number = hashString(s)
|
||||
// Split the 32-bit hash value into parts of 12, 10 and 10 bits.
|
||||
const part1: number = (hash >> 20) & 0xfff
|
||||
const part2: number = (hash >> 10) & 0x3ff
|
||||
const part3: number = hash & 0x3ff
|
||||
// Range values below can be adjusted if necessary, they were chosen arbitrarily.
|
||||
const chroma = mapInt32(part1, 0.1, 0.16, 12)
|
||||
const hue = mapInt32(part2, 0, 360, 10)
|
||||
const lightness = mapInt32(part3, 0.52, 0.57, 10)
|
||||
const color: Oklch = {
|
||||
const hue = mapInt32(hash & 0x3ff, 0, 1, 10)
|
||||
return formatCssColor(ensoColor(hue))
|
||||
}
|
||||
|
||||
/* Returns the enso color for a given hue, in the range 0-1. */
|
||||
export function ensoColor(hue: number): Oklch {
|
||||
return {
|
||||
mode: 'oklch',
|
||||
l: lightness,
|
||||
c: chroma,
|
||||
h: hue,
|
||||
l: 0.545,
|
||||
c: 0.14,
|
||||
h: hue * 360,
|
||||
}
|
||||
return supportsOklch ? formatCss(color) : formatRgb(color)
|
||||
}
|
||||
|
||||
/** Format an OKLCH color in CSS. */
|
||||
export function formatCssColor(color: Oklch) {
|
||||
return browserSupportsOklch ? formatCss(color) : formatRgb(color)
|
||||
}
|
||||
|
||||
/* Parse the input as a CSS color value; convert it to Oklch if it isn't already. */
|
||||
export function parseCssColor(cssColor: string): Oklch | undefined {
|
||||
return oklch(cssColor)
|
||||
}
|
||||
|
||||
/* Map `bits`-wide unsigned integer to the range `[rangeStart, rangeEnd)`. */
|
||||
|
17
package-lock.json
generated
17
package-lock.json
generated
@ -59,7 +59,6 @@
|
||||
"rimraf": "^5.0.5",
|
||||
"semver": "^7.5.4",
|
||||
"sucrase": "^3.34.0",
|
||||
"verte-vue3": "^1.1.1",
|
||||
"vue": "^3.4.19",
|
||||
"ws": "^8.13.0",
|
||||
"y-codemirror.next": "^0.3.2",
|
||||
@ -8489,11 +8488,6 @@
|
||||
"color-name": "1.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/color-fns": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/color-fns/-/color-fns-0.0.10.tgz",
|
||||
"integrity": "sha512-QFKowTE9CXCLp09Gz5cQo8VPUP55hf73iHEI52JC3NyKfMpQG2VoLWmTxYeTKH6ngkEnoMrCdEX//M6J4PVQBA=="
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
|
||||
@ -20575,17 +20569,6 @@
|
||||
"node": ">=0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/verte-vue3": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/verte-vue3/-/verte-vue3-1.1.1.tgz",
|
||||
"integrity": "sha512-U8shxtA88VA7jh63CSOq+hGoh4LuGAd//nuYY0Mly5oFi0nWv8JtaBJlIpJ60rHE7YG/p1cotCCD6IpOUDheoQ==",
|
||||
"dependencies": {
|
||||
"color-fns": "^0.0.10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "4.5.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz",
|
||||
|
Loading…
Reference in New Issue
Block a user