mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-09-18 09:29:24 +00:00
change bulk selection panel to allow more versatile input
This commit is contained in:
parent
1a3e8e7ecf
commit
c2b0631005
@ -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": {
|
||||
|
@ -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": {
|
||||
|
234
frontend/src/components/pageEditor/BulkSelectionPanel.module.css
Normal file
234
frontend/src/components/pageEditor/BulkSelectionPanel.module.css
Normal 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;
|
||||
}
|
||||
|
116
frontend/src/components/pageEditor/BulkSelectionPanel.ts
Normal file
116
frontend/src/components/pageEditor/BulkSelectionPanel.ts
Normal 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}`;
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
@ -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}
|
||||
|
@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
@ -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",
|
||||
},
|
||||
|
@ -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 click‑to‑pin 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**: Purpose‑built logic for sidebar/navigation contexts.
|
||||
* ♿ **Accessible**: Keyboard and screen‑reader 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 re‑renders.
|
||||
* 📜 **Scrollable Content**: When content exceeds max height.
|
||||
* 📌 **Click‑to‑Pin**: (Optional) Pin open; close via outside click or close button.
|
||||
* 🔗 **Link‑Safe**: Fully clickable links in descriptions, bullets, and custom content.
|
||||
* 🖱️ **Pointer‑Friendly**: 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.
|
||||
|
||||
### Click‑to‑Pin (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**: Outside‑click 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 trigger’s 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 sidebar’s 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 doesn’t block pointer events between trigger and tooltip.
|
||||
* When using `portalTarget`, confirm it’s 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 outside‑click behavior for pinned vs unpinned.
|
||||
* Documented `closeOnOutside` and `minWidth`, `containerStyle`, `pinOnClick`.
|
||||
* Removed references to non‑existent props (e.g., `delayAppearance`).
|
||||
* Corrected defaults (no hard default `maxWidth`; sidebar visually \~`25rem`).
|
||||
|
@ -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">
|
||||
|
44
frontend/src/components/tooltips/usePageSelectionTips.ts
Normal file
44
frontend/src/components/tooltips/usePageSelectionTips.ts
Normal 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`,
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
|
77
frontend/src/utils/bulkselection/README.md
Normal file
77
frontend/src/utils/bulkselection/README.md
Normal 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
|
260
frontend/src/utils/bulkselection/parseSelection.test.ts
Normal file
260
frontend/src/utils/bulkselection/parseSelection.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
|
||||
|
413
frontend/src/utils/bulkselection/parseSelection.ts
Normal file
413
frontend/src/utils/bulkselection/parseSelection.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user