feat: add the format brush extension to the default rich text editor (#5603)

#### What type of PR is this?

/kind feature
/area editor
/area ui

#### What this PR does / why we need it:

为默认富文本编辑器添加格式刷扩展。用以给 text node 复制格式。

使用方式:

1. 选中一串具有格式的文本
2. 点击格式刷或者使用 `Shift + Mod + c` 快捷键复制格式。
3. 选中需要进行格式处理的文本
4. 松开鼠标,格式刷生效。

#### How to test it?

测试格式刷功能是否正常可用。需要测试 text node 以及 block node 内部的 text 格式功能。

#### Which issue(s) this PR fixes:

Fixes #5591 

#### Does this PR introduce a user-facing change?
```release-note
为默认富文本编辑器添加格式刷扩展。
```
pull/5724/head^2
Takagi 2024-04-18 12:26:07 +08:00 committed by GitHub
parent 0ba50b806e
commit 410a7557f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 264 additions and 0 deletions

View File

@ -46,6 +46,7 @@ import {
ExtensionListKeymap, ExtensionListKeymap,
ExtensionSearchAndReplace, ExtensionSearchAndReplace,
ExtensionClearFormat, ExtensionClearFormat,
ExtensionFormatBrush,
} from "../index"; } from "../index";
const content = useLocalStorage("content", ""); const content = useLocalStorage("content", "");
@ -113,6 +114,7 @@ const editor = useEditor({
ExtensionListKeymap, ExtensionListKeymap,
ExtensionSearchAndReplace, ExtensionSearchAndReplace,
ExtensionClearFormat, ExtensionClearFormat,
ExtensionFormatBrush,
], ],
parseOptions: { parseOptions: {
preserveWhitespace: true, preserveWhitespace: true,

View File

@ -0,0 +1,120 @@
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
import { i18n } from "@/locales";
import { CoreEditor, Extension, Plugin, PluginKey } from "@/tiptap";
import { markRaw } from "vue";
import BxsBrushAlt from "~icons/bxs/brush-alt";
import { getMarksByFirstTextNode, setMarks } from "./util";
declare module "@/tiptap" {
interface Commands<ReturnType> {
formatBrush: {
copyFormatBrush: () => ReturnType;
pasteFormatBrush: () => ReturnType;
};
}
}
export interface FormatBrushStore {
formatBrush: boolean;
formatBrushMarks: any[];
}
const formatBrush = Extension.create<any, FormatBrushStore>({
addOptions() {
return {
...this.parent?.(),
getToolbarItems({ editor }: { editor: CoreEditor }) {
const formatBrush =
editor.view.dom.classList.contains("format-brush-mode");
return {
priority: 25,
component: markRaw(ToolbarItem),
props: {
editor,
isActive: formatBrush,
icon: markRaw(BxsBrushAlt),
title: formatBrush
? i18n.global.t(
"editor.extensions.format_brush.toolbar_item.cancel"
)
: i18n.global.t(
"editor.extensions.format_brush.toolbar_item.title"
),
action: () => {
if (formatBrush) {
editor.commands.pasteFormatBrush();
} else {
editor.commands.copyFormatBrush();
}
},
},
};
},
};
},
addCommands() {
return {
copyFormatBrush:
() =>
({ state }) => {
const markRange = getMarksByFirstTextNode(state);
this.storage.formatBrushMarks = markRange;
this.storage.formatBrush = true;
this.editor.view.dom.classList.add("format-brush-mode");
return true;
},
pasteFormatBrush: () => () => {
this.storage.formatBrushMarks = [];
this.storage.formatBrush = false;
this.editor.view.dom.classList.remove("format-brush-mode");
return true;
},
};
},
addStorage() {
return {
formatBrush: false,
formatBrushMarks: [],
};
},
addProseMirrorPlugins() {
const storage = this.storage;
const editor = this.editor;
return [
new Plugin({
key: new PluginKey("formatBrushPlugin"),
props: {
handleDOMEvents: {
mouseup(view) {
if (!storage.formatBrush) {
return;
}
editor
.chain()
.command(({ tr }) => {
setMarks(view.state, storage.formatBrushMarks, tr);
return true;
})
.pasteFormatBrush()
.run();
},
},
},
}),
];
},
addKeyboardShortcuts() {
return {
"Shift-Mod-c": () => {
this.editor.commands.copyFormatBrush();
return true;
},
};
},
});
export default formatBrush;

View File

@ -0,0 +1,119 @@
import type { EditorState, MarkRange, Transaction } from "@/tiptap";
import { AddMarkStep, CellSelection, RemoveMarkStep } from "@/tiptap/pm";
/**
* get its marks through the first text node in the selector
*
* @param state editor state
* @returns the marks of the current first text node
*/
export const getMarksByFirstTextNode = (state: EditorState): MarkRange[] => {
const marks: MarkRange[] = [];
const { doc, selection } = state;
const { from, to, empty } = selection;
if (empty) {
return marks;
}
let flag = false;
doc.nodesBetween(from, to, (node, pos) => {
if (!node || node?.nodeSize === undefined) {
return;
}
if (node.isText && !flag) {
flag = true;
marks.push(
...node.marks.map((mark) => ({
from: pos,
to: pos + node.nodeSize,
mark,
}))
);
return false;
}
});
return marks;
};
/**
*
* Set marks for the text in the currently selected content. This method will first remove all marks
* from the currently selected text, and then add marks again.
*
* For CellSelection, it is necessary to iterate through ranges and set marks for each range.
*
* @param state editor state
* @param marks the marks to be set
* @param transaction transaction
*
* @returns transaction
*
* */
export const setMarks = (
state: EditorState,
marks: MarkRange[],
transaction?: Transaction
): Transaction => {
const { selection } = state;
const tr = transaction || state.tr;
const { from, to } = selection;
// When selection is CellSelection, iterate through ranges
if (selection instanceof CellSelection) {
selection.ranges.forEach((cellRange) => {
const range = {
from: cellRange.$from.pos,
to: cellRange.$to.pos,
};
setMarksByRange(tr, state, range, marks);
});
} else {
setMarksByRange(
tr,
state,
{
from,
to,
},
marks
);
}
return tr;
};
export const setMarksByRange = (
tr: Transaction,
state: EditorState,
range: {
from: number;
to: number;
},
marks: MarkRange[]
) => {
const { from, to } = range;
state.doc.nodesBetween(from, to, (node, pos) => {
if (!node || node?.nodeSize === undefined) {
return;
}
if (node.isText) {
// the range of the current text node
const range = {
from: Math.max(pos, from),
to: Math.min(pos + node.nodeSize, to),
};
// remove all marks of the current text node
node.marks.forEach((mark) => {
tr.step(new RemoveMarkStep(range.from, range.to, mark));
});
// add all marks of the current text node
marks.forEach((mark) => {
tr.step(new AddMarkStep(range.from, range.to, mark.mark));
});
}
return true;
});
};

View File

@ -44,6 +44,7 @@ import ExtensionNodeSelected from "./node-selected";
import ExtensionTrailingNode from "./trailing-node"; import ExtensionTrailingNode from "./trailing-node";
import ExtensionSearchAndReplace from "./search-and-replace"; import ExtensionSearchAndReplace from "./search-and-replace";
import ExtensionClearFormat from "./clear-format"; import ExtensionClearFormat from "./clear-format";
import ExtensionFormatBrush from "./format-brush";
const allExtensions = [ const allExtensions = [
ExtensionBlockquote, ExtensionBlockquote,
@ -102,6 +103,7 @@ const allExtensions = [
ExtensionTrailingNode, ExtensionTrailingNode,
ExtensionSearchAndReplace, ExtensionSearchAndReplace,
ExtensionClearFormat, ExtensionClearFormat,
ExtensionFormatBrush,
]; ];
export { export {
@ -150,4 +152,5 @@ export {
ExtensionListKeymap, ExtensionListKeymap,
ExtensionSearchAndReplace, ExtensionSearchAndReplace,
ExtensionClearFormat, ExtensionClearFormat,
ExtensionFormatBrush,
}; };

View File

@ -83,6 +83,10 @@ editor:
match_word: Match Whole Word match_word: Match Whole Word
use_regex: Use Regular Expression use_regex: Use Regular Expression
close: Close close: Close
format_brush:
toolbar_item:
title: Format Brush
cancel: Cancel Format Brush
components: components:
color_picker: color_picker:
more_color: More more_color: More

View File

@ -83,6 +83,10 @@ editor:
match_word: 全字匹配 match_word: 全字匹配
use_regex: 使用正则表达式 use_regex: 使用正则表达式
close: 关闭 close: 关闭
format_brush:
toolbar_item:
title: 格式刷
cancel: 取消格式刷
components: components:
color_picker: color_picker:
more_color: 更多颜色 more_color: 更多颜色

View File

@ -0,0 +1,9 @@
.halo-rich-text-editor {
.ProseMirror {
&.format-brush-mode {
cursor: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMjQgMjQiPjxnIGZpbGw9Im5vbmUiIGZpbGwtcnVsZT0iZXZlbm9kZCI+PHBhdGggZD0iTTI0IDB2MjRIMFYwek0xMi41OTQgMjMuMjU4bC0uMDEyLjAwMmwtLjA3MS4wMzVsLS4wMi4wMDRsLS4wMTQtLjAwNGwtLjA3MS0uMDM2Yy0uMDEtLjAwMy0uMDE5IDAtLjAyNC4wMDZsLS4wMDQuMDFsLS4wMTcuNDI4bC4wMDUuMDJsLjAxLjAxM2wuMTA0LjA3NGwuMDE1LjAwNGwuMDEyLS4wMDRsLjEwNC0uMDc0bC4wMTItLjAxNmwuMDA0LS4wMTdsLS4wMTctLjQyN2MtLjAwMi0uMDEtLjAwOS0uMDE3LS4wMTYtLjAxOG0uMjY0LS4xMTNsLS4wMTQuMDAybC0uMTg0LjA5M2wtLjAxLjAxbC0uMDAzLjAxMWwuMDE4LjQzbC4wMDUuMDEybC4wMDguMDA4bC4yMDEuMDkyYy4wMTIuMDA0LjAyMyAwIC4wMjktLjAwOGwuMDA0LS4wMTRsLS4wMzQtLjYxNGMtLjAwMy0uMDEyLS4wMS0uMDItLjAyLS4wMjJtLS43MTUuMDAyYS4wMjMuMDIzIDAgMCAwLS4wMjcuMDA2bC0uMDA2LjAxNGwtLjAzNC42MTRjMCAuMDEyLjAwNy4wMi4wMTcuMDI0bC4wMTUtLjAwMmwuMjAxLS4wOTNsLjAxLS4wMDhsLjAwMy0uMDExbC4wMTgtLjQzbC0uMDAzLS4wMTJsLS4wMS0uMDF6Ii8+PHBhdGggZmlsbD0iY3VycmVudENvbG9yIiBkPSJNMTQuMzMgMi4zMDdhMyAzIDAgMCAxIDIuMTIgMy42NzRsLTEuMDM1IDMuODY0bDEuMzU4LjM2M2MxLjY1NC40NDQgMi41MSAyLjEyIDIuMjc4IDMuNjU2Yy0uMTEuNzI5LS4xNTQgMS40NDYtLjA2NSAxLjk2OGMuMTg1IDEuMDg0LjgwOCAyLjE0NiAxLjQ1OCAzLjA3M2wuMzU0LjQ5MkExIDEgMCAwIDEgMjAgMjFINS41Yy0uMzIgMC0uNjI3LS4xNTgtLjgxMy0uNDE4Yy0uNDg5LS42ODQtLjgzOC0xLjQ1OC0xLjEyNS0yLjI0NGMtLjUtMS4zNjQtLjkxNy0zLjI5My0uNTQ4LTUuNTAyYTExLjYzMyAxMS42MzMgMCAwIDEgMS40MjgtMy45OTRjLjY1NS0xLjEwOCAxLjkzMS0xLjQyIDIuOTkxLTEuMTM2bDIuMTg3LjU4NmwxLjAzNS0zLjg2NGEzIDMgMCAwIDEgMy42NzQtMi4xMjFaTTcuOTc4IDE1YTEgMSAwIDAgMC0uOTkzIDEuMDA3Yy4wMDcgMS4wMjQuMzI3IDIuMDA1LjY2OSAyLjc3bC4xMDIuMjIzaDIuMjc3YTguOTQ0IDguOTQ0IDAgMCAxLS4zOTItLjY5OWMtLjMxLS42MTctLjU4My0xLjM0OC0uNjQ0LTIuMDQ3bC0uMDEyLS4yNjFBMSAxIDAgMCAwIDcuOTc5IDE1Wm00LjYwOC0xMC4wNTRsLTEuMjk0IDQuODNhMSAxIDAgMCAxLTEuMjI1LjcwNmwtMy4xNTItLjg0NGMtLjM4My0uMTAzLS42NDYuMDQzLS43NTEuMjIxYTkuMTExIDkuMTExIDAgMCAwLS43MDMgMS40OTRsMTEuNTEgMy4wNTNjLjAyNS0uMjkzLjA2MS0uNTc4LjEwMS0uODQyYy4xMDUtLjY5LS4yOTctMS4yODQtLjgxOC0xLjQyNGwtMi4zMjMtLjYyMmExIDEgMCAwIDEtLjcwNy0xLjIyNWwxLjI5NC00LjgzYTEgMSAwIDAgMC0xLjkzMi0uNTE3Ii8+PC9nPjwvc3ZnPg==)
5 10,
text;
}
}
}

View File

@ -3,3 +3,4 @@
@import "./draggable.scss"; @import "./draggable.scss";
@import "./columns.scss"; @import "./columns.scss";
@import "./search.scss"; @import "./search.scss";
@import "./format-brush.scss";

View File

@ -48,6 +48,7 @@ import {
ExtensionListKeymap, ExtensionListKeymap,
ExtensionSearchAndReplace, ExtensionSearchAndReplace,
ExtensionClearFormat, ExtensionClearFormat,
ExtensionFormatBrush,
} from "@halo-dev/richtext-editor"; } from "@halo-dev/richtext-editor";
// ui custom extension // ui custom extension
import { import {
@ -398,6 +399,7 @@ onMounted(() => {
UiExtensionUpload, UiExtensionUpload,
ExtensionSearchAndReplace, ExtensionSearchAndReplace,
ExtensionClearFormat, ExtensionClearFormat,
ExtensionFormatBrush,
], ],
parseOptions: { parseOptions: {
preserveWhitespace: true, preserveWhitespace: true,