chromium/chrome/browser/resources/pdf/elements/viewer_side_panel.ts

// Copyright 2024 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} from 'chrome://resources/js/assert.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from 'chrome://resources/lit/v3_0/lit.rollup.js';

import {AnnotationBrushType} from '../constants.js';
import type {AnnotationBrush, Color} from '../constants.js';
import {PluginController} from '../controller.js';

import {getCss} from './viewer_side_panel.css.js';
import {getHtml} from './viewer_side_panel.html.js';

interface ColorOption {
  name: string;
  color: string;
}

export const HIGHLIGHTER_COLORS: ColorOption[] = [
  // Row 1:
  {name: 'highlighterColorRed300', color: '#f28b82'},
  {name: 'highlighterColorYellow300', color: '#fdd663'},
  {name: 'highlighterColorGreen300', color: '#34a853'},
  {name: 'highlighterColorBlue', color: '#4285f4'},
  {name: 'highlighterColorOrange', color: '#ffae80'},
  // Row 2:
  {name: 'highlighterColorRed600', color: '#d93025'},
  {name: 'highlighterColorLemon400', color: '#ddf300'},
  {name: 'highlighterColorAloe400', color: '#25e387'},
  {name: 'highlighterColorIndigo', color: '#5379ff'},
  {name: 'highlighterColorOrange', color: '#ff630c'},
];

export const PEN_COLORS: ColorOption[] = [
  // Row 1:
  {name: 'penColorBlack', color: '#000000'},
  {name: 'penColorGrey700', color: '#5f6368'},
  {name: 'penColorGrey500', color: '#9aa0a6'},
  {name: 'penColorGrey300', color: '#dadce0'},
  {name: 'penColorWhite', color: '#ffffff'},
  // Row 2:
  {name: 'penColorRed300', color: '#f28b82'},
  {name: 'penColorYellow300', color: '#fdd663'},
  {name: 'penColorGreen300', color: '#81c995'},
  {name: 'penColorBlue300', color: '#8ab4f8'},
  {name: 'penColorBrown1', color: '#eec9ae'},
  // Row 3:
  {name: 'penColorRed500', color: '#ea4335'},
  {name: 'penColorYellow500', color: '#fbbc04'},
  {name: 'penColorGreen500', color: '#34a853'},
  {name: 'penColorBlue500', color: '#4285f4'},
  {name: 'penColorBrown2', color: '#e2a185'},
  // Row 4:
  {name: 'penColorRed700', color: '#c5221f'},
  {name: 'penColorYellow700', color: '#f29900'},
  {name: 'penColorGreen700', color: '#188038'},
  {name: 'penColorBlue700', color: '#1967d2'},
  {name: 'penColorBrown3', color: '#885945'},
];

interface SizeOption {
  icon: string;
  name: string;
  size: number;
}

// TODO(crbug.com/341282609): Choose production size values. Add labels.
const ERASER_SIZES: SizeOption[] = [
  {icon: 'eraser-size-1', name: 'sizeExtraThin', size: 1},
  {icon: 'eraser-size-2', name: 'sizeThin', size: 2},
  {icon: 'eraser-size-3', name: 'sizeExtraMedium', size: 3},
  {icon: 'eraser-size-4', name: 'sizeThick', size: 6},
  {icon: 'eraser-size-5', name: 'sizeExtraThick', size: 8},
];

const HIGHLIGHTER_SIZES: SizeOption[] = [
  {icon: 'highlighter-size-1', name: 'sizeExtraThin', size: 4},
  {icon: 'highlighter-size-2', name: 'sizeThin', size: 6},
  {icon: 'highlighter-size-3', name: 'sizeExtraMedium', size: 8},
  {icon: 'highlighter-size-4', name: 'sizeThick', size: 12},
  {icon: 'highlighter-size-5', name: 'sizeExtraThick', size: 16},
];

const PEN_SIZES: SizeOption[] = [
  {icon: 'pen-size-1', name: 'sizeExtraThin', size: 1},
  {icon: 'pen-size-2', name: 'sizeThin', size: 2},
  {icon: 'pen-size-3', name: 'sizeExtraMedium', size: 3},
  {icon: 'pen-size-4', name: 'sizeThick', size: 6},
  {icon: 'pen-size-5', name: 'sizeExtraThick', size: 8},
];

/**
 * @param hex A hex-coded color string, formatted as '#ffffff'.
 * @returns The `Color` in RGB values.
 */
