change bulk selection panel to allow more versatile input

This commit is contained in:
EthanHealy01 2025-09-05 00:54:21 +01:00
parent 1a3e8e7ecf
commit c2b0631005
14 changed files with 2022 additions and 426 deletions

View File

@ -1022,9 +1022,7 @@
},
"pageSelection": {
"tooltip": {
"header": {
"title": "Page Selection Guide"
},
"header": { "title": "Page Selection Guide" },
"basic": {
"title": "Basic Usage",
"text": "Select specific pages from your PDF document using simple syntax.",
@ -1041,7 +1039,51 @@
"bullet1": "Page numbers start from 1 (not 0)",
"bullet2": "Spaces are automatically removed",
"bullet3": "Invalid expressions are ignored"
},
"syntax": {
"title": "Syntax Basics",
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
"bullets": {
"numbers": "Numbers/ranges: 5, 10-20",
"keywords": "Keywords: odd, even",
"progressions": "Progressions: 3n, 4n+1"
}
},
"operators": {
"title": "Operators",
"text": "AND has higher precedence than comma. NOT applies within the document range.",
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
},
"examples": { "title": "Examples" }
}
},
"bulkSelection": {
"header": { "title": "Page Selection Guide" },
"syntax": {
"title": "Syntax Basics",
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
"bullets": {
"numbers": "Numbers/ranges: 5, 10-20",
"keywords": "Keywords: odd, even",
"progressions": "Progressions: 3n, 4n+1"
}
},
"operators": {
"title": "Operators",
"text": "AND has higher precedence than comma. NOT applies within the document range.",
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
},
"examples": {
"title": "Examples",
"first50": "First 50",
"last50": "Last 50",
"every3rd": "Every 3rd",
"oddWithinExcluding": "Odd within 1-20 excluding 5-7",
"combineSets": "Combine sets"
}
},
"compressPdfs": {

View File

@ -807,7 +807,51 @@
"bullet1": "Page numbers start from 1 (not 0)",
"bullet2": "Spaces are automatically removed",
"bullet3": "Invalid expressions are ignored"
},
"syntax": {
"title": "Syntax Basics",
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
"bullets": {
"numbers": "Numbers/ranges: 5, 10-20",
"keywords": "Keywords: odd, even",
"progressions": "Progressions: 3n, 4n+1"
}
},
"operators": {
"title": "Operators",
"text": "AND has higher precedence than comma. NOT applies within the document range.",
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
},
"examples": { "title": "Examples" }
}
},
"bulkSelection": {
"header": { "title": "Page Selection Guide" },
"syntax": {
"title": "Syntax Basics",
"text": "Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.",
"bullets": {
"numbers": "Numbers/ranges: 5, 10-20",
"keywords": "Keywords: odd, even",
"progressions": "Progressions: 3n, 4n+1"
}
},
"operators": {
"title": "Operators",
"text": "AND has higher precedence than comma. NOT applies within the document range.",
"and": "AND: & or \"and\" — require both conditions (e.g., 1-50 & even)",
"comma": "Comma: , or | — combine selections (e.g., 1-10, 20)",
"not": "NOT: ! or \"not\" — exclude pages (e.g., 3n & not 30)"
},
"examples": {
"title": "Examples",
"first50": "First 50",
"last50": "Last 50",
"every3rd": "Every 3rd",
"oddWithinExcluding": "Odd within 1-20 excluding 5-7",
"combineSets": "Combine sets"
}
},
"compressPdfs": {

View File

@ -0,0 +1,234 @@
.panelGroup {
max-width: 100%;
flex-wrap: wrap;
min-width: 24rem;
}
.textInput {
flex: 1;
max-width: 100%;
}
.dropdownContainer {
margin-top: 0.5rem;
}
.menuDropdown {
min-width: 22.5rem;
}
.dropdownContent {
display: flex;
gap: 0.75rem;
}
.leftCol {
flex: 1 1 auto;
min-width: 0;
max-width: calc(100% - 8rem - 0.75rem);
overflow: hidden;
}
.rightCol {
width: 8rem;
border-left: 0.0625rem solid var(--mantine-color-gray-3);
padding-left: 0.75rem;
display: flex;
flex-direction: column;
}
.operatorGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.operatorChip {
width: 100%;
border-radius: 1.25rem;
border: 0.0625rem solid var(--mantine-color-gray-4);
background-color: var(--mantine-color-white);
transition: all 0.2s ease;
min-height: 2rem;
}
.operatorChip:hover:not(:disabled) {
border-color: var(--primary-color, #3b82f6);
background-color: var(--mantine-color-blue-0);
transform: translateY(-0.0625rem);
box-shadow: 0 0.125rem 0.25rem rgba(59, 130, 246, 0.1);
}
.operatorChip:active:not(:disabled) {
transform: translateY(0);
box-shadow: 0 0.0625rem 0.125rem rgba(59, 130, 246, 0.1);
}
.operatorChip:disabled {
opacity: 0.4;
cursor: not-allowed;
}
:global([data-mantine-color-scheme='dark']) .operatorChip {
background-color: var(--mantine-color-dark-6);
border-color: var(--mantine-color-dark-4);
}
:global([data-mantine-color-scheme='dark']) .operatorChip:hover:not(:disabled) {
background-color: var(--mantine-color-dark-5);
border-color: var(--primary-color, #3b82f6);
}
.dropdownHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 0.0625rem solid var(--mantine-color-gray-3);
margin-bottom: 0.5rem;
}
.closeButton {
min-width: 1.5rem;
height: 1.5rem;
padding: 0;
font-size: 1.25rem;
font-weight: bold;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.menuItemRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.chevron {
color: var(--mantine-color-dimmed);
}
/* Icon-based chevrons */
.chevronIcon {
transition: transform 150ms ease;
display: inline-flex;
align-items: center;
}
.chevronDown {
transform: rotate(90deg);
}
.chevronUp {
transform: rotate(270deg);
}
.inlineRow {
padding: 0.75rem 0.5rem;
}
.inlineRowCompact {
padding: 0.5rem 0.5rem 0.75rem 0.5rem;
}
.menuItemCloseHover {
background-color: var(--mantine-color-red-1);
transition: background-color 150ms ease;
}
:global([data-mantine-color-scheme='dark']) .menuItemCloseHover {
background-color: var(--mantine-color-red-9);
}
.selectedList {
max-height: 8rem;
overflow: auto;
border: 0.0625rem solid var(--mantine-color-gray-3);
border-radius: 0.25rem;
padding: 0.5rem;
margin-top: 0.5rem;
min-width: 24rem;
}
.selectedText {
word-break: break-word;
max-width: 100%;
}
.advancedSection {
margin-top: 0.5rem;
min-width: 24rem;
}
.advancedHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 0.0625rem solid var(--mantine-color-gray-3);
margin-bottom: 0.5rem;
}
.advancedContent {
display: flex;
gap: 0.75rem;
padding: 0 0.75rem 0.75rem 0.75rem;
}
.advancedItem {
padding: 0.5rem;
cursor: pointer;
border-radius: 0.25rem;
transition: background-color 150ms ease;
}
.advancedItem:hover {
background-color: var(--mantine-color-gray-1);
}
:global([data-mantine-color-scheme='dark']) .advancedItem:hover {
background-color: var(--mantine-color-gray-8);
}
.advancedCard {
background-color: var(--mantine-color-gray-0);
border: 0.0625rem solid var(--mantine-color-gray-2);
border-radius: 0.5rem;
padding: 1rem;
margin-bottom: 1rem;
width: 100%;
box-sizing: border-box;
transition: all 0.2s ease;
}
.advancedCard:hover {
border-color: var(--mantine-color-gray-3);
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.05);
}
:global([data-mantine-color-scheme='dark']) .advancedCard {
background-color: var(--mantine-color-dark-7);
border-color: var(--mantine-color-dark-5);
}
:global([data-mantine-color-scheme='dark']) .advancedCard:hover {
border-color: var(--mantine-color-dark-4);
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.2);
}
.inputGroup {
width: 100%;
}
.fullWidthInput {
flex: 1;
}
.applyButton {
min-width: 4rem;
flex-shrink: 0;
}

View File

@ -0,0 +1,116 @@
// Pure helper utilities for the BulkSelectionPanel UI
export type LogicalOperator = 'and' | 'or' | 'not';
// Returns a new CSV expression with expr appended.
// If current ends with an operator token, expr is appended directly.
// Otherwise, it is joined with " or ".
export function appendExpression(currentInput: string, expr: string): string {
const current = (currentInput || '').trim();
if (!current) return expr;
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current);
return endsWithOperator ? `${current}${expr}` : `${current} or ${expr}`;
}
// Smartly inserts/normalizes a logical operator at the end of the current input.
// Produces a trailing space to allow the next token to be typed naturally.
export function insertOperatorSmart(currentInput: string, op: LogicalOperator): string {
const text = (currentInput || '').trim();
if (text.length === 0) return `${op} `;
// Extract up to the last two operator tokens (words or symbols) from the end
const tokens: string[] = [];
let rest = text;
for (let i = 0; i < 2; i++) {
const m = rest.match(/(?:\s*)(?:(&|\||,|!|\band\b|\bor\b|\bnot\b))\s*$/i);
if (!m || m.index === undefined) break;
const raw = m[1].toLowerCase();
const word = raw === '&' ? 'and' : raw === '|' || raw === ',' ? 'or' : raw === '!' ? 'not' : raw;
tokens.unshift(word);
rest = rest.slice(0, m.index).trimEnd();
}
const emit = (base: string, phrase: string) => `${base} ${phrase} `;
const click = op; // desired operator
if (tokens.length === 0) {
return emit(text, click);
}
// Normalize to allowed set
const phrase = tokens.join(' ');
const allowed = new Set(['and', 'or', 'not', 'and or', 'and not']);
// Helpers for transitions from a single trailing token
const fromSingle = (t: string): string => {
if (t === 'and') {
if (click === 'and') return 'and';
if (click === 'or') return 'and or';
return 'and not';
}
if (t === 'or') {
if (click === 'and') return 'and';
if (click === 'or') return 'or';
return 'not';
}
// t === 'not'
if (click === 'and') return 'and';
if (click === 'or') return 'or';
return 'not';
};
// From combined phrase
const fromCombo = (p: string): string => {
if (p === 'and or') {
if (click === 'or') return 'and or';
if (click === 'and') return 'and';
return 'and not';
}
if (p === 'and not') {
if (click === 'not') return 'and not';
if (click === 'and') return 'and';
return 'and or';
}
// Invalid combos (e.g., 'not and', 'not or', 'or and') → collapse to clicked op
return click;
};
const base = rest.trim();
const nextPhrase = tokens.length === 1 ? fromSingle(tokens[0]) : fromCombo(phrase);
if (!allowed.has(nextPhrase)) {
return emit(base, click);
}
return emit(base, nextPhrase);
}
// Expression builders for Advanced actions
export function firstNExpression(n: number, maxPages: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
const end = Math.min(maxPages, Math.max(1, Math.floor(n)));
return `1-${end}`;
}
export function lastNExpression(n: number, maxPages: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
const count = Math.max(1, Math.floor(n));
const start = Math.max(1, maxPages - count + 1);
if (maxPages <= 0) return null;
return `${start}-${maxPages}`;
}
export function everyNthExpression(n: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
return `${Math.max(1, Math.floor(n))}n`;
}
export function rangeExpression(start: number, end: number, maxPages: number): string | null {
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
let s = Math.floor(start);
let e = Math.floor(end);
if (s > e) [s, e] = [e, s];
s = Math.max(1, s);
e = maxPages > 0 ? Math.min(maxPages, e) : e;
return `${s}-${e}`;
}

