| // Copyright 2025 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import {assert, assertNotReached} from 'chrome://resources/js/assert.js'; |
| import {PromiseResolver} from 'chrome://resources/js/promise_resolver.js'; |
| import {isRTL} from 'chrome://resources/js/util.js'; |
| |
| import type {AnnotationBrush, Color, Point, TextAnnotation, TextAttributes, TextBoxRect, TextStyles} from './constants.js'; |
| import {AnnotationBrushType, TextAlignment, TextStyle, TextTypeface} from './constants.js'; |
| import {PluginController, PluginControllerEventType} from './controller.js'; |
| import type {Viewport, ViewportRect} from './viewport.js'; |
| |
| export interface ViewportParams { |
| clockwiseRotations: number; |
| pageDimensions: ViewportRect; |
| zoom: number; |
| } |
| |
| export interface TextBoxInit { |
| annotation: TextAnnotation; |
| pageCoordinates: Point; |
| } |
| |
| export const DEFAULT_TEXTBOX_WIDTH: number = 200; |
| export const DEFAULT_TEXTBOX_HEIGHT: number = 100; |
| |
| export function colorsEqual(color1: Color, color2: Color): boolean { |
| return color1.r === color2.r && color1.g === color2.g && |
| color1.b === color2.b; |
| } |
| |
| export function stylesEqual(style1: TextStyles, style2: TextStyles): boolean { |
| return style1.bold === style2.bold && style1.italic === style2.italic; |
| } |
| |
| /** |
| * Converts `rect` from `oldRotations` clockwise rotations to `newRotations` |
| * clockwise rotations. `newPageWidth` should be the page width in |
| * `newRotations` coordinates, and `newPageHeight` should be the page height in |
| * `newRotations` coordinates. |
| */ |
| export function convertRotatedCoordinates( |
| rect: TextBoxRect, oldRotations: number, newRotations: number, |
| newPageWidth: number, newPageHeight: number): TextBoxRect { |
| const pageWidthNR = newRotations % 2 === 0 ? newPageWidth : newPageHeight; |
| const pageHeightNR = newRotations % 2 === 0 ? newPageHeight : newPageWidth; |
| const nonRotated: TextBoxRect = { |
| locationX: rect.locationX, |
| locationY: rect.locationY, |
| width: oldRotations % 2 === 0 ? rect.width : rect.height, |
| height: oldRotations % 2 === 0 ? rect.height : rect.width, |
| }; |
| switch (oldRotations % 4) { |
| case 0: |
| // Already populated correctly. |
| break; |
| case 1: |
| nonRotated.locationX = rect.locationY; |
| nonRotated.locationY = pageHeightNR - rect.locationX - rect.width; |
| break; |
| case 2: |
| nonRotated.locationX = pageWidthNR - rect.locationX - rect.width; |
| nonRotated.locationY = pageHeightNR - rect.locationY - rect.height; |
| break; |
| case 3: |
| nonRotated.locationX = pageWidthNR - rect.locationY - rect.height; |
| nonRotated.locationY = rect.locationX; |
| break; |
| default: |
| assertNotReached(); |
| } |
| |
| const newRotated = { |
| locationX: nonRotated.locationX, |
| locationY: nonRotated.locationY, |
| width: newRotations % 2 === 0 ? nonRotated.width : nonRotated.height, |
| height: newRotations % 2 === 0 ? nonRotated.height : nonRotated.width, |
| }; |
| switch (newRotations % 4) { |
| case 0: |
| break; |
| case 1: |
| newRotated.locationX = |
| pageHeightNR - nonRotated.locationY - nonRotated.height; |
| newRotated.locationY = nonRotated.locationX; |
| break; |
| case 2: |
| newRotated.locationX = |
| pageWidthNR - nonRotated.locationX - nonRotated.width; |
| newRotated.locationY = |
| pageHeightNR - nonRotated.locationY - nonRotated.height; |
| break; |
| case 3: |
| newRotated.locationX = nonRotated.locationY; |
| newRotated.locationY = |
| pageWidthNR - nonRotated.locationX - nonRotated.width; |
| break; |
| default: |
| assertNotReached(); |
| } |
| return newRotated; |
| } |
| |
| export class Ink2Manager extends EventTarget { |
| private brush_: AnnotationBrush = {type: AnnotationBrushType.PEN}; |
| // Map from page numbers to annotations on that page. |
| // The annotations on each page are stored in a map from id to TextAnnotation. |
| private annotations_: Map<number, Map<number, TextAnnotation>> = new Map(); |
| // The attributes selected by the user for new annotations. |
| private attributes_: TextAttributes = { |
| typeface: TextTypeface.SANS_SERIF, |
| size: 12, |
| color: {r: 0, g: 0, b: 0}, |
| alignment: TextAlignment.LEFT, |
| styles: { |
| [TextStyle.BOLD]: false, |
| [TextStyle.ITALIC]: false, |
| }, |
| }; |
| private brushResolver_: PromiseResolver<void>|null = null; |
| // Holds text attributes pre-populated from an existing annotation that the |
| // user is editing. Null if the user is not editing an annotation or is |
| // creating a new annotation using |attributes_|. |
| private existingAnnotationAttributes_: TextAttributes|null = null; |
| private pageNumber_: number = -1; |
| private pluginController_: PluginController = PluginController.getInstance(); |
| private viewport_: Viewport|null = null; |
| private viewportParams_: ViewportParams = { |
| clockwiseRotations: 0, |
| pageDimensions: {x: 0, y: 0, width: 0, height: 0}, |
| zoom: 1.0, |
| }; |
| private nextAnnotationId_: number = 0; |
| |
| setViewport(viewport: Viewport) { |
| this.viewport_ = viewport; |
| } |
| |
| // Initialize a text annotation at `location` in screen coordinates. |
| // No-op if there is no PDF page at `location`. |
| initializeTextAnnotation(location: Point) { |
| assert(this.viewport_); |
| // First check if the click was on a scrollbar. If so, ignore it to avoid |
| // interfering with scroll. |
| const hasScrollbars = this.viewport_.documentHasScrollbars(); |
| if (hasScrollbars.vertical && |
| (isRTL() && location.x <= this.viewport_.scrollbarWidth) || |
| (!isRTL() && |
| location.x >= |
| (this.viewport_.size.width - this.viewport_.scrollbarWidth))) { |
| return; |
| } |
| if (hasScrollbars.horizontal && |
| location.y >= |
| (this.viewport_.size.height - this.viewport_.scrollbarWidth)) { |
| return; |
| } |
| |
| const page = this.viewport_.getPageAtPoint(location); |
| if (page === -1) { |
| return; |
| } |
| |
| const pageDimensions = this.viewport_.getPageScreenRect(page); |
| // Is the click in an existing box? |
| let existing = null; |
| // Get the annotations for the current page. |
| const annotationsMap = this.annotations_.get(page); |
| const annotations = |
| annotationsMap ? Array.from(annotationsMap.values()) : []; |
| for (const annotation of annotations) { |
| // Convert box to screen coordinates. |
| const screenBox = |
| this.pageToScreenCoordinates_(page, annotation.textBoxRect); |
| if (location.x >= screenBox.locationX && |
| location.x <= (screenBox.locationX + screenBox.width) && |
| location.y >= screenBox.locationY && |
| location.y <= (screenBox.locationY + screenBox.height)) { |
| // Don't update the original. Create a new object and update its |
| // rectangle to use the computed screen coordinates. |
| existing = structuredClone(annotation); |
| existing.textBoxRect = screenBox; |
| break; |
| } |
| } |
| |
| this.pageNumber_ = page; |
| const annotation = existing ? existing : { |
| text: '', |
| id: this.nextAnnotationId_, |
| pageNumber: page, |
| textAttributes: structuredClone(this.attributes_), |
| textBoxRect: { |
| height: DEFAULT_TEXTBOX_HEIGHT, |
| locationX: location.x, |
| locationY: location.y, |
| width: DEFAULT_TEXTBOX_WIDTH, |
| }, |
| textOrientation: (4 - this.viewport_.getClockwiseRotations()) % 4, |
| }; |
| |
| if (existing) { |
| this.pluginController_.startTextAnnotation(existing.id); |
| this.existingAnnotationAttributes_ = annotation.textAttributes; |
| } else { |
| this.nextAnnotationId_++; |
| this.existingAnnotationAttributes_ = null; |
| } |
| |
| this.dispatchEvent(new CustomEvent('initialize-text-box', { |
| detail: { |
| annotation, |
| pageCoordinates: {x: pageDimensions.x, y: pageDimensions.y}, |
| }, |
| })); |
| |
| // Notify other listeners of any changes to the viewport and/or attributes, |
| // since these may change with the annotation. |
| this.viewportChanged(); |
| this.fireAttributesChanged_(); |
| } |
| |
| getViewportParams(): ViewportParams { |
| return this.viewportParams_; |
| } |
| |
| viewportChanged() { |
| assert(this.viewport_, 'Must call setViewport() before viewportChanged()'); |
| const zoom = this.viewport_.getZoom(); |
| const page = this.pageNumber_ !== -1 ? this.pageNumber_ : |
| this.viewport_.getMostVisiblePage(); |
| const pageDimensions = this.viewport_.getPageScreenRect(page); |
| const rotations = this.viewport_.getClockwiseRotations(); |
| if (rotations === this.viewportParams_.clockwiseRotations && |
| pageDimensions.x === this.viewportParams_.pageDimensions.x && |
| pageDimensions.y === this.viewportParams_.pageDimensions.y && |
| pageDimensions.width === this.viewportParams_.pageDimensions.width && |
| pageDimensions.height === this.viewportParams_.pageDimensions.height && |
| zoom === this.viewportParams_.zoom) { |
| // Early return to avoid firing unnecessary events. |
| return; |
| } |
| |
| this.viewportParams_ = { |
| clockwiseRotations: rotations, |
| pageDimensions: pageDimensions, |
| zoom, |
| }; |
| this.dispatchEvent( |
| new CustomEvent('viewport-changed', {detail: this.viewportParams_})); |
| } |
| |
| isInitializationStarted(): boolean { |
| return this.brushResolver_ !== null; |
| } |
| |
| isInitializationComplete(): boolean { |
| return this.isInitializationStarted() && this.brushResolver_!.isFulfilled; |
| } |
| |
| getCurrentBrush(): AnnotationBrush { |
| assert(this.isInitializationComplete()); |
| return this.brush_; |
| } |
| |
| getCurrentTextAttributes(): TextAttributes { |
| return this.existingAnnotationAttributes_ ? |
| this.existingAnnotationAttributes_ : |
| this.attributes_; |
| } |
| |
| initializeBrush(): Promise<void> { |
| assert(this.brushResolver_ === null); |
| this.brushResolver_ = new PromiseResolver(); |
| this.pluginController_.getAnnotationBrush().then(defaultBrushMessage => { |
| this.setAnnotationBrush_(defaultBrushMessage.data); |
| assert(this.brushResolver_); |
| this.brushResolver_.resolve(); |
| }); |
| return this.brushResolver_.promise; |
| } |
| |
| setBrushColor(color: Color) { |
| assert(this.brush_.type !== AnnotationBrushType.ERASER); |
| if (this.brush_.color === color) { |
| return; |
| } |
| |
| this.brush_.color = color; |
| this.fireBrushChanged_(); |
| this.setAnnotationBrushInPlugin_(); |
| } |
| |
| setBrushSize(size: number) { |
| if (this.brush_.size === size) { |
| return; |
| } |
| |
| this.brush_.size = size; |
| this.fireBrushChanged_(); |
| this.setAnnotationBrushInPlugin_(); |
| } |
| |
| async setBrushType(type: AnnotationBrushType): Promise<void> { |
| if (this.brush_.type === type) { |
| return; |
| } |
| |
| const brushMessage = await this.pluginController_.getAnnotationBrush(type); |
| this.setAnnotationBrush_(brushMessage.data); |
| this.setAnnotationBrushInPlugin_(); |
| } |
| |
| setTextTypeface(typeface: TextTypeface) { |
| const current = this.getCurrentTextAttributes(); |
| if (current.typeface === typeface) { |
| return; |
| } |
| |
| current.typeface = typeface; |
| this.fireAttributesChanged_(); |
| } |
| |
| setTextSize(size: number) { |
| const current = this.getCurrentTextAttributes(); |
| if (current.size === size) { |
| return; |
| } |
| |
| current.size = size; |
| this.fireAttributesChanged_(); |
| } |
| |
| setTextColor(color: Color) { |
| const current = this.getCurrentTextAttributes(); |
| if (colorsEqual(current.color, color)) { |
| return; |
| } |
| |
| current.color = color; |
| this.fireAttributesChanged_(); |
| } |
| |
| setTextAlignment(alignment: TextAlignment) { |
| const current = this.getCurrentTextAttributes(); |
| if (current.alignment === alignment) { |
| return; |
| } |
| |
| current.alignment = alignment; |
| this.fireAttributesChanged_(); |
| } |
| |
| setTextStyles(styles: TextStyles) { |
| const current = this.getCurrentTextAttributes(); |
| if (stylesEqual(current.styles, styles)) { |
| return; |
| } |
| |
| current.styles = styles; |
| this.fireAttributesChanged_(); |
| } |
| |
| private pageToScreenCoordinates_(pageNumber: number, pageRect: TextBoxRect): |
| TextBoxRect { |
| assert(this.viewport_); |
| const pageDimensions = this.viewport_.getPageScreenRect(pageNumber); |
| const zoom = this.viewport_.getZoom(); |
| |
| // Apply zoom. |
| const zoomed = { |
| locationX: pageRect.locationX * zoom, |
| locationY: pageRect.locationY * zoom, |
| width: pageRect.width * zoom, |
| height: pageRect.height * zoom, |
| }; |
| |
| // Apply rotation |
| const rotated = convertRotatedCoordinates( |
| zoomed, 0, this.viewport_.getClockwiseRotations(), pageDimensions.width, |
| pageDimensions.height); |
| |
| // Apply offsets. |
| return { |
| locationX: rotated.locationX + pageDimensions.x, |
| locationY: rotated.locationY + pageDimensions.y, |
| height: rotated.height, |
| width: rotated.width, |
| }; |
| } |
| |
| private screenToPageCoordinates_(pageNumber: number, screenRect: TextBoxRect): |
| TextBoxRect { |
| assert(this.viewport_); |
| const zoom = this.viewport_.getZoom(); |
| const pageDimensions = this.viewport_.getPageScreenRect(pageNumber); |
| |
| // Undo offset |
| const noOffset = { |
| locationX: screenRect.locationX - pageDimensions.x, |
| locationY: screenRect.locationY - pageDimensions.y, |
| width: screenRect.width, |
| height: screenRect.height, |
| }; |
| |
| // Undo rotation |
| const rotations = this.viewport_.getClockwiseRotations(); |
| // Need to pass the width and height for the new number of desired rotations |
| // (0 in this case) to convertRotatedCoordinates(). |
| const pageWidth = |
| rotations % 2 === 0 ? pageDimensions.width : pageDimensions.height; |
| const pageHeight = |
| rotations % 2 === 0 ? pageDimensions.height : pageDimensions.width; |
| const noRotation = convertRotatedCoordinates( |
| noOffset, rotations, 0, pageWidth, pageHeight); |
| |
| // Undo zoom. |
| return { |
| height: noRotation.height / zoom, |
| locationX: noRotation.locationX / zoom, |
| locationY: noRotation.locationY / zoom, |
| width: noRotation.width / zoom, |
| }; |
| } |
| |
| /** |
| * Updates the stored annotation and notifies the plugin of the new or |
| * modified annotation. |
| */ |
| commitTextAnnotation(annotation: TextAnnotation, edited: boolean) { |
| annotation.textBoxRect = this.screenToPageCoordinates_( |
| annotation.pageNumber, annotation.textBoxRect); |
| |
| let pageAnnotations = this.annotations_.get(annotation.pageNumber); |
| if (!pageAnnotations) { |
| // Adding a new annotation, on a page that doesn't have any existing ones. |
| // Create and add the new map. |
| pageAnnotations = new Map(); |
| this.annotations_.set(annotation.pageNumber, pageAnnotations); |
| } |
| |
| if (pageAnnotations.has(annotation.id) && annotation.text === '') { |
| // Delete an existing annotation. |
| pageAnnotations.delete(annotation.id); |
| } else { |
| pageAnnotations.set(annotation.id, annotation); |
| } |
| this.pluginController_.finishTextAnnotation(annotation); |
| this.existingAnnotationAttributes_ = null; |
| |
| if (edited) { |
| // Using PluginController's event target to dispatch this event, even |
| // though it originates here, because PluginController dispatches this |
| // event for normal ink strokes and this way clients only need to listen |
| // on one instance. |
| this.pluginController_.getEventTarget().dispatchEvent( |
| new CustomEvent(PluginControllerEventType.FINISH_INK_STROKE)); |
| } |
| } |
| |
| /** |
| * Sets the current brush properties to the values in `brush`. |
| */ |
| private setAnnotationBrush_(brush: AnnotationBrush): void { |
| this.brush_ = brush; |
| this.fireBrushChanged_(); |
| } |
| |
| /** |
| * Sets the annotation brush in the plugin with the current brush parameters. |
| */ |
| private setAnnotationBrushInPlugin_(): void { |
| this.pluginController_.setAnnotationBrush(this.brush_); |
| } |
| |
| private fireBrushChanged_() { |
| this.dispatchEvent(new CustomEvent('brush-changed', {detail: this.brush_})); |
| } |
| |
| private fireAttributesChanged_() { |
| this.dispatchEvent(new CustomEvent( |
| 'attributes-changed', |
| {detail: structuredClone(this.getCurrentTextAttributes())})); |
| } |
| |
| static getInstance(): Ink2Manager { |
| return instance || (instance = new Ink2Manager()); |
| } |
| |
| static setInstance(obj: Ink2Manager) { |
| instance = obj; |
| } |
| } |
| |
| let instance: (Ink2Manager|null) = null; |