improvement: FDI chart tool selection modes updated. shift+ and crtl+ both are assigned with a selection mode.

This commit is contained in:
2026-07-17 11:36:02 +03:30
parent 1b63bfff00
commit 58676f702e
9 changed files with 164 additions and 92 deletions

View File

@@ -809,7 +809,7 @@
"entryStepNext": "Next",
"entryStepLabUnavailable": "Lab dispatch is available after you save a lab-dependent detail with teeth selected.",
"toothChartTitleCompact": "Tooth chart",
"toothChartHint": "Tap teeth to select. Shift-click another tooth in the same arch to select a connected span.",
"toothChartHint": "Tap teeth to select. Shift-click another tooth in the same arch for a connected span. Ctrl-click (⌘ on Mac) the same way to select each tooth separately.",
"toothConnectedHint": "Connected selection",
"toothConnectedSuffix": " (connected)",
"connectedBadge": "Connected",

View File

@@ -803,7 +803,7 @@
"confirmSend": "تأیید ارسال",
"toothChartTitle": "نمودار دندان‌ها FDI",
"toothChartTitleCompact": "نمودار دندان",
"toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل می‌سازد.",
"toothChartHint": "برای انتخاب روی دندان ضربه بزنید. Shift+کلیک روی دندان دیگر در همان قوس، یک بازه متصل می‌سازد. Ctrl+کلیک (⌘ در مک) به همان شکل هر دندان را جداگانه انتخاب می‌کند.",
"toothConnectedHint": "انتخاب متصل",
"toothConnectedSuffix": " (متصل)",
"connectedBadge": "متصل",

View File

@@ -802,7 +802,7 @@
"confirmSend": "Bevestig verzending",
"toothChartTitle": "FDI-tanddiagram",
"toothChartTitleCompact": "Tanddiagram",
"toothChartHint": "Tik op tanden om te selecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden span.",
"toothChartHint": "Tik op tanden om te selecteren. Shift-klik op een andere tand in dezelfde boog voor een verbonden span. Ctrl-klik (⌘ op Mac) op dezelfde manier om elke tand apart te selecteren.",
"toothConnectedHint": "Verbonden selectie",
"toothConnectedSuffix": " (verbonden)",
"connectedBadge": "Verbonden",

View File