View File

@ -1,12 +1,24 @@
import React from 'react';
import { Group, TextInput, Button, Text } from '@mantine/core';
import React, { useState } from 'react';
import { Group, TextInput, Button, Text, Menu, NumberInput, Divider, Box, Flex } from '@mantine/core';
import LocalIcon from '../shared/LocalIcon';
import { Tooltip } from '../shared/Tooltip';
import { usePageSelectionTips } from '../tooltips/usePageSelectionTips';
import classes from './BulkSelectionPanel.module.css';
import {
appendExpression,
insertOperatorSmart,
firstNExpression,
lastNExpression,
everyNthExpression,
rangeExpression,
} from './BulkSelectionPanel';
interface BulkSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
onUpdatePagesFromCSV: () => void;
onUpdatePagesFromCSV: (override?: string) => void;
}
const BulkSelectionPanel = ({
@ -16,29 +28,358 @@ const BulkSelectionPanel = ({
displayDocument,
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
const pageSelectionTips = usePageSelectionTips();
const [advancedOpened, setAdvancedOpened] = useState<boolean>(false);
const [pendingAction, setPendingAction] = useState<null | 'firstN' | 'lastN' | 'everyNth' | 'range'>(null);
const [firstNValue, setFirstNValue] = useState<number | ''>('');
const [lastNValue, setLastNValue] = useState<number | ''>('');
const [everyNthValue, setEveryNthValue] = useState<number | ''>('');
const [rangeStart, setRangeStart] = useState<number | ''>('');
const [rangeEnd, setRangeEnd] = useState<number | ''>('');
const [firstNError, setFirstNError] = useState<string | null>(null);
const [lastNError, setLastNError] = useState<string | null>(null);
const [rangeError, setRangeError] = useState<string | null>(null);
const maxPages = displayDocument?.pages?.length ?? 0;
const applyExpression = (expr: string) => {
const nextInput = appendExpression(csvInput, expr);
setCsvInput(nextInput);
onUpdatePagesFromCSV(nextInput);
setPendingAction(null);
};
const handleNone = () => {
setCsvInput('');
onUpdatePagesFromCSV();
setPendingAction(null);
setFirstNValue('');
setLastNValue('');
setEveryNthValue('');
};
const selectAction = (action: 'firstN' | 'lastN' | 'everyNth' | 'range') => {
setPendingAction(action);
setFirstNValue('');
setLastNValue('');
setEveryNthValue('');
setRangeStart('');
setRangeEnd('');
setFirstNError(null);
setLastNError(null);
setRangeError(null);
};
const insertOperator = (op: 'and' | 'or' | 'not') => {
const next = insertOperatorSmart(csvInput, op);
setCsvInput(next);
};
return (
<>
<Group>
<Group className={classes.panelGroup}>
<TextInput
value={csvInput}
onChange={(e) => setCsvInput(e.target.value)}
onChange={(e) => {
const next = e.target.value;
setCsvInput(next);
onUpdatePagesFromCSV(next);
}}
placeholder="1,3,5-10"
label="Page Selection"
onBlur={onUpdatePagesFromCSV}
rightSection={
csvInput && (
<Button
variant="subtle"
size="xs"
onClick={handleNone}
style={{
color: 'var(--mantine-color-gray-6)',
minWidth: 'auto',
width: '24px',
height: '24px',
padding: 0
}}
>
×
</Button>
)
}
label={
<Tooltip
position="left"
offset={20}
header={pageSelectionTips.header}
portalTarget={document.body}
pinOnClick={true}
containerStyle={{ marginTop: "1rem"}}
tips={pageSelectionTips.tips}
>
<Flex onClick={(e) => e.stopPropagation()} align="center" gap="xs" my="sm">
<LocalIcon icon="gpp-maybe-outline-rounded" width="1rem" height="1rem" style={{ color: 'var(--primary-color, #3b82f6)' }} />
<Text>Page Selection</Text>
</Flex>
</Tooltip>
}
onBlur={() => onUpdatePagesFromCSV()}
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
style={{ flex: 1 }}
className={classes.textInput}
/>
<Button onClick={onUpdatePagesFromCSV} mt="xl">
Apply
</Button>
</Group>
{/* Selected pages container */}
{selectedPageIds.length > 0 && (
<Text size="sm" c="dimmed" mt="sm">
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(n => n > 0).join(', ') : ''})
</Text>
<div className={classes.selectedList}>
<Text size="sm" c="dimmed" className={classes.selectedText}>
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(n => n > 0).join(', ') : ''})
</Text>
</div>
)}
{/* Advanced button */}
<div className={classes.dropdownContainer}>
<Button
variant="light"
size="xs"
onClick={() => setAdvancedOpened(!advancedOpened)}
>
Advanced
</Button>
</div>
{/* Advanced section */}
{advancedOpened && (
<div className={classes.advancedSection}>
<div className={classes.advancedHeader}>
<Text size="sm" fw={500}>Advanced Selection</Text>
<Button
size="xs"
variant="subtle"
color="gray"
onClick={() => setAdvancedOpened(false)}
className={classes.closeButton}
>
×
</Button>
</div>
<div className={classes.advancedContent}>
<div className={classes.leftCol}>
{/* First N Pages - Card Style */}
<div className={classes.advancedCard}>
<Text size="sm" fw={600} c="gray.7" mb="sm">First N Pages</Text>
{firstNError && (<Text size="xs" c="red" mb="xs">{firstNError}</Text>)}
<div className={classes.inputGroup}>
<Text size="xs" c="gray.6" mb="xs">Number of pages:</Text>
<Group gap="sm" align="flex-end" wrap="nowrap">
<NumberInput
size="sm"
value={firstNValue}
onChange={(val) => {
const next = typeof val === 'number' ? val : '';
setFirstNValue(next);
if (next === '') setFirstNError(null);
else if (typeof next === 'number' && next <= 0) setFirstNError('Enter a positive number');
else setFirstNError(null);
}}
min={1}
placeholder="10"
className={classes.fullWidthInput}
error={Boolean(firstNError)}
/>
<Button
size="sm"
className={classes.applyButton}
onClick={() => {
if (!firstNValue || typeof firstNValue !== 'number') return;
const expr = firstNExpression(firstNValue, maxPages);
if (expr) applyExpression(expr);
setFirstNValue('');
}}
disabled={Boolean(firstNError) || firstNValue === ''}
>
Apply
</Button>
</Group>
</div>
</div>
{/* Range - Card Style */}
<div className={classes.advancedCard}>
<Text size="sm" fw={600} c="gray.7" mb="sm">Range</Text>
{rangeError && (<Text size="xs" c="red" mb="xs">{rangeError}</Text>)}
<div className={classes.inputGroup}>
<Group gap="sm" align="flex-end" wrap="nowrap" mb="sm">
<div style={{ flex: 1 }}>
<Text size="xs" c="gray.6" mb="xs">From:</Text>
<NumberInput
size="sm"
value={rangeStart}
onChange={(val) => {
const next = typeof val === 'number' ? val : '';
setRangeStart(next);
const s = typeof next === 'number' ? next : null;
const e = typeof rangeEnd === 'number' ? rangeEnd : null;
if (s !== null && s <= 0) setRangeError('Values must be positive');
else if (s !== null && e !== null && s > e) setRangeError('From must be less than or equal to To');
else setRangeError(null);
}}
min={1}
placeholder="5"
error={Boolean(rangeError)}
/>
</div>
<div style={{ flex: 1 }}>
<Text size="xs" c="gray.6" mb="xs">To:</Text>
<NumberInput
size="sm"
value={rangeEnd}
onChange={(val) => {
const next = typeof val === 'number' ? val : '';
setRangeEnd(next);
const e = typeof next === 'number' ? next : null;
const s = typeof rangeStart === 'number' ? rangeStart : null;
if (e !== null && e <= 0) setRangeError('Values must be positive');
else if (s !== null && e !== null && s > e) setRangeError('From must be less than or equal to To');
else setRangeError(null);
}}
min={1}
placeholder="10"
error={Boolean(rangeError)}
/>
</div>
</Group>
<Button
size="sm"
className={classes.applyButton}
onClick={() => {
if (
rangeStart === '' || rangeEnd === '' ||
typeof rangeStart !== 'number' || typeof rangeEnd !== 'number'
) return;
const expr = rangeExpression(rangeStart, rangeEnd, maxPages);
if (expr) applyExpression(expr);
setRangeStart('');
setRangeEnd('');
}}
disabled={Boolean(rangeError) || rangeStart === '' || rangeEnd === ''}
>
Apply
</Button>
</div>
</div>
{/* Last N Pages - Card Style */}
<div className={classes.advancedCard}>
<Text size="sm" fw={600} c="gray.7" mb="sm">Last N Pages</Text>
{lastNError && (<Text size="xs" c="red" mb="xs">{lastNError}</Text>)}
<div className={classes.inputGroup}>
<Text size="xs" c="gray.6" mb="xs">Number of pages:</Text>
<Group gap="sm" align="flex-end" wrap="nowrap">
<NumberInput
size="sm"
value={lastNValue}
onChange={(val) => {
const next = typeof val === 'number' ? val : '';
setLastNValue(next);
if (next === '') setLastNError(null);
else if (typeof next === 'number' && next <= 0) setLastNError('Enter a positive number');
else setLastNError(null);
}}
min={1}
placeholder="10"
className={classes.fullWidthInput}
error={Boolean(lastNError)}
/>
<Button
size="sm"
className={classes.applyButton}
onClick={() => {
if (!lastNValue || typeof lastNValue !== 'number') return;
const expr = lastNExpression(lastNValue, maxPages);
if (expr) applyExpression(expr);
setLastNValue('');
}}
disabled={Boolean(lastNError) || lastNValue === ''}
>
Apply
</Button>
</Group>
</div>
</div>
{/* Every Nth Page - Card Style */}
<div className={classes.advancedCard}>
<Text size="sm" fw={600} c="gray.7" mb="sm">Every Nth Page</Text>
<div className={classes.inputGroup}>
<Text size="xs" c="gray.6" mb="xs">Step size:</Text>
<Group gap="sm" align="flex-end" wrap="nowrap">
<NumberInput
size="sm"
value={everyNthValue}
onChange={(val) => setEveryNthValue(typeof val === 'number' ? val : '')}
min={1}
placeholder="5"
className={classes.fullWidthInput}
/>
<Button
size="sm"
className={classes.applyButton}
onClick={() => {
if (!everyNthValue || typeof everyNthValue !== 'number') return;
const expr = everyNthExpression(everyNthValue);
if (expr) applyExpression(expr);
setEveryNthValue('');
}}
disabled={!everyNthValue}
>
Apply
</Button>
</Group>
</div>
</div>
</div>
<div className={classes.rightCol}>
<Text size="xs" c="gray.6" fw={500} mb="sm">Add Operators:</Text>
<div className={classes.operatorGroup}>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => insertOperator('and')}
disabled={!csvInput.trim()}
title="Combine selections (both conditions must be true)"
>
<Text size="xs" fw={500}>and</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => insertOperator('or')}
disabled={!csvInput.trim()}
title="Add to selection (either condition can be true)"
>
<Text size="xs" fw={500}>or</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => insertOperator('not')}
disabled={!csvInput.trim()}
title="Exclude from selection"
>
<Text size="xs" fw={500}>not</Text>
</Button>
</div>
</div>
</div>
</div>
)}
</>
);

