chromium/ash/webui/recorder_app_ui/resources/components/recording-title.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 'chrome://resources/cros_components/textfield/textfield.js';
import './cra/cra-icon.js';
import './cra/cra-icon-button.js';
import './cra/cra-tooltip.js';
import './recording-title-suggestion.js';

import {
  Textfield,
} from 'chrome://resources/cros_components/textfield/textfield.js';
import {
  createRef,
  css,
  html,
  nothing,
  PropertyDeclarations,
  ref,
} from 'chrome://resources/mwc/lit/index.js';

import {i18n} from '../core/i18n.js';
import {
  usePlatformHandler,
  useRecordingDataManager,
} from '../core/lit/context.js';
import {
  ReactiveLitElement,
  ScopedAsyncComputed,
} from '../core/reactive/lit.js';
import {computed, signal} from '../core/reactive/signal.js';
import {RecordingMetadata} from '../core/recording_data_manager.js';
import {settings, SummaryEnableState} from '../core/state/settings.js';
import {assertExists, assertInstanceof} from '../core/utils/assert.js';

import {RecordingTitleSuggestion} from './recording-title-suggestion.js';

/**
 * The title of the recording in playback page of Recorder App.
 */
export class RecordingTitle extends ReactiveLitElement {
  static override styles = css`
    :host {
      display: block;
      min-width: 0;
    }

    cros-textfield {
      anchor-name: --title-textfield;
      width: 283px;
    }

    recording-title-suggestion {
      /*
       * TODO: b/361221415 - Remove the old properties when stable Chrome
       * supports new one.
       */
      inset-area: bottom span-right;
      position: absolute;
      position-anchor: --title-textfield;
      position-area: bottom span-right;
      margin-top: 4.5px;
      max-width: 402px;
      min-width: 360px;
    }

    #title {
      anchor-name: --title;
      border-radius: 12px;
      box-sizing: border-box;
      font: var(--cros-headline-1-font);
      overflow: hidden;
      padding: 8px 16px;
      text-overflow: ellipsis;
      white-space: nowrap;

      & > cra-tooltip {
        position-anchor: --title;
        display: none;
      }

      &:hover {
        background-color: var(--cros-sys-hover_on_subtle);

        & > cra-tooltip {
          display: block;
        }
      }
    }
  `;

  static override properties: PropertyDeclarations = {
    recordingMetadata: {attribute: false},
  };

  recordingMetadata: RecordingMetadata|null = null;

  private readonly recordingMetadataSignal =
    this.propSignal('recordingMetadata');

  private readonly editing = signal(false);

  private readonly suggestionShown = signal(false);

  private readonly recordingDataManager = useRecordingDataManager();

  private readonly platformHandler = usePlatformHandler();

  private readonly renameContainer = createRef<HTMLDivElement>();

  private readonly suggestTitleButton = createRef<HTMLButtonElement>();

  private readonly recordingTitleSuggestion =
    createRef<RecordingTitleSuggestion>();

  get renameContainerForTest(): HTMLDivElement {
    return assertExists(this.renameContainer.value);
  }

  get suggestTitleButtonForTest(): HTMLButtonElement {
    return assertExists(this.suggestTitleButton.value);
  }

  get titleSuggestionForTest(): RecordingTitleSuggestion {
    return assertExists(this.recordingTitleSuggestion.value);
  }

  private readonly transcription = new ScopedAsyncComputed(this, async () => {
    if (this.recordingMetadataSignal.value === null) {
      return null;
    }
    return this.recordingDataManager.getTranscription(
      this.recordingMetadataSignal.value.id,
    );
  });

  private readonly shouldShowTitleSuggestion = computed(() => {
    const modelState = this.platformHandler.titleSuggestionModelLoader.state;
    return (
      modelState.value.kind === 'installed' &&
      settings.value.summaryEnabled === SummaryEnableState.ENABLED &&
      this.transcription.value !== null && !this.transcription.value.isEmpty()
    );
  });

  private readonly suggestedTitles = new ScopedAsyncComputed(this, async () => {
    // TODO(pihsun): Cache title suggestion between hide/show the suggestion
    // dialog?
    if (!this.suggestionShown.value || this.recordingMetadata === null ||
        !this.shouldShowTitleSuggestion.value) {
      return null;
    }
    if (this.transcription.value === null) {
      return null;
    }
    // TODO(pihsun): Have a specific format for transcription to be used as
    // model input.
    const text = this.transcription.value.toPlainText();
    const model = await this.platformHandler.titleSuggestionModelLoader.load();
    try {
      return await model.execute(text);
      // TODO(pihsun): Handle error.
    } finally {
      model.close();
    }
  });

