diff --git a/src/components/MorphemeEditor.tsx b/src/components/MorphemeEditor.tsx index b4218638..12b79059 100644 --- a/src/components/MorphemeEditor.tsx +++ b/src/components/MorphemeEditor.tsx @@ -58,9 +58,19 @@ export function MorphemeBreakdownPopover({ inputRef.current?.select(); }, []); + /** + * Collapses leading/trailing and repeated internal whitespace to a single space. + * + * @param s - The string to normalize. + * @returns The string with surrounding whitespace trimmed and internal runs collapsed. + */ + const normalize = (s: string) => s.trim().replace(/\s+/g, ' '); + // Whether the draft matches the pre-filled value. Shared by the Done/Enter and outside-click - // commit paths so the two can never disagree about what counts as an edit. - const isUnedited = draft.trim() === initialValue.trim(); + // commit paths so the two can never disagree about what counts as an edit. Whitespace is + // normalized because the save path splits on /\s+/, so differing spacing yields identical forms — + // comparing normalized text avoids a no-op persistence round-trip. + const isUnedited = normalize(draft) === normalize(initialValue); /** * Commits the current draft and closes the popover. Skips the save when the token already has a diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index ade4aa22..0795d055 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -161,6 +161,11 @@ export function TokenChip({ // otherwise paint later segment rows over it. The popover is modal so interactions // outside the panel are blocked while it is open. The popover component is mounted only // while open so its draft state re-initializes from the current forms on every open. + // + // `onOpenChange` is intentionally omitted: this consumer owns every dismissal path + // (onEscapeKeyDown, onInteractOutside, explicit button clicks), so Radix's internal close + // requests aren't needed. Don't wire onOpenChange without also removing those, or closes + // would double-fire.