From ba2987b585a3620ab7e9396b193b382b08d8ad7c Mon Sep 17 00:00:00 2001
From: Takagi <1103069291@qq.com>
Date: Wed, 26 Jun 2024 14:14:50 +0800
Subject: [PATCH] feat: range selection feature to default editor (#6117)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
#### What type of PR is this?
/kind feature
/area editor
/milestone 2.17.x
#### What this PR does / why we need it:
为默认编辑器添加 `RangeSelection` 选择器。
它的功能基本与 TextSelection 相反,例如:
1. TextSelection 支持光标展示,RangeSelection 不允许空内容,即它并不支持光标。
2. TextSelection 会抛弃被选择的 Node 节点部分偏移量,而 RangeSelection 会扩展偏移量至 Node 节点结束。
3. TextSelection 支持 Text 而 RangeSelection 支持 Node 节点。
`RangeSelection` 可以用于范围选中块节点并进行操作,可用于全选内容并进行删除操作。
#### How to test it?
测试使用点击,拖拽,释放鼠标的操作,能否选中某些节点。
测试删除选中的节点。
#### Which issue(s) this PR fixes:
Fixes #5194
#### Does this PR introduce a user-facing change?
```release-note
为默认编辑器添加 RangeSelection 选择器
```
---
ui/packages/editor/src/dev/App.vue | 2 +
.../src/extensions/code-block/code-block.ts | 3 +
.../editor/src/extensions/columns/columns.ts | 1 +
ui/packages/editor/src/extensions/index.ts | 4 +
.../src/extensions/range-selection/index.ts | 163 +++++++++++++++
.../range-selection/range-selection.ts | 190 ++++++++++++++++++
.../editor/src/extensions/table/index.ts | 2 +
.../editor/src/extensions/table/table-row.ts | 1 +
ui/packages/editor/src/styles/index.scss | 1 +
.../editor/src/styles/range-selection.scss | 33 +++
ui/src/components/editor/DefaultEditor.vue | 2 +
11 files changed, 402 insertions(+)
create mode 100644 ui/packages/editor/src/extensions/range-selection/index.ts
create mode 100644 ui/packages/editor/src/extensions/range-selection/range-selection.ts
create mode 100644 ui/packages/editor/src/styles/range-selection.scss
diff --git a/ui/packages/editor/src/dev/App.vue b/ui/packages/editor/src/dev/App.vue
index 7dbbf873e..9a682b395 100644
--- a/ui/packages/editor/src/dev/App.vue
+++ b/ui/packages/editor/src/dev/App.vue
@@ -46,6 +46,7 @@ import {
ExtensionSearchAndReplace,
ExtensionClearFormat,
ExtensionFormatBrush,
+ ExtensionRangeSelection,
} from "../index";
const content = useLocalStorage("content", "");
@@ -114,6 +115,7 @@ const editor = useEditor({
ExtensionSearchAndReplace,
ExtensionClearFormat,
ExtensionFormatBrush,
+ ExtensionRangeSelection,
],
parseOptions: {
preserveWhitespace: true,
diff --git a/ui/packages/editor/src/extensions/code-block/code-block.ts b/ui/packages/editor/src/extensions/code-block/code-block.ts
index 59ff94b34..561dde37d 100644
--- a/ui/packages/editor/src/extensions/code-block/code-block.ts
+++ b/ui/packages/editor/src/extensions/code-block/code-block.ts
@@ -100,6 +100,9 @@ const getRenderContainer = (node: HTMLElement) => {
export default CodeBlockLowlight.extend<
CustomCodeBlockLowlightOptions & CodeBlockLowlightOptions
>({
+ // It needs to have a higher priority than range-selection,
+ // otherwise the Mod-a shortcut key will be overridden.
+ priority: 110,
addCommands() {
return {
...this.parent?.(),
diff --git a/ui/packages/editor/src/extensions/columns/columns.ts b/ui/packages/editor/src/extensions/columns/columns.ts
index 7b6d74124..aa45a240f 100644
--- a/ui/packages/editor/src/extensions/columns/columns.ts
+++ b/ui/packages/editor/src/extensions/columns/columns.ts
@@ -175,6 +175,7 @@ const Columns = Node.create({
isolating: true,
allowGapCursor: false,
content: "column{1,}",
+ fakeSelection: false,
addOptions() {
return {
diff --git a/ui/packages/editor/src/extensions/index.ts b/ui/packages/editor/src/extensions/index.ts
index bda8a3b43..1f0ec1192 100644
--- a/ui/packages/editor/src/extensions/index.ts
+++ b/ui/packages/editor/src/extensions/index.ts
@@ -45,6 +45,7 @@ import ExtensionTrailingNode from "./trailing-node";
import ExtensionSearchAndReplace from "./search-and-replace";
import ExtensionClearFormat from "./clear-format";
import ExtensionFormatBrush from "./format-brush";
+import { ExtensionRangeSelection, RangeSelection } from "./range-selection";
const allExtensions = [
ExtensionBlockquote,
@@ -104,6 +105,7 @@ const allExtensions = [
ExtensionSearchAndReplace,
ExtensionClearFormat,
ExtensionFormatBrush,
+ ExtensionRangeSelection,
];
export {
@@ -153,4 +155,6 @@ export {
ExtensionSearchAndReplace,
ExtensionClearFormat,
ExtensionFormatBrush,
+ ExtensionRangeSelection,
+ RangeSelection,
};
diff --git a/ui/packages/editor/src/extensions/range-selection/index.ts b/ui/packages/editor/src/extensions/range-selection/index.ts
new file mode 100644
index 000000000..2be5c5a56
--- /dev/null
+++ b/ui/packages/editor/src/extensions/range-selection/index.ts
@@ -0,0 +1,163 @@
+import {
+ Decoration,
+ DecorationSet,
+ EditorView,
+ Extension,
+ Plugin,
+ PluginKey,
+ callOrReturn,
+ getExtensionField,
+ type ParentConfig,
+} from "@/tiptap";
+import RangeSelection from "./range-selection";
+
+declare module "@tiptap/core" {
+ export interface NodeConfig {
+ /**
+ * Whether to allow displaying a fake selection state on the node.
+ *
+ * Typically, it is only necessary to display a fake selection state on child nodes,
+ * so the parent node can be set to false.
+ *
+ * default: true
+ */
+ fakeSelection?:
+ | boolean
+ | null
+ | ((this: {
+ name: string;
+ options: Options;
+ storage: Storage;
+ parent: ParentConfig>["fakeSelection"];
+ }) => boolean | null);
+ }
+}
+
+const range = {
+ anchor: 0,
+ head: 0,
+ enable: false,
+};
+const ExtensionRangeSelection = Extension.create({
+ priority: 100,
+ name: "rangeSelectionExtension",
+
+ addProseMirrorPlugins() {
+ return [
+ new Plugin({
+ key: new PluginKey("rangeSelectionPlugin"),
+ props: {
+ decorations: ({ doc, selection }) => {
+ const { isEditable, isFocused } = this.editor;
+ if (!isEditable || !isFocused) {
+ return null;
+ }
+ if (!(selection instanceof RangeSelection)) {
+ return null;
+ }
+ const { $from, $to } = selection;
+ const decorations: Decoration[] = [];
+ doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
+ if (node.isText || node.type.name === "paragraph") {
+ return;
+ }
+ let className = "no-selection";
+ if (node.type.spec.fakeSelection) {
+ className = className + " range-fake-selection";
+ }
+
+ decorations.push(
+ Decoration.node(pos, pos + node.nodeSize, {
+ class: className,
+ })
+ );
+ });
+ return DecorationSet.create(doc, decorations);
+ },
+
+ createSelectionBetween: (view, anchor, head) => {
+ if (anchor.pos === head.pos) {
+ return null;
+ }
+ return RangeSelection.valid(view.state, anchor.pos, head.pos)
+ ? new RangeSelection(anchor, head)
+ : null;
+ },
+ handleDOMEvents: {
+ mousedown: (view: EditorView, event) => {
+ const coords = { left: event.clientX, top: event.clientY };
+ const $pos = view.posAtCoords(coords);
+ if (!$pos || !$pos.pos) {
+ return;
+ }
+ range.enable = true;
+ range.anchor = $pos.pos;
+ },
+ mousemove: (view, event) => {
+ if (!range.enable) {
+ return;
+ }
+ const coords = { left: event.clientX, top: event.clientY };
+ const $pos = view.posAtCoords(coords);
+ if (
+ !$pos ||
+ !$pos.pos ||
+ $pos.pos === range.anchor ||
+ $pos.pos === range.head
+ ) {
+ return;
+ }
+ range.head = $pos.pos;
+ const selection = RangeSelection.between(
+ view.state.doc.resolve(range.anchor),
+ view.state.doc.resolve(range.head)
+ );
+ if (selection) {
+ view.dispatch(view.state.tr.setSelection(selection));
+ }
+ },
+ mouseup: () => {
+ range.enable = false;
+ range.anchor = 0;
+ range.head = 0;
+ },
+ mouseleave: () => {
+ range.enable = false;
+ range.anchor = 0;
+ range.head = 0;
+ },
+ },
+ },
+ }),
+ ];
+ },
+
+ addKeyboardShortcuts() {
+ return {
+ "Mod-a": ({ editor }) => {
+ editor.view.dispatch(
+ editor.view.state.tr.setSelection(
+ RangeSelection.allRange(editor.view.state.doc)
+ )
+ );
+ return true;
+ },
+ };
+ },
+
+ extendNodeSchema(extension) {
+ const context = {
+ name: extension.name,
+ options: extension.options,
+ storage: extension.storage,
+ };
+
+ return {
+ fakeSelection:
+ callOrReturn(getExtensionField(extension, "fakeSelection", context)) ??
+ true,
+ };
+ },
+});
+
+export { RangeSelection, ExtensionRangeSelection };
diff --git a/ui/packages/editor/src/extensions/range-selection/range-selection.ts b/ui/packages/editor/src/extensions/range-selection/range-selection.ts
new file mode 100644
index 000000000..378c471ff
--- /dev/null
+++ b/ui/packages/editor/src/extensions/range-selection/range-selection.ts
@@ -0,0 +1,190 @@
+import {
+ ResolvedPos,
+ Selection,
+ Node,
+ type Mappable,
+ EditorState,
+} from "@/tiptap/pm";
+
+/**
+ * The RangeSelection class represents a selection range within a document.
+ * The content can include text, paragraphs, block-level content, etc.
+ *
+ * It has a starting position and an ending position. When the given range includes block-level content,
+ * the RangeSelection will automatically expand to include the block-level content at the corresponding depth.
+ *
+ * The RangeSelection must not contain empty content.
+ */
+class RangeSelection extends Selection {
+ /**
+ * Creates a RangeSelection between the specified positions.
+ *
+ * @param $anchor - The starting position of the selection.
+ * @param $head - The ending position of the selection.
+ */
+ constructor($anchor: ResolvedPos, $head: ResolvedPos) {
+ checkRangeSelection($anchor, $head);
+ super($anchor, $head);
+ }
+
+ map(doc: Node, mapping: Mappable): Selection {
+ const $head = doc.resolve(mapping.map(this.head));
+ const $anchor = doc.resolve(mapping.map(this.anchor));
+ return new RangeSelection($anchor, $head);
+ }
+
+ eq(other: Selection): boolean {
+ return (
+ other instanceof RangeSelection &&
+ other.anchor == this.anchor &&
+ other.head == this.head
+ );
+ }
+
+ getBookmark() {
+ return new RangeBookmark(this.anchor, this.head);
+ }
+
+ toJSON(): any {
+ return { type: "range", anchor: this.anchor, head: this.head };
+ }
+
+ /**
+ * Validates if the given positions can form a valid RangeSelection in the given state.
+ *
+ * @param state - The editor state.
+ * @param anchor - The starting position.
+ * @param head - The ending position.
+ * @returns True if the positions form a valid RangeSelection, otherwise false.
+ */
+ static valid(state: EditorState, anchor: number, head: number) {
+ const nodes = rangeNodesBetween(
+ state.doc.resolve(anchor),
+ state.doc.resolve(head)
+ );
+
+ if (nodes.length === 0) {
+ return false;
+ }
+
+ if (nodes.reverse()[0].pos < 0) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Returns a RangeSelection spanning the given positions.
+ *
+ * When the given range includes block-level content, if only a part is included,
+ * the selection will be expanded to encompass the block-level content at the corresponding depth.
+ *
+ * Expansion: If the selection includes all depth nodes of the current block-level content but not the entire last node,
+ * the selection will be expanded to include the node at that depth.
+ *
+ * @param $anchor - The starting position of the selection.
+ * @param $head - The ending position of the selection.
+ * @returns A new RangeSelection that spans the given positions.
+ */
+ static between($anchor: ResolvedPos, $head: ResolvedPos) {
+ checkRangeSelection($anchor, $head);
+
+ const doc = $anchor.doc;
+ const dir = $anchor.pos < $head.pos ? 1 : -1;
+ const anchorPos = dir > 0 ? $anchor.pos : $head.pos;
+ const headPos = dir > 0 ? $head.pos : $anchor.pos;
+ const nodes = rangeNodesBetween($anchor, $head);
+
+ if (nodes.length === 0) {
+ return null;
+ }
+
+ const lastNode = nodes[nodes.length - 1];
+ if (lastNode.pos < 0) {
+ return null;
+ }
+
+ let fromOffset = 0;
+ nodes.forEach(({ pos }) => {
+ if (pos < 0) {
+ fromOffset = pos;
+ }
+ });
+
+ const toOffset =
+ headPos - anchorPos - lastNode.pos - lastNode.node.nodeSize;
+ const anchor =
+ dir > 0
+ ? anchorPos + fromOffset
+ : headPos - (toOffset > 0 ? 0 : toOffset);
+ const head =
+ dir > 0
+ ? headPos - (toOffset > 0 ? 0 : toOffset)
+ : anchorPos + fromOffset;
+ return new RangeSelection(doc.resolve(anchor), doc.resolve(head));
+ }
+
+ static fromJSON(doc: Node, json: any) {
+ if (typeof json.anchor != "number" || typeof json.head != "number") {
+ throw new RangeError("Invalid input for RangeSelection.fromJSON");
+ }
+
+ return new RangeSelection(doc.resolve(json.anchor), doc.resolve(json.head));
+ }
+
+ static create(doc: Node, anchor: number, head: number) {
+ return new this(doc.resolve(anchor), doc.resolve(head));
+ }
+
+ static allRange(doc: Node) {
+ return new RangeSelection(doc.resolve(0), doc.resolve(doc.content.size));
+ }
+}
+
+Selection.jsonID("range", RangeSelection);
+
+class RangeBookmark {
+ constructor(readonly anchor: number, readonly head: number) {}
+
+ map(mapping: Mappable) {
+ return new RangeBookmark(mapping.map(this.anchor), mapping.map(this.head));
+ }
+ resolve(doc: Node) {
+ return new RangeSelection(doc.resolve(this.anchor), doc.resolve(this.head));
+ }
+}
+
+export function checkRangeSelection($anchor: ResolvedPos, $head: ResolvedPos) {
+ if ($anchor.pos === $head.pos) {
+ console.warn("The RangeSelection cannot be empty.");
+ }
+}
+
+export function rangeNodesBetween($anchor: ResolvedPos, $head: ResolvedPos) {
+ const doc = $anchor.doc;
+ const dir = $anchor.pos < $head.pos ? 1 : -1;
+ const anchorPos = dir > 0 ? $anchor.pos : $head.pos;
+ const headPos = dir > 0 ? $head.pos : $anchor.pos;
+
+ const nodes: Array<{
+ node: Node;
+ pos: number;
+ parent: Node | null;
+ index: number;
+ }> = [];
+ doc.nodesBetween(
+ anchorPos,
+ headPos,
+ (node, pos, parent, index) => {
+ if (node.isText || node.type.name === "paragraph") {
+ return true;
+ }
+ nodes.push({ node, pos, parent, index });
+ },
+ -anchorPos
+ );
+ return nodes;
+}
+
+export default RangeSelection;
diff --git a/ui/packages/editor/src/extensions/table/index.ts b/ui/packages/editor/src/extensions/table/index.ts
index 86283cfb0..a904fae5f 100644
--- a/ui/packages/editor/src/extensions/table/index.ts
+++ b/ui/packages/editor/src/extensions/table/index.ts
@@ -208,6 +208,8 @@ class TableView implements NodeView {
}
const Table = TiptapTable.extend({
+ fakeSelection: false,
+
addExtensions() {
return [TableCell, TableRow, TableHeader];
},
diff --git a/ui/packages/editor/src/extensions/table/table-row.ts b/ui/packages/editor/src/extensions/table/table-row.ts
index 8f16a9ab8..a39dcfc91 100644
--- a/ui/packages/editor/src/extensions/table/table-row.ts
+++ b/ui/packages/editor/src/extensions/table/table-row.ts
@@ -2,6 +2,7 @@ import { TableRow as BuiltInTableRow } from "@tiptap/extension-table-row";
const TableRow = BuiltInTableRow.extend({
allowGapCursor: false,
+ fakeSelection: false,
addAttributes() {
return {
diff --git a/ui/packages/editor/src/styles/index.scss b/ui/packages/editor/src/styles/index.scss
index bbe66c4eb..af72d2f31 100644
--- a/ui/packages/editor/src/styles/index.scss
+++ b/ui/packages/editor/src/styles/index.scss
@@ -5,3 +5,4 @@
@import "./search.scss";
@import "./format-brush.scss";
@import "./node-select.scss";
+@import "./range-selection.scss";
diff --git a/ui/packages/editor/src/styles/range-selection.scss b/ui/packages/editor/src/styles/range-selection.scss
new file mode 100644
index 000000000..c3cd02788
--- /dev/null
+++ b/ui/packages/editor/src/styles/range-selection.scss
@@ -0,0 +1,33 @@
+.halo-rich-text-editor {
+ $editorRangeFakeSelection: rgba(27, 162, 227, 0.2);
+
+ .no-selection {
+ *::selection {
+ background-color: transparent;
+ color: inherit;
+ }
+ }
+
+ .range-fake-selection {
+ position: relative;
+
+ &::after {
+ content: " ";
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: $editorRangeFakeSelection;
+ pointer-events: none;
+ z-index: 99;
+ caret-color: transparent;
+ }
+
+ &.column {
+ &::after {
+ border-radius: 5px;
+ }
+ }
+ }
+}
diff --git a/ui/src/components/editor/DefaultEditor.vue b/ui/src/components/editor/DefaultEditor.vue
index f8fe653a6..125e66e79 100644
--- a/ui/src/components/editor/DefaultEditor.vue
+++ b/ui/src/components/editor/DefaultEditor.vue
@@ -49,6 +49,7 @@ import {
ToolboxItem,
lowlight,
type AnyExtension,
+ ExtensionRangeSelection,
} from "@halo-dev/richtext-editor";
// ui custom extension
import { i18n } from "@/locales";
@@ -401,6 +402,7 @@ onMounted(async () => {
ExtensionSearchAndReplace,
ExtensionClearFormat,
ExtensionFormatBrush,
+ ExtensionRangeSelection,
],
parseOptions: {
preserveWhitespace: true,