Calixte Denizet cee65fcd4e [Editor] Add a new base class to allow to add a drawing in the SVG layer.
This patch makes a clear separation between the way to draw and the editing stuff.
It adds a class DrawEditor which should be extended in order to create new drawing tools.
As an example, the ink tool has been rewritten in order to use it.
2024-11-28 15:23:03 +01:00

853 lines
21 KiB
JavaScript

/* Copyright 2022 Mozilla Foundation
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AnnotationEditorParamsType, unreachable } from "../../shared/util.js";
import { noContextMenu, stopEvent } from "../display_utils.js";
import { AnnotationEditor } from "./editor.js";
class DrawingOptions {
#svgProperties = Object.create(null);
updateProperty(name, value) {
this[name] = value;
this.updateSVGProperty(name, value);
}
updateProperties(properties) {
if (!properties) {
return;
}
for (const [name, value] of Object.entries(properties)) {
this.updateProperty(name, value);
}
}
updateSVGProperty(name, value) {
this.#svgProperties[name] = value;
}
toSVGProperties() {
const root = this.#svgProperties;
this.#svgProperties = Object.create(null);
return { root };
}
reset() {
this.#svgProperties = Object.create(null);
}
updateAll(options = this) {
this.updateProperties(options);
}
clone() {
unreachable("Not implemented");
}
}
/**
* Basic draw editor.
*/
class DrawingEditor extends AnnotationEditor {
#drawOutlines = null;
#mustBeCommitted;
_drawId = null;
static _currentDrawId = -1;
static _currentDraw = null;
static _currentDrawingOptions = null;
static _currentParent = null;
static _INNER_MARGIN = 3;
constructor(params) {
super(params);
this.#mustBeCommitted = params.mustBeCommitted || false;
if (params.drawOutlines) {
this.#createDrawOutlines(params);
this.#addToDrawLayer();
}
}
#createDrawOutlines({ drawOutlines, drawId, drawingOptions }) {
this.#drawOutlines = drawOutlines;
this._drawingOptions ||= drawingOptions;
if (drawId >= 0) {
this._drawId = drawId;
// We need to redraw the drawing because we changed the coordinates to be
// in the box coordinate system.
this.parent.drawLayer.finalizeDraw(
drawId,
drawOutlines.defaultProperties
);
} else {
// We create a new drawing.
this._drawId = this.#createDrawing(drawOutlines, this.parent);
}
this.#updateBbox(drawOutlines.box);
}
#createDrawing(drawOutlines, parent) {
const { id } = parent.drawLayer.draw(
DrawingEditor._mergeSVGProperties(
this._drawingOptions.toSVGProperties(),
drawOutlines.defaultSVGProperties
),
/* isPathUpdatable = */ false,
/* hasClip = */ false
);
return id;
}
static _mergeSVGProperties(p1, p2) {
const p1Keys = new Set(Object.keys(p1));
for (const [key, value] of Object.entries(p2)) {
if (p1Keys.has(key)) {
Object.assign(p1[key], value);
} else {
p1[key] = value;
}
}
return p1;
}
/**
* @param {Object} options
* @return {DrawingOptions} the default options to use for a new editor.
*/
static getDefaultDrawingOptions(_options) {
unreachable("Not implemented");
}
/**
* @return {Map<AnnotationEditorParamsType, string>} a map between the
* parameter types and the name of the options.
*/
// eslint-disable-next-line getter-return
static get typesMap() {
unreachable("Not implemented");
}
static get isDrawer() {
return true;
}
/**
* @returns {boolean} `true` if several drawings can be added to the
* annotation.
*/
static get supportMultipleDrawings() {
return false;
}
/** @inheritdoc */
static updateDefaultParams(type, value) {
const propertyName = this.typesMap.get(type);
if (propertyName) {
this._defaultDrawingOptions.updateProperty(propertyName, value);
}
if (this._currentParent) {
this._currentDraw.updateProperty(propertyName, value);
this._currentParent.drawLayer.updateProperties(
this._currentDrawId,
this._defaultDrawingOptions.toSVGProperties()
);
}
}
/** @inheritdoc */
updateParams(type, value) {
const propertyName = this.constructor.typesMap.get(type);
if (propertyName) {
this._updateProperty(type, propertyName, value);
}
}
/** @inheritdoc */
static get defaultPropertiesToUpdate() {
const properties = [];
const options = this._defaultDrawingOptions;
for (const [type, name] of this.typesMap) {
properties.push([type, options[name]]);
}
return properties;
}
/** @inheritdoc */
get propertiesToUpdate() {
const properties = [];
const { _drawingOptions } = this;
for (const [type, name] of this.constructor.typesMap) {
properties.push([type, _drawingOptions[name]]);
}
return properties;
}
/**
* Update a property and make this action undoable.
* @param {string} color
*/
_updateProperty(type, name, value) {
const options = this._drawingOptions;
const savedValue = options[name];
const setter = val => {
options.updateProperty(name, val);
const bbox = this.#drawOutlines.updateProperty(name, val);
if (bbox) {
this.#updateBbox(bbox);
}
this.parent?.drawLayer.updateProperties(
this._drawId,
options.toSVGProperties()
);
};
this.addCommands({
cmd: setter.bind(this, value),
undo: setter.bind(this, savedValue),
post: this._uiManager.updateUI.bind(this._uiManager, this),
mustExec: true,
type,
overwriteIfSameType: true,
keepUndo: true,
});
}
/** @inheritdoc */
_onResizing() {
this.parent?.drawLayer.updateProperties(
this._drawId,
DrawingEditor._mergeSVGProperties(
this.#drawOutlines.getPathResizingSVGProperties(
this.#convertToDrawSpace()
),
{
bbox: this.#rotateBox(),
}
)
);
}
/** @inheritdoc */
_onResized() {
this.parent?.drawLayer.updateProperties(
this._drawId,
DrawingEditor._mergeSVGProperties(
this.#drawOutlines.getPathResizedSVGProperties(
this.#convertToDrawSpace()
),
{
bbox: this.#rotateBox(),
}
)
);
}
/** @inheritdoc */
_onTranslating(x, y) {
this.parent?.drawLayer.updateProperties(this._drawId, {
bbox: this.#rotateBox(x, y),
});
}
/** @inheritdoc */
_onTranslated() {
this.parent?.drawLayer.updateProperties(
this._drawId,
DrawingEditor._mergeSVGProperties(
this.#drawOutlines.getPathTranslatedSVGProperties(
this.#convertToDrawSpace(),
this.parentDimensions
),
{
bbox: this.#rotateBox(),
}
)
);
}
_onStartDragging() {
this.parent?.drawLayer.updateProperties(this._drawId, {
rootClass: {
moving: true,
},
});
}
_onStopDragging() {
this.parent?.drawLayer.updateProperties(this._drawId, {
rootClass: {
moving: false,
},
});
}
/** @inheritdoc */
commit() {
super.commit();
this.disableEditMode();
this.disableEditing();
}
/** @inheritdoc */
disableEditing() {
super.disableEditing();
this.div.classList.toggle("disabled", true);
}
/** @inheritdoc */
enableEditing() {
super.enableEditing();
this.div.classList.toggle("disabled", false);
}
/** @inheritdoc */
getBaseTranslation() {
// The editor itself doesn't have any CSS border (we're drawing one
// ourselves in using SVG).
return [0, 0];
}
/** @inheritdoc */
get isResizable() {
return true;
}
/** @inheritdoc */
onceAdded() {
if (!this.annotationElementId) {
this.parent.addUndoableEditor(this);
}
this._isDraggable = true;
if (this.#mustBeCommitted) {
this.#mustBeCommitted = false;
this.commit();
this.parent.setSelected(this);
this.div.focus();
}
}
/** @inheritdoc */
remove() {
this.#cleanDrawLayer();
super.remove();
}
/** @inheritdoc */
rebuild() {
if (!this.parent) {
return;
}
super.rebuild();
if (this.div === null) {
return;
}
this.#addToDrawLayer();
this.#updateBbox(this.#drawOutlines.box);
if (!this.isAttachedToDOM) {
// At some point this editor was removed and we're rebuilding it,
// hence we must add it to its parent.
this.parent.add(this);
}
}
setParent(parent) {
let mustBeSelected = false;
if (this.parent && !parent) {
this._uiManager.removeShouldRescale(this);
this.#cleanDrawLayer();
} else if (parent) {
this._uiManager.addShouldRescale(this);
this.#addToDrawLayer(parent);
// If mustBeSelected is true it means that this editor was selected
// when its parent has been destroyed, hence we must select it again.
mustBeSelected =
!this.parent && this.div?.classList.contains("selectedEditor");
}
super.setParent(parent);
if (mustBeSelected) {
// We select it after the parent has been set.
this.select();
}
}
#cleanDrawLayer() {
if (this._drawId === null || !this.parent) {
return;
}
this.parent.drawLayer.remove(this._drawId);
this._drawId = null;
// All the SVG properties must be reset in order to make it possible to
// undo.
this._drawingOptions.reset();
}
#addToDrawLayer(parent = this.parent) {
if (this._drawId !== null && this.parent === parent) {
return;
}
if (this._drawId !== null) {
// The parent has changed, we need to move the drawing to the new parent.
this.parent.drawLayer.updateParent(this._drawId, parent.drawLayer);
return;
}
this._drawingOptions.updateAll();
this._drawId = this.#createDrawing(this.#drawOutlines, parent);
}
#convertToParentSpace([x, y, width, height]) {
const {
parentDimensions: [pW, pH],
rotation,
} = this;
switch (rotation) {
case 90:
return [y, 1 - x, width * (pH / pW), height * (pW / pH)];
case 180:
return [1 - x, 1 - y, width, height];
case 270:
return [1 - y, x, width * (pH / pW), height * (pW / pH)];
default:
return [x, y, width, height];
}
}
#convertToDrawSpace() {
const {
x,
y,
width,
height,
parentDimensions: [pW, pH],
rotation,
} = this;
switch (rotation) {
case 90:
return [1 - y, x, width * (pW / pH), height * (pH / pW)];
case 180:
return [1 - x, 1 - y, width, height];
case 270:
return [y, 1 - x, width * (pW / pH), height * (pH / pW)];
default:
return [x, y, width, height];
}
}
#updateBbox(bbox) {
[this.x, this.y, this.width, this.height] =
this.#convertToParentSpace(bbox);
if (this.div) {
this.fixAndSetPosition();
const [parentWidth, parentHeight] = this.parentDimensions;
this.setDims(this.width * parentWidth, this.height * parentHeight);
}
this._onResized();
}
#rotateBox() {
// We've to deal with two rotations: the rotation of the annotation and the
// rotation of the parent page.
// When the page is rotated, all the layers are just rotated thanks to CSS
// but there is a notable exception: the canvas wrapper.
// The canvas wrapper is not rotated but the dimensions are (or not) swapped
// and the page is redrawn with the rotation applied to the canvas.
// The drawn layer is under the canvas wrapper and is not rotated so we have
// to "manually" rotate the coordinates.
//
// The coordinates (this.x, this.y) correspond to the top-left corner of
// the editor after it has been rotated in the page coordinate system.
const {
x,
y,
width,
height,
rotation,
parentRotation,
parentDimensions: [pW, pH],
} = this;
switch ((rotation * 4 + parentRotation) / 90) {
case 1:
// 0 -> 90
return [1 - y - height, x, height, width];
case 2:
// 0 -> 180
return [1 - x - width, 1 - y - height, width, height];
case 3:
// 0 -> 270
return [y, 1 - x - width, height, width];
case 4:
// 90 -> 0
return [
x,
y - width * (pW / pH),
height * (pH / pW),
width * (pW / pH),
];
case 5:
// 90 -> 90
return [1 - y, x, width * (pW / pH), height * (pH / pW)];
case 6:
// 90 -> 180
return [
1 - x - height * (pH / pW),
1 - y,
height * (pH / pW),
width * (pW / pH),
];
case 7:
// 90 -> 270
return [
y - width * (pW / pH),
1 - x - height * (pH / pW),
width * (pW / pH),
height * (pH / pW),
];
case 8:
// 180 -> 0
return [x - width, y - height, width, height];
case 9:
// 180 -> 90
return [1 - y, x - width, height, width];
case 10:
// 180 -> 180
return [1 - x, 1 - y, width, height];
case 11:
// 180 -> 270
return [y - height, 1 - x, height, width];
case 12:
// 270 -> 0
return [
x - height * (pH / pW),
y,
height * (pH / pW),
width * (pW / pH),
];
case 13:
// 270 -> 90
return [
1 - y - width * (pW / pH),
x - height * (pH / pW),
width * (pW / pH),
height * (pH / pW),
];
case 14:
// 270 -> 180
return [
1 - x,
1 - y - width * (pW / pH),
height * (pH / pW),
width * (pW / pH),
];
case 15:
// 270 -> 270
return [y, 1 - x, width * (pW / pH), height * (pH / pW)];
default:
// 0 -> 0
return [x, y, width, height];
}
}
/** @inheritdoc */
rotate() {
if (!this.parent) {
return;
}
this.parent.drawLayer.updateProperties(
this._drawId,
DrawingEditor._mergeSVGProperties(
{
bbox: this.#rotateBox(),
},
this.#drawOutlines.updateRotation(
(this.parentRotation - this.rotation + 360) % 360
)
)
);
}
onScaleChanging() {
if (!this.parent) {
return;
}
this.#updateBbox(
this.#drawOutlines.updateParentDimensions(
this.parentDimensions,
this.parent.scale
)
);
}
static onScaleChangingWhenDrawing() {}
/** @inheritdoc */
render() {
if (this.div) {
return this.div;
}
const div = super.render();
div.classList.add("draw");
const drawDiv = document.createElement("div");
div.append(drawDiv);
drawDiv.setAttribute("aria-hidden", "true");
drawDiv.className = "internal";
const [parentWidth, parentHeight] = this.parentDimensions;
this.setDims(this.width * parentWidth, this.height * parentHeight);
this._uiManager.addShouldRescale(this);
this.disableEditing();
return div;
}
/**
* Create a new drawer instance.
* @param {number} x - The x coordinate of the event.
* @param {number} y - The y coordinate of the event.
* @param {number} parentWidth - The parent width.
* @param {number} parentHeight - The parent height.
* @param {number} rotation - The parent rotation.
*/
static createDrawerInstance(_x, _y, _parentWidth, _parentHeight, _rotation) {
unreachable("Not implemented");
}
static startDrawing(
parent,
uiManager,
_isLTR,
{ target, offsetX: x, offsetY: y }
) {
const {
viewport: { rotation },
} = parent;
const { width: parentWidth, height: parentHeight } =
target.getBoundingClientRect();
const ac = new AbortController();
const signal = parent.combinedSignal(ac);
window.addEventListener(
"pointerup",
e => {
ac.abort();
parent.toggleDrawing(true);
this._endDraw(e);
},
{ signal }
);
window.addEventListener(
"pointerdown",
stopEvent /* Avoid to have undesired clicks during drawing. */,
{
capture: true,
passive: false,
signal,
}
);
window.addEventListener("contextmenu", noContextMenu, { signal });
target.addEventListener("pointermove", this._drawMove.bind(this), {
signal,
});
parent.toggleDrawing();
if (this._currentDraw) {
parent.drawLayer.updateProperties(
this._currentDrawId,
this._currentDraw.startNew(x, y, parentWidth, parentHeight, rotation)
);
return;
}
uiManager.updateUIForDefaultProperties(this);
this._currentDraw = this.createDrawerInstance(
x,
y,
parentWidth,
parentHeight,
rotation
);
this._currentDrawingOptions = this.getDefaultDrawingOptions();
this._currentParent = parent;
({ id: this._currentDrawId } = parent.drawLayer.draw(
this._mergeSVGProperties(
this._currentDrawingOptions.toSVGProperties(),
this._currentDraw.defaultSVGProperties
),
/* isPathUpdatable = */ true,
/* hasClip = */ false
));
}
static _drawMove({ offsetX, offsetY }) {
this._currentParent.drawLayer.updateProperties(
this._currentDrawId,
this._currentDraw.add(offsetX, offsetY)
);
}
static _endDraw({ offsetX, offsetY }) {
const parent = this._currentParent;
parent.drawLayer.updateProperties(
this._currentDrawId,
this._currentDraw.end(offsetX, offsetY)
);
if (this.supportMultipleDrawings) {
const draw = this._currentDraw;
const drawId = this._currentDrawId;
const lastElement = draw.getLastElement();
parent.addCommands({
cmd: () => {
parent.drawLayer.updateProperties(
drawId,
draw.setLastElement(lastElement)
);
},
undo: () => {
parent.drawLayer.updateProperties(drawId, draw.removeLastElement());
},
mustExec: false,
type: AnnotationEditorParamsType.DRAW_STEP,
});
return;
}
this.endDrawing();
}
static endDrawing() {
const parent = this._currentParent;
if (!parent) {
return;
}
parent.toggleDrawing(true);
parent.cleanUndoStack(AnnotationEditorParamsType.DRAW_STEP);
if (!this._currentDraw.isEmpty()) {
const {
pageDimensions: [pageWidth, pageHeight],
scale,
} = parent;
parent.createAndAddNewEditor({ offsetX: 0, offsetY: 0 }, false, {
drawId: this._currentDrawId,
drawOutlines: this._currentDraw.getOutlines(
pageWidth * scale,
pageHeight * scale,
scale,
this._INNER_MARGIN
),
drawingOptions: this._currentDrawingOptions,
mustBeCommitted: true,
});
} else {
parent.drawLayer.remove(this._currentDrawId);
}
this._currentDrawId = -1;
this._currentDraw = null;
this._currentDrawingOptions = null;
this._currentParent = null;
}
/**
* Create the drawing options.
* @param {Object} _data
*/
createDrawingOptions(_data) {}
/**
* Deserialize the drawing outlines.
* @param {number} pageX - The x coordinate of the page.
* @param {number} pageY - The y coordinate of the page.
* @param {number} pageWidth - The width of the page.
* @param {number} pageHeight - The height of the page.
* @param {number} innerWidth - The inner width.
* @param {Object} data - The data to deserialize.
* @returns {Object} The deserialized outlines.
*/
static deserializeDraw(
_pageX,
_pageY,
_pageWidth,
_pageHeight,
_innerWidth,
_data
) {
unreachable("Not implemented");
}
/** @inheritdoc */
static async deserialize(data, parent, uiManager) {
const {
rawDims: { pageWidth, pageHeight, pageX, pageY },
} = parent.viewport;
const drawOutlines = this.deserializeDraw(
pageX,
pageY,
pageWidth,
pageHeight,
this._INNER_MARGIN,
data
);
const editor = await super.deserialize(data, parent, uiManager);
editor.createDrawingOptions(data);
editor.#createDrawOutlines({ drawOutlines });
editor.#addToDrawLayer();
editor.onScaleChanging();
editor.rotate();
return editor;
}
serializeDraw(isForCopying) {
const [pageX, pageY] = this.pageTranslation;
const [pageWidth, pageHeight] = this.pageDimensions;
return this.#drawOutlines.serialize(
[pageX, pageY, pageWidth, pageHeight],
isForCopying
);
}
/** @inheritdoc */
renderAnnotationElement(annotation) {
annotation.updateEdited({
rect: this.getRect(0, 0),
});
return null;
}
static canCreateNewEmptyEditor() {
return false;
}
}
export { DrawingEditor, DrawingOptions };