mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-08 04:36:13 +08:00
feat(editor): rich text package (#10689)
This PR performs a significant architectural refactoring by extracting rich text functionality into a dedicated package. Here are the key changes: 1. **New Package Creation** - Created a new package `@blocksuite/affine-rich-text` to house rich text related functionality - Moved rich text components, utilities, and types from `@blocksuite/affine-components` to this new package 2. **Dependency Updates** - Updated multiple block packages to include the new `@blocksuite/affine-rich-text` as a direct dependency: - block-callout - block-code - block-database - block-edgeless-text - block-embed - block-list - block-note - block-paragraph 3. **Import Path Updates** - Refactored all imports that previously referenced rich text functionality from `@blocksuite/affine-components/rich-text` to now use `@blocksuite/affine-rich-text` - Updated imports for components like: - DefaultInlineManagerExtension - RichText types and interfaces - Text manipulation utilities (focusTextModel, textKeymap, etc.) - Reference node components and providers 4. **Build Configuration Updates** - Added references to the new rich text package in the `tsconfig.json` files of all affected packages - Maintained workspace dependencies using the `workspace:*` version specifier The primary motivation appears to be: 1. Better separation of concerns by isolating rich text functionality 2. Improved maintainability through more modular package structure 3. Clearer dependencies between packages 4. Potential for better tree-shaking and bundle optimization This is primarily an architectural improvement that should make the codebase more maintainable and better organized.
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import {
|
||||
BoldIcon,
|
||||
CodeIcon,
|
||||
ItalicIcon,
|
||||
LinkIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
} from '@blocksuite/affine-components/icons';
|
||||
import { type EditorHost, TextSelection } from '@blocksuite/block-std';
|
||||
import type { TemplateResult } from 'lit';
|
||||
|
||||
import {
|
||||
isTextStyleActive,
|
||||
toggleBold,
|
||||
toggleCode,
|
||||
toggleItalic,
|
||||
toggleLink,
|
||||
toggleStrike,
|
||||
toggleUnderline,
|
||||
} from './text-style.js';
|
||||
|
||||
export interface TextFormatConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: TemplateResult<1>;
|
||||
hotkey?: string;
|
||||
activeWhen: (host: EditorHost) => boolean;
|
||||
action: (host: EditorHost) => void;
|
||||
textChecker?: (host: EditorHost) => boolean;
|
||||
}
|
||||
|
||||
export const textFormatConfigs: TextFormatConfig[] = [
|
||||
{
|
||||
id: 'bold',
|
||||
name: 'Bold',
|
||||
icon: BoldIcon,
|
||||
hotkey: 'Mod-b',
|
||||
activeWhen: host => {
|
||||
const [result] = host.std.command
|
||||
.chain()
|
||||
.pipe(isTextStyleActive, { key: 'bold' })
|
||||
.run();
|
||||
return result;
|
||||
},
|
||||
action: host => {
|
||||
host.std.command.chain().pipe(toggleBold).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'italic',
|
||||
name: 'Italic',
|
||||
icon: ItalicIcon,
|
||||
hotkey: 'Mod-i',
|
||||
activeWhen: host => {
|
||||
const [result] = host.std.command
|
||||
.chain()
|
||||
.pipe(isTextStyleActive, { key: 'italic' })
|
||||
.run();
|
||||
return result;
|
||||
},
|
||||
action: host => {
|
||||
host.std.command.chain().pipe(toggleItalic).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'underline',
|
||||
name: 'Underline',
|
||||
icon: UnderlineIcon,
|
||||
hotkey: 'Mod-u',
|
||||
activeWhen: host => {
|
||||
const [result] = host.std.command
|
||||
.chain()
|
||||
.pipe(isTextStyleActive, { key: 'underline' })
|
||||
.run();
|
||||
return result;
|
||||
},
|
||||
action: host => {
|
||||
host.std.command.chain().pipe(toggleUnderline).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'strike',
|
||||
name: 'Strikethrough',
|
||||
icon: StrikethroughIcon,
|
||||
hotkey: 'Mod-shift-s',
|
||||
activeWhen: host => {
|
||||
const [result] = host.std.command
|
||||
.chain()
|
||||
.pipe(isTextStyleActive, { key: 'strike' })
|
||||
.run();
|
||||
return result;
|
||||
},
|
||||
action: host => {
|
||||
host.std.command.chain().pipe(toggleStrike).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
name: 'Code',
|
||||
icon: CodeIcon,
|
||||
hotkey: 'Mod-e',
|
||||
activeWhen: host => {
|
||||
const [result] = host.std.command
|
||||
.chain()
|
||||
.pipe(isTextStyleActive, { key: 'code' })
|
||||
.run();
|
||||
return result;
|
||||
},
|
||||
action: host => {
|
||||
host.std.command.chain().pipe(toggleCode).run();
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'link',
|
||||
name: 'Link',
|
||||
icon: LinkIcon,
|
||||
hotkey: 'Mod-k',
|
||||
activeWhen: host => {
|
||||
const [result] = host.std.command
|
||||
.chain()
|
||||
.pipe(isTextStyleActive, { key: 'link' })
|
||||
.run();
|
||||
return result;
|
||||
},
|
||||
action: host => {
|
||||
host.std.command.chain().pipe(toggleLink).run();
|
||||
},
|
||||
// should check text length
|
||||
textChecker: host => {
|
||||
const textSelection = host.std.selection.find(TextSelection);
|
||||
if (!textSelection || textSelection.isCollapsed()) return false;
|
||||
|
||||
return Boolean(
|
||||
textSelection.from.length + (textSelection.to?.length ?? 0)
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,19 @@
|
||||
// corresponding to `formatText` command
|
||||
import { TableModelFlavour } from '@blocksuite/affine-model';
|
||||
|
||||
export const FORMAT_TEXT_SUPPORT_FLAVOURS = [
|
||||
'affine:paragraph',
|
||||
'affine:list',
|
||||
'affine:code',
|
||||
];
|
||||
// corresponding to `formatBlock` command
|
||||
export const FORMAT_BLOCK_SUPPORT_FLAVOURS = [
|
||||
'affine:paragraph',
|
||||
'affine:list',
|
||||
'affine:code',
|
||||
];
|
||||
// corresponding to `formatNative` command
|
||||
export const FORMAT_NATIVE_SUPPORT_FLAVOURS = [
|
||||
'affine:database',
|
||||
TableModelFlavour,
|
||||
];
|
||||
@@ -0,0 +1,81 @@
|
||||
import { RootBlockModel } from '@blocksuite/affine-model';
|
||||
import { matchModels } from '@blocksuite/affine-shared/utils';
|
||||
import { type Command, TextSelection } from '@blocksuite/block-std';
|
||||
import type { Text } from '@blocksuite/store';
|
||||
|
||||
export const deleteTextCommand: Command<{
|
||||
currentTextSelection?: TextSelection;
|
||||
textSelection?: TextSelection;
|
||||
}> = (ctx, next) => {
|
||||
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
|
||||
if (!textSelection) return;
|
||||
|
||||
const range = ctx.std.range.textSelectionToRange(textSelection);
|
||||
if (!range) return;
|
||||
const selectedElements = ctx.std.range.getSelectedBlockComponentsByRange(
|
||||
range,
|
||||
{
|
||||
mode: 'flat',
|
||||
}
|
||||
);
|
||||
|
||||
const { from, to } = textSelection;
|
||||
|
||||
const fromElement = selectedElements.find(el => from.blockId === el.blockId);
|
||||
if (!fromElement) return;
|
||||
|
||||
let fromText: Text | undefined;
|
||||
if (matchModels(fromElement.model, [RootBlockModel])) {
|
||||
fromText = fromElement.model.title;
|
||||
} else {
|
||||
fromText = fromElement.model.text;
|
||||
}
|
||||
if (!fromText) return;
|
||||
if (!to) {
|
||||
fromText.delete(from.index, from.length);
|
||||
ctx.std.selection.setGroup('note', [
|
||||
ctx.std.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: from.blockId,
|
||||
index: from.index,
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
}),
|
||||
]);
|
||||
return next();
|
||||
}
|
||||
|
||||
const toElement = selectedElements.find(el => to.blockId === el.blockId);
|
||||
if (!toElement) return;
|
||||
|
||||
const toText = toElement.model.text;
|
||||
if (!toText) return;
|
||||
|
||||
fromText.delete(from.index, from.length);
|
||||
toText.delete(0, to.length);
|
||||
|
||||
fromText.join(toText);
|
||||
|
||||
selectedElements
|
||||
.filter(el => el.model.id !== fromElement.model.id)
|
||||
.forEach(el => {
|
||||
ctx.std.store.deleteBlock(el.model, {
|
||||
bringChildrenTo:
|
||||
el.model.id === toElement.model.id ? fromElement.model : undefined,
|
||||
});
|
||||
});
|
||||
|
||||
ctx.std.selection.setGroup('note', [
|
||||
ctx.std.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: from.blockId,
|
||||
index: from.index,
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { BlockSelection, Command } from '@blocksuite/block-std';
|
||||
import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline';
|
||||
|
||||
import { FORMAT_BLOCK_SUPPORT_FLAVOURS } from './consts.js';
|
||||
|
||||
// for block selection
|
||||
export const formatBlockCommand: Command<{
|
||||
currentBlockSelections?: BlockSelection[];
|
||||
blockSelections?: BlockSelection[];
|
||||
styles: AffineTextAttributes;
|
||||
mode?: 'replace' | 'merge';
|
||||
}> = (ctx, next) => {
|
||||
const blockSelections = ctx.blockSelections ?? ctx.currentBlockSelections;
|
||||
if (!blockSelections) {
|
||||
console.error(
|
||||
'`blockSelections` is required, you need to pass it in args or use `getBlockSelections` command before adding this command to the pipeline.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blockSelections.length === 0) return;
|
||||
|
||||
const styles = ctx.styles;
|
||||
const mode = ctx.mode ?? 'merge';
|
||||
|
||||
const success = ctx.std.command
|
||||
.chain()
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
blockSelections,
|
||||
filter: el => FORMAT_BLOCK_SUPPORT_FLAVOURS.includes(el.model.flavour),
|
||||
types: ['block'],
|
||||
})
|
||||
.pipe((ctx, next) => {
|
||||
const { selectedBlocks } = ctx;
|
||||
if (!selectedBlocks) {
|
||||
console.error(
|
||||
'`selectedBlocks` is required, you need to pass it in args or use `getSelectedBlocksCommand` command before adding this command to the pipeline.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedInlineEditors = selectedBlocks.flatMap(el => {
|
||||
const inlineRoot = el.querySelector<
|
||||
InlineRootElement<AffineTextAttributes>
|
||||
>(`[${INLINE_ROOT_ATTR}]`);
|
||||
if (inlineRoot) {
|
||||
return inlineRoot.inlineEditor;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
selectedInlineEditors.forEach(inlineEditor => {
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: 0,
|
||||
length: inlineEditor.yTextLength,
|
||||
},
|
||||
styles,
|
||||
{
|
||||
mode,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
next();
|
||||
})
|
||||
.run();
|
||||
|
||||
if (success) next();
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
type BlockComponent,
|
||||
type Command,
|
||||
} from '@blocksuite/block-std';
|
||||
import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline';
|
||||
|
||||
import { FORMAT_NATIVE_SUPPORT_FLAVOURS } from './consts.js';
|
||||
|
||||
// for native range
|
||||
export const formatNativeCommand: Command<{
|
||||
range?: Range;
|
||||
styles: AffineTextAttributes;
|
||||
mode?: 'replace' | 'merge';
|
||||
}> = (ctx, next) => {
|
||||
const { styles, mode = 'merge' } = ctx;
|
||||
|
||||
let range = ctx.range;
|
||||
if (!range) {
|
||||
const selection = document.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
range = selection.getRangeAt(0);
|
||||
}
|
||||
if (!range) return;
|
||||
|
||||
const selectedInlineEditors = Array.from<InlineRootElement>(
|
||||
ctx.std.host.querySelectorAll(`[${INLINE_ROOT_ATTR}]`)
|
||||
)
|
||||
.filter(el => range?.intersectsNode(el))
|
||||
.filter(el => {
|
||||
const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
|
||||
if (block) {
|
||||
return FORMAT_NATIVE_SUPPORT_FLAVOURS.includes(block.model.flavour);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(el => el.inlineEditor);
|
||||
|
||||
selectedInlineEditors.forEach(inlineEditor => {
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
inlineEditor.formatText(inlineRange, styles, {
|
||||
mode,
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
import { getSelectedBlocksCommand } from '@blocksuite/affine-shared/commands';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { Command, TextSelection } from '@blocksuite/block-std';
|
||||
import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline';
|
||||
|
||||
import { FORMAT_TEXT_SUPPORT_FLAVOURS } from './consts.js';
|
||||
import { clearMarksOnDiscontinuousInput } from './utils.js';
|
||||
|
||||
// for text selection
|
||||
export const formatTextCommand: Command<{
|
||||
currentTextSelection?: TextSelection;
|
||||
textSelection?: TextSelection;
|
||||
styles: AffineTextAttributes;
|
||||
mode?: 'replace' | 'merge';
|
||||
}> = (ctx, next) => {
|
||||
const { styles, mode = 'merge' } = ctx;
|
||||
|
||||
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
|
||||
if (!textSelection) return;
|
||||
|
||||
const success = ctx.std.command
|
||||
.chain()
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
textSelection,
|
||||
filter: el => FORMAT_TEXT_SUPPORT_FLAVOURS.includes(el.model.flavour),
|
||||
types: ['text'],
|
||||
})
|
||||
.pipe((ctx, next) => {
|
||||
const { selectedBlocks } = ctx;
|
||||
if (!selectedBlocks) return;
|
||||
|
||||
const selectedInlineEditors = selectedBlocks.flatMap(el => {
|
||||
const inlineRoot = el.querySelector<
|
||||
InlineRootElement<AffineTextAttributes>
|
||||
>(`[${INLINE_ROOT_ATTR}]`);
|
||||
if (inlineRoot && inlineRoot.inlineEditor.getInlineRange()) {
|
||||
return inlineRoot.inlineEditor;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
selectedInlineEditors.forEach(inlineEditor => {
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return;
|
||||
|
||||
if (inlineRange.length === 0) {
|
||||
const delta = inlineEditor.getDeltaByRangeIndex(inlineRange.index);
|
||||
|
||||
inlineEditor.setMarks({
|
||||
...inlineEditor.marks,
|
||||
...Object.fromEntries(
|
||||
Object.entries(styles).map(([key, value]) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return [
|
||||
key,
|
||||
(inlineEditor.marks &&
|
||||
inlineEditor.marks[key as keyof AffineTextAttributes]) ||
|
||||
(delta &&
|
||||
delta.attributes &&
|
||||
delta.attributes[key as keyof AffineTextAttributes])
|
||||
? null
|
||||
: value,
|
||||
];
|
||||
}
|
||||
return [key, value];
|
||||
})
|
||||
),
|
||||
});
|
||||
clearMarksOnDiscontinuousInput(inlineEditor);
|
||||
} else {
|
||||
inlineEditor.formatText(inlineRange, styles, {
|
||||
mode,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Promise.all(selectedBlocks.map(el => el.updateComplete))
|
||||
.then(() => {
|
||||
ctx.std.range.syncTextSelectionToRange(textSelection);
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
next();
|
||||
})
|
||||
.run();
|
||||
|
||||
if (success) next();
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
export type { TextFormatConfig } from './config.js';
|
||||
export { textFormatConfigs } from './config.js';
|
||||
export {
|
||||
FORMAT_BLOCK_SUPPORT_FLAVOURS,
|
||||
FORMAT_NATIVE_SUPPORT_FLAVOURS,
|
||||
FORMAT_TEXT_SUPPORT_FLAVOURS,
|
||||
} from './consts.js';
|
||||
export { deleteTextCommand } from './delete-text.js';
|
||||
export { formatBlockCommand } from './format-block.js';
|
||||
export { formatNativeCommand } from './format-native.js';
|
||||
export { formatTextCommand } from './format-text.js';
|
||||
export { insertInlineLatex } from './insert-inline-latex.js';
|
||||
export {
|
||||
getTextStyle,
|
||||
isTextStyleActive,
|
||||
toggleBold,
|
||||
toggleCode,
|
||||
toggleItalic,
|
||||
toggleLink,
|
||||
toggleStrike,
|
||||
toggleTextStyleCommand,
|
||||
toggleUnderline,
|
||||
} from './text-style.js';
|
||||
export {
|
||||
clearMarksOnDiscontinuousInput,
|
||||
insertContent,
|
||||
isFormatSupported,
|
||||
} from './utils.js';
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Command, TextSelection } from '@blocksuite/block-std';
|
||||
|
||||
export const insertInlineLatex: Command<{
|
||||
currentTextSelection?: TextSelection;
|
||||
textSelection?: TextSelection;
|
||||
}> = (ctx, next) => {
|
||||
const textSelection = ctx.textSelection ?? ctx.currentTextSelection;
|
||||
if (!textSelection || !textSelection.isCollapsed()) return;
|
||||
|
||||
const blockComponent = ctx.std.view.getBlock(textSelection.from.blockId);
|
||||
if (!blockComponent) return;
|
||||
|
||||
const richText = blockComponent.querySelector('rich-text');
|
||||
if (!richText) return;
|
||||
|
||||
const inlineEditor = richText.inlineEditor;
|
||||
if (!inlineEditor) return;
|
||||
|
||||
inlineEditor.insertText(
|
||||
{
|
||||
index: textSelection.from.index,
|
||||
length: 0,
|
||||
},
|
||||
' '
|
||||
);
|
||||
inlineEditor.formatText(
|
||||
{
|
||||
index: textSelection.from.index,
|
||||
length: 1,
|
||||
},
|
||||
{
|
||||
latex: '',
|
||||
}
|
||||
);
|
||||
inlineEditor.setInlineRange({
|
||||
index: textSelection.from.index,
|
||||
length: 1,
|
||||
});
|
||||
|
||||
inlineEditor
|
||||
.waitForUpdate()
|
||||
.then(async () => {
|
||||
await inlineEditor.waitForUpdate();
|
||||
|
||||
const textPoint = inlineEditor.getTextPoint(textSelection.from.index + 1);
|
||||
if (!textPoint) return;
|
||||
const [text] = textPoint;
|
||||
const latexNode = text.parentElement?.closest('affine-latex-node');
|
||||
if (!latexNode) return;
|
||||
latexNode.toggleEditor();
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
next();
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
getBlockSelectionsCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import type { Command } from '@blocksuite/block-std';
|
||||
import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline';
|
||||
|
||||
import { toggleLinkPopup } from '../inline/index.js';
|
||||
import { formatBlockCommand } from './format-block.js';
|
||||
import { formatNativeCommand } from './format-native.js';
|
||||
import { formatTextCommand } from './format-text.js';
|
||||
import { getCombinedTextStyle } from './utils.js';
|
||||
|
||||
export const toggleTextStyleCommand: Command<{
|
||||
key: Extract<
|
||||
keyof AffineTextAttributes,
|
||||
'bold' | 'italic' | 'underline' | 'strike' | 'code'
|
||||
>;
|
||||
}> = (ctx, next) => {
|
||||
const { std, key } = ctx;
|
||||
const [active] = std.command.chain().pipe(isTextStyleActive, { key }).run();
|
||||
|
||||
const payload: {
|
||||
styles: AffineTextAttributes;
|
||||
mode?: 'replace' | 'merge';
|
||||
} = {
|
||||
styles: {
|
||||
[key]: active ? null : true,
|
||||
},
|
||||
};
|
||||
|
||||
const [result] = std.command
|
||||
.chain()
|
||||
.try(chain => [
|
||||
chain.pipe(getTextSelectionCommand).pipe(formatTextCommand, payload),
|
||||
chain.pipe(getBlockSelectionsCommand).pipe(formatBlockCommand, payload),
|
||||
chain.pipe(formatNativeCommand, payload),
|
||||
])
|
||||
.run();
|
||||
|
||||
if (result) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const toggleTextStyleCommandWrapper = (
|
||||
key: Extract<
|
||||
keyof AffineTextAttributes,
|
||||
'bold' | 'italic' | 'underline' | 'strike' | 'code'
|
||||
>
|
||||
): Command => {
|
||||
return (ctx, next) => {
|
||||
const [success] = ctx.std.command
|
||||
.chain()
|
||||
.pipe(toggleTextStyleCommand, { key })
|
||||
.run();
|
||||
if (success) next();
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
export const toggleBold = toggleTextStyleCommandWrapper('bold');
|
||||
export const toggleItalic = toggleTextStyleCommandWrapper('italic');
|
||||
export const toggleUnderline = toggleTextStyleCommandWrapper('underline');
|
||||
export const toggleStrike = toggleTextStyleCommandWrapper('strike');
|
||||
export const toggleCode = toggleTextStyleCommandWrapper('code');
|
||||
|
||||
export const toggleLink: Command = (ctx, next) => {
|
||||
const selection = document.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return false;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (range.collapsed) return false;
|
||||
const inlineRoot = range.startContainer.parentElement?.closest<
|
||||
InlineRootElement<AffineTextAttributes>
|
||||
>(`[${INLINE_ROOT_ATTR}]`);
|
||||
if (!inlineRoot) return false;
|
||||
|
||||
const inlineEditor = inlineRoot.inlineEditor;
|
||||
const targetInlineRange = inlineEditor.getInlineRange();
|
||||
|
||||
if (!targetInlineRange || targetInlineRange.length === 0) return false;
|
||||
|
||||
const format = inlineEditor.getFormat(targetInlineRange);
|
||||
if (format.link) {
|
||||
inlineEditor.formatText(targetInlineRange, { link: null });
|
||||
return next();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const popup = toggleLinkPopup(
|
||||
ctx.std,
|
||||
'create',
|
||||
inlineEditor,
|
||||
targetInlineRange,
|
||||
abortController
|
||||
);
|
||||
abortController.signal.addEventListener('abort', () => popup.remove());
|
||||
return next();
|
||||
};
|
||||
|
||||
export const getTextStyle: Command<{}, { textStyle: AffineTextAttributes }> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
const [result, innerCtx] = getCombinedTextStyle(
|
||||
ctx.std.command.chain()
|
||||
).run();
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return next({ textStyle: innerCtx.textStyle });
|
||||
};
|
||||
|
||||
export const isTextStyleActive: Command<{ key: keyof AffineTextAttributes }> = (
|
||||
ctx,
|
||||
next
|
||||
) => {
|
||||
const key = ctx.key;
|
||||
const [result] = getCombinedTextStyle(ctx.std.command.chain())
|
||||
.pipe((ctx, next) => {
|
||||
const { textStyle } = ctx;
|
||||
|
||||
if (textStyle && key in textStyle) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return false;
|
||||
})
|
||||
.run();
|
||||
|
||||
if (!result) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
@@ -0,0 +1,258 @@
|
||||
import {
|
||||
getBlockSelectionsCommand,
|
||||
getSelectedBlocksCommand,
|
||||
getTextSelectionCommand,
|
||||
} from '@blocksuite/affine-shared/commands';
|
||||
import type { AffineTextAttributes } from '@blocksuite/affine-shared/types';
|
||||
import {
|
||||
BLOCK_ID_ATTR,
|
||||
type BlockComponent,
|
||||
type Chain,
|
||||
type EditorHost,
|
||||
type InitCommandCtx,
|
||||
} from '@blocksuite/block-std';
|
||||
import {
|
||||
INLINE_ROOT_ATTR,
|
||||
type InlineEditor,
|
||||
type InlineRange,
|
||||
type InlineRootElement,
|
||||
} from '@blocksuite/inline';
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
import { effect } from '@preact/signals-core';
|
||||
|
||||
import { getInlineEditorByModel } from '../dom.js';
|
||||
import type { AffineInlineEditor } from '../inline/index.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.
|
||||
|
||||
newFormat[typedKey] = acc[typedKey] as any;
|
||||
}
|
||||
}
|
||||
return newFormat;
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedInlineEditors(
|
||||
blocks: BlockComponent[],
|
||||
filter: (
|
||||
inlineRoot: InlineRootElement<AffineTextAttributes>
|
||||
) => InlineEditor<AffineTextAttributes> | []
|
||||
) {
|
||||
return blocks.flatMap(el => {
|
||||
const inlineRoot = el.querySelector<
|
||||
InlineRootElement<AffineTextAttributes>
|
||||
>(`[${INLINE_ROOT_ATTR}]`);
|
||||
|
||||
if (inlineRoot) {
|
||||
return filter(inlineRoot);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function handleCurrentSelection(
|
||||
chain: Chain<InitCommandCtx>,
|
||||
handler: (
|
||||
type: 'text' | 'block' | 'native',
|
||||
inlineEditors: InlineEditor<AffineTextAttributes>[]
|
||||
) => { textStyle: AffineTextAttributes } | boolean | void
|
||||
): Chain<InitCommandCtx & { textStyle: AffineTextAttributes }> {
|
||||
return chain.try(chain => [
|
||||
// text selection, corresponding to `formatText` command
|
||||
chain
|
||||
.pipe(getTextSelectionCommand)
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
types: ['text'],
|
||||
filter: el => FORMAT_TEXT_SUPPORT_FLAVOURS.includes(el.model.flavour),
|
||||
})
|
||||
.pipe((ctx, next) => {
|
||||
const { selectedBlocks } = ctx;
|
||||
if (!selectedBlocks) {
|
||||
console.error(
|
||||
'`selectedBlocks` is required, you need to pass it in args or use `getSelectedBlocksCommand` command before adding this command to the pipeline.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedInlineEditors = getSelectedInlineEditors(
|
||||
selectedBlocks,
|
||||
inlineRoot => {
|
||||
const inlineRange = inlineRoot.inlineEditor.getInlineRange();
|
||||
if (!inlineRange) return [];
|
||||
return inlineRoot.inlineEditor;
|
||||
}
|
||||
);
|
||||
|
||||
const result = handler('text', selectedInlineEditors);
|
||||
if (!result) return false;
|
||||
if (result === true) {
|
||||
return next();
|
||||
}
|
||||
return next(result);
|
||||
}),
|
||||
// block selection, corresponding to `formatBlock` command
|
||||
chain
|
||||
.pipe(getBlockSelectionsCommand)
|
||||
.pipe(getSelectedBlocksCommand, {
|
||||
types: ['block'],
|
||||
filter: el => FORMAT_BLOCK_SUPPORT_FLAVOURS.includes(el.model.flavour),
|
||||
})
|
||||
.pipe((ctx, next) => {
|
||||
const { selectedBlocks } = ctx;
|
||||
if (!selectedBlocks) {
|
||||
console.error(
|
||||
'`selectedBlocks` is required, you need to pass it in args or use `getSelectedBlocksCommand` command before adding this command to the pipeline.'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedInlineEditors = getSelectedInlineEditors(
|
||||
selectedBlocks,
|
||||
inlineRoot =>
|
||||
inlineRoot.inlineEditor.yTextLength > 0
|
||||
? inlineRoot.inlineEditor
|
||||
: []
|
||||
);
|
||||
|
||||
const result = handler('block', selectedInlineEditors);
|
||||
if (!result) return false;
|
||||
if (result === true) {
|
||||
return next();
|
||||
}
|
||||
return next(result);
|
||||
}),
|
||||
// native selection, corresponding to `formatNative` command
|
||||
chain.pipe((ctx, next) => {
|
||||
const selectedInlineEditors = Array.from<InlineRootElement>(
|
||||
ctx.std.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 block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
|
||||
if (block) {
|
||||
return FORMAT_NATIVE_SUPPORT_FLAVOURS.includes(block.model.flavour);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map((el): AffineInlineEditor => el.inlineEditor);
|
||||
|
||||
const result = handler('native', selectedInlineEditors);
|
||||
if (!result) return false;
|
||||
if (result === true) {
|
||||
return next();
|
||||
}
|
||||
return next(result);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export function getCombinedTextStyle(chain: Chain<InitCommandCtx>) {
|
||||
return handleCurrentSelection(chain, (type, inlineEditors) => {
|
||||
if (type === 'text') {
|
||||
return {
|
||||
textStyle: getCombinedFormatFromInlineEditors(
|
||||
inlineEditors.map(e => [e, e.getInlineRange()])
|
||||
),
|
||||
};
|
||||
}
|
||||
if (type === 'block') {
|
||||
return {
|
||||
textStyle: getCombinedFormatFromInlineEditors(
|
||||
inlineEditors.map(e => [e, { index: 0, length: e.yTextLength }])
|
||||
),
|
||||
};
|
||||
}
|
||||
if (type === 'native') {
|
||||
return {
|
||||
textStyle: getCombinedFormatFromInlineEditors(
|
||||
inlineEditors.map(e => [e, e.getInlineRange()])
|
||||
),
|
||||
};
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
export function isFormatSupported(chain: Chain<InitCommandCtx>) {
|
||||
return handleCurrentSelection(
|
||||
chain,
|
||||
(_type, inlineEditors) => inlineEditors.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
// When the user selects a range, check if it matches the previous selection.
|
||||
// If it does, apply the marks from the previous selection.
|
||||
// If it does not, remove the marks from the previous selection.
|
||||
export function clearMarksOnDiscontinuousInput(
|
||||
inlineEditor: InlineEditor
|
||||
): void {
|
||||
let inlineRange = inlineEditor.getInlineRange();
|
||||
const dispose = effect(() => {
|
||||
const r = inlineEditor.inlineRange$.value;
|
||||
if (
|
||||
inlineRange &&
|
||||
r &&
|
||||
(inlineRange.index === r.index || inlineRange.index === r.index + 1)
|
||||
) {
|
||||
inlineRange = r;
|
||||
} else {
|
||||
inlineEditor.resetMarks();
|
||||
dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function insertContent(
|
||||
editorHost: EditorHost,
|
||||
model: BlockModel,
|
||||
text: string,
|
||||
attributes?: AffineTextAttributes
|
||||
) {
|
||||
if (!model.text) {
|
||||
console.error("Can't insert text! Text not found");
|
||||
return;
|
||||
}
|
||||
const inlineEditor = getInlineEditorByModel(editorHost, model);
|
||||
if (!inlineEditor) {
|
||||
console.error("Can't insert text! Inline editor not found");
|
||||
return;
|
||||
}
|
||||
const inlineRange = inlineEditor.getInlineRange();
|
||||
const index = inlineRange ? inlineRange.index : model.text.length;
|
||||
model.text.insert(text, index, attributes as Record<string, unknown>);
|
||||
// Update the caret to the end of the inserted text
|
||||
inlineEditor.setInlineRange({
|
||||
index: index + text.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user