diff --git a/PanTS-Demo/src/components/segmentation/FillBetweenSlicesFlyout.tsx b/PanTS-Demo/src/components/segmentation/FillBetweenSlicesFlyout.tsx index 95172ce7..372eceaf 100644 --- a/PanTS-Demo/src/components/segmentation/FillBetweenSlicesFlyout.tsx +++ b/PanTS-Demo/src/components/segmentation/FillBetweenSlicesFlyout.tsx @@ -27,7 +27,13 @@ export default function FillBetweenSlicesFlyout({ segmentIndex, maskFilter, onLo const picker = useSliceAnchorPicker({ segmentIndex, - lastRequiresSegment: false, + // Unlike Copy-across-slices (whose destination legitimately doesn't + // need existing content), Fill needs a real segment on BOTH ends to + // interpolate between — a blank second slice has nothing to fill + // toward. Was `false` (copied from CopyAcrossSlicesFlyout without + // updating for this), which let step 2 accept any slice with no + // segment check at all. + lastRequiresSegment: true, onError: (d) => { setPickError(d); onLog?.(d); }, }); const { phase, step, first, last } = picker; diff --git a/PanTS-Demo/src/components/segmentation/SegmentEffectPanel.css b/PanTS-Demo/src/components/segmentation/SegmentEffectPanel.css index 2db02c56..548404f5 100644 --- a/PanTS-Demo/src/components/segmentation/SegmentEffectPanel.css +++ b/PanTS-Demo/src/components/segmentation/SegmentEffectPanel.css @@ -311,7 +311,7 @@ .seg-effect__number:focus { outline: none; - border-color: #E76F51; + border-color: #0b17ff; background: rgba(104, 172, 229, 0.08); } @@ -387,7 +387,7 @@ .seg-effect__icon-btn--primary { border-color: rgba(15, 23, 42, 0.4); - color: #E76F51; + color: #0b17ff; } .seg-effect__icon-btn--primary:hover:not(:disabled) { diff --git a/PanTS-Demo/src/components/segmentation/SegmentsPopup.css b/PanTS-Demo/src/components/segmentation/SegmentsPopup.css index 9b504606..fc6a999b 100644 --- a/PanTS-Demo/src/components/segmentation/SegmentsPopup.css +++ b/PanTS-Demo/src/components/segmentation/SegmentsPopup.css @@ -278,7 +278,7 @@ } .segpop__color-popover-swatch.is-active { - outline: 2px solid #E76F51; + outline: 2px solid #7cb7da; outline-offset: 1px; } @@ -303,6 +303,53 @@ appearance: none; } +/* Explicit commit footer for the color popover — live preview + hex value + + a clear "Done" close button, so picking a color visibly registers + before the popover disappears instead of a swatch click silently + closing it with no confirmation of what was chosen. Doesn't itself save + the class (that's still the row's own Save/ApplyButton); this is purely + about the popover's own selection state being unambiguous. */ +.segpop__color-popover-footer { + display: flex; + align-items: center; + gap: 8px; + margin-top: 2px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.09); +} + +.segpop__color-popover-preview { + width: 22px; + height: 22px; + border-radius: 6px; + border: 1px solid rgba(255, 255, 255, 0.2); + flex-shrink: 0; +} + +.segpop__color-popover-hex { + font-size: 11px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.02em; + color: rgba(255, 255, 255, 0.7); + flex: 1; +} + +.segpop__color-popover-done { + appearance: none; + border: none; + border-radius: 6px; + padding: 5px 12px; + font-size: 11px; + font-weight: 600; + background: #7cb7da; + color: #16181d; + cursor: pointer; +} + +.segpop__color-popover-done:hover { + background: #7cb7da; +} + /* Static color chip shown in the normal (non-editing) row — color is only changed together with the name via the pen-icon editor, so this isn't an and doesn't open a picker on click. */ @@ -327,7 +374,7 @@ width: 18px; height: 18px; border-radius: 50%; - color: #E76F51; + color: #7cb7da; background: rgba(104, 172, 229, 0.16); } @@ -339,8 +386,8 @@ } .segpop__target-badge--btn:hover { - background: rgba(232, 93, 93, 0.22); - color: #E85D5D; + background: rgb(17, 0, 75); + color: #7cb7da; transform: scale(1.08); } @@ -380,7 +427,7 @@ .segpop__edit-btn:hover, .segpop__edit-btn.is-active { - color: #E76F51; + color: #7cb7da; } /* Floating add/edit form (see FormFlyout in SegmentsPopup.tsx). Portaled to @@ -515,7 +562,7 @@ } .segpop__delete:hover:not(:disabled) { - color: #E76F51; + color: #7cb7da; } .segpop__err { @@ -653,7 +700,7 @@ width: 6px; height: 6px; border-radius: 50%; - background: #E76F51; + background: #7cb7da; } .segpop__tab.is-active .segpop__tab-dot { diff --git a/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx b/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx index f7828cf4..732dd888 100644 --- a/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx +++ b/PanTS-Demo/src/components/segmentation/SegmentsPopup.tsx @@ -105,6 +105,7 @@ interface ColorPickerPopoverProps { function ColorPickerPopover({ value, onChange, onClose, anchorRef }: ColorPickerPopoverProps) { const [closing, setClosing] = useState(false); const popRef = useRef(null); + const colorInputRef = useRef(null); const [pos, setPos] = useState<{ top: number; left: number } | null>(null); const requestClose = () => { @@ -146,6 +147,16 @@ function ColorPickerPopover({ value, onChange, onClose, anchorRef }: ColorPicker const onDown = (e: MouseEvent) => { if (popRef.current?.contains(e.target as Node)) return; if (anchorRef.current?.contains(e.target as Node)) return; + // The native OS color picker (behind the "Custom…" ) can fire a synthetic mousedown on `document` + // itself — outside both the input and this whole component tree — + // when the user drags/picks within the OS dialog, in some + // browsers. Without this guard that got misread as "clicked + // outside the popover" and closed it mid-pick, which is exactly + // why changing the color via the custom picker felt broken — + // every drag/selection risked closing before it registered. Skip + // closing while the color input itself still has focus. + if (document.activeElement === colorInputRef.current) return; requestClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") requestClose(); }; @@ -163,6 +174,7 @@ function ColorPickerPopover({ value, onChange, onClose, anchorRef }: ColorPicker return createPortal(
e.stopPropagation()} @@ -175,14 +187,34 @@ function ColorPickerPopover({ value, onChange, onClose, anchorRef }: ColorPicker className={`segpop__color-popover-swatch ${value.toLowerCase() === hex.toLowerCase() ? "is-active" : ""}`} style={{ background: hex }} aria-label={hex} + // Stages the color (updates the draft the caller holds) but does + // NOT close the popover — picking a swatch used to auto-close + // immediately, which looked like "nothing happened" since the + // actual save is a separate action on the row below. Leaving it + // open lets the person see the live preview/hex below update + // and confirm with an explicit "Done" instead. onClick={() => onChange(hex)} /> ))}
+ {/* Explicit commit step for the popover itself — a live preview swatch + plus the hex value, so it's visually obvious a selection has been + made and staged, then "Done" closes the popover. This does NOT save + the class — that's still the row's own Save/ApplyButton — it just + makes clear the color choice registered before the popover goes + away, instead of a swatch click silently vanishing the popover with + no confirmation of what got picked. */} +
+
, document.body ); @@ -268,6 +300,16 @@ function FormFlyout({ anchorEl, onClose, children }: FormFlyoutProps) { const onDown = (e: MouseEvent) => { if (panelRef.current?.contains(e.target as Node)) return; if (anchorEl?.contains(e.target as Node)) return; + // The color swatch popover portals to document.body on its OWN, + // separate from this FormFlyout's portal — so a click on a preset + // swatch or the native color input isn't a descendant of either + // panelRef or anchorEl, and without this check got misread as + // "clicked outside the name/color editor," closing the whole + // editor instead of just the small color popover. The color + // popover already closes itself independently on its own outside + // click; this just stops THIS flyout from also reacting to a + // click that was actually still inside it, conceptually. + if ((e.target as Element | null)?.closest?.("[data-color-popover-portal]")) return; requestClose(); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") requestClose(); }; diff --git a/PanTS-Demo/src/components/viewer/AnnotationToolbar.css b/PanTS-Demo/src/components/viewer/AnnotationToolbar.css index 52f88603..de48b81c 100644 --- a/PanTS-Demo/src/components/viewer/AnnotationToolbar.css +++ b/PanTS-Demo/src/components/viewer/AnnotationToolbar.css @@ -152,7 +152,7 @@ .atb-flyout__range:active::-webkit-slider-thumb { background: #0F172A; - border-color: #E76F51; + border-color: #68ACE5; } .atb-flyout__range::-moz-range-track { @@ -348,8 +348,8 @@ background: rgba(34, 34, 42, 0.62); backdrop-filter: blur(20px) saturate(150%); -webkit-backdrop-filter: blur(20px) saturate(150%); - border-left: 2px solid #E76F51; - border-top: 2px solid #E76F51; + border-left: 2px solid #68ACE5; + border-top: 2px solid #68ACE5; border-radius: 4px 0 0 0; transform: translateX(-50%) rotate(45deg); transition: left 0.2s ease; @@ -920,7 +920,7 @@ font-weight: 800; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--accent, #E76F51); + color: var(--jhu-blue-accent, #68ACE5); margin: 0 0 8px; } @@ -979,7 +979,7 @@ padding: 10px 0; border-radius: 999px; background: var(--jhu-blue, #0F172A); - border: 1px solid var(--accent, #E76F51); + border: 1px solid var(--jhu-blue-accent, #68ACE5); color: #ffffff; font-size: 12.5px; font-weight: 800; diff --git a/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx b/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx index ee68a132..9af523e4 100644 --- a/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx +++ b/PanTS-Demo/src/components/viewer/AnnotationToolbar.tsx @@ -130,6 +130,15 @@ interface AnnotationToolbarProps { // and the guided-overlay tools (close once their full-screen walkthrough // takes over). renderFlyout: (tool: Exclude, onApplied: () => void, onCloseSettings: () => void, onGuidedControlsChange: (controls: GuidedFlowControls | null) => void) => React.ReactNode; + /** Fired whenever a guided slice-anchor pick flow (Copy/Fill across + * slices) becomes active or finishes — true for the flow's entire + * lifecycle (both click steps, the ready-to-apply confirm, and while + * committing), not just the literal picking sub-phase, since the + * crosshair shouldn't be live for any of it: clicking a pane during a + * guided pick is meant to choose a slice/anchor, not move the + * crosshair. The caller (VisualizationPage) uses this to suppress + * Crosshairs for the duration. */ + onGuidedPickingChange?: (active: boolean) => void; popupRef?: React.RefObject; popupDragRef?: React.RefObject; @@ -146,20 +155,20 @@ interface AnnotationToolbarProps { } const TOOL_DEFS: Array<{ id: Exclude; label: string; Icon: typeof IconBrush; description: string }> = [ - { id: "paint", label: "Brush", Icon: IconBrush, description: "Paint with a round brush." }, - { id: "erase", label: "Erase", Icon: IconEraser, description: "Erase with a round brush." }, - { id: "scissors", label: "Scissors", Icon: IconScissors, description: "Cut through the entire class from the current viewpoint." }, - { id: "levelTracing", label: "Level Tracing", Icon: IconRipple, description: "Trace the boundary of similar intensity around the cursor." }, - { id: "margin", label: "Margin", Icon: IconArrowsDiagonal, description: "Grow or shrink the selected class by a specified margin size." }, - { id: "smoothing", label: "Smoothing", Icon: IconWaveSine, description: "Make class boundaries smoother." }, + { id: "paint", label: "Brush", Icon: IconBrush, description: "Paint freehand with a round brush." }, + { id: "erase", label: "Erase", Icon: IconEraser, description: "Erase parts of a shape manually." }, + { id: "scissors", label: "Scissors", Icon: IconScissors, description: "Lasso tool using anchor points." }, + { id: "levelTracing", label: "Level Tracing", Icon: IconRipple, description: "Traces the boundary of similar intensity around cursor." }, + { id: "margin", label: "Margin", Icon: IconArrowsDiagonal, description: "Grow or shrink by a specified margin size." }, + { id: "smoothing", label: "Smoothing", Icon: IconWaveSine, description: "Smooth class boundaries." }, { id: "islands", label: "Islands", Icon: IconDroplet, description: "Edit islands (connected components) in a class." }, { id: "logicalOperators", label: "Logical operators", Icon: IconMathFunction, description: "Apply logical operators or combine classes." }, - { id: "growFromSeeds", label: "Grow from seeds", Icon: IconWand, description: "Grow a class from user-placed seed scribbles." }, + { id: "growFromSeeds", label: "Grow from seeds", Icon: IconWand, description: "Grow a class from user-placed scribbles." }, { id: "fillBetweenSlices", label: "Fill between slices", Icon: IconStack2, description: "Interpolate a class's shape between two annotated slices." }, - { id: "copyAcrossSlices", label: "Copy across slices", Icon: IconCopy, description: "Copy a class's shape from one slice across a range." }, + { id: "copyAcrossSlices", label: "Copy across slices", Icon: IconCopy, description: "Copy a class's shape from first to last slice." }, { id: "hollow", label: "Hollow", Icon: IconCircleDashed, description: "Make the class hollow by replacing it with a uniform-thickness shell." }, - { id: "pointSegment", label: "Click to segment", Icon: IconPointer, description: "Click a point to propose a segment there (grows a region from the click; box-restrict or refine after)." }, - { id: "boxSegment", label: "Box to segment", Icon: IconFrame, description: "Draw a box to propose a segment restricted to that region." }, + { id: "pointSegment", label: "Click to segment", Icon: IconPointer, description: "Click an object to fill its shape" }, + { id: "boxSegment", label: "Box to segment", Icon: IconFrame, description: "Draw a box to find a shape inside that area." }, ]; const SCISSORS_OPERATIONS: { value: ScissorsOperation; label: string }[] = [ @@ -191,7 +200,7 @@ const GUIDED_HINT_COPY: Record = { growSeeds: "Continue moves on once you've placed your seed scribbles. Start over clears every seed and lets you begin again. Exit leaves Grow from Seeds without changing anything.", sliceOps: - "Continue (labeled Remove picked/Keep picked/etc. depending on the tool) applies your picks. Start over clears them and lets you pick again. Exit leaves the tool without changing anything.", + "Start over clears any choices made and lets you pick again. Exit leaves the tool without changing anything.", }; // Ribbon height, matches --atb-ribbon-h in CSS. Exported so SegmentsPopup @@ -393,7 +402,7 @@ function RenderingIndicator({ label: _label, visible }: { label?: string; visibl width: 7, height: 7, borderRadius: "50%", - background: "var(--accent, #E76F51)", + background: "var(--jhu-blue-accent, #68ACE5)", animation: "seg-effect-render-pulse 0.9s ease-in-out infinite", }} /> @@ -465,7 +474,7 @@ export default function AnnotationToolbar({ renderFlyout, scissorsPointCount, onScissorsCancel, maskingArea, onMaskingAreaChange, hasAnySegments, scopeLocked, isRendering, isDeletingSegment, targetKey, showOnlyTargetMask, onShowOnlyTargetMaskChange, - popupRef, popupDragRef, popupMinRef, anchorRef, + popupRef, popupDragRef, popupMinRef, anchorRef, onGuidedPickingChange, }: AnnotationToolbarProps) { const [hoveredTool, setHoveredTool] = useState(null); const [hoveredRect, setHoveredRect] = useState(null); @@ -854,6 +863,9 @@ export default function AnnotationToolbar({ useEffect(() => { guidedControlsRef.current = guidedControls; }, [guidedControls]); + useEffect(() => { + onGuidedPickingChange?.(guidedControls != null); + }, [guidedControls, onGuidedPickingChange]); // Small non-blocking hint shown right next to the cursor when Continue // is clicked while `continueDisabled` — e.g. "Mark at least one point @@ -1012,11 +1024,17 @@ export default function AnnotationToolbar({ aria-orientation="horizontal" >
-
+
{TOOL_DEFS.map(({ id, label, Icon, description }) => { // Only equip-and-use tools (paint/erase/scissors/level tracing) // get a settings arrow; other tools open settings on icon click. - const hasSettingsArrow = LIVE_COMMIT_TOOLS.includes(id); + // pointSegment/boxSegment are equip-and-use too (no separate + // settings step needed to click/drag) but have no configurable + // settings at all, so they're excluded from the arrow itself even + // though they stay in LIVE_COMMIT_TOOLS for the rest of its + // behavior (outside-click keeps them armed, etc). + const hasSettingsArrow = + LIVE_COMMIT_TOOLS.includes(id) && id !== "pointSegment" && id !== "boxSegment"; const settingsOpenHere = toolFlyout.open && activeTool === id; return (
{ + // Mirrors the main icon button's disabled-click handling above: + // the arrow previously called openToolSettings unconditionally, + // even when `enabled` was false, silently opening a tool's + // settings flyout with no class selected. Route it through the + // same "pick a class first" walkthrough popout instead. + if (!enabled) { setPickClassHintOpen(true); return; } if (settingsOpenHere) toolFlyout.setOpen(false); else openToolSettings(id); }} @@ -1086,7 +1110,7 @@ export default function AnnotationToolbar({ flexShrink: 0, display: "flex", alignItems: "center", - gap: 8, + gap: 15, marginLeft: 14, paddingLeft: 14, borderLeft: "1px solid rgba(255, 255, 255, 0.09)", @@ -1123,7 +1147,7 @@ export default function AnnotationToolbar({ width: 7, height: 7, borderRadius: "50%", - background: "var(--accent, #E76F51)", + background: "var(--jhu-blue-accent, #68ACE5)", animation: "seg-effect-render-pulse 0.9s ease-in-out infinite", }} /> @@ -1347,7 +1371,7 @@ export default function AnnotationToolbar({ left: pickClassHintRect.left - 3, width: pickClassHintRect.width + 6, height: pickClassHintRect.height + 3, - border: "2px dashed var(--accent, #E76F51)", + border: "2px dashed var(--jhu-blue-accent, #68ACE5)", borderRadius: 12, pointerEvents: "none", zIndex: 120, @@ -1372,7 +1396,7 @@ export default function AnnotationToolbar({ }} >
- Select an existing class or create a custom one, then start annotating. + Select an existing class or create a custom one to start annotating.
@@ -3246,7 +3274,7 @@ const aiAvailableOrgans = useMemo(() => {