Skip to content

Commit 781d68c

Browse files
committed
Serve viewer text selection from the canvas UI instead of a DOM text layer
1 parent 9117d0b commit 781d68c

10 files changed

Lines changed: 2468 additions & 500 deletions

File tree

scribe-ui/basic-viewer/pdf-viewer.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import scribe from '../../scribe.js';
22
import { ScribeViewer } from '../viewer.js';
3+
// Both engines are imported so `ScribeViewer.customSelection` can toggle between them at runtime.
4+
// Import only one for a slimmer build.
5+
import '../js/selection/customSelectionEngine.js';
6+
import '../js/selection/domSelectionEngine.js';
37
import { applyHighlight } from '../js/viewerHighlights.js';
48
import { destroyContextMenu } from '../js/viewerCanvasInteraction.js';
59
import {

scribe-ui/js/controls/debugMenu.js

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// Debug menu: developer-only tools, installed only when `DEBUG_MENU` in scribe-ui/devFlags.js is on and stripped from public builds.
2+
import { UiText } from '../viewerWordObjects.js';
23

34
/** Bug glyph for the Debug section's rows. */
45
const BUG_SVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
@@ -26,8 +27,33 @@ export function installDebugMenu(appMenu, viewer) {
2627
});
2728
appMenu.menuElem.appendChild(header);
2829

29-
// The overlay is built from this flag, so toggling it requires clearing the built text and re-rendering to apply.
30+
// Off (the default) = the model-driven built-in engine; On = the DOM engine, whose invisible word spans sit under the browser's native selection.
3031
appMenu.addToggle(
32+
'DOM-based text selection',
33+
BUG_SVG,
34+
() => !viewer.useCustomSelection,
35+
() => {
36+
viewer.setSelectionEngineDebug(viewer.useCustomSelection ? 'dom' : 'custom');
37+
syncOverlayRow();
38+
},
39+
);
40+
41+
// Rebuild the text layer so the mode change reaches already-rendered pages.
42+
appMenu.addToggle(
43+
'Proof mode (word editing)',
44+
BUG_SVG,
45+
() => UiText.enableEditing,
46+
() => {
47+
UiText.enableEditing = !UiText.enableEditing;
48+
viewer.state.displayMode = UiText.enableEditing ? 'proof' : 'invis';
49+
viewer.destroyText(false);
50+
viewer.displayPage(viewer.state.cp.n, false, true);
51+
},
52+
);
53+
54+
// The overlay is built from this flag, so toggling rebuilds the text layer to apply.
55+
// Only the DOM engine has an overlay to disable (the built-in engine draws selection from the model), so this row is greyed and inert while the built-in engine is active.
56+
const overlayRow = appMenu.addToggle(
3157
'Disable text overlay',
3258
BUG_SVG,
3359
() => viewer.textOverlayDisabledDebug,
@@ -38,11 +64,13 @@ export function installDebugMenu(appMenu, viewer) {
3864
},
3965
);
4066

41-
// The flag is read live on the next right-click, so toggling it needs no re-render.
42-
appMenu.addToggle(
43-
'Disable custom context menu',
44-
BUG_SVG,
45-
() => viewer.contextMenuDisabledDebug,
46-
() => { viewer.contextMenuDisabledDebug = !viewer.contextMenuDisabledDebug; },
47-
);
67+
const syncOverlayRow = () => {
68+
const inert = viewer.useCustomSelection;
69+
overlayRow.item.style.opacity = inert ? '0.45' : '';
70+
overlayRow.item.style.pointerEvents = inert ? 'none' : '';
71+
overlayRow.item.title = inert ? 'DOM-based text selection only' : '';
72+
overlayRow.item.tabIndex = inert ? -1 : 0;
73+
overlayRow.sync();
74+
};
75+
syncOverlayRow();
4876
}

