Merge pull request #15200 from calixteman/multiple_selection

[Editor] Add the ability to make multiple selections (bug 1779582)
This commit is contained in:
calixteman 2022-07-21 23:15:16 +02:00 committed by GitHub
commit 6138e16ce2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 490 additions and 150 deletions

View File

@ -136,7 +136,7 @@ class AnnotationEditorLayer {
} else { } else {
this.enableClick(); this.enableClick();
} }
this.setActiveEditor(null); this.#uiManager.unselectAll();
} }
addInkEditorIfNeeded(isCommitting) { addInkEditorIfNeeded(isCommitting) {
@ -210,14 +210,6 @@ class AnnotationEditorLayer {
} }
this.#uiManager.setActiveEditor(editor); this.#uiManager.setActiveEditor(editor);
if (currentActive && currentActive !== editor) {
currentActive.commitOrRemove();
}
if (editor) {
this.#uiManager.unselectAll();
}
} }
enableClick() { enableClick() {
@ -250,11 +242,19 @@ class AnnotationEditorLayer {
this.#uiManager.removeEditor(editor); this.#uiManager.removeEditor(editor);
this.detach(editor); this.detach(editor);
this.annotationStorage.removeKey(editor.id); this.annotationStorage.removeKey(editor.id);
editor.div.remove(); editor.div.style.display = "none";
editor.isAttachedToDOM = false; setTimeout(() => {
if (this.#uiManager.isActive(editor) || this.#editors.size === 0) { // When the div is removed from DOM the focus can move on the
this.setActiveEditor(null); // document.body, so we just slightly postpone the removal in
} // order to let an element potentially grab the focus before
// the body.
editor.div.style.display = "";
editor.div.remove();
editor.isAttachedToDOM = false;
if (document.activeElement === document.body) {
this.#uiManager.focusMainContainer();
}
}, 0);
if (!this.#isCleaningUp) { if (!this.#isCleaningUp) {
this.addInkEditorIfNeeded(/* isCommitting = */ false); this.addInkEditorIfNeeded(/* isCommitting = */ false);
@ -271,10 +271,6 @@ class AnnotationEditorLayer {
return; return;
} }
if (this.#uiManager.isActive(editor)) {
editor.parent?.setActiveEditor(null);
}
this.attach(editor); this.attach(editor);
editor.pageIndex = this.pageIndex; editor.pageIndex = this.pageIndex;
editor.parent?.detach(editor); editor.parent?.detach(editor);
@ -546,6 +542,42 @@ class AnnotationEditorLayer {
return editor; return editor;
} }
/**
* Set the last selected editor.
* @param {AnnotationEditor} editor
*/
setSelected(editor) {
this.#uiManager.setSelected(editor);
}
/**
* Check if the editor is selected.
* @param {AnnotationEditor} editor
*/
isSelected(editor) {
return this.#uiManager.isSelected(editor);
}
/**
* Unselect an editor.
* @param {AnnotationEditor} editor
*/
unselect(editor) {
this.#uiManager.unselect(editor);
}
get isMultipleSelection() {
return this.#uiManager.isMultipleSelection;
}
/**
* An editor just got a mousedown with ctrl key pressed.
* @param {boolean}} isMultiple
*/
set isMultipleSelection(isMultiple) {
this.#uiManager.isMultipleSelection = isMultiple;
}
/** /**
* Mouseclick callback. * Mouseclick callback.
* @param {MouseEvent} event * @param {MouseEvent} event
@ -662,7 +694,6 @@ class AnnotationEditorLayer {
* @param {Object} parameters * @param {Object} parameters
*/ */
update(parameters) { update(parameters) {
this.setActiveEditor(null);
this.viewport = parameters.viewport; this.viewport = parameters.viewport;
this.setDimensions(); this.setDimensions();
this.updateMode(); this.updateMode();

View File

@ -16,12 +16,8 @@
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
/** @typedef {import("./annotation_editor_layer.js").AnnotationEditorLayer} AnnotationEditorLayer */ /** @typedef {import("./annotation_editor_layer.js").AnnotationEditorLayer} AnnotationEditorLayer */
import {
AnnotationEditorPrefix,
shadow,
unreachable,
} from "../../shared/util.js";
import { bindEvents, ColorManager } from "./tools.js"; import { bindEvents, ColorManager } from "./tools.js";
import { shadow, unreachable } from "../../shared/util.js";
/** /**
* @typedef {Object} AnnotationEditorParameters * @typedef {Object} AnnotationEditorParameters
@ -35,8 +31,20 @@ import { bindEvents, ColorManager } from "./tools.js";
* Base class for editors. * Base class for editors.
*/ */
class AnnotationEditor { class AnnotationEditor {
#boundFocusin = this.focusin.bind(this);
#boundFocusout = this.focusout.bind(this);
#isEditing = false;
#isFocused = false;
#isInEditMode = false; #isInEditMode = false;
#wasSelected = false;
#wasFocused = false;
#zIndex = AnnotationEditor._zIndex++; #zIndex = AnnotationEditor._zIndex++;
static _colorManager = new ColorManager(); static _colorManager = new ColorManager();
@ -88,17 +96,32 @@ class AnnotationEditor {
this.div.style.zIndex = this.#zIndex; this.div.style.zIndex = this.#zIndex;
} }
#select() {
if (this.#wasSelected) {
this.parent.unselect(this);
this.unselect();
this.#wasSelected = true;
} else {
this.parent.setSelected(this);
this.select();
}
}
/** /**
* onfocus callback. * onfocus callback.
*/ */
focusin(/* event */) { focusin(event) {
this.parent.setActiveEditor(this); this.#isFocused =
event.target === this.div ||
!!event.relatedTarget?.closest(`#${this.id}`);
if (event.target === this.div) {
this.#select();
}
} }
/** /**
* onblur callback. * onblur callback.
* @param {FocusEvent} event * @param {FocusEvent} event
* @returns {undefined}
*/ */
focusout(event) { focusout(event) {
if (!this.isAttachedToDOM) { if (!this.isAttachedToDOM) {
@ -116,10 +139,14 @@ class AnnotationEditor {
event.preventDefault(); event.preventDefault();
this.commitOrRemove(); this.#isFocused = false;
if (!this.parent.isMultipleSelection) {
if (!target?.id?.startsWith(AnnotationEditorPrefix)) { this.commitOrRemove();
this.parent.setActiveEditor(null); if (target?.closest(".annotationEditorLayer")) {
// We only unselect the element when another editor (or its parent)
// is grabbing the focus.
this.parent.unselect(this);
}
} }
} }
@ -228,15 +255,13 @@ class AnnotationEditor {
this.setInForeground(); this.setInForeground();
this.div.addEventListener("focusin", this.#boundFocusin);
this.div.addEventListener("focusout", this.#boundFocusout);
const [tx, ty] = this.getInitialTranslation(); const [tx, ty] = this.getInitialTranslation();
this.translate(tx, ty); this.translate(tx, ty);
bindEvents(this, this.div, [ bindEvents(this, this.div, ["dragstart", "mousedown", "mouseup"]);
"dragstart",
"focusin",
"focusout",
"mousedown",
]);
return this.div; return this.div;
} }
@ -250,6 +275,23 @@ class AnnotationEditor {
// Avoid to focus this editor because of a non-left click. // Avoid to focus this editor because of a non-left click.
event.preventDefault(); event.preventDefault();
} }
const isMultipleSelection = (this.parent.isMultipleSelection =
event.ctrlKey || event.shiftKey);
this.#wasSelected = isMultipleSelection && this.parent.isSelected(this);
this.#wasFocused = this.#isFocused;
}
/**
* Onmouseup callback.
* @param {MouseEvent} event
*/
mouseup(event) {
if (this.#wasFocused) {
this.#select();
}
this.parent.isMultipleSelection = false;
this.#wasFocused = false;
} }
getRect(tx, ty) { getRect(tx, ty) {
@ -331,7 +373,6 @@ class AnnotationEditor {
/** /**
* Enable edit mode. * Enable edit mode.
* @returns {undefined}
*/ */
enableEditMode() { enableEditMode() {
this.#isInEditMode = true; this.#isInEditMode = true;
@ -339,7 +380,6 @@ class AnnotationEditor {
/** /**
* Disable edit mode. * Disable edit mode.
* @returns {undefined}
*/ */
disableEditMode() { disableEditMode() {
this.#isInEditMode = false; this.#isInEditMode = false;
@ -374,10 +414,9 @@ class AnnotationEditor {
* Rebuild the editor in case it has been removed on undo. * Rebuild the editor in case it has been removed on undo.
* *
* To implement in subclasses. * To implement in subclasses.
* @returns {undefined}
*/ */
rebuild() { rebuild() {
unreachable("An editor must be rebuildable"); this.div?.addEventListener("focusin", this.#boundFocusin);
} }
/** /**
@ -386,7 +425,6 @@ class AnnotationEditor {
* new annotation to add to the pdf document. * new annotation to add to the pdf document.
* *
* To implement in subclasses. * To implement in subclasses.
* @returns {undefined}
*/ */
serialize() { serialize() {
unreachable("An editor must be serializable"); unreachable("An editor must be serializable");
@ -423,10 +461,11 @@ class AnnotationEditor {
/** /**
* Remove this editor. * Remove this editor.
* It's used on ctrl+backspace action. * It's used on ctrl+backspace action.
*
* @returns {undefined}
*/ */
remove() { remove() {
this.div.removeEventListener("focusin", this.#boundFocusin);
this.div.removeEventListener("focusout", this.#boundFocusout);
if (!this.isEmpty()) { if (!this.isEmpty()) {
// The editor is removed but it can be back at some point thanks to // The editor is removed but it can be back at some point thanks to
// undo/redo so we must commit it before. // undo/redo so we must commit it before.
@ -439,18 +478,14 @@ class AnnotationEditor {
* Select this editor. * Select this editor.
*/ */
select() { select() {
if (this.div) { this.div?.classList.add("selectedEditor");
this.div.classList.add("selectedEditor");
}
} }
/** /**
* Unselect this editor. * Unselect this editor.
*/ */
unselect() { unselect() {
if (this.div) { this.div?.classList.remove("selectedEditor");
this.div.classList.remove("selectedEditor");
}
} }
/** /**
@ -494,6 +529,29 @@ class AnnotationEditor {
get contentDiv() { get contentDiv() {
return this.div; return this.div;
} }
/**
* If true then the editor is currently edited.
* @type {boolean}
*/
get isEditing() {
return this.#isEditing;
}
/**
* When set to true, it means that this editor is currently edited.
* @param {boolean} value
*/
set isEditing(value) {
this.#isEditing = value;
if (value) {
this.select();
this.parent.setSelected(this);
this.parent.setActiveEditor(this);
} else {
this.parent.setActiveEditor(null);
}
}
} }
export { AnnotationEditor }; export { AnnotationEditor };

View File

@ -30,6 +30,10 @@ import { AnnotationEditor } from "./editor.js";
* Basic text editor in order to create a FreeTex annotation. * Basic text editor in order to create a FreeTex annotation.
*/ */
class FreeTextEditor extends AnnotationEditor { class FreeTextEditor extends AnnotationEditor {
#boundEditorDivBlur = this.editorDivBlur.bind(this);
#boundEditorDivFocus = this.editorDivFocus.bind(this);
#boundEditorDivKeydown = this.editorDivKeydown.bind(this); #boundEditorDivKeydown = this.editorDivKeydown.bind(this);
#color; #color;
@ -199,6 +203,7 @@ class FreeTextEditor extends AnnotationEditor {
/** @inheritdoc */ /** @inheritdoc */
rebuild() { rebuild() {
super.rebuild();
if (this.div === null) { if (this.div === null) {
return; return;
} }
@ -220,6 +225,8 @@ class FreeTextEditor extends AnnotationEditor {
this.div.draggable = false; this.div.draggable = false;
this.div.removeAttribute("tabIndex"); this.div.removeAttribute("tabIndex");
this.editorDiv.addEventListener("keydown", this.#boundEditorDivKeydown); this.editorDiv.addEventListener("keydown", this.#boundEditorDivKeydown);
this.editorDiv.addEventListener("focus", this.#boundEditorDivFocus);
this.editorDiv.addEventListener("blur", this.#boundEditorDivBlur);
} }
/** @inheritdoc */ /** @inheritdoc */
@ -231,6 +238,8 @@ class FreeTextEditor extends AnnotationEditor {
this.div.draggable = true; this.div.draggable = true;
this.div.tabIndex = 0; this.div.tabIndex = 0;
this.editorDiv.removeEventListener("keydown", this.#boundEditorDivKeydown); this.editorDiv.removeEventListener("keydown", this.#boundEditorDivKeydown);
this.editorDiv.removeEventListener("focus", this.#boundEditorDivFocus);
this.editorDiv.removeEventListener("blur", this.#boundEditorDivBlur);
} }
/** @inheritdoc */ /** @inheritdoc */
@ -251,6 +260,7 @@ class FreeTextEditor extends AnnotationEditor {
/** @inheritdoc */ /** @inheritdoc */
remove() { remove() {
this.isEditing = false;
this.parent.setEditingState(true); this.parent.setEditingState(true);
super.remove(); super.remove();
} }
@ -333,6 +343,14 @@ class FreeTextEditor extends AnnotationEditor {
FreeTextEditor._keyboardManager.exec(this, event); FreeTextEditor._keyboardManager.exec(this, event);
} }
editorDivFocus(event) {
this.isEditing = true;
}
editorDivBlur(event) {
this.isEditing = false;
}
/** @inheritdoc */ /** @inheritdoc */
disableEditing() { disableEditing() {
this.editorDiv.setAttribute("role", "comment"); this.editorDiv.setAttribute("role", "comment");

View File

@ -123,8 +123,16 @@ class InkEditor extends AnnotationEditor {
/** @inheritdoc */ /** @inheritdoc */
get propertiesToUpdate() { get propertiesToUpdate() {
return [ return [
[AnnotationEditorParamsType.INK_THICKNESS, this.thickness], [
[AnnotationEditorParamsType.INK_COLOR, this.color], AnnotationEditorParamsType.INK_THICKNESS,
this.thickness || InkEditor._defaultThickness,
],
[
AnnotationEditorParamsType.INK_COLOR,
this.color ||
InkEditor._defaultColor ||
AnnotationEditor._defaultLineColor,
],
]; ];
} }
@ -174,6 +182,7 @@ class InkEditor extends AnnotationEditor {
/** @inheritdoc */ /** @inheritdoc */
rebuild() { rebuild() {
super.rebuild();
if (this.div === null) { if (this.div === null) {
return; return;
} }
@ -284,6 +293,7 @@ class InkEditor extends AnnotationEditor {
* @param {number} y * @param {number} y
*/ */
#startDrawing(x, y) { #startDrawing(x, y) {
this.isEditing = true;
if (!this.#isCanvasInitialized) { if (!this.#isCanvasInitialized) {
this.#isCanvasInitialized = true; this.#isCanvasInitialized = true;
this.#setCanvasDims(); this.#setCanvasDims();
@ -390,6 +400,7 @@ class InkEditor extends AnnotationEditor {
return; return;
} }
this.isEditing = false;
this.disableEditMode(); this.disableEditMode();
// This editor must be on top of the main ink editor. // This editor must be on top of the main ink editor.
@ -408,8 +419,8 @@ class InkEditor extends AnnotationEditor {
} }
/** @inheritdoc */ /** @inheritdoc */
focusin(/* event */) { focusin(event) {
super.focusin(); super.focusin(event);
this.enableEditMode(); this.enableEditMode();
} }

View File

@ -377,19 +377,19 @@ class AnnotationEditorUIManager {
#currentPageIndex = 0; #currentPageIndex = 0;
#isMultipleSelection = false;
#editorTypes = null; #editorTypes = null;
#eventBus = null; #eventBus = null;
#idManager = new IdManager(); #idManager = new IdManager();
#isAllSelected = false;
#isEnabled = false; #isEnabled = false;
#mode = AnnotationEditorType.NONE; #mode = AnnotationEditorType.NONE;
#previousActiveEditor = null; #selectedEditors = new Set();
#boundKeydown = this.keydown.bind(this); #boundKeydown = this.keydown.bind(this);
@ -435,6 +435,7 @@ class AnnotationEditorUIManager {
], ],
AnnotationEditorUIManager.prototype.delete, AnnotationEditorUIManager.prototype.delete,
], ],
[["Escape"], AnnotationEditorUIManager.prototype.unselectAll],
]); ]);
constructor(container, eventBus) { constructor(container, eventBus) {
@ -456,6 +457,7 @@ class AnnotationEditorUIManager {
this.#allLayers.clear(); this.#allLayers.clear();
this.#allEditors.clear(); this.#allEditors.clear();
this.#activeEditor = null; this.#activeEditor = null;
this.#selectedEditors.clear();
this.#clipboardManager.destroy(); this.#clipboardManager.destroy();
this.#commandManager.destroy(); this.#commandManager.destroy();
} }
@ -470,6 +472,10 @@ class AnnotationEditorUIManager {
layer?.onTextLayerRendered(); layer?.onTextLayerRendered();
} }
focusMainContainer() {
this.#container.focus();
}
#addKeyboardManager() { #addKeyboardManager() {
// The keyboard events are caught at the container level in order to be able // The keyboard events are caught at the container level in order to be able
// to execute some callbacks even if the current page doesn't have focus. // to execute some callbacks even if the current page doesn't have focus.
@ -631,10 +637,10 @@ class AnnotationEditorUIManager {
* @param {*} value * @param {*} value
*/ */
updateParams(type, value) { updateParams(type, value) {
(this.#activeEditor || this.#previousActiveEditor)?.updateParams( for (const editor of this.#selectedEditors) {
type, editor.updateParams(type, value);
value }
);
for (const editorType of this.#editorTypes) { for (const editorType of this.#editorTypes) {
editorType.updateDefaultParams(type, value); editorType.updateDefaultParams(type, value);
} }
@ -656,6 +662,7 @@ class AnnotationEditorUIManager {
* Disable all the layers. * Disable all the layers.
*/ */
#disableAll() { #disableAll() {
this.unselectAll();
if (this.#isEnabled) { if (this.#isEnabled) {
this.#isEnabled = false; this.#isEnabled = false;
for (const layer of this.#allLayers.values()) { for (const layer of this.#allLayers.values()) {
@ -702,6 +709,9 @@ class AnnotationEditorUIManager {
*/ */
removeEditor(editor) { removeEditor(editor) {
this.#allEditors.delete(editor.id); this.#allEditors.delete(editor.id);
if (this.hasSelection) {
this.#selectedEditors.delete(editor);
}
} }
/** /**
@ -726,24 +736,80 @@ class AnnotationEditorUIManager {
return; return;
} }
this.#previousActiveEditor = this.#activeEditor;
this.#activeEditor = editor; this.#activeEditor = editor;
if (editor) { if (editor) {
this.#dispatchUpdateUI(editor.propertiesToUpdate); this.#dispatchUpdateUI(editor.propertiesToUpdate);
this.#dispatchUpdateStates({ hasSelectedEditor: true });
} else {
this.#dispatchUpdateStates({ hasSelectedEditor: false });
if (this.#previousActiveEditor) {
this.#dispatchUpdateUI(this.#previousActiveEditor.propertiesToUpdate);
} else {
for (const editorType of this.#editorTypes) {
this.#dispatchUpdateUI(editorType.defaultPropertiesToUpdate);
}
}
} }
} }
/**
* Set the last selected editor.
* @param {AnnotationEditor} editor
*/
setSelected(editor) {
if (!this.#isMultipleSelection) {
if (this.#selectedEditors.has(editor)) {
if (this.#selectedEditors.size > 1) {
for (const ed of this.#selectedEditors) {
if (ed !== editor) {
ed.unselect();
}
}
this.#selectedEditors.clear();
this.#selectedEditors.add(editor);
this.#dispatchUpdateUI(editor.propertiesToUpdate);
}
return;
}
for (const ed of this.#selectedEditors) {
ed.unselect();
}
this.#selectedEditors.clear();
}
this.#selectedEditors.add(editor);
this.#dispatchUpdateUI(editor.propertiesToUpdate);
this.#dispatchUpdateStates({
hasSelectedEditor: this.hasSelection,
});
}
/**
* Check if the editor is selected.
* @param {AnnotationEditor} editor
*/
isSelected(editor) {
return this.#selectedEditors.has(editor);
}
/**
* Unselect an editor.
* @param {AnnotationEditor} editor
*/
unselect(editor) {
editor.unselect();
this.#selectedEditors.delete(editor);
this.#dispatchUpdateStates({
hasSelectedEditor: this.hasSelection,
});
}
get hasSelection() {
return this.#selectedEditors.size !== 0;
}
get isMultipleSelection() {
return this.#isMultipleSelection;
}
/**
* An editor just got a mousedown with ctrl key pressed.
* @param {boolean} isMultiple
*/
set isMultipleSelection(isMultiple) {
this.#isMultipleSelection = isMultiple;
}
/** /**
* Undo the last command. * Undo the last command.
*/ */
@ -795,52 +861,26 @@ class AnnotationEditorUIManager {
return false; return false;
} }
/**
* Unselect the current editor.
*/
unselect() {
if (this.#activeEditor) {
this.#activeEditor.parent.setActiveEditor(null);
}
}
/** /**
* Delete the current editor or all. * Delete the current editor or all.
*/ */
delete() { delete() {
let cmd, undo; if (!this.hasSelection) {
if (this.#isAllSelected) { return;
this.#previousActiveEditor = this.#activeEditor = null;
const editors = Array.from(this.#allEditors.values());
cmd = () => {
for (const editor of editors) {
if (!editor.isEmpty()) {
editor.remove();
}
}
};
undo = () => {
for (const editor of editors) {
this.#addEditorToLayer(editor);
}
};
this.addCommands({ cmd, undo, mustExec: true });
} else {
if (!this.#activeEditor) {
return;
}
const editor = this.#activeEditor;
this.#previousActiveEditor = this.#activeEditor = null;
cmd = () => {
editor.remove();
};
undo = () => {
this.#addEditorToLayer(editor);
};
} }
const editors = [...this.#selectedEditors];
const cmd = () => {
for (const editor of editors) {
editor.remove();
}
};
const undo = () => {
for (const editor of editors) {
this.#addEditorToLayer(editor);
}
};
this.addCommands({ cmd, undo, mustExec: true }); this.addCommands({ cmd, undo, mustExec: true });
} }
@ -848,8 +888,8 @@ class AnnotationEditorUIManager {
* Copy the selected editor. * Copy the selected editor.
*/ */
copy() { copy() {
if (this.#activeEditor) { if (this.hasSelection) {
this.#clipboardManager.copy(this.#activeEditor); this.#clipboardManager.copy([...this.#selectedEditors]);
this.#dispatchUpdateStates({ hasEmptyClipboard: false }); this.#dispatchUpdateStates({ hasEmptyClipboard: false });
} }
} }
@ -858,10 +898,8 @@ class AnnotationEditorUIManager {
* Cut the selected editor. * Cut the selected editor.
*/ */
cut() { cut() {
if (this.#activeEditor) { this.copy();
this.#clipboardManager.copy(this.#activeEditor); this.delete();
this.delete();
}
} }
/** /**
@ -873,42 +911,63 @@ class AnnotationEditorUIManager {
return; return;
} }
this.unselectAll();
const layer = this.#allLayers.get(this.#currentPageIndex); const layer = this.#allLayers.get(this.#currentPageIndex);
const newEditors = this.#clipboardManager const newEditors = this.#clipboardManager
.paste() .paste()
.map(data => layer.deserialize(data)); .map(data => layer.deserialize(data));
const cmd = () => { const cmd = () => {
newEditors.map(editor => this.#addEditorToLayer(editor)); for (const editor of newEditors) {
this.#addEditorToLayer(editor);
}
this.#selectEditors(newEditors);
}; };
const undo = () => { const undo = () => {
newEditors.map(editor => editor.remove()); for (const editor of newEditors) {
editor.remove();
}
}; };
this.addCommands({ cmd, undo, mustExec: true }); this.addCommands({ cmd, undo, mustExec: true });
} }
/** /**
* Select all the editors. * Select the editors.
* @param {Array<AnnotationEditor>} editors
*/ */
selectAll() { #selectEditors(editors) {
this.#isAllSelected = true; this.#selectedEditors.clear();
for (const editor of this.#allEditors.values()) { for (const editor of editors) {
if (editor.isEmpty()) {
continue;
}
this.#selectedEditors.add(editor);
editor.select(); editor.select();
} }
this.#dispatchUpdateStates({ hasSelectedEditor: true }); this.#dispatchUpdateStates({ hasSelectedEditor: true });
} }
/** /**
* Unselect all the editors. * Select all the editors.
*/
selectAll() {
for (const editor of this.#selectedEditors) {
editor.commit();
}
this.#selectEditors(this.#allEditors.values());
}
/**
* Unselect all the selected editors.
*/ */
unselectAll() { unselectAll() {
this.#isAllSelected = false; for (const editor of this.#selectedEditors) {
for (const editor of this.#allEditors.values()) {
editor.unselect(); editor.unselect();
} }
this.#selectedEditors.clear();
this.#dispatchUpdateStates({ this.#dispatchUpdateStates({
hasSelectedEditor: this.#activeEditor !== null, hasSelectedEditor: false,
}); });
} }

View File

@ -252,4 +252,176 @@ describe("Editor", () => {
); );
}); });
}); });
describe("FreeText (multiselection)", () => {
let pages;
beforeAll(async () => {
pages = await loadAndWait("tracemonkey.pdf", ".annotationEditorLayer");
});
afterAll(async () => {
await closePages(pages);
});
function getSelected(page) {
return page.evaluate(prefix => {
const elements = document.querySelectorAll(".selectedEditor");
const results = [];
for (const element of elements) {
results.push(parseInt(element.id.slice(prefix.length)));
}
results.sort();
return results;
}, editorPrefix.slice(1));
}
it("must select/unselect several editors and check copy, paste and delete operations", async () => {
await Promise.all(
pages.map(async ([browserName, page]) => {
await page.click("#editorFreeText");
const rect = await page.$eval(".annotationEditorLayer", el => {
// With Chrome something is wrong when serializing a DomRect,
// hence we extract the values and just return them.
const { x, y } = el.getBoundingClientRect();
return { x, y };
});
const editorCenters = [];
for (let i = 0; i < 4; i++) {
const data = `FreeText ${i}`;
await page.mouse.click(
rect.x + (i + 1) * 100,
rect.y + (i + 1) * 100
);
await page.type(`${editorPrefix}${i} .internal`, data);
const editorRect = await page.$eval(`${editorPrefix}${i}`, el => {
const { x, y, width, height } = el.getBoundingClientRect();
return {
x,
y,
width,
height,
};
});
editorCenters.push({
x: editorRect.x + editorRect.width / 2,
y: editorRect.y + editorRect.height / 2,
});
// Commit.
await page.mouse.click(
editorRect.x,
editorRect.y + 2 * editorRect.height
);
}
await page.keyboard.down("Control");
await page.keyboard.press("a");
await page.keyboard.up("Control");
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([0, 1, 2, 3]);
await page.keyboard.down("Control");
await page.mouse.click(editorCenters[1].x, editorCenters[1].y);
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([0, 2, 3]);
await page.mouse.click(editorCenters[2].x, editorCenters[2].y);
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([0, 3]);
await page.mouse.click(editorCenters[1].x, editorCenters[1].y);
await page.keyboard.up("Control");
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([0, 1, 3]);
await page.keyboard.down("Control");
await page.keyboard.press("c");
await page.keyboard.up("Control");
await page.keyboard.down("Control");
await page.keyboard.press("v");
await page.keyboard.up("Control");
// 0,1,3 are unselected and new pasted editors are selected.
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([4, 5, 6]);
// No ctrl here, hence all are unselected and 2 is selected.
await page.mouse.click(editorCenters[2].x, editorCenters[2].y);
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([2]);
await page.mouse.click(editorCenters[1].x, editorCenters[1].y);
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([1]);
await page.keyboard.down("Control");
await page.mouse.click(editorCenters[3].x, editorCenters[3].y);
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([1, 3]);
await page.keyboard.up("Control");
// Delete 1 and 3.
await page.keyboard.press("Backspace");
await page.keyboard.down("Control");
await page.keyboard.press("a");
await page.keyboard.up("Control");
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([0, 2, 4, 5, 6]);
// Create an empty editor.
await page.mouse.click(rect.x + 700, rect.y + 100);
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([7]);
// Set the focus to 2 and check that only 2 is selected.
await page.mouse.click(editorCenters[2].x, editorCenters[2].y);
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([2]);
// Create an empty editor.
await page.mouse.click(rect.x + 700, rect.y + 100);
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([8]);
// Dismiss it.
await page.keyboard.press("Escape");
// Select all.
await page.keyboard.down("Control");
await page.keyboard.press("a");
await page.keyboard.up("Control");
// Check that all the editors are correctly selected (and the focus
// didn't move to the body when the empty editor was removed).
expect(await getSelected(page))
.withContext(`In ${browserName}`)
.toEqual([0, 2, 4, 5, 6]);
})
);
});
});
}); });

View File

@ -47,6 +47,11 @@
transform-origin: 0 0; transform-origin: 0 0;
} }
.annotationEditorLayer .selectedEditor {
outline: var(--focus-outline);
resize: none;
}
.annotationEditorLayer .freeTextEditor { .annotationEditorLayer .freeTextEditor {
position: absolute; position: absolute;
background: transparent; background: transparent;
@ -94,21 +99,17 @@
outline: none; outline: none;
} }
.annotationEditorLayer .freeTextEditor:focus-within { .annotationEditorLayer .inkEditor.disabled {
outline: var(--focus-outline);
}
.annotationEditorLayer .inkEditor:not(:focus) {
resize: none; resize: none;
} }
.annotationEditorLayer .freeTextEditor:hover:not(:focus-within), .annotationEditorLayer .inkEditor.disabled.selectedEditor {
.annotationEditorLayer .inkEditor:hover:not(:focus) { resize: horizontal;
outline: var(--hover-outline);
} }
.annotationEditorLayer .inkEditor.disabled:focus { .annotationEditorLayer .freeTextEditor:hover:not(.selectedEditor),
resize: horizontal; .annotationEditorLayer .inkEditor:hover:not(.selectedEditor) {
outline: var(--hover-outline);
} }
.annotationEditorLayer .inkEditor { .annotationEditorLayer .inkEditor {
@ -123,11 +124,6 @@
cursor: auto; cursor: auto;
} }
.annotationEditorLayer .inkEditor:focus {
outline: var(--focus-outline);
resize: both;
}
.annotationEditorLayer .inkEditor.editing { .annotationEditorLayer .inkEditor.editing {
resize: none; resize: none;
cursor: var(--editorInk-editing-cursor), pointer; cursor: var(--editorInk-editing-cursor), pointer;
@ -140,8 +136,3 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.annotationEditorLayer .selectedEditor {
outline: var(--focus-outline);
resize: none;
}