Repository: toeverything/AFFiNE
File: blocksuite/framework/std/src/clipboard/clipboard.ts (TypeScript)

Code before:
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type {
  BlockSnapshot,
  Slice,
  Store,
  TransformerMiddleware,
} from '@blocksuite/store';
import DOMPurify from 'dompurify';
import * as lz from 'lz-string';
import rehypeParse from 'rehype-parse';
import { unified } from 'unified';

import { LifeCycleWatcher } from '../extension/index.js';
import { ClipboardAdapterConfigIdentifier } from './clipboard-adapter.js';
import { onlyContainImgElement } from './utils.js';

export class Clipboard extends LifeCycleWatcher {
  static override key = 'clipboard';

  protected get _adapters() {
    const adapterConfigs = this.std.provider.getAll(
      ClipboardAdapterConfigIdentifier
    );
    return Array.from(adapterConfigs.values());
  }

  // Need to be cloned to a map for later use
  private readonly _getDataByType = (clipboardData: DataTransfer) => {
    const data = new Map<string, string | File[]>();
    for (const type of clipboardData.types) {
      if (type === 'Files') {
        data.set(type, Array.from(clipboardData.files));
      } else {
        data.set(type, clipboardData.getData(type));
      }
    }
    if (data.get('Files') && data.get('text/html')) {
      const htmlAst = unified()
        .use(rehypeParse)
        .parse(data.get('text/html') as string);

      const isImgOnly =
        htmlAst.children.map(onlyContainImgElement).reduce((a, b) => {
          if (a === 'no' || b === 'no') {
            return 'no';
          }
          if (a === 'maybe' && b === 'maybe') {
            return 'maybe';
          }
          return 'yes';
        }, 'maybe') === 'yes';

      if (isImgOnly) {
        data.delete('text/html');
      }
    }
    return (type: string) => {
      const item = data.get(type);
      if (item) {
        return item;
      }
      const files = (data.get('Files') ?? []) as File[];
      if (files.length > 0) {
        return files;
      }
      return '';
    };
  };

  private readonly _getSnapshotByPriority = async (
    getItem: (type: string) => string | File[],
    doc: Store,
    parent?: string,
    index?: number
  ) => {
    const byPriority = Array.from(this._adapters).sort(
      (a, b) => b.priority - a.priority
    );
    for (const { adapter, mimeType } of byPriority) {
      const item = getItem(mimeType);
      if (Array.isArray(item)) {
        if (item.length === 0) {
          continue;
        }
        if (
          // if all files are not the same target type, fallback to */*
          !item
            .map(f => f.type === mimeType || mimeType === '*/*')
            .reduce((a, b) => a && b, true)
        ) {
          continue;
        }
      }
      if (item) {
        const job = this._getJob();
        const adapterInstance = new adapter(job, this.std.store.provider);
        const payload = {
          file: item,
          assets: job.assetsManager,
          workspaceId: doc.workspace.id,
          pageId: doc.id,
        };
        const result = await adapterInstance.toSlice(
          payload,
          doc,
          parent,
          index
        );
        if (result) {
          return result;
        }
      }
    }
    return null;
  };

  private _jobMiddlewares: TransformerMiddleware[] = [];

  copy = async (slice: Slice) => {
    return this.copySlice(slice);
  };

  // Gated by https://developer.mozilla.org/en-US/docs/Glossary/Transient_activation
  copySlice = async (slice: Slice) => {
    const adapterKeys = this._adapters.map(adapter => adapter.mimeType);

    await this.writeToClipboard(async _items => {
      const items = { ..._items };

      await Promise.all(
        adapterKeys.map(async type => {
          const item = await this._getClipboardItem(slice, type);
          if (typeof item === 'string') {
            items[type] = item;
          }
        })
      );
      return items;
    });
  };

  duplicateSlice = async (
    slice: Slice,
    doc: Store,
    parent?: string,
    index?: number,
    type = 'BLOCKSUITE/SNAPSHOT'
  ) => {
    const items = {
      [type]: await this._getClipboardItem(slice, type),
    };

    await this._getSnapshotByPriority(
      type => (items[type] as string | File[]) ?? '',
      doc,
      parent,
      index
    );
  };

  paste = async (
    event: ClipboardEvent,
    doc: Store,
    parent?: string,
    index?: number
  ) => {
    const data = event.clipboardData;
    if (!data) return;

    try {
      const json = this.readFromClipboard(data);
      const slice = await this._getSnapshotByPriority(
        type => json[type],
        doc,
        parent,
        index
      );
      if (!slice) {
        throw new BlockSuiteError(
          ErrorCode.TransformerError,
          'No snapshot found'
        );
      }
      return slice;
    } catch {
      const getDataByType = this._getDataByType(data);
      const slice = await this._getSnapshotByPriority(
        type => getDataByType(type),
        doc,
        parent,
        index
      );

      return slice;
    }
  };