View File

@ -11,6 +11,7 @@ import LanguageSelector from '../shared/LanguageSelector';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
import { parseSelection } from '../../utils/bulkselection/parseSelection';
export default function RightRail() {
const { t } = useTranslation();
@ -112,50 +113,13 @@ export default function RightRail() {
setSelectedFiles([]);
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
// CSV parsing functions for page selection
const parseCSVInput = useCallback((csv: string) => {
const pageNumbers: number[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
ranges.forEach(range => {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
for (let i = start; i <= end; i++) {
if (i > 0) {
pageNumbers.push(i);
}
}
} else {
const pageNum = parseInt(range);
if (pageNum > 0) {
pageNumbers.push(pageNum);
}
}
});
return pageNumbers;
}, []);
const updatePagesFromCSV = useCallback(() => {
const rawPages = parseCSVInput(csvInput);
// Use PageEditor's total pages for validation
const updatePagesFromCSV = useCallback((override?: string) => {
const maxPages = pageEditorFunctions?.totalPages || 0;
const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
// Use PageEditor's function to set selected pages
const normalized = parseSelection(override ?? csvInput, maxPages);
pageEditorFunctions?.handleSetSelectedPages?.(normalized);
}, [csvInput, parseCSVInput, pageEditorFunctions]);
}, [csvInput, pageEditorFunctions]);
// Sync csvInput with PageEditor's selected pages
useEffect(() => {
const sortedPageNumbers = Array.isArray(pageEditorFunctions?.selectedPageIds) && pageEditorFunctions.displayDocument
? pageEditorFunctions.selectedPageIds.map(id => {
const page = pageEditorFunctions.displayDocument!.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(num => num > 0).sort((a, b) => a - b)
: [];
const newCsvInput = sortedPageNumbers.join(', ');
setCsvInput(newCsvInput);
}, [pageEditorFunctions?.selectedPageIds]);
// Do not overwrite user's expression input when selection changes.
// Clear CSV input when files change (use stable signature to avoid ref churn)
useEffect(() => {
@ -261,7 +225,7 @@ export default function RightRail() {
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: 280 }}>
<div style={{ minWidth: "16rem", maxWidth: '24rem' }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}

View File

@ -1,12 +1,12 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
import LocalIcon from './LocalIcon';
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
import { addEventListenerWithCleanup, isClickOutside } from '../../utils/genericUtils';
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
import { TooltipTip } from '../../types/tips';
import { TooltipContent } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext';
import styles from './tooltip/Tooltip.module.css'
import styles from './tooltip/Tooltip.module.css';
export interface TooltipProps {
sidebarTooltip?: boolean;
@ -21,12 +21,12 @@ export interface TooltipProps {
onOpenChange?: (open: boolean) => void;
arrow?: boolean;
portalTarget?: HTMLElement;
header?: {
title: string;
logo?: React.ReactNode;
};
header?: { title: string; logo?: React.ReactNode };
delay?: number;
containerStyle?: React.CSSProperties;
pinOnClick?: boolean;
/** If true, clicking outside also closes when not pinned (default true) */
closeOnOutside?: boolean;
}
export const Tooltip: React.FC<TooltipProps> = ({
@ -44,57 +44,41 @@ export const Tooltip: React.FC<TooltipProps> = ({
portalTarget,
header,
delay = 0,
containerStyle={},
containerStyle = {},
pinOnClick = false,
closeOnOutside = true,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const triggerRef = useRef<HTMLElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLElement | null>(null);
const tooltipRef = useRef<HTMLDivElement | null>(null);
const openTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimers = () => {
const clickPendingRef = useRef(false);
const tooltipIdRef = useRef(`tooltip-${Math.random().toString(36).slice(2)}`);
const clearTimers = useCallback(() => {
if (openTimeoutRef.current) {
clearTimeout(openTimeoutRef.current);
openTimeoutRef.current = null;
}
};
// Get sidebar context for tooltip positioning
}, []);
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
// Always use controlled mode - if no controlled props provided, use internal state
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const open = isControlled ? !!controlledOpen : internalOpen;
const handleOpenChange = (newOpen: boolean) => {
clearTimers();
if (isControlled) {
onOpenChange?.(newOpen);
} else {
setInternalOpen(newOpen);
}
const setOpen = useCallback(
(newOpen: boolean) => {
if (newOpen === open) return; // avoid churn
if (isControlled) onOpenChange?.(newOpen);
else setInternalOpen(newOpen);
if (!newOpen) setIsPinned(false);
},
[isControlled, onOpenChange, open]
);
// Reset pin state when closing
if (!newOpen) {
setIsPinned(false);
}
};
const handleTooltipClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsPinned(true);
};
const handleDocumentClick = (e: MouseEvent) => {
// If tooltip is pinned and we click outside of it, unpin it
if (isPinned && isClickOutside(e, tooltipRef.current)) {
setIsPinned(false);
handleOpenChange(false);
}
};
// Use the positioning hook
const { coords, positionReady } = useTooltipPosition({
open,
sidebarTooltip,
@ -103,56 +87,209 @@ export const Tooltip: React.FC<TooltipProps> = ({
triggerRef,
tooltipRef,
sidebarRefs: sidebarContext?.sidebarRefs,
sidebarState: sidebarContext?.sidebarState
sidebarState: sidebarContext?.sidebarState,
});
// Add document click listener for unpinning
// Close on outside click: pinned → close; not pinned → optionally close
const handleDocumentClick = useCallback(
(e: MouseEvent) => {
const tEl = tooltipRef.current;
const trg = triggerRef.current;
const target = e.target as Node | null;
const insideTooltip = tEl && target && tEl.contains(target);
const insideTrigger = trg && target && trg.contains(target);
// If pinned: only close when clicking outside BOTH tooltip & trigger
if (isPinned) {
if (!insideTooltip && !insideTrigger) {
setIsPinned(false);
setOpen(false);
}
return;
}
// Not pinned and configured to close on outside
if (closeOnOutside && !insideTooltip && !insideTrigger) {
setOpen(false);
}
},
[isPinned, closeOnOutside, setOpen]
);
useEffect(() => {
if (isPinned) {
// Attach global click when open (so hover tooltips can also close on outside if desired)
if (open || isPinned) {
return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener);
}
}, [isPinned]);
}, [open, isPinned, handleDocumentClick]);
useEffect(() => {
return () => {
clearTimers();
};
}, []);
useEffect(() => () => clearTimers(), [clearTimers]);
const getArrowClass = () => {
// No arrow for sidebar tooltips
const arrowClass = useMemo(() => {
if (sidebarTooltip) return null;
const map: Record<NonNullable<TooltipProps['position']>, string> = {
top: 'tooltip-arrow-bottom',
bottom: 'tooltip-arrow-top',
left: 'tooltip-arrow-left',
right: 'tooltip-arrow-right',
};
return map[position] || map.right;
}, [position, sidebarTooltip]);
switch (position) {
case 'top': return "tooltip-arrow tooltip-arrow-bottom";
case 'bottom': return "tooltip-arrow tooltip-arrow-top";
case 'left': return "tooltip-arrow tooltip-arrow-left";
case 'right': return "tooltip-arrow tooltip-arrow-right";
default: return "tooltip-arrow tooltip-arrow-right";
}
};
const getArrowStyleClass = useCallback(
(key: string) =>
styles[key as keyof typeof styles] ||
styles[key.replace(/-([a-z])/g, (_, l) => l.toUpperCase()) as keyof typeof styles] ||
'',
[]
);
const getArrowStyleClass = (arrowClass: string) => {
const styleKey = arrowClass.split(' ')[1];
// Handle both kebab-case and camelCase CSS module exports
return styles[styleKey as keyof typeof styles] ||
styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] ||
'';
};
// === Trigger handlers ===
const openWithDelay = useCallback(() => {
clearTimers();
openTimeoutRef.current = setTimeout(() => setOpen(true), Math.max(0, delay || 0));
}, [clearTimers, setOpen, delay]);
const handlePointerEnter = useCallback(
(e: React.PointerEvent) => {
if (!isPinned) openWithDelay();
(children.props as any)?.onPointerEnter?.(e);
},
[isPinned, openWithDelay, children.props]
);
const handlePointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
// Moving into the tooltip → keep open
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
// Ignore transient leave between mousedown and click
if (clickPendingRef.current) {
(children.props as any)?.onPointerLeave?.(e);
return;
}
clearTimers();
if (!isPinned) setOpen(false);
(children.props as any)?.onPointerLeave?.(e);
},
[clearTimers, isPinned, setOpen, children.props]
);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
clickPendingRef.current = true;
(children.props as any)?.onMouseDown?.(e);
},
[children.props]
);
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
// allow microtask turn so click can see this false
queueMicrotask(() => (clickPendingRef.current = false));
(children.props as any)?.onMouseUp?.(e);
},
[children.props]
);
const handleClick = useCallback(
(e: React.MouseEvent) => {
clearTimers();
if (pinOnClick) {
e.preventDefault?.();
e.stopPropagation?.();
if (!open) setOpen(true);
setIsPinned(true);
clickPendingRef.current = false;
return;
}
clickPendingRef.current = false;
(children.props as any)?.onClick?.(e);
},
[clearTimers, pinOnClick, open, setOpen, children.props]
);
// Keyboard / focus accessibility
const handleFocus = useCallback(
(e: React.FocusEvent) => {
if (!isPinned) openWithDelay();
(children.props as any)?.onFocus?.(e);
},
[isPinned, openWithDelay, children.props]
);
const handleBlur = useCallback(
(e: React.FocusEvent) => {
const related = e.relatedTarget as Node | null;
if (related && tooltipRef.current && tooltipRef.current.contains(related)) {
(children.props as any)?.onBlur?.(e);
return;
}
if (!isPinned) setOpen(false);
(children.props as any)?.onBlur?.(e);
},
[isPinned, setOpen, children.props]
);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
}, [setOpen]);
// Keep open while pointer is over the tooltip; close when leaving it (if not pinned)
const handleTooltipPointerEnter = useCallback(() => {
clearTimers();
}, [clearTimers]);
const handleTooltipPointerLeave = useCallback(
(e: React.PointerEvent) => {
const related = e.relatedTarget as Node | null;
if (related && triggerRef.current && triggerRef.current.contains(related)) return;
if (!isPinned) setOpen(false);
},
[isPinned, setOpen]
);
// Enhance child with handlers and ref
const childWithHandlers = React.cloneElement(children as any, {
ref: (node: HTMLElement | null) => {
triggerRef.current = node || null;
const originalRef = (children as any).ref;
if (typeof originalRef === 'function') originalRef(node);
else if (originalRef && typeof originalRef === 'object') (originalRef as any).current = node;
},
'aria-describedby': open ? tooltipIdRef.current : undefined,
onPointerEnter: handlePointerEnter,
onPointerLeave: handlePointerLeave,
onMouseDown: handleMouseDown,
onMouseUp: handleMouseUp,
onClick: handleClick,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
});
// Always mount when open so we can measure; hide until positioned to avoid flash
const shouldShowTooltip = open;
const tooltipElement = shouldShowTooltip ? (
<div
id={tooltipIdRef.current}
ref={tooltipRef}
role="tooltip"
tabIndex={-1}
onPointerEnter={handleTooltipPointerEnter}
onPointerLeave={handleTooltipPointerLeave}
style={{
position: 'fixed',
top: coords.top,
left: coords.left,
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)),
minWidth: minWidth,
width: maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' as const : undefined),
minWidth,
zIndex: 9999,
visibility: positionReady ? 'visible' : 'hidden',
opacity: positionReady ? 1 : 0,
@ -160,7 +297,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
...containerStyle,
}}
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={handleTooltipClick}
onClick={pinOnClick ? (e) => { e.stopPropagation(); setIsPinned(true); } : undefined}
>
{isPinned && (
<button
@ -168,97 +305,48 @@ export const Tooltip: React.FC<TooltipProps> = ({
onClick={(e) => {
e.stopPropagation();
setIsPinned(false);
handleOpenChange(false);
setOpen(false);
}}
title="Close tooltip"
aria-label="Close tooltip"
>
<LocalIcon icon="close-rounded" width="1.25rem" height="1.25rem" />
</button>
)}
{arrow && getArrowClass() && (
{arrow && !sidebarTooltip && (
<div
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(getArrowClass()!)}`}
style={coords.arrowOffset !== null ? {
[position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset
} : undefined}
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(arrowClass!)}`}
style={
coords.arrowOffset !== null
? { [position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset }
: undefined
}
/>
)}
{header && (
<div className={styles['tooltip-header']}>
<div className={styles['tooltip-logo']}>
{header.logo || <img src="/logo-tooltip.svg" alt="Stirling PDF" style={{ width: '1.4rem', height: '1.4rem', display: 'block' }} />}
{header.logo || (
<img
src="/logo-tooltip.svg"
alt="Stirling PDF"
style={{ width: '1.4rem', height: '1.4rem', display: 'block' }}
/>
)}
</div>
<span className={styles['tooltip-title']}>{header.title}</span>
</div>
)}
<TooltipContent
content={content}
tips={tips}
/>
<TooltipContent content={content} tips={tips} />
</div>
) : null;
const handleMouseEnter = (e: React.MouseEvent) => {
clearTimers();
if (!isPinned) {
const effectiveDelay = Math.max(0, delay || 0);
openTimeoutRef.current = setTimeout(() => {
handleOpenChange(true);
}, effectiveDelay);
}
(children.props as any)?.onMouseEnter?.(e);
};
const handleMouseLeave = (e: React.MouseEvent) => {
clearTimers();
openTimeoutRef.current = null;
if (!isPinned) {
handleOpenChange(false);
}
(children.props as any)?.onMouseLeave?.(e);
};
const handleClick = (e: React.MouseEvent) => {
// Toggle pin state on click
if (open) {
setIsPinned(!isPinned);
} else {
clearTimers();
handleOpenChange(true);
setIsPinned(true);
}
(children.props as any)?.onClick?.(e);
};
// Take the child element and add tooltip behavior to it
const childWithTooltipHandlers = React.cloneElement(children as any, {
// Keep track of the element for positioning
ref: (node: HTMLElement) => {
triggerRef.current = node;
// Don't break if the child already has a ref
const originalRef = (children as any).ref;
if (typeof originalRef === 'function') {
originalRef(node);
} else if (originalRef && typeof originalRef === 'object') {
originalRef.current = node;
}
},
// Add mouse events to show/hide tooltip
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
onClick: handleClick,
});
return (
<>
{childWithTooltipHandlers}
{childWithHandlers}
{portalTarget && document.body.contains(portalTarget)
? tooltipElement && createPortal(tooltipElement, portalTarget)
: tooltipElement}
</>
);
};
};

