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:
Kaz Wesley 2024-05-06 10:37:13 -07:00 committed by GitHub
parent 36dcbf1a07
commit 01a2ca458b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 619 additions and 173 deletions

View File

@ -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",

View File

@ -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.
*/

View File

@ -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;
}

View File

@ -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 vertes 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>

View 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>

View 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)

View 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
}

View File

@ -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>

View File

@ -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"

View File

@ -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="

View File

@ -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>

View File

@ -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

View 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 }
}

View 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)

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
View File

@ -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",