  pasteBlockSnapshot = async (
    snapshot: BlockSnapshot,
    doc: Store,
    parent?: string,
    index?: number
  ) => {
    return this._getJob().snapshotToBlock(snapshot, doc, parent, index);
  };

  unuse = (middleware: TransformerMiddleware) => {
    this._jobMiddlewares = this._jobMiddlewares.filter(m => m !== middleware);
  };

  use = (middleware: TransformerMiddleware) => {
    this._jobMiddlewares.push(middleware);
  };

  get configs() {
    return this._getJob().adapterConfigs;
  }

  private async _getClipboardItem(slice: Slice, type: string) {
    const job = this._getJob();
    const adapterItem = this.std.getOptional(
      ClipboardAdapterConfigIdentifier(type)
    );
    if (!adapterItem) {
      return;
    }
    const { adapter } = adapterItem;
    const adapterInstance = new adapter(job, this.std.store.provider);
    const result = await adapterInstance.fromSlice(slice);
    if (!result) {
      return;
    }
    return result.file;
  }

  private _getJob() {
    return this.std.store.getTransformer(this._jobMiddlewares);
  }

  readFromClipboard(clipboardData: DataTransfer) {
    const items = clipboardData.getData('text/html');
    const sanitizedItems = DOMPurify.sanitize(items);
    const domParser = new DOMParser();
    const doc = domParser.parseFromString(sanitizedItems, 'text/html');
    const dom = doc.querySelector<HTMLDivElement>('[data-blocksuite-snapshot]');
    if (!dom) {
      throw new BlockSuiteError(
        ErrorCode.TransformerError,
        'No snapshot found'
      );
    }
    const json = JSON.parse(
      lz.decompressFromEncodedURIComponent(
        dom.dataset.blocksuiteSnapshot as string
      )
    );
    return json;
  }

  sliceToSnapshot(slice: Slice) {
    const job = this._getJob();
    return job.sliceToSnapshot(slice);
  }

  async writeToClipboard(
    updateItems: (
      items: Record<string, unknown>
    ) => Promise<Record<string, unknown>> | Record<string, unknown>
  ) {
    const clipboardItems: Record<string, Blob> = {};

    const items = await updateItems({});

    const text = items['text/plain'] as string;
    const innerHTML = items['text/html'] as string;
    const image = Object.entries(items).find(([key]) =>
      key.startsWith('image/')
    );

    delete items['text/plain'];
    delete items['text/html'];

    if (image) {
      const [type, blob] = image;

      delete items[type];

      if (typeof blob === 'string') {
        clipboardItems[type] = new Blob([blob], { type });
      } else if (blob instanceof Blob) {
        clipboardItems[type] = blob;
      }
    }

    if (text?.length > 0) {
      const type = 'text/plain';
      clipboardItems[type] = new Blob([text], { type });
    }

    // If there are no items, fall back to snapshot.
    if (innerHTML?.length > 0 || Object.keys(clipboardItems).length === 0) {
      const type = 'text/html';
      const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
      const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
      clipboardItems[type] = new Blob([html], { type });
    }

    await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
  }
}


Review comment (suggestion):
When `innerHTML === undefined` and `clipboardItems` is empty, you will get a string including a substring "undefeined", which may be rendered to users.

Previous version provided a empty string value to `text/html`, so it is ok.

Code after:

    if (image) {
      const type = 'image/png';

      delete items[type];

      if (typeof image === 'string') {
        clipboardItems[type] = new Blob([image], { type });
      } else if (image instanceof Blob) {
        clipboardItems[type] = image;
      }
    }

    if (text.length > 0) {
      const type = 'text/plain';
      clipboardItems[type] = new Blob([text], { type });
    }

    const hasInnerHTML = Boolean(innerHTML.length);
    const isEmpty = Object.keys(clipboardItems).length === 0;

    // If there are no items, fall back to snapshot.
    if (hasInnerHTML || isEmpty) {
      const type = 'text/html';
      const snapshot = lz.compressToEncodedURIComponent(JSON.stringify(items));
      const html = `<div data-blocksuite-snapshot='${snapshot}'>${innerHTML}</div>`;
      clipboardItems[type] = new Blob([html], { type });
    }

    await navigator.clipboard.write([new ClipboardItem(clipboardItems)]);
  }
}