View File

@ -6,7 +6,7 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
import { Tooltip } from "./Tooltip";
const viewOptionStyle = {
display: 'inline-flex',
@ -18,7 +18,7 @@ const viewOptionStyle = {
}
// Build view options showing text only for current view; others icon-only with tooltip
// Build view options showing text always
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => [
{
label: (
@ -35,35 +35,37 @@ const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchTyp
},
{
label: (
<Tooltip content="Page Editor" position="bottom" arrow={true}>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
) : (
switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />
)}
</div>
</Tooltip>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "pageEditor" ? (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
) : (
<>
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
<span>Page Editor</span>
</>
)}
</div>
),
value: "pageEditor",
},
{
label: (
<Tooltip content="Active Files" position="bottom" arrow={true}>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />
)}
</div>
</Tooltip>
<div style={viewOptionStyle as React.CSSProperties}>
{currentView === "fileEditor" ? (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
) : (
<>
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
<span>Active Files</span>
</>
)}
</div>
),
value: "fileEditor",
},

View File

@ -1,177 +1,155 @@
# Tooltip Component
A flexible, accessible tooltip component that supports both regular positioning and special sidebar positioning logic with click-to-pin functionality. The tooltip is controlled by default, appearing on hover and pinning on click.
A flexible, accessible tooltip component supporting regular positioning and special sidebar positioning, with optional clicktopin behavior. By default, it opens on hover/focus and can be pinned on click when `pinOnClick` is enabled.
## Features
---
- 🎯 **Smart Positioning**: Automatically positions tooltips to stay within viewport bounds
- 📱 **Sidebar Support**: Special positioning logic for sidebar/navigation elements
- ♿ **Accessible**: Works with mouse interactions and click-to-pin functionality
- 🎨 **Customizable**: Support for arrows, structured content, and custom JSX
- 🌙 **Theme Support**: Built-in dark mode and theme variable support
- ⚡ **Performance**: Memoized calculations and efficient event handling
- 📜 **Scrollable**: Content area scrolls when content exceeds max height
- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin
- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content
- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior
- ⏱️ **Hover Timing Controls**: Optional long-hover requirement via `delayAppearance` and `delay`
## Highlights
* 🎯 **Smart Positioning**: Keeps tooltips within the viewport and aligns the arrow dynamically.
* 📱 **Sidebar Aware**: Purposebuilt logic for sidebar/navigation contexts.
* ♿ **Accessible**: Keyboard and screenreader friendly (`role="tooltip"`, `aria-describedby`, Escape to close, focus/blur support).
* 🎨 **Customizable**: Arrows, headers, rich JSX content, and structured tips.
* 🌙 **Themeable**: Uses CSS variables; supports dark mode out of the box.
* ⚡ **Efficient**: Memoized calculations and stable callbacks to minimize rerenders.
* 📜 **Scrollable Content**: When content exceeds max height.
* 📌 **ClicktoPin**: (Optional) Pin open; close via outside click or close button.
* 🔗 **LinkSafe**: Fully clickable links in descriptions, bullets, and custom content.
* 🖱️ **PointerFriendly**: Uses pointer events (works with mouse/pen/touch hover where applicable).
---
## Behavior
### Default Behavior (Controlled)
- **Hover**: Tooltips appear on hover with a small delay when leaving to prevent flickering
- **Click**: Click the trigger to pin the tooltip open
- **Click tooltip**: Pins the tooltip to keep it open
- **Click close button**: Unpins and closes the tooltip (red X button in top-right when pinned)
- **Click outside**: Unpins and closes the tooltip
- **Visual indicator**: Pinned tooltips have a blue border and close button
### Default
### Manual Control (Optional)
- Use `open` and `onOpenChange` props for complete external control
- Useful for complex state management or custom interaction patterns
* **Hover/Focus**: Opens on pointer **enter** or when the trigger receives **focus** (respects optional `delay`).
* **Leave/Blur**: Closes on pointer **leave** (from trigger *and* tooltip) or when the trigger/tooltip **blurs** to the page—unless pinned.
* **Inside Tooltip**: Moving from trigger → tooltip keeps it open; moving out of both closes it (unless pinned).
* **Escape**: Press **Esc** to close.
### ClicktoPin (optional)
* Enable with `pinOnClick`.
* **Click trigger** (or tooltip) to pin open.
* **Click outside** **both** trigger and tooltip to close when pinned.
* Use the close button (X) to unpin and close.
> **Note**: Outsideclick closing when **not** pinned is configurable via `closeOnOutside` (default `true`).
---
## Installation
```tsx
import { Tooltip } from '@/components/shared';
```
---
## Basic Usage
```tsx
import { Tooltip } from '@/components/shared';
function MyComponent() {
return (
<Tooltip content="This is a helpful tooltip">
<button>Hover me</button>
</Tooltip>
);
}
```
## API Reference
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `content` | `ReactNode` | - | Custom JSX content to display in the tooltip |
| `tips` | `TooltipTip[]` | - | Structured content with title, description, bullets, and optional body |
| `children` | `ReactElement` | **required** | Element that triggers the tooltip |
| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic |
| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Tooltip position (ignored if `sidebarTooltip` is true) |
| `offset` | `number` | `8` | Distance in pixels between trigger and tooltip |
| `maxWidth` | `number \| string` | `280` | Maximum width constraint for the tooltip |
| `open` | `boolean` | `undefined` | External open state (makes component fully controlled) |
| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback for external control |
| `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element |
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into |
| `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo |
| `delay` | `number` | `0` | Optional hover-open delay (ms). If omitted or 0, opens immediately |
### TooltipTip Interface
```typescript
interface TooltipTip {
title?: string; // Optional pill label
description?: string; // Optional description text (supports HTML including <a> tags)
bullets?: string[]; // Optional bullet points (supports HTML including <a> tags)
body?: React.ReactNode; // Optional custom JSX for this tip
}
```
## Usage Examples
### Default Behavior (Recommended)
```tsx
// Simple tooltip with hover and click-to-pin
<Tooltip content="This tooltip appears on hover and pins on click">
<Tooltip content="This is a helpful tooltip">
<button>Hover me</button>
</Tooltip>
```
// Structured content with tips
<Tooltip
tips={[
{
title: "OCR Mode",
description: "Choose how to process text in your documents.",
bullets: [
"<strong>Auto</strong> skips pages that already contain text.",
"<strong>Force</strong> re-processes every page.",
"<strong>Strict</strong> stops if text is found.",
"<a href='https://docs.example.com' target='_blank'>Learn more</a>"
]
}
]}
header={{
title: "Basic Settings Overview",
logo: <img src="/logo.svg" alt="Logo" />
}}
With structured tips and a header:
```tsx
<Tooltip
tips={[{
title: 'OCR Mode',
description: 'Choose how to process text in your documents.',
bullets: [
'<strong>Auto</strong> skips pages that already contain text.',
'<strong>Force</strong> re-processes every page.',
'<strong>Strict</strong> stops if text is found.',
"<a href='https://docs.example.com' target='_blank' rel='noreferrer'>Learn more</a>",
],
}]}
header={{ title: 'Basic Settings Overview', logo: <img src="/logo.svg" alt="Logo" /> }}
>
<button>Settings</button>
</Tooltip>
```
---
## API
### `<Tooltip />` Props
| Prop | Type | Default | Description |
| ---------------- | ---------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `children` | `ReactElement` | **required** | The trigger element. Receives ARIA and event handlers. |
| `content` | `ReactNode` | `undefined` | Custom JSX content rendered below any `tips`. |
| `tips` | `TooltipTip[]` | `undefined` | Structured content (title, description, bullets, optional body). |
| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic (no arrow in sidebar mode). |
| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Preferred placement (ignored if `sidebarTooltip` is `true`). |
| `offset` | `number` | `8` | Gap (px) between trigger and tooltip. |
| `maxWidth` | `number \| string` | `undefined` | Max width. If omitted and `sidebarTooltip` is true, defaults visually to \~`25rem`. |
| `minWidth` | `number \| string` | `undefined` | Min width. |
| `open` | `boolean` | `undefined` | Controlled open state. If provided, the component is controlled. |
| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback when open state would change. |
| `arrow` | `boolean` | `false` | Shows a directional arrow (suppressed in sidebar mode). |
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into. |
| `header` | `{ title: string; logo?: ReactNode }` | `undefined` | Optional header with title and logo. |
| `delay` | `number` | `0` | Hover/focus open delay in ms. |
| `containerStyle` | `React.CSSProperties` | `{}` | Inline style overrides for the tooltip container. |
| `pinOnClick` | `boolean` | `false` | Clicking the trigger pins the tooltip open. |
| `closeOnOutside` | `boolean` | `true` | When not pinned, clicking outside closes the tooltip. Always closes when pinned and clicking outside both trigger & tooltip. |
### `TooltipTip`
```ts
export interface TooltipTip {
title?: string; // Optional pill label
description?: string; // HTML allowed (e.g., <a>)
bullets?: string[]; // HTML allowed in each string
body?: React.ReactNode; // Optional custom JSX
}
```
---
## Accessibility
* The tooltip container uses `role="tooltip"` and gets a stable `id`.
* The trigger receives `aria-describedby` when the tooltip is open.
* Opens on **focus** and closes on **blur** (unless pinned), supporting keyboard navigation.
* **Escape** closes the tooltip.
* Pointer events are mirrored with keyboard/focus for parity.
> Ensure custom triggers remain focusable (e.g., `button`, `a`, or add `tabIndex=0`).
---
## Interaction Details
* **Hover Timing**: Opening can be delayed via `delay`. Closing is immediate on pointer leave from both trigger and tooltip (unless pinned). Timers are cleared on state changes and unmounts.
* **Outside Clicks**: When pinned, clicking outside **both** the trigger and tooltip closes it. When not pinned, outside clicks close it if `closeOnOutside` is `true`.
* **Event Preservation**: Original child event handlers (`onClick`, `onPointerEnter`, etc.) are called after the tooltip augments them.
* **Refs**: The triggers existing `ref` (function or object) is preserved.
---
## Examples
### With Arrow
```tsx
<Tooltip content="Arrow tooltip" arrow position="top">
<button>Arrow tooltip</button>
</Tooltip>
```
### Optional Hover Delay
```tsx
// Show after a 1s hover
<Tooltip content="Appears after a long hover" delay={1000} />
// Custom long-hover duration (2 seconds)
<Tooltip content="Appears after 2s" delay={2000} />
```
### Custom JSX Content
```tsx
<Tooltip
content={
<div>
<h3>Custom Content</h3>
<p>Any JSX you want here</p>
<button>Action</button>
<a href="https://example.com">External link</a>
</div>
}
>
<button>Custom tooltip</button>
</Tooltip>
```
### Mixed Content (Tips + Custom JSX)
```tsx
<Tooltip
tips={[
{ title: "Section", description: "Description" }
]}
content={<div>Additional custom content below tips</div>}
>
<button>Mixed content</button>
</Tooltip>
```
### Sidebar Tooltips
```tsx
// For items in a sidebar/navigation
<Tooltip
content="This tooltip appears to the right of the sidebar"
sidebarTooltip={true}
>
<div className="sidebar-item">
📁 File Manager
</div>
</Tooltip>
```
### With Arrows
```tsx
<Tooltip
content="Tooltip with arrow pointing to trigger"
arrow={true}
position="top"
>
<button>Arrow tooltip</button>
<Tooltip content="Appears after 1s" delay={1000}>
<button>Delayed</button>
</Tooltip>
```
@ -180,63 +158,55 @@ interface TooltipTip {
```tsx
function ManualControlTooltip() {
const [open, setOpen] = useState(false);
return (
<Tooltip
content="Fully controlled tooltip"
open={open}
onOpenChange={setOpen}
>
<button onClick={() => setOpen(!open)}>
Toggle tooltip
</button>
<Tooltip content="Fully controlled tooltip" open={open} onOpenChange={setOpen}>
<button onClick={() => setOpen(!open)}>Toggle tooltip</button>
</Tooltip>
);
}
```
## Click-to-Pin Interaction
### Sidebar Tooltip
### How to Use (Default Behavior)
1. **Hover** over the trigger element to show the tooltip
2. **Click** the trigger element to pin the tooltip open
3. **Click** the red X button in the top-right corner to close
4. **Click** anywhere outside the tooltip to close
5. **Click** the trigger again to toggle pin state
```tsx
<Tooltip content="Appears to the right of the sidebar" sidebarTooltip>
<div className="sidebar-item">📁 File Manager</div>
</Tooltip>
```
### Visual States
- **Unpinned**: Normal tooltip appearance
- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner
### Mixed Content
## Link Support
```tsx
<Tooltip
tips={[{ title: 'Section', description: 'Description' }]}
content={<div>Additional custom content below tips</div>}
>
<button>Mixed content</button>
</Tooltip>
```
The tooltip fully supports clickable links in all content areas:
---
- **Descriptions**: Use `<a href="...">` in description strings
- **Bullets**: Use `<a href="...">` in bullet point strings
- **Body**: Use JSX `<a>` elements in the body ReactNode
- **Content**: Use JSX `<a>` elements in custom content
## Positioning Notes
Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`.
* Initial placement is derived from `position` (or sidebar rules when `sidebarTooltip` is true).
* Tooltip is clamped within the viewport; the arrow is offset to remain visually aligned with the trigger.
* Sidebar mode positions to the sidebars edge and clamps vertically. Arrows are disabled in sidebar mode.
## Positioning Logic
---
### Regular Tooltips
- Uses the `position` prop to determine initial placement
- Automatically clamps to viewport boundaries
- Calculates optimal position based on trigger element's `getBoundingClientRect()`
- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped
## Caveats & Tips
## Timing Details
* Ensure your container doesnt block pointer events between trigger and tooltip.
* When using `portalTarget`, confirm its attached to `document.body` before rendering.
* For very dynamic layouts, call positioning after layout changes (the hook already listens to open/refs/viewport).
- Opening uses `delay` (ms) if provided; otherwise opens immediately. Closing occurs immediately when the cursor leaves (unless pinned).
- All internal timers are cleared on state changes, mouse transitions, and unmount to avoid overlaps.
- Only one tooltip can be open at a time; hovering a new trigger closes others immediately.
---
### Sidebar Tooltips
- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar
- Vertical positioning follows the trigger but clamps to viewport
- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position
- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed
- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`)
- **No arrows** - sidebar tooltips don't show arrows
## Changelog (since previous README)
* Added keyboard & ARIA details (focus/blur, Escape, `aria-describedby`).
* Clarified outsideclick behavior for pinned vs unpinned.
* Documented `closeOnOutside` and `minWidth`, `containerStyle`, `pinOnClick`.
* Removed references to nonexistent props (e.g., `delayAppearance`).
* Corrected defaults (no hard default `maxWidth`; sidebar visually \~`25rem`).

View File

@ -48,6 +48,7 @@ const renderTooltipTitle = (
tips={tooltip.tips}
header={tooltip.header}
sidebarTooltip={true}
pinOnClick={true}
>
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
<Text fw={400} size="sm">

View File

@ -0,0 +1,44 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const usePageSelectionTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t('bulkSelection.header.title', 'Page Selection Guide'),
},
tips: [
{
title: t('bulkSelection.syntax.title', 'Syntax Basics'),
description: t('bulkSelection.syntax.text', 'Use numbers, ranges, keywords, and progressions (n starts at 0). Parentheses are supported.'),
bullets: [
t('bulkSelection.syntax.bullets.numbers', 'Numbers/ranges: 5, 10-20'),
t('bulkSelection.syntax.bullets.keywords', 'Keywords: odd, even'),
t('bulkSelection.syntax.bullets.progressions', 'Progressions: 3n, 4n+1'),
]
},
{
title: t('bulkSelection.operators.title', 'Operators'),
description: t('bulkSelection.operators.text', 'AND has higher precedence than comma. NOT applies within the document range.'),
bullets: [
t('bulkSelection.operators.and', 'AND: & or "and" — require both conditions (e.g., 1-50 & even)'),
t('bulkSelection.operators.comma', 'Comma: , or | — combine selections (e.g., 1-10, 20)'),
t('bulkSelection.operators.not', 'NOT: ! or "not" — exclude pages (e.g., 3n & not 30)'),
]
},
{
title: t('bulkSelection.examples.title', 'Examples'),
bullets: [
`${t('bulkSelection.examples.first50', 'First 50')}: 1-50`,
`${t('bulkSelection.examples.last50', 'Last 50')}: 451-500`,
`${t('bulkSelection.examples.every3rd', 'Every 3rd')}: 3n`,
`${t('bulkSelection.examples.oddWithinExcluding', 'Odd within 1-20 excluding 5-7')}: 1-20 & odd & !5-7`,
`${t('bulkSelection.examples.combineSets', 'Combine sets')}: 1-50, 451-500`,
]
}
]
};
};