@@ -146,6 +146,38 @@ export function applyShiftRange(
return next;
}
/**
* Ctrl-click selects every tooth in the same-arch range as its own single group
* (not connected). Lab dispatch gets one prosthesis row per tooth.
*/
export function applyCtrlRange(
groups: ToothSelectionGroup[],
anchor: FdiToothId,
target: FdiToothId,
): ToothSelectionGroup[] | null {
if (anchor === target) return null;
const span = teethBetweenInclusive(anchor, target);
if (!span || span.length < 2) return null;
const spanSet = new Set(span);
const next = groups
.map((g) => ({
...g,
teeth: g.teeth.filter((t) => !spanSet.has(t)) as FdiToothId[],
}))
.filter((g) => g.teeth.length > 0)
.map((g) =>
g.kind === 'connected' && g.teeth.length < 2
? { ...g, kind: 'single' as const }
: g,
);
for (const tooth of span) {
next.push({ groupId: newGroupId(), kind: 'single', teeth: [tooth] });
}
return next;
}
/**
* Drop prosthesis rows for teeth no longer selected on a detail, and refresh
* selectionGroupId so connected→single collapses do not leave stale group ids.

View File

@@ -23,7 +23,7 @@ interface FdiToothChartProps {
selected: ReadonlySet<FdiToothId>;
/** Teeth that belong to a connected selection span (dots above/below). */
connectedTeeth?: ReadonlySet<FdiToothId>;
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean }) => void;
onToggle?: (fdi: FdiToothId, event: { shiftKey: boolean; ctrlKey: boolean }) => void;
disabled?: boolean;
/** Non-interactive display (Cases / connection history). */
readOnly?: boolean;
@@ -144,91 +144,125 @@ export function FdiToothChart({
return accent ? { color: accent } : undefined;
};
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
{teeth.map((fdi, i) => {
const kind = getToothShapeKind(fdi);
const gid = `${uid}-g-${fdi}-${i}`;
const isSel = selected.has(fdi);
const isConnected = Boolean(connectedTeeth?.has(fdi));
const size = toothSizeClass(fdi);
const tweak = TOOTH_TWEAKS[fdi];
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
const alignItems = upper ? 'items-end' : 'items-start';
const accent = toothAccent(fdi);
/** Dots + thin links — only for Shift-connected spans (not plain / Ctrl singles). */
const ConnectedRail = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => {
const hasConnected = teeth.some((fdi) => connectedTeeth?.has(fdi));
if (!hasConnected) return null;
const glyph = (
<ToothGlyph
fdi={fdi}
kind={kind}
gradientId={gid}
selected={isSel}
upper={upper}
mirrored={quadrantMirrored(fdi)}
upsideDown={upper}
className={tweak?.glyph ?? size.glyph}
accentColor={isSel ? accent : undefined}
/>
);
const connectedDot = isConnected ? (
<span
className={`block h-1.5 w-1.5 rounded-full bg-primary ${upper ? 'mb-0.5' : 'mt-0.5'}`}
aria-hidden
title={t('toothConnectedHint')}
/>
) : (
<span className={`block h-1.5 w-1.5 ${upper ? 'mb-0.5' : 'mt-0.5'}`} aria-hidden />
);
return (
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
{upper ? connectedDot : null}
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
{interactive ? (
<button
type="button"
disabled={isDisabled}
onMouseDown={(e) => {
// Shift+click otherwise triggers browser text-selection / sticky focus boxes.
if (e.shiftKey) e.preventDefault();
}}
onClick={(e) => {
e.preventDefault();
onToggle?.(fdi, { shiftKey: e.shiftKey });
}}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={
isSel
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}${
isConnected ? t('toothConnectedSuffix') : ''
}`
: t('toothAria', { fdi })
}
>
{glyph}
</button>
return (
<div
className={`flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit ${upper ? 'mb-1' : 'mt-1'}`}
aria-hidden
>
{teeth.map((fdi, i) => {
const size = toothSizeClass(fdi);
const isConnected = Boolean(connectedTeeth?.has(fdi));
const next = teeth[i + 1];
const linkToNext = Boolean(isConnected && next && connectedTeeth?.has(next));
return (
<div
key={`c-${fdi}`}
className={`relative flex h-2.5 items-center justify-center ${size.wrapper}`}
title={isConnected ? t('toothConnectedHint') : undefined}
>
{linkToNext ? (
<span
className="pointer-events-none absolute top-1/2 left-1/2 z-0 h-[2px] -translate-y-1/2 bg-primary/75"
style={{ width: 'calc(100% + 0.25rem)' }}
/>
) : null}
{isConnected ? (
<span className="relative z-10 block h-2 w-2 rounded-full bg-primary shadow-sm" />
) : (
<div
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className="rounded-[var(--radius-sm)] p-0.5"
aria-hidden={!isSel}
>
{glyph}
</div>
<span className="block h-2 w-2" />
)}
</div>
{!upper ? connectedDot : null}
</div>
);
})}
);
})}
</div>
);
};
const Row = ({ teeth, upper }: { teeth: FdiToothId[]; upper?: boolean }) => (
<div className="flex flex-col items-center min-w-max mx-auto w-fit">
{upper ? <ConnectedRail teeth={teeth} upper /> : null}
<div className="flex flex-nowrap justify-center gap-x-1 min-w-max mx-auto w-fit">
{teeth.map((fdi, i) => {
const kind = getToothShapeKind(fdi);
const gid = `${uid}-g-${fdi}-${i}`;
const isSel = selected.has(fdi);
const isConnected = Boolean(connectedTeeth?.has(fdi));
const size = toothSizeClass(fdi);
const tweak = TOOTH_TWEAKS[fdi];
const offsetY = tweak ? tweak.offset : archOffset(i, teeth.length, upper);
const rotate = tweak ? tweak.rotate : archRotate(i, teeth.length);
const alignItems = upper ? 'items-end' : 'items-start';
const accent = toothAccent(fdi);
const glyph = (
<ToothGlyph
fdi={fdi}
kind={kind}
gradientId={gid}
selected={isSel}
upper={upper}
mirrored={quadrantMirrored(fdi)}
upsideDown={upper}
className={tweak?.glyph ?? size.glyph}
accentColor={isSel ? accent : undefined}
/>
);
return (
<div key={fdi} className={`flex flex-col items-center ${size.wrapper}`}>
<div className={`h-[5.9rem] flex ${alignItems} justify-center`}>
{interactive ? (
<button
type="button"
disabled={isDisabled}
onMouseDown={(e) => {
// Modifier+click otherwise triggers browser text-selection / sticky focus boxes.
if (e.shiftKey || e.ctrlKey || e.metaKey) e.preventDefault();
}}
onClick={(e) => {
e.preventDefault();
onToggle?.(fdi, {
shiftKey: e.shiftKey,
ctrlKey: e.ctrlKey || e.metaKey,
});
}}
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className={`
rounded-[var(--radius-sm)] p-0.5 transition-transform select-none
focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50
${isDisabled ? 'opacity-50 cursor-not-allowed' : 'hover:scale-105 active:scale-95'}
`}
aria-pressed={isSel}
aria-label={
isSel
? `${t('toothAria', { fdi })}${t('toothSelectedSuffix')}${
isConnected ? t('toothConnectedSuffix') : ''
}`
: t('toothAria', { fdi })
}
>
{glyph}
</button>
) : (
<div
style={{ transform: `translateY(${offsetY}px) rotate(${rotate}deg)` }}
className="rounded-[var(--radius-sm)] p-0.5"
aria-hidden={!isSel}
>
{glyph}
</div>
)}
</div>
</div>
);
})}
</div>
{!upper ? <ConnectedRail teeth={teeth} /> : null}
</div>
);

View File

@@ -41,6 +41,7 @@ import {
isLabDependentDetailMissingTeeth,
} from '@/components/treatment/treatmentDetailRules';
import {
applyCtrlRange,
applyShiftRange,
connectedTeethSet,
deriveTeethFromGroups,
@@ -1937,14 +1938,17 @@ export function TreatmentWorkspace({
let nextGroups: ReturnType<typeof toggleToothInGroups> | null = null;
if (event.shiftKey) {
// Shift wins if both modifiers are held (connected span).
if (event.shiftKey || event.ctrlKey) {
const anchor = rangeAnchorRef.current;
// Need a prior click as range start; shift alone on one tooth does nothing.
// Need a prior click as range start; modifier alone on one tooth does nothing.
if (!anchor || anchor === fdi) {
rangeAnchorRef.current = fdi;
return;
}
nextGroups = applyShiftRange(currentGroups, anchor, fdi);
nextGroups = event.shiftKey
? applyShiftRange(currentGroups, anchor, fdi)
: applyCtrlRange(currentGroups, anchor, fdi);
rangeAnchorRef.current = fdi;
if (!nextGroups) return;
} else {