* docs(spec): annotation Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plan): implementation plan for Share tool + customizable toolbar (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(annotator): add 'share' annotation tool type and button (#4014) * feat(annotator): add pure toolbar order/visibility helpers (#4014) * feat(annotator): add annotationToolbarItems view setting (#4014) * feat(annotator): add shareSelectedText ladder helper (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(annotator): render Share tool and honor toolbar order in selection popup (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(settings): add drag-and-drop annotation toolbar customizer (#4014) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(settings): open the toolbar customizer from the Behavior panel (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(i18n): extract and translate annotation share/toolbar strings (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(annotator): extract canShareText helper, preserve hidden Share on cross-platform edit (#4014) Addresses final-review findings: de-duplicate the triplicated canShare definition into share.ts::canShareText, trim ShareCapableService to the fields actually read, and stop the toolbar customizer from dropping a synced 'share' tool when edited on a non-share-capable device. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(settings): WYSIWYG drag-and-drop toolbar customizer (#4014) Rework the customizer per live testing: - Render 'In toolbar' as a faithful, content-width, start-aligned preview of the real selection popup (gray bar, icon-only buttons); 'Available' tools show as labeled chips. - Multi-container dnd-kit pattern: in-place dragging (no DragOverlay, which a transformed modal offsets), pointerWithin collision so empty zones accept drops, live onDragOver reparent, itemsRef to dodge dnd-kit's drag-start handler-capture stale closure. - Add 'Add all' (canonical predefined order) and 'Clear all' shortcuts. - Align zone labels with the SubPageHeader breadcrumb. - Empty toolbar now suppresses the selection popup entirely (no empty bar), while still allowing highlight-edit/notes popups. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(i18n): translate Add all / Clear all toolbar shortcuts (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(annotator): size selection popup to visible tool count (#4014) With the customizable toolbar a fixed-width popup looked sparse for a 2-3 tool toolbar (buttons spread to the corners). Size the popup to the number of visible tools (responsive) capped at the previous max; annotated selections keep the max width since they show highlight options / notes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(settings): reword empty-toolbar hint to 'No tools, drag one here' (#4014) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(annotator): render default tools (not Share) in popup layout screenshot (#4014) The visual regression test rendered every annotationToolButtons entry, so adding the Share tool shifted the toolbar to 9 buttons and broke the baselines. Share is hidden by default (added via Customize Toolbar), so the popup screenshot should mirror the default-enabled set — filter to DEFAULT_ANNOTATION_TOOLBAR_ITEMS, keeping the existing baselines valid. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { IconType } from 'react-icons';
|
||||
import { FiSearch } from 'react-icons/fi';
|
||||
import { FiCopy } from 'react-icons/fi';
|
||||
import { FiShare } from 'react-icons/fi';
|
||||
import { PiHighlighterFill } from 'react-icons/pi';
|
||||
import { BsPencilSquare } from 'react-icons/bs';
|
||||
import { BsTranslate } from 'react-icons/bs';
|
||||
@@ -89,6 +90,13 @@ export const annotationToolButtons = createAnnotationToolButtons([
|
||||
tooltip: _('Proofread text after selection'),
|
||||
Icon: IoIosBuild,
|
||||
},
|
||||
{
|
||||
type: 'share',
|
||||
label: _('Share'),
|
||||
tooltip: _('Share text after selection'),
|
||||
Icon: FiShare,
|
||||
quickAction: true,
|
||||
},
|
||||
]);
|
||||
|
||||
export const annotationToolQuickActions = annotationToolButtons.filter(
|
||||
|
||||
@@ -48,6 +48,9 @@ import { runSimpleCC } from '@/utils/simplecc';
|
||||
import { getWordCount } from '@/utils/word';
|
||||
import { getIndexFromCfi } from '@/utils/cfi';
|
||||
import { writeTextToClipboard } from '@/utils/clipboard';
|
||||
import { canShareText, shareSelectedText } from '@/utils/share';
|
||||
import { getToolbarToolTypes } from '@/utils/annotationToolbar';
|
||||
import { AnnotationToolType } from '@/types/annotator';
|
||||
import { TransformContext } from '@/services/transformers/types';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
import {
|
||||
@@ -188,7 +191,20 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
const transPopupHeight = Math.min(265, maxHeight);
|
||||
const proofreadPopupWidth = Math.min(440, maxWidth);
|
||||
const proofreadPopupHeight = Math.min(200, maxHeight);
|
||||
const annotPopupWidth = Math.min(useResponsiveSize(300), maxWidth);
|
||||
const canShare = canShareText(appService);
|
||||
// The toolbar is now customizable, so size the selection popup to the number
|
||||
// of visible tools (responsive) up to a max — otherwise a 2-tool toolbar
|
||||
// renders a sparse, full-width bar. Annotated selections keep the max width
|
||||
// since they show the wider highlight options / notes instead of the buttons.
|
||||
const annotPopupMaxWidth = Math.min(useResponsiveSize(300), maxWidth);
|
||||
const annotPopupToolSize = useResponsiveSize(44);
|
||||
const visibleToolCount = getToolbarToolTypes(
|
||||
viewSettings.annotationToolbarItems,
|
||||
canShare,
|
||||
).length;
|
||||
const annotPopupWidth = selection?.annotated
|
||||
? annotPopupMaxWidth
|
||||
: Math.min(Math.max(visibleToolCount, 1) * annotPopupToolSize, annotPopupMaxWidth);
|
||||
const annotPopupHeight = useResponsiveSize(44);
|
||||
const androidSelectionHandlerHeight = 0;
|
||||
|
||||
@@ -738,6 +754,9 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
case 'tts':
|
||||
handleSpeakText(true);
|
||||
break;
|
||||
case 'share':
|
||||
handleShare();
|
||||
break;
|
||||
}
|
||||
};
|
||||
// On Android, a long-press fires selectionchange (and this handler) while
|
||||
@@ -924,6 +943,19 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
if (!selection?.text) return;
|
||||
const position = trianglePosition
|
||||
? {
|
||||
x: trianglePosition.point.x,
|
||||
y: trianglePosition.point.y,
|
||||
preferredEdge: 'bottom' as const,
|
||||
}
|
||||
: undefined;
|
||||
void shareSelectedText(selection.text, position, appService);
|
||||
handleDismissPopupAndSelection();
|
||||
};
|
||||
|
||||
const handleHighlight = (update = false, highlightStyle?: HighlightStyle) => {
|
||||
if (!selection || !selection.text) return;
|
||||
setHighlightOptionsVisible(true);
|
||||
@@ -1446,7 +1478,10 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
!!selection?.text &&
|
||||
selection.text.trim().length > 0;
|
||||
const globalToggleActive = !!currentAnnotation?.global;
|
||||
const toolButtons = annotationToolButtons.map(({ type, label, Icon }) => {
|
||||
const buildToolButton = (type: AnnotationToolType) => {
|
||||
const def = annotationToolButtons.find((button) => button.type === type);
|
||||
if (!def) return null;
|
||||
const { label, Icon } = def;
|
||||
switch (type) {
|
||||
case 'copy':
|
||||
return { tooltipText: _(label), Icon, onClick: handleCopy };
|
||||
@@ -1457,27 +1492,15 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
onClick: handleHighlight,
|
||||
};
|
||||
case 'annotate':
|
||||
return {
|
||||
tooltipText: _(label),
|
||||
Icon,
|
||||
onClick: handleAnnotate,
|
||||
};
|
||||
return { tooltipText: _(label), Icon, onClick: handleAnnotate };
|
||||
case 'search':
|
||||
return {
|
||||
tooltipText: _(label),
|
||||
Icon,
|
||||
onClick: handleSearch,
|
||||
};
|
||||
return { tooltipText: _(label), Icon, onClick: handleSearch };
|
||||
case 'dictionary':
|
||||
return { tooltipText: _(label), Icon, onClick: handleDictionary };
|
||||
case 'translate':
|
||||
return { tooltipText: _(label), Icon, onClick: handleTranslation };
|
||||
case 'tts':
|
||||
return {
|
||||
tooltipText: _(label),
|
||||
Icon,
|
||||
onClick: handleSpeakText,
|
||||
};
|
||||
return { tooltipText: _(label), Icon, onClick: handleSpeakText };
|
||||
case 'proofread':
|
||||
return {
|
||||
tooltipText: _(label),
|
||||
@@ -1485,10 +1508,16 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
onClick: handleProofread,
|
||||
disabled: bookData.book?.format !== 'EPUB',
|
||||
};
|
||||
case 'share':
|
||||
return { tooltipText: _(label), Icon, onClick: handleShare };
|
||||
default:
|
||||
return { tooltipText: '', Icon, onClick: () => {} };
|
||||
return null;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const toolButtons = getToolbarToolTypes(viewSettings.annotationToolbarItems, canShare)
|
||||
.map(buildToolButton)
|
||||
.filter((button): button is NonNullable<typeof button> => button !== null);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} role='toolbar' tabIndex={-1}>
|
||||
@@ -1541,27 +1570,33 @@ const Annotator: React.FC<{ bookKey: string; contentInsets: Insets }> = ({
|
||||
onDismiss={handleDismissPopupAndSelection}
|
||||
/>
|
||||
)}
|
||||
{showAnnotPopup && trianglePosition && annotPopupPosition && (
|
||||
<AnnotationPopup
|
||||
bookKey={bookKey}
|
||||
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
|
||||
isVertical={viewSettings.vertical}
|
||||
buttons={toolButtons}
|
||||
notes={annotationNotes}
|
||||
position={annotPopupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
highlightOptionsVisible={highlightOptionsVisible}
|
||||
selectedStyle={selectedStyle}
|
||||
selectedColor={selectedColor}
|
||||
popupWidth={annotPopupWidth}
|
||||
popupHeight={annotPopupHeight}
|
||||
globalToggleAvailable={globalToggleAvailable}
|
||||
globalToggleActive={globalToggleActive}
|
||||
onToggleGlobal={handleToggleGlobal}
|
||||
onHighlight={handleHighlight}
|
||||
onDismiss={handleDismissPopupAndSelection}
|
||||
/>
|
||||
)}
|
||||
{showAnnotPopup &&
|
||||
trianglePosition &&
|
||||
annotPopupPosition &&
|
||||
// With an empty toolbar, suppress the popup on a plain selection rather
|
||||
// than showing an empty bar. Still allow it for editing an existing
|
||||
// highlight (options) or viewing its notes.
|
||||
(toolButtons.length > 0 || highlightOptionsVisible || annotationNotes.length > 0) && (
|
||||
<AnnotationPopup
|
||||
bookKey={bookKey}
|
||||
dir={viewSettings.rtl ? 'rtl' : 'ltr'}
|
||||
isVertical={viewSettings.vertical}
|
||||
buttons={toolButtons}
|
||||
notes={annotationNotes}
|
||||
position={annotPopupPosition}
|
||||
trianglePosition={trianglePosition}
|
||||
highlightOptionsVisible={highlightOptionsVisible}
|
||||
selectedStyle={selectedStyle}
|
||||
selectedColor={selectedColor}
|
||||
popupWidth={annotPopupWidth}
|
||||
popupHeight={annotPopupHeight}
|
||||
globalToggleAvailable={globalToggleAvailable}
|
||||
globalToggleActive={globalToggleActive}
|
||||
onToggleGlobal={handleToggleGlobal}
|
||||
onHighlight={handleHighlight}
|
||||
onDismiss={handleDismissPopupAndSelection}
|
||||
/>
|
||||
)}
|
||||
{showProofreadPopup && trianglePosition && proofreadPopupPosition && selection && (
|
||||
<ProofreadPopup
|
||||
bookKey={bookKey}
|
||||
|
||||
Reference in New Issue
Block a user