function hexToColor(hex: string): Color {
  assert(/^#[0-9a-f]{6}$/.test(hex));

  return {
    r: Number.parseInt(hex.substring(1, 3), 16),
    g: Number.parseInt(hex.substring(3, 5), 16),
    b: Number.parseInt(hex.substring(5, 7), 16),
  };
}

/**
 * @returns Whether `lhs` and `rhs` have the same RGB values or not.
 */
function areColorsEqual(lhs: Color, rhs: Color): boolean {
  return lhs.r === rhs.r && lhs.g === rhs.g && lhs.b === rhs.b;
}

export interface ViewerSidePanelElement {
  $: {
    eraser: HTMLElement,
    highlighter: HTMLElement,
    pen: HTMLElement,
  };
}

export class ViewerSidePanelElement extends CrLitElement {
  static get is() {
    return 'viewer-side-panel';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      brushDirty_: {type: Boolean},
      currentType_: {type: String},
    };
  }

  // Indicates the brush has changes and should be updated in
  // `this.pluginController_`.
  private brushDirty_: boolean = false;
  private currentType_: AnnotationBrushType = AnnotationBrushType.PEN;

  private brushes_: Map<AnnotationBrushType, AnnotationBrush>;
  private pluginController_: PluginController = PluginController.getInstance();

  constructor() {
    super();

    // Default brushes.
    this.brushes_ = new Map([
      [
        AnnotationBrushType.ERASER,
        {
          type: AnnotationBrushType.ERASER,
          size: ERASER_SIZES[2]!.size,
        },
      ],
      [
        AnnotationBrushType.HIGHLIGHTER,
        {
          type: AnnotationBrushType.HIGHLIGHTER,
          color: hexToColor(HIGHLIGHTER_COLORS[0]!.color),
          size: HIGHLIGHTER_SIZES[2]!.size,
        },
      ],
      [
        AnnotationBrushType.PEN,
        {
          type: AnnotationBrushType.PEN,
          color: hexToColor(PEN_COLORS[0]!.color),
          size: PEN_SIZES[2]!.size,
        },
      ],
    ]);
  }

  override updated(changedProperties: PropertyValues<this>) {
    super.updated(changedProperties);

    const changedPrivateProperties =
        changedProperties as Map<PropertyKey, unknown>;

    if (changedPrivateProperties.has('brushDirty_') &&
        (this.brushDirty_ ||
         changedPrivateProperties.get('brushDirty_') === undefined)) {
      this.onBrushChanged_();
    }
  }

  protected onBrushClick_(e: Event) {
    const targetElement = e.currentTarget as HTMLElement;
    const newType = targetElement.dataset['brush'] as AnnotationBrushType;
    if (this.currentType_ === newType) {
      return;
    }

    this.currentType_ = newType;
    this.brushDirty_ = true;
  }

  protected onSizeClick_(e: Event) {
    const targetElement = e.currentTarget as HTMLElement;
    const size = Number(targetElement.dataset['size']);

    const currentBrush = this.getCurrentBrush_();
    if (currentBrush.size === size) {
      return;
    }

    currentBrush.size = size;
    this.brushDirty_ = true;
  }

  protected onColorClick_(e: Event) {
    assert(this.shouldShowColorOptions_());

    const currentBrush = this.getCurrentBrush_();
    const currentColor = currentBrush.color;
    assert(currentColor);

    const targetElement = e.currentTarget as HTMLInputElement;
    const hex = targetElement.value;
    assert(hex);

    const newColor: Color = hexToColor(hex);
    if (areColorsEqual(currentColor, newColor)) {
      return;
    }

    currentBrush.color = newColor;
    this.brushDirty_ = true;
  }

  protected isCurrentType_(type: AnnotationBrushType): boolean {
    return this.currentType_ === type;
  }

  protected isCurrentColor_(hex: string): boolean {
    assert(this.shouldShowColorOptions_());

    const currentColor = this.getCurrentBrush_().color;
    assert(currentColor);

    return areColorsEqual(currentColor, hexToColor(hex));
  }

  protected shouldShowColorOptions_(): boolean {
    return this.currentType_ !== AnnotationBrushType.ERASER;
  }

  protected getColorName_(): string {
    assert(this.currentType_ !== AnnotationBrushType.ERASER);
    return this.currentType_ === AnnotationBrushType.HIGHLIGHTER ?
        'highlighterColors' :
        'penColors';
  }

  protected getCurrentBrushSizes_(): SizeOption[] {
    switch (this.currentType_) {
      case AnnotationBrushType.ERASER:
        return ERASER_SIZES;
      case AnnotationBrushType.HIGHLIGHTER:
        return HIGHLIGHTER_SIZES;
      case AnnotationBrushType.PEN:
        return PEN_SIZES;
    }
  }

  protected getCurrentBrushColors_(): ColorOption[] {
    assert(this.currentType_ !== AnnotationBrushType.ERASER);
    return this.currentType_ === AnnotationBrushType.HIGHLIGHTER ?
        HIGHLIGHTER_COLORS :
        PEN_COLORS;
  }

  /**
   * When the brush changes, the new brush should be sent to
   * `this.pluginController_`.
   */
  private onBrushChanged_(): void {
    this.pluginController_.setAnnotationBrush(this.getCurrentBrush_());
    this.brushDirty_ = false;
  }

  private getCurrentBrush_(): AnnotationBrush {
    const brush = this.brushes_.get(this.currentType_);
    assert(brush);
    return brush;
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'viewer-side-panel': ViewerSidePanelElement;
  }
}

customElements.define(ViewerSidePanelElement.is, ViewerSidePanelElement);