Fix hover transfer bugs occurring with the color picker popover (#4146)

* Fix hover transfer bugs occurring with the color picker popover

* Code review fix
This commit is contained in:
Keavon Chambers 2026-05-13 20:19:56 -07:00 committed by GitHub
parent 4d5dce976e
commit 696b625a3e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 25 additions and 23 deletions

View File

@ -65,14 +65,6 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
self.allow_none = allow_none; self.allow_none = allow_none;
self.disabled = disabled; self.disabled = disabled;
// Each `<ColorPicker>` Svelte instance maintains its own local layout state, but the Rust `LayoutMessageHandler` keeps a single shared layout per target. When a new picker instance opens after a previous one closed, the new instance's layout starts empty and a diff from the previously-shared state would not apply. Destroying the stored layouts here forces the next `SendLayout` to send the full layout instead of a diff.
responses.add(LayoutMessage::DestroyLayout {
layout_target: LayoutTarget::ColorPickerPickersAndGradient,
});
responses.add(LayoutMessage::DestroyLayout {
layout_target: LayoutTarget::ColorPickerDetails,
});
match initial_value { match initial_value {
FillChoice::None => { FillChoice::None => {
self.set_new_hsva(0., 0., 0., 1., true); self.set_new_hsva(0., 0., 0., 1., true);
@ -99,10 +91,6 @@ impl MessageHandler<ColorPickerMessage, ()> for ColorPickerMessageHandler {
self.send_layouts(responses); self.send_layouts(responses);
} }
ColorPickerMessage::Close => { ColorPickerMessage::Close => {
self.gradient = None;
self.active_marker_index = None;
self.active_marker_is_midpoint = false;
responses.add(DocumentMessage::EndTransaction); responses.add(DocumentMessage::EndTransaction);
} }
ColorPickerMessage::VisualUpdate { update } => { ColorPickerMessage::VisualUpdate { update } => {

View File

@ -4,7 +4,7 @@
import LayoutCol from "/src/components/layout/LayoutCol.svelte"; import LayoutCol from "/src/components/layout/LayoutCol.svelte";
import LayoutRow from "/src/components/layout/LayoutRow.svelte"; import LayoutRow from "/src/components/layout/LayoutRow.svelte";
import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte"; import WidgetLayout from "/src/components/widgets/WidgetLayout.svelte";
import type { ColorPickerStore } from "/src/stores/color-picker"; import type { ColorPickerCallbacks, ColorPickerStore } from "/src/stores/color-picker";
import type { EditorWrapper, FillChoice, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper"; import type { EditorWrapper, FillChoice, MenuDirection } from "/wrapper/pkg/graphite_wasm_wrapper";
const dispatch = createEventDispatcher<{ colorOrGradient: FillChoice; startHistoryTransaction: undefined; commitHistoryTransaction: undefined }>(); const dispatch = createEventDispatcher<{ colorOrGradient: FillChoice; startHistoryTransaction: undefined; commitHistoryTransaction: undefined }>();
@ -27,15 +27,18 @@
// Open/close lifecycle: when `open` flips, register/clear the global callbacks (so events route to *this* instance) // Open/close lifecycle: when `open` flips, register/clear the global callbacks (so events route to *this* instance)
// and tell the Rust handler to (re)initialize its state from the current `colorOrGradient`. // and tell the Rust handler to (re)initialize its state from the current `colorOrGradient`.
let lastOpen = false; let lastOpen = false;
// Identity used by `clearCallbacks` to skip stale clears when another picker has already taken over the store's callbacks
let installedCallbacks: ColorPickerCallbacks | undefined;
$: handleOpenChange(open); $: handleOpenChange(open);
function handleOpenChange(isOpen: boolean) { function handleOpenChange(isOpen: boolean) {
if (isOpen && !lastOpen) { if (isOpen && !lastOpen) {
colorPickerStore.setCallbacks({ installedCallbacks = {
onColorChanged: (value) => dispatch("colorOrGradient", value), onColorChanged: (value) => dispatch("colorOrGradient", value),
onStartTransaction: () => dispatch("startHistoryTransaction"), onStartTransaction: () => dispatch("startHistoryTransaction"),
onCommitTransaction: () => dispatch("commitHistoryTransaction"), onCommitTransaction: () => dispatch("commitHistoryTransaction"),
}); };
colorPickerStore.setCallbacks(installedCallbacks);
editor.openColorPicker(colorOrGradient, allowNone, disabled); editor.openColorPicker(colorOrGradient, allowNone, disabled);
// Auto-select the hex color code text input. Deferred so the layout has time to render after the picker opens. // Auto-select the hex color code text input. Deferred so the layout has time to render after the picker opens.
setTimeout(() => { setTimeout(() => {
@ -43,7 +46,8 @@
if (hexInput instanceof HTMLInputElement) hexInput.select(); if (hexInput instanceof HTMLInputElement) hexInput.select();
}, 0); }, 0);
} else if (!isOpen && lastOpen) { } else if (!isOpen && lastOpen) {
colorPickerStore.clearCallbacks(); if (installedCallbacks) colorPickerStore.clearCallbacks(installedCallbacks);
installedCallbacks = undefined;
editor.closeColorPicker(); editor.closeColorPicker();
} }
lastOpen = isOpen; lastOpen = isOpen;
@ -55,7 +59,8 @@
onDestroy(() => { onDestroy(() => {
if (!lastOpen) return; if (!lastOpen) return;
colorPickerStore.clearCallbacks(); if (installedCallbacks) colorPickerStore.clearCallbacks(installedCallbacks);
installedCallbacks = undefined;
editor.closeColorPicker(); editor.closeColorPicker();
}); });
</script> </script>

View File

@ -336,7 +336,7 @@
// HOVER TRANSFER // HOVER TRANSFER
// Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner // Transfer from this open floating menu to a sibling floating menu if the pointer hovers to a valid neighboring floating menu spawner
hoverTransfer(self, ownSpawner, targetSpawner); if (strayCloses) hoverTransfer(self, ownSpawner, targetSpawner);
// POINTER STRAY // POINTER STRAY
// Close the floating menu if the pointer has strayed far enough from its bounds (and it's not hovering over its own spawner) // Close the floating menu if the pointer has strayed far enough from its bounds (and it's not hovering over its own spawner)

View File

@ -40,10 +40,16 @@
// The `unFocus()` call in `onTextChangeCanceled()` causes itself to be run again, so this if statement skips a second run // The `unFocus()` call in `onTextChangeCanceled()` causes itself to be run again, so this if statement skips a second run
if (!editing) return; if (!editing) return;
// Capture before `onTextChangeCanceled` blurs the input
const currentValue = self?.getValue();
onTextChangeCanceled(); onTextChangeCanceled();
// TODO: Find a less hacky way to do this // Only commit on a real edit, so a blur fired when the focused input is removed from the DOM (e.g., from a picker closing
if (self) dispatch("commitText", self.getValue()); // during hover transfer) doesn't round-trip the original value back to the backend and overwrite concurrent state.
if (self && currentValue !== undefined && currentValue !== value) {
dispatch("commitText", currentValue);
}
// Required if value is not changed by the parent component upon update:value event // Required if value is not changed by the parent component upon update:value event
self?.setInputElementValue(self.getValue()); self?.setInputElementValue(self.getValue());

View File

@ -35,7 +35,8 @@ const { subscribe, update } = store;
export type ColorPickerStore = { export type ColorPickerStore = {
subscribe: typeof subscribe; subscribe: typeof subscribe;
setCallbacks: (callbacks: ColorPickerCallbacks) => void; setCallbacks: (callbacks: ColorPickerCallbacks) => void;
clearCallbacks: () => void; // Identity-checked so a hover-transfer race where another picker's `setCallbacks` already replaced these doesn't clobber the new picker's callbacks
clearCallbacks: (expected: ColorPickerCallbacks) => void;
setDragging: (dragging: boolean) => void; setDragging: (dragging: boolean) => void;
}; };
@ -88,10 +89,12 @@ export function createColorPickerStore(subscriptions: SubscriptionsRouter): Colo
return state; return state;
}); });
}, },
clearCallbacks: () => { clearCallbacks: (expected: ColorPickerCallbacks) => {
update((state) => { update((state) => {
state.callbacks = {};
state.isDragging = false; state.isDragging = false;
if (state.callbacks === expected) {
state.callbacks = {};
}
return state; return state;
}); });
}, },