View File

@ -0,0 +1,77 @@
## Bulk Selection Expressions
### What this does
- Lets you select pages using compact expressions instead of typing long CSV lists.
- Your input expression is preserved exactly as typed; we only expand it under the hood into concrete page numbers based on the current document's page count.
- The final selection is always deduplicated, clamped to valid page numbers, and sorted ascending.
### Basic forms
- Numbers: `5` selects page 5.
- Ranges: `3-7` selects pages 3,4,5,6,7 (inclusive). If the start is greater than the end, it is swapped automatically (e.g., `7-3``3-7`).
- Lists (OR): `1,3-5,10` selects pages 1,3,4,5,10.
You can still use the original CSV format. For example, `1,2,3,4,5` (first five pages) continues to work.
### Logical operators
- OR (union): `,` or `|` or the word `or`
- AND (intersection): `&` or the word `and`
- NOT (complement within 1..max): `!term` or `!(group)` or the word `not term` / `not (group)`
Operator precedence (from highest to lowest):
1) `!` (NOT)
2) `&` / `and` (AND)
3) `,` / `|` / `or` (OR)
Use parentheses `(...)` to override precedence where needed.
### Keywords and progressions
- Keywords (case-insensitive):
- `even`: all even pages (2, 4, 6, ...)
- `odd`: all odd pages (1, 3, 5, ...)
- Arithmetic progressions: `k n ± c`, e.g. `2n`, `3n+1`, `4n-1`
- `n` starts at 0 (CSS-style: `:nth-child`), then increases by 1 (n = 0,1,2,...). Non-positive results are discarded.
- `k` must be a positive integer (≥ 1). `c` can be any integer (including negative).
- Examples:
- `2n` → 0,2,4,6,... → becomes 2,4,6,... after discarding non-positive
- `2n-1` → -1,1,3,5,... → becomes 1,3,5,... (odd)
- `3n+1` → 1,4,7,10,13,...
All selections are automatically limited to the current document's valid page numbers `[1..maxPages]`.
### Parentheses
- Group with parentheses to control evaluation order and combine NOT with groups.
- Examples:
- `1-10 & (even, 15)` → even pages 2,4,6,8,10 (15 is outside 1-10)
- `!(1-5, odd)` → remove pages 1..5 and all odd pages; for a 10-page doc this yields 6,8,10
- `!(10-20 & !2n)` → complement of odd pages from 11..19 inside 10..20
- `(2n | 3n+1) & 1-20` → union of even numbers and 3n+1 numbers, intersected with 1..20
### Whitespace and case
- Whitespace is ignored: ` odd & 1 - 7` is valid.
- Keywords are case-insensitive: `ODD`, `Odd`, `odd` all work.
### Universe, clamping, deduplication
- The selection universe is the document's pages `[1..maxPages]`.
- Numbers outside the universe are discarded.
- Ranges are clamped to `[1..maxPages]` (e.g., `0-5``1-5`, `9-999` in a 10-page doc → `9-10`).
- Duplicates are removed; the final result is sorted ascending.
### Examples
- `1-10 & 2n & !5-7` → 2,4,8,10
- `odd` → 1,3,5,7,9,...
- `even` → 2,4,6,8,10,...
- `2n-1` → 1,3,5,7,9,...
- `3n+1` → 4,7,10,13,16,... (up to max pages)
- `1-3, 8-9` → 1,2,3,8,9
- `1-2 | 9-10 or 5` → 1,2,5,9,10
- `!(1-5)` → remove the first five pages from the universe
- `!(10-20 & !2n)` → complement of odd pages between 10 and 20

View File