  private get editTextfield(): Textfield|null {
    return this.shadowRoot?.querySelector('cros-textfield') ?? null;
  }

  private get titleSuggestionDialog(): RecordingTitleSuggestion|null {
    return this.shadowRoot?.querySelector('recording-title-suggestion') ?? null;
  }

  private async startEditTitle() {
    this.editing.value = true;
    // TODO(pihsun): This somehow requires three updates before the
    // .focusTextfield() work, investigate why. Might be related to the
    // additional update pass caused by signal integration.
    await this.updateComplete;
    await this.updateComplete;
    await this.updateComplete;

    this.editTextfield?.focusTextfield();
  }

  private onFocusout(ev: FocusEvent) {
    const newTarget = ev.relatedTarget;
    if (newTarget !== null && newTarget instanceof Node &&
        (this.editTextfield?.contains(newTarget) ||
         this.titleSuggestionDialog?.contains(newTarget))) {
      // New target is a child of the textfield or title suggestion, don't stop
      // editing.
      return;
    }
    this.editing.value = false;
    this.suggestionShown.value = false;
    // TODO(pihsun): The focusout/blur event got triggered synchronously on
    // render when an element is removed, which breaks the assumption in the
    // reactive/lit.ts implementation of ReactiveLitElement, so setting values
    // above won't trigger rerender. Call requestUpdate() by ourselves to
    // workaround this.
    this.requestUpdate();
  }

  private setTitle(title: string) {
    const meta = this.recordingMetadata;
    if (meta === null) {
      return;
    }
    this.recordingDataManager.setMetadata(meta.id, {
      ...meta,
      title,
    });
  }

  private onChangeTitle(ev: Event) {
    const target = assertInstanceof(ev.target, Textfield);
    this.setTitle(target.value);
  }

  private onSuggestTitle(ev: CustomEvent<string>) {
    this.setTitle(ev.detail);
  }

  private openSuggestionDialog() {
    // We focus on the text field before showing the suggestion, so the
    // focusout event from the icon button won't cause edit to be exited.
    // TODO(pihsun): Check a11y on where we should focus in this case.
    this.editTextfield?.focusTextfield();
    this.suggestionShown.value = true;
  }

  private closeSuggestionDialog() {
    // We focus on the text field before closing the suggestion, so the
    // focusout event from the suggestion dialog won't cause edit to be exited.
    this.editTextfield?.focusTextfield();
    this.suggestionShown.value = false;
  }

  private renderSuggestionDialog() {
    if (!this.suggestionShown.value) {
      return nothing;
    }

    return html`<recording-title-suggestion
      @focusout=${this.onFocusout}
      @close=${this.closeSuggestionDialog}
      @change=${this.onSuggestTitle}
      .suggestedTitles=${this.suggestedTitles}
      ${ref(this.recordingTitleSuggestion)}
    ></recording-title-suggestion>`;
  }

  override render(): RenderResult {
    if (this.editing.value) {
      const suggestionIconButton =
        this.suggestionShown.value || !this.shouldShowTitleSuggestion.value ?
        nothing :
        html`<cra-icon-button
              buttonstyle="floating"
              size="small"
              slot="trailing"
              shape="circle"
              @click=${this.openSuggestionDialog}
              ${ref(this.suggestTitleButton)}
            >
              <cra-icon slot="icon" name="pen_spark"></cra-icon>
            </cra-icon-button>`;
      // TODO(pihsun): Handle keyboard event like "enter".
      return html`<cros-textfield
          type="text"
          .value=${this.recordingMetadata?.title ?? ''}
          @change=${this.onChangeTitle}
          @focusout=${this.onFocusout}
        >
          ${suggestionIconButton}
        </cros-textfield>
        ${this.renderSuggestionDialog()}`;
    }
    // TODO(pihsun): Have a directive for tooltip instead of having user to
    // manually add <cra-tooltip> and CSS styles.
    return html`
      <div
        id="title"
        tabindex="0"
        @focus=${this.startEditTitle}
        @click=${this.startEditTitle}
        ${ref(this.renameContainer)}
      >
        ${this.recordingMetadata?.title ?? ''}
        <cra-tooltip>${i18n.titleRenameTooltip}</cra-tooltip>
      </div>
    `;
  }
}

window.customElements.define('recording-title', RecordingTitle);

declare global {
  interface HTMLElementTagNameMap {
    'recording-title': RecordingTitle;
  }
}