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,
|
||||
ExtensionSearchAndReplace,
|
||||
ExtensionClearFormat,
|
||||
ExtensionFormatBrush,
|
||||
} from "../index";
|
||||
|
||||
const content = useLocalStorage("content", "");
|
||||
|
@ -113,6 +114,7 @@ const editor = useEditor({
|
|||
ExtensionListKeymap,
|
||||
ExtensionSearchAndReplace,
|
||||
ExtensionClearFormat,
|
||||
ExtensionFormatBrush,
|
||||
],
|
||||
parseOptions: {
|
||||
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 ExtensionSearchAndReplace from "./search-and-replace";
|
||||
import ExtensionClearFormat from "./clear-format";
|
||||
import ExtensionFormatBrush from "./format-brush";
|
||||
|
||||
const allExtensions = [
|
||||
ExtensionBlockquote,
|
||||
|
@ -102,6 +103,7 @@ const allExtensions = [
|
|||
ExtensionTrailingNode,
|
||||
ExtensionSearchAndReplace,
|
||||
ExtensionClearFormat,
|
||||
ExtensionFormatBrush,
|
||||
];
|
||||
|
||||
export {
|
||||
|
@ -150,4 +152,5 @@ export {
|
|||
ExtensionListKeymap,
|
||||
ExtensionSearchAndReplace,
|
||||
ExtensionClearFormat,
|
||||
ExtensionFormatBrush,
|
||||
};
|
||||
|
|
|
@ -83,6 +83,10 @@ editor:
|
|||
match_word: Match Whole Word
|
||||
use_regex: Use Regular Expression
|
||||
close: Close
|
||||
format_brush:
|
||||
toolbar_item:
|
||||
title: Format Brush
|
||||
cancel: Cancel Format Brush
|
||||
components:
|
||||
color_picker:
|
||||
more_color: More
|
||||
|
|
|
@ -83,6 +83,10 @@ editor:
|
|||
match_word: 全字匹配
|
||||
use_regex: 使用正则表达式
|
||||
close: 关闭
|
||||
format_brush:
|
||||
toolbar_item:
|
||||
title: 格式刷
|
||||
cancel: 取消格式刷
|
||||
components:
|
||||
color_picker:
|
||||
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 "./columns.scss";
|
||||
@import "./search.scss";
|
||||
@import "./format-brush.scss";
|
||||
|
|
|
@ -48,6 +48,7 @@ import {
|
|||
ExtensionListKeymap,
|
||||
ExtensionSearchAndReplace,
|
||||
ExtensionClearFormat,
|
||||
ExtensionFormatBrush,
|
||||
} from "@halo-dev/richtext-editor";
|
||||
// ui custom extension
|
||||
import {
|
||||
|
@ -398,6 +399,7 @@ onMounted(() => {
|
|||
UiExtensionUpload,
|
||||
ExtensionSearchAndReplace,
|
||||
ExtensionClearFormat,
|
||||
ExtensionFormatBrush,
|
||||
],
|
||||
parseOptions: {
|
||||
preserveWhitespace: true,
|
||||
|
|
Loading…
Reference in New Issue