import { assertExists } from '@blockeditor/global/utils'; import { INLINE_ROOT_ATTR, type InlineRange, type InlineRootElement, } from '@blockeditor/inline'; import type { BlockElement, EditorHost } from '@blockeditor/lit'; import type { Flavour } from '../../../models.js'; import { BLOCK_ID_ATTR } from '../../consts.js'; import type { AffineInlineEditor, AffineTextAttributes, } from '../../inline/presets/affine-inline-specs.js'; import { FORMAT_BLOCK_SUPPORT_FLAVOURS, FORMAT_NATIVE_SUPPORT_FLAVOURS, FORMAT_TEXT_SUPPORT_FLAVOURS, } from './consts.js'; function getCombinedFormatFromInlineEditors( inlineEditors: [AffineInlineEditor, InlineRange | null][] ): AffineTextAttributes { const formatArr: AffineTextAttributes[] = []; inlineEditors.forEach(([inlineEditor, inlineRange]) => { if (!inlineRange) return; const format = inlineEditor.getFormat(inlineRange); formatArr.push(format); }); if (formatArr.length === 0) return {}; // format will be active only when all inline editors have the same format. return formatArr.reduce((acc, cur) => { const newFormat: AffineTextAttributes = {}; for (const key in acc) { const typedKey = key as keyof AffineTextAttributes; if (acc[typedKey] === cur[typedKey]) { // This cast is secure because we have checked that the value of the key is the same. // eslint-disable-next-line @typescript-eslint/no-explicit-any newFormat[typedKey] = acc[typedKey] as any; } } return newFormat; }); } function getCombinedFormat(host: EditorHost): AffineTextAttributes { let result: AffineTextAttributes = {}; host.std.command .pipe() .withHost() .try(chain => [ // text selection, corresponding to `formatText` command chain .getTextSelection() .getSelectedBlocks({ types: ['text'], filter: el => FORMAT_TEXT_SUPPORT_FLAVOURS.includes(el.model.flavour as Flavour), }) .inline((ctx, next) => { const { selectedBlocks } = ctx; assertExists(selectedBlocks); const selectedInlineEditors = selectedBlocks.flatMap(el => { const inlineRoot = el.querySelector< InlineRootElement >(`[${INLINE_ROOT_ATTR}]`); if (inlineRoot && inlineRoot.inlineEditor.getInlineRange()) { return inlineRoot.inlineEditor; } return []; }); result = getCombinedFormatFromInlineEditors( selectedInlineEditors.map(e => [e, e.getInlineRange()]) ); next(); }), // block selection, corresponding to `formatBlock` command chain .getBlockSelections() .getSelectedBlocks({ types: ['block'], filter: el => FORMAT_BLOCK_SUPPORT_FLAVOURS.includes(el.model.flavour as Flavour), }) .inline((ctx, next) => { const { selectedBlocks } = ctx; assertExists(selectedBlocks); const selectedInlineEditors = selectedBlocks.flatMap(el => { const inlineRoot = el.querySelector< InlineRootElement >(`[${INLINE_ROOT_ATTR}]`); if (inlineRoot) { return inlineRoot.inlineEditor; } return []; }); result = getCombinedFormatFromInlineEditors( selectedInlineEditors.map(e => [ e, { index: 0, length: e.yTextLength, }, ]) ); next(); }), // native selection, corresponding to `formatNative` command chain.inline(() => { const selectedInlineEditors = Array.from( host.querySelectorAll(`[${INLINE_ROOT_ATTR}]`) ) .filter(el => { const selection = document.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); return range.intersectsNode(el); }) .filter(el => { const blockElement = el.closest(`[${BLOCK_ID_ATTR}]`); if (blockElement) { return FORMAT_NATIVE_SUPPORT_FLAVOURS.includes( blockElement.model.flavour as Flavour ); } return false; }) .map(el => el.inlineEditor); result = getCombinedFormatFromInlineEditors( selectedInlineEditors.map(e => [e, e.getInlineRange()]) ); }), ]) .run(); return result; } export function handleCommonStyle( host: EditorHost, key: Extract< keyof AffineTextAttributes, 'bold' | 'italic' | 'underline' | 'strike' | 'code' > ) { const active = commonActiveWhen(host, key); const payload: { styles: AffineTextAttributes; mode?: 'replace' | 'merge'; } = { styles: { [key]: active ? null : true, }, }; host.std.command .pipe() .withHost() .try(chain => [ chain.getTextSelection().formatText(payload), chain.getBlockSelections().formatBlock(payload), chain.formatNative(payload), ]) .run(); } export function commonActiveWhen(host: EditorHost, key: string) { const format = getCombinedFormat(host); return key in format; } export function isFormatSupported(host: EditorHost) { let result = false; host.std.command .pipe() .withHost() .try(chain => [ // text selection, corresponding to `formatText` command chain .getTextSelection() .getSelectedBlocks({ types: ['text'], filter: el => FORMAT_TEXT_SUPPORT_FLAVOURS.includes(el.model.flavour as Flavour), }) .inline((ctx, next) => { const { currentTextSelection, selectedBlocks } = ctx; assertExists(currentTextSelection); assertExists(selectedBlocks); if (currentTextSelection.isCollapsed()) return; const selectedInlineEditors = selectedBlocks.flatMap(el => { const inlineRoot = el.querySelector< InlineRootElement >(`[${INLINE_ROOT_ATTR}]`); if (inlineRoot && inlineRoot.inlineEditor.getInlineRange()) { return inlineRoot.inlineEditor; } return []; }); result = selectedInlineEditors.length > 0; next(); }), // block selection, corresponding to `formatBlock` command chain .getBlockSelections() .getSelectedBlocks({ types: ['block'], filter: el => FORMAT_BLOCK_SUPPORT_FLAVOURS.includes(el.model.flavour as Flavour), }) .inline((ctx, next) => { const { selectedBlocks } = ctx; assertExists(selectedBlocks); const selectedInlineEditors = selectedBlocks.flatMap(el => { const inlineRoot = el.querySelector< InlineRootElement >(`[${INLINE_ROOT_ATTR}]`); if (inlineRoot) { return inlineRoot.inlineEditor; } return []; }); result = selectedInlineEditors.length > 0; next(); }), // native selection, corresponding to `formatNative` command chain.inline(() => { const selectedInlineEditors = Array.from( host.querySelectorAll(`[${INLINE_ROOT_ATTR}]`) ) .filter(el => { const selection = document.getSelection(); if (!selection || selection.rangeCount === 0) return false; const range = selection.getRangeAt(0); return range.intersectsNode(el); }) .filter(el => { const blockElement = el.closest(`[${BLOCK_ID_ATTR}]`); if (blockElement) { return FORMAT_NATIVE_SUPPORT_FLAVOURS.includes( blockElement.model.flavour as Flavour ); } return false; }) .map(el => el.inlineEditor); result = selectedInlineEditors.length > 0; }), ]) .run(); return result; }