scribe-ui/js/controls/tools.js

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ const TB_PANEL_SVG = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24
4040
* @param {string} cfg.rootClass - The app's root class (for scoping the cursor rule).
4141
* @returns {{
4242
* highlightElem: HTMLSpanElement, toolbarElem: HTMLSpanElement,
43-
* getSelectedOverlayWords: () => Array<import('../viewerWordObjects.js').UiOcrWord>, updateCommentIcons: () => void,
43+
* updateCommentIcons: () => void,
4444
* installBehaviors: () => (() => void)
4545
* }}
4646
*/
@@ -64,8 +64,14 @@ export function createHighlightTool(scribe, rootElem, { colors, defaultColor, ro
6464
const setTipColor = (c) => { if (tipPath && c) tipPath.style.fill = c; };
6565
setTipColor(highlightColor);
6666

67-
/** Toggle the highlighter cursor on the overlay words when highlight mode is active. */
67+
/** Toggle the highlighter cursor over the page's text when highlight mode is active. */
6868
function updateHighlightCursorStyle() {
69+
if (scribe.useCustomSelection) {
70+
// No word elements to hang a cursor rule on: the selection engine sets the container's cursor.
71+
scribe.textSel.cursorOverride = highlightMode ? HIGHLIGHT_CURSOR : null;
72+
if (!highlightMode) scribe.scrollContainer.style.cursor = '';
73+
return;
74+
}
6975
if (!cursorStyleElem) {
7076
cursorStyleElem = document.createElement('style');
7177
document.head.appendChild(cursorStyleElem);
@@ -75,16 +81,11 @@ export function createHighlightTool(scribe, rootElem, { colors, defaultColor, ro
7581
: '';
7682
}
7783

78-
/** UiOcrWord objects under the current browser text selection (via the HTML overlay). */
79-
function getSelectedOverlayWords() {
80-
return scribe.getWordsUnderTextSelection();
81-
}
82-
8384
function applyToSelection() {
84-
const matchedWords = getSelectedOverlayWords();
85+
const matchedWords = scribe.getWordsUnderTextSelection();
8586
if (matchedWords.length === 0 || !highlightColor) return false;
8687
applyHighlight(scribe, matchedWords, highlightColor, 0.5);
87-
window.getSelection()?.removeAllRanges();
88+
scribe.clearTextSelection();
8889
return true;
8990
}
9091

@@ -643,6 +644,17 @@ export function createHighlightTool(scribe, rootElem, { colors, defaultColor, ro
643644
cmtText.focus();
644645
};
645646

647+
/**
648+
* The highlighted word under an event's pointer.
649+
* @param {MouseEvent} event
650+
*/
651+
const highlightWordAt = (event) => {
652+
// The custom engine has no word spans, so hit-test the highlight by page geometry rather than event.target.
653+
if (scribe.useCustomSelection) return scribe.textSel.hitTestHighlight(event.clientX, event.clientY)?.kw ?? null;
654+
const wordEl = /** @type {Element} */ (event.target).closest('.scribe-word');
655+
return wordEl ? /** @type {any} */ (wordEl)._scribeObj : null;
656+
};
657+
646658
/** Resolve the card target under an event: a comment mark, a note mark, or a commented word. */
647659
const cmtTargetFromEvent = (event) => {
648660
if (!(event.target instanceof Element)) return null;
@@ -659,8 +671,7 @@ export function createHighlightTool(scribe, rootElem, { colors, defaultColor, ro
659671
const annot = (scribe.doc.annotations.pages[n] || []).filter((a) => a.type === 'text')[Number(noteEl.dataset.noteIdx)];
660672
return annot ? { kind: 'note', annot, n } : null;
661673
}
662-
const wordEl = event.target.closest('.scribe-word');
663-
const kw = wordEl && /** @type {any} */ (wordEl)._scribeObj;
674+
const kw = highlightWordAt(event);
664675
if (kw && kw.highlightGroupId && kw.highlightComment) {
665676
return {
666677
kind: 'highlight', kw, groupId: kw.highlightGroupId, n: kw.word.line.page.n,
@@ -671,6 +682,19 @@ export function createHighlightTool(scribe, rootElem, { colors, defaultColor, ro
671682

672683
const cmtOver = (event) => { const t = cmtTargetFromEvent(event); if (t) cmtShow(t); };
673684
const cmtOut = (event) => { if (cmtTargetFromEvent(event)) cmtScheduleHide(); };
685+
// With no word spans, the pointer crosses no element boundary over the text,
686+
// so a hovered commented highlight must be sampled on pointer move rather than delegated from mouseover.
687+
let cmtMoveRaf = null;
688+
const cmtMove = (event) => {
689+
if (cmtMoveRaf !== null) return;
690+
const { clientX, clientY, target } = event;
691+
cmtMoveRaf = requestAnimationFrame(() => {
692+
cmtMoveRaf = null;
693+
const t = cmtTargetFromEvent({ clientX, clientY, target });
694+
if (t) cmtShow(t);
695+
else if (cmtTarget && cmtTarget.kind === 'highlight') cmtScheduleHide();
696+
});
697+
};
674698
const cmtPress = (event) => {
675699
if (!(event.target instanceof Element)) return;
676700
const el = event.target.closest('.scribe-hl-cmark, .scribe-note-icon');
@@ -683,12 +707,10 @@ export function createHighlightTool(scribe, rootElem, { colors, defaultColor, ro
683707
return;
684708
}
685709
// Gate on the color, not the comment: the card's footer is the only place to recolor or delete an uncommented highlight.
686-
const wordEl = event.target.closest('.scribe-word');
687-
const kw = wordEl && /** @type {any} */ (wordEl)._scribeObj;
710+
const kw = highlightWordAt(event);
688711
if (!kw || !kw.highlightColor) return;
689712
// A drag that leaves a text selection is a selection gesture, not a click on the object.
690-
const sel = window.getSelection();
691-
if (sel && !sel.isCollapsed) return;
713+
if (scribe.hasTextSelection()) return;
692714
event.stopPropagation();
693715
cmtPin({
694716
kind: 'highlight', kw, groupId: kw.highlightGroupId || null, n: kw.word.line.page.n,
@@ -704,6 +726,7 @@ export function createHighlightTool(scribe, rootElem, { colors, defaultColor, ro
704726
};
705727
scribe.elem.addEventListener('mouseover', cmtOver);
706728
scribe.elem.addEventListener('mouseout', cmtOut);
729+
if (scribe.useCustomSelection) scribe.elem.addEventListener('mousemove', cmtMove);
707730
scribe.elem.addEventListener('click', cmtPress);
708731
scribe.elem.addEventListener('focusin', cmtOver);
709732
scribe.elem.addEventListener('focusout', cmtOut);
@@ -888,6 +911,7 @@ export function createHighlightTool(scribe, rootElem, { colors, defaultColor, ro
888911
commentObserver.disconnect();
889912
scribe.elem.removeEventListener('mouseover', cmtOver);
890913
scribe.elem.removeEventListener('mouseout', cmtOut);
914+
scribe.elem.removeEventListener('mousemove', cmtMove);
891915
scribe.elem.removeEventListener('click', cmtPress);
892916
scribe.elem.removeEventListener('focusin', cmtOver);
893917
scribe.elem.removeEventListener('focusout', cmtOut);
@@ -914,7 +938,6 @@ export function createHighlightTool(scribe, rootElem, { colors, defaultColor, ro
914938
return {
915939
highlightElem,
916940
toolbarElem,
917-
getSelectedOverlayWords,
918941
updateCommentIcons,
919942
installBehaviors,
920943
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { ScribeViewer } from '../../viewer.js';
2+
import { TextSelection } from '../viewerTextSelection.js';
3+
4+
/**
5+
* Importing this module is the build-time opt-in for the custom (DOM-less) selection engine.
6+
* Import both this and `domSelectionEngine.js` to defer the choice to runtime via `ScribeViewer.customSelection`.
7+
*/
8+
ScribeViewer.registerSelectionEngine({
9+
kind: 'custom',
10+
/** @param {ScribeViewer} viewer */
11+
attach(viewer) {
12+
const textSel = new TextSelection(viewer);
13+
viewer.textSel = textSel;
14+
return textSel;
15+
},
16+
});
17+
18+
export { TextSelection };

0 commit comments

Comments
 (0)