@ -0,0 +1,260 @@
import { describe, it, expect } from 'vitest';
import { parseSelection } from './parseSelection';
// Helper to stringify result for readability
function arr(max: number, fn: (i: number) => boolean): number[] {
const out: number[] = [];
for (let i = 1; i <= max; i++) if (fn(i)) out.push(i);
return out;
}
describe('parseSelection', () => {
const max = 120;
it('1) parses single numbers', () => {
expect(parseSelection('5', max)).toEqual([5]);
});
it('2) parses simple range', () => {
expect(parseSelection('3-7', max)).toEqual([3,4,5,6,7]);
});
it('3) parses multiple numbers and ranges via comma OR', () => {
expect(parseSelection('1,3-5,10', max)).toEqual([1,3,4,5,10]);
});
it('4) respects bounds (clamps to 1..max and filters invalid)', () => {
expect(parseSelection('0, -2, 1-2, 9999', max)).toEqual([1,2]);
});
it('5) supports even keyword', () => {
expect(parseSelection('even', 10)).toEqual([2,4,6,8,10]);
});
it('6) supports odd keyword', () => {
expect(parseSelection('odd', 10)).toEqual([1,3,5,7,9]);
});
it('7) supports 2n progression', () => {
expect(parseSelection('2n', 12)).toEqual([2,4,6,8,10,12]);
});
it('8) supports kn±c progression (3n+1)', () => {
expect(parseSelection('3n+1', 10)).toEqual([1,4,7,10]);
});
it('9) supports kn±c progression (4n-1)', () => {
expect(parseSelection('4n-1', 15)).toEqual([3,7,11,15]);
});
it('10) supports logical AND (&) intersection', () => {
// even AND 1-10 => even numbers within 1..10
expect(parseSelection('even & 1-10', 20)).toEqual([2,4,6,8,10]);
});
it('11) supports logical OR with comma', () => {
expect(parseSelection('1-3, 8-9', 20)).toEqual([1,2,3,8,9]);
});
it('12) supports logical OR with | and word or', () => {
expect(parseSelection('1-2 | 9-10 or 5', 20)).toEqual([1,2,5,9,10]);
});
it('13) supports NOT operator !', () => {
// !1-5 within max=10 -> 6..10
expect(parseSelection('!1-5', 10)).toEqual([6,7,8,9,10]);
});
it('14) supports combination: 1-10 & 2n & !5-7', () => {
expect(parseSelection('1-10 & 2n & !5-7', 20)).toEqual([2,4,8,10]);
});
it('15) preserves precedence: AND over OR', () => {
// 1-10 & even, 15 OR => ( (1-10 & even) , 15 )
expect(parseSelection('1-10 & even, 15', 20)).toEqual([2,4,6,8,10,15]);
});
it('16) handles whitespace and case-insensitive keywords', () => {
expect(parseSelection(' OdD & 1-7 ', 10)).toEqual([1,3,5,7]);
});
it('17) progression plus range: 2n | 9-11 within 12', () => {
expect(parseSelection('2n | 9-11', 12)).toEqual([2,4,6,8,9,10,11,12]);
});
it('18) complex: (2n-1 & 1-20) & ! (5-7)', () => {
expect(parseSelection('2n-1 & 1-20 & !5-7', 20)).toEqual([1,3,9,11,13,15,17,19]);
});
it('19) falls back to CSV when expression malformed', () => {
// malformed: "2x" -> fallback should treat as CSV tokens -> only 2 ignored -> result empty
expect(parseSelection('2x', 10)).toEqual([]);
// malformed middle; still fallback handles CSV bits
expect(parseSelection('1, 3-5, foo, 9', 10)).toEqual([1,3,4,5,9]);
});
it('20) clamps ranges that exceed bounds', () => {
expect(parseSelection('0-5, 9-10', 10)).toEqual([1,2,3,4,5,9,10]);
});
it('21) supports parentheses to override precedence', () => {
// Without parentheses: 1-10 & even, 15 => [2,4,6,8,10,15]
// With parentheses around OR: 1-10 & (even, 15) => [2,4,6,8,10]
expect(parseSelection('1-10 & (even, 15)', 20)).toEqual([2,4,6,8,10]);
});
it('22) NOT over a grouped intersection', () => {
// !(10-20 & !2n) within 1..25
// Inner: 10-20 & !2n => odd numbers from 11..19 plus 10,12,14,16,18,20 excluded
// Complement in 1..25 removes those, keeping others
const result = parseSelection('!(10-20 & !2n)', 25);
expect(result).toEqual([1,2,3,4,5,6,7,8,9,10,12,14,16,18,20,21,22,23,24,25]);
});
it('23) nested parentheses with progressions', () => {
expect(parseSelection('(2n | 3n+1) & 1-20', 50)).toEqual([
1,2,4,6,7,8,10,12,13,14,16,18,19,20
]);
});
it('24) parentheses with NOT directly on group', () => {
expect(parseSelection('!(1-5, odd)', 10)).toEqual([6,8,10]);
});
it('25) whitespace within parentheses is ignored', () => {
expect(parseSelection('( 1 - 3 , 6 )', 10)).toEqual([1,2,3,6]);
});
it('26) malformed missing closing parenthesis falls back to CSV', () => {
// Expression parse should fail; fallback CSV should pick numbers only
expect(parseSelection('(1-3, 6', 10)).toEqual([6]);
});
it('27) nested NOT and AND with parentheses', () => {
// !(odd & 5-9) within 1..12 => remove odd numbers 5,7,9
expect(parseSelection('!(odd & 5-9)', 12)).toEqual([1,2,3,4,6,8,10,11,12]);
});
it('28) deep nesting and mixing operators', () => {
const expr = '(1-4 & 2n) , ( (5-10 & odd) & !(7) ), (3n+1 & 1-20)';
expect(parseSelection(expr, 20)).toEqual([1,2,4,5,7,9,10,13,16,19]);
});
it('31) word NOT works like ! for terms', () => {
expect(parseSelection('not 1-3', 6)).toEqual([4,5,6]);
});
it('32) word NOT works like ! for groups', () => {
expect(parseSelection('not (odd & 1-6)', 8)).toEqual([2,4,6,7,8]);
});
it('29) parentheses around a single term has no effect', () => {
expect(parseSelection('(even)', 8)).toEqual([2,4,6,8]);
});
it('30) redundant nested parentheses', () => {
expect(parseSelection('(((1-3))), ((2n))', 6)).toEqual([1,2,3,4,6]);
});
// Additional edge cases and comprehensive coverage
it('33) handles empty input gracefully', () => {
expect(parseSelection('', 10)).toEqual([]);
expect(parseSelection(' ', 10)).toEqual([]);
});
it('34) handles zero or negative maxPages', () => {
expect(parseSelection('1-10', 0)).toEqual([]);
expect(parseSelection('1-10', -5)).toEqual([]);
});
it('35) handles large progressions efficiently', () => {
expect(parseSelection('100n', 1000)).toEqual([100, 200, 300, 400, 500, 600, 700, 800, 900, 1000]);
});
it('36) handles progressions with large offsets', () => {
expect(parseSelection('5n+97', 100)).toEqual([97]);
expect(parseSelection('3n-2', 10)).toEqual([1, 4, 7, 10]);
});
it('37) mixed case keywords work correctly', () => {
expect(parseSelection('EVEN & Odd', 6)).toEqual([]);
expect(parseSelection('Even OR odd', 6)).toEqual([1, 2, 3, 4, 5, 6]);
});
it('38) complex nested expressions with all operators', () => {
const expr = '(1-20 & even) | (odd & !5-15) | (3n+1 & 1-10)';
// (1-20 & even) = [2,4,6,8,10,12,14,16,18,20]
// (odd & !5-15) = odd numbers not in 5-15 = [1,3,17,19]
// (3n+1 & 1-10) = [1,4,7,10]
// Union of all = [1,2,3,4,6,7,8,10,12,14,16,17,18,19,20]
expect(parseSelection(expr, 20)).toEqual([1, 2, 3, 4, 6, 7, 8, 10, 12, 14, 16, 17, 18, 19, 20]);
});
it('39) multiple NOT operators in sequence', () => {
expect(parseSelection('not not 1-5', 10)).toEqual([1, 2, 3, 4, 5]);
expect(parseSelection('!!!1-3', 10)).toEqual([4, 5, 6, 7, 8, 9, 10]);
});
it('40) edge case: single page selection', () => {
expect(parseSelection('1', 1)).toEqual([1]);
expect(parseSelection('5', 3)).toEqual([]);
});
it('41) backwards ranges are handled correctly', () => {
expect(parseSelection('10-5', 15)).toEqual([5, 6, 7, 8, 9, 10]);
});
it('42) progressions that start beyond maxPages', () => {
expect(parseSelection('10n+50', 40)).toEqual([]);
expect(parseSelection('5n+35', 40)).toEqual([35, 40]);
});
it('43) complex operator precedence with mixed syntax', () => {
// AND has higher precedence than OR
expect(parseSelection('1-3, 5-7 & even', 10)).toEqual([1, 2, 3, 6]);
expect(parseSelection('1-3 | 5-7 and even', 10)).toEqual([1, 2, 3, 6]);
});
it('44) whitespace tolerance in complex expressions', () => {
const expr1 = '1-5&even|odd&!3';
const expr2 = ' 1 - 5 & even | odd & ! 3 ';
expect(parseSelection(expr1, 10)).toEqual(parseSelection(expr2, 10));
});
it('45) fallback behavior with partial valid expressions', () => {
// Should fallback and extract valid CSV parts
expect(parseSelection('1, 2-4, invalid, 7', 10)).toEqual([1, 2, 3, 4, 7]);
expect(parseSelection('1-3, @#$, 8-9', 10)).toEqual([1, 2, 3, 8, 9]);
});
it('46) progressions with k=1 (equivalent to n)', () => {
expect(parseSelection('1n', 5)).toEqual([1, 2, 3, 4, 5]);
expect(parseSelection('1n+2', 5)).toEqual([2, 3, 4, 5]);
});
it('47) very large ranges are clamped correctly', () => {
expect(parseSelection('1-999999', 10)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
// Note: -100-5 would fallback to CSV and reject -100, but 0-5 should work
expect(parseSelection('0-5', 10)).toEqual([1, 2, 3, 4, 5]);
});
it('48) multiple comma-separated ranges', () => {
expect(parseSelection('1-2, 4-5, 7-8, 10', 10)).toEqual([1, 2, 4, 5, 7, 8, 10]);
});
it('49) combination of all features in one expression', () => {
const expr = '(1-10 & even) | (odd & 15-25) & !(3n+1 & 1-30) | 50n';
const result = parseSelection(expr, 100);
// This should combine: even numbers 2,4,6,8,10 with odd 15-25 excluding 3n+1 matches, plus 50n
expect(result.length).toBeGreaterThan(0);
expect(result).toContain(50);
expect(result).toContain(100);
});
it('50) stress test with deeply nested parentheses', () => {
const expr = '((((1-5)))) & ((((even)))) | ((((odd & 7-9))))';
expect(parseSelection(expr, 10)).toEqual([2, 4, 7, 9]);
});
});

View File

@ -0,0 +1,413 @@
// A parser that converts selection expressions (e.g., "1-10 & 2n & !50-100", "odd", "2n-1")
// into a list of page numbers within [1, maxPages].
/*
Supported grammar (case-insensitive for words):
expression := disjunction
disjunction := conjunction ( ("," | "|" | "or") conjunction )*
conjunction := unary ( ("&" | "and") unary )*
unary := ("!" unary) | ("not" unary) | primary
primary := "(" expression ")" | range | progression | keyword | number
range := number "-" number // inclusive
progression := k ["*"] "n" (("+" | "-") c)? // k >= 1, c any integer, n starts at 0
keyword := "even" | "odd"
number := digits (>= 1)
Precedence: "!" (NOT) > "&"/"and" (AND) > "," "|" "or" (OR)
Associativity: left-to-right within the same precedence level
Notes:
- Whitespace is ignored.
- The universe is [1..maxPages]. The complement operator ("!" / "not") applies within this universe.
- Out-of-bounds numbers are clamped in ranges and ignored as singletons.
- On parse failure, the parser falls back to CSV (numbers and ranges separated by commas).
Examples:
1-10 & even -> even pages between 1 and 10
!(5-7) -> all pages except 5..7
3n+1 -> 1,4,7,... (n starts at 0)
(2n | 3n+1) & 1-20 -> multiples of 2 or numbers of the form 3n+1 within 1..20
*/
export function parseSelection(input: string, maxPages: number): number[] {
const clampedMax = Math.max(0, Math.floor(maxPages || 0));
if (clampedMax === 0) return [];
const trimmed = (input || '').trim();
if (trimmed.length === 0) return [];
try {
const parser = new ExpressionParser(trimmed, clampedMax);
const resultSet = parser.parse();
return toSortedArray(resultSet);
} catch {
// Fallback: simple CSV parser (e.g., "1,3,5-10")
return toSortedArray(parseCsvFallback(trimmed, clampedMax));
}
}
export function parseSelectionWithDiagnostics(
input: string,
maxPages: number,
options?: { strict?: boolean }
): { pages: number[]; warning?: string } {
const clampedMax = Math.max(0, Math.floor(maxPages || 0));
if (clampedMax === 0) return { pages: [] };
const trimmed = (input || '').trim();
if (trimmed.length === 0) return { pages: [] };
try {
const parser = new ExpressionParser(trimmed, clampedMax);
const resultSet = parser.parse();
return { pages: toSortedArray(resultSet) };
} catch (err) {
if (options?.strict) {
throw err;
}
const pages = toSortedArray(parseCsvFallback(trimmed, clampedMax));
const tokens = trimmed.split(',').map(t => t.trim()).filter(Boolean);
const bad = tokens.find(tok => !/^(\d+\s*-\s*\d+|\d+)$/.test(tok));
const warning = `Malformed expression${bad ? ` at: '${bad}'` : ''}. Falling back to CSV interpretation.`;
return { pages, warning };
}
}
function toSortedArray(set: Set<number>): number[] {
return Array.from(set).sort((a, b) => a - b);
}
function parseCsvFallback(input: string, max: number): Set<number> {
const result = new Set<number>();
const parts = input.split(',').map(p => p.trim()).filter(Boolean);
for (const part of parts) {
const rangeMatch = part.match(/^(\d+)\s*-\s*(\d+)$/);
if (rangeMatch) {
const start = clampToRange(parseInt(rangeMatch[1], 10), 1, max);
const end = clampToRange(parseInt(rangeMatch[2], 10), 1, max);
if (Number.isFinite(start) && Number.isFinite(end)) {
const [lo, hi] = start <= end ? [start, end] : [end, start];
for (let i = lo; i <= hi; i++) result.add(i);
}
continue;
}
// Accept only pure positive integers (no signs, no letters)
if (/^\d+$/.test(part)) {
const n = parseInt(part, 10);
if (Number.isFinite(n) && n >= 1 && n <= max) result.add(n);
}
}
return result;
}
function clampToRange(v: number, min: number, max: number): number {
if (!Number.isFinite(v)) return NaN as unknown as number;
return Math.min(Math.max(v, min), max);
}
class ExpressionParser {
private readonly src: string;
private readonly max: number;
private idx: number = 0;
constructor(source: string, maxPages: number) {
this.src = source;
this.max = maxPages;
}
parse(): Set<number> {
this.skipWs();
const set = this.parseDisjunction();
this.skipWs();
// If there are leftover non-space characters, treat as error
if (this.idx < this.src.length) {
throw new Error('Unexpected trailing input');
}
return set;
}
private parseDisjunction(): Set<number> {
let left = this.parseConjunction();
while (true) {
this.skipWs();
const op = this.peekWordOrSymbol();
if (!op) break;
if (op.type === 'symbol' && (op.value === ',' || op.value === '|')) {
this.consume(op.length);
const right = this.parseConjunction();
left = union(left, right);
continue;
}
if (op.type === 'word' && op.value === 'or') {
this.consume(op.length);
const right = this.parseConjunction();
left = union(left, right);
continue;
}
break;
}
return left;
}
private parseConjunction(): Set<number> {
let left = this.parseUnary();
while (true) {
this.skipWs();
const op = this.peekWordOrSymbol();
if (!op) break;
if (op.type === 'symbol' && op.value === '&') {
this.consume(op.length);
const right = this.parseUnary();
left = intersect(left, right);
continue;
}
if (op.type === 'word' && op.value === 'and') {
this.consume(op.length);
const right = this.parseUnary();
left = intersect(left, right);
continue;
}
break;
}
return left;
}
private parseUnary(): Set<number> {
this.skipWs();
if (this.peek('!')) {
this.consume(1);
const inner = this.parseUnary();
return complement(inner, this.max);
}
// Word-form NOT
if (this.tryConsumeNot()) {
const inner = this.parseUnary();
return complement(inner, this.max);
}
return this.parsePrimary();
}
private parsePrimary(): Set<number> {
this.skipWs();
// Parenthesized expression: '(' expression ')'
if (this.peek('(')) {
this.consume(1);
const inner = this.parseDisjunction();
this.skipWs();
if (!this.peek(')')) throw new Error('Expected )');
this.consume(1);
return inner;
}
// Keywords: even / odd
const keyword = this.tryReadKeyword();
if (keyword) {
if (keyword === 'even') return this.buildEven();
if (keyword === 'odd') return this.buildOdd();
}
// Progression: k n ( +/- c )?
const progression = this.tryReadProgression();
if (progression) {
return this.buildProgression(progression.k, progression.c);
}
// Number or Range
const num = this.tryReadNumber();
if (num !== null) {
this.skipWs();
if (this.peek('-')) {
// Range
this.consume(1);
this.skipWs();
const end = this.readRequiredNumber();
return this.buildRange(num, end);
}
return this.buildSingleton(num);
}
// If nothing matched, error
throw new Error('Expected primary');
}
private buildSingleton(n: number): Set<number> {
const set = new Set<number>();
if (n >= 1 && n <= this.max) set.add(n);
return set;
}
private buildRange(a: number, b: number): Set<number> {
const set = new Set<number>();
let start = a, end = b;
if (!Number.isFinite(start) || !Number.isFinite(end)) return set;
if (start > end) [start, end] = [end, start];
start = Math.max(1, start);
end = Math.min(this.max, end);
for (let i = start; i <= end; i++) set.add(i);
return set;
}
private buildProgression(k: number, c: number): Set<number> {
const set = new Set<number>();
if (!(k >= 1)) return set;
// n starts at 0: k*n + c, for n=0,1,2,... while within [1..max]
for (let n = 0; ; n++) {
const value = k * n + c;
if (value > this.max) break;
if (value >= 1) set.add(value);
}
return set;
}
private buildEven(): Set<number> {
return this.buildProgression(2, 0);
}
private buildOdd(): Set<number> {
return this.buildProgression(2, -1);
}
private tryReadKeyword(): 'even' | 'odd' | null {
const start = this.idx;
const word = this.readWord();
if (!word) return null;
const lower = word.toLowerCase();
if (lower === 'even' || lower === 'odd') {
return lower as 'even' | 'odd';
}
// Not a keyword; rewind
this.idx = start;
return null;
}
private tryReadProgression(): { k: number; c: number } | null {
const start = this.idx;
this.skipWs();
const k = this.tryReadNumber();
if (k === null) {
this.idx = start;
return null;
}
this.skipWs();
// Optional '*'
if (this.peek('*')) this.consume(1);
this.skipWs();
if (!this.peek('n') && !this.peek('N')) {
this.idx = start;
return null;
}
this.consume(1); // consume 'n'
this.skipWs();
// Optional (+|-) c
let c = 0;
if (this.peek('+') || this.peek('-')) {
const sign = this.src[this.idx];
this.consume(1);
this.skipWs();
const cVal = this.tryReadNumber();
if (cVal === null) {
this.idx = start;
return null;
}
c = sign === '-' ? -cVal : cVal;
}
return { k, c };
}
private tryReadNumber(): number | null {
this.skipWs();
const m = this.src.slice(this.idx).match(/^(\d+)/);
if (!m) return null;
this.consume(m[1].length);
const num = parseInt(m[1], 10);
return Number.isFinite(num) ? num : null;
}
private readRequiredNumber(): number {
const n = this.tryReadNumber();
if (n === null) throw new Error('Expected number');
return n;
}
private readWord(): string | null {
this.skipWs();
const m = this.src.slice(this.idx).match(/^([A-Za-z]+)/);
if (!m) return null;
this.consume(m[1].length);
return m[1];
}
private tryConsumeNot(): boolean {
const start = this.idx;
const word = this.readWord();
if (!word) {
this.idx = start;
return false;
}
if (word.toLowerCase() === 'not') {
return true;
}
this.idx = start;
return false;
}
private peekWordOrSymbol(): { type: 'word' | 'symbol'; value: string; raw: string; length: number } | null {
this.skipWs();
if (this.idx >= this.src.length) return null;
const ch = this.src[this.idx];
if (/[A-Za-z]/.test(ch)) {
const start = this.idx;
const word = this.readWord();
if (!word) return null;
const lower = word.toLowerCase();
// Always rewind; the caller will consume if it uses this token
const len = word.length;
this.idx = start;
if (lower === 'and' || lower === 'or') {
return { type: 'word', value: lower, raw: word, length: len };
}
return null;
}
if (ch === '&' || ch === '|' || ch === ',') {
return { type: 'symbol', value: ch, raw: ch, length: 1 };
}
return null;
}
private skipWs() {
while (this.idx < this.src.length && /\s/.test(this.src[this.idx])) this.idx++;
}
private peek(s: string): boolean {
return this.src.startsWith(s, this.idx);
}
private consume(n: number) {
this.idx += n;
}
}
function union(a: Set<number>, b: Set<number>): Set<number> {
if (a.size === 0) return new Set(b);
if (b.size === 0) return new Set(a);
const out = new Set<number>(a);
for (const v of b) out.add(v);
return out;
}
function intersect(a: Set<number>, b: Set<number>): Set<number> {
if (a.size === 0 || b.size === 0) return new Set<number>();
const out = new Set<number>();
const [small, large] = a.size <= b.size ? [a, b] : [b, a];
for (const v of small) if (large.has(v)) out.add(v);
return out;
}
function complement(a: Set<number>, max: number): Set<number> {
const out = new Set<number>();
for (let i = 1; i <= max; i++) if (!a.has(i)) out.add(i);
return out;
}