mirror of https://github.com/halo-dev/halo
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
parent
0ba50b806e
commit
410a7557f9
|
@ -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,
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
||||||
|
});
|
||||||
|
};
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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: 更多颜色
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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";
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue