feat: range selection feature to default editor (#6117)

#### What type of PR is this?

/kind feature
/area editor
/milestone 2.17.x

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

为默认编辑器添加 `RangeSelection` 选择器。

<img width="989" alt="image" src="https://github.com/halo-dev/halo/assets/31335418/c976cf99-0d6e-4346-9b05-8b9b0dc95183">


它的功能基本与 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 选择器
```
pull/6150/head
Takagi 2024-06-26 14:14:50 +08:00 committed by GitHub
parent c1ba566e08
commit ba2987b585
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 402 additions and 0 deletions

View File

@ -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,

View File

@ -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?.(),

View File

@ -175,6 +175,7 @@ const Columns = Node.create({
isolating: true,
allowGapCursor: false,
content: "column{1,}",
fakeSelection: false,
addOptions() {
return {

View File

@ -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,
};

View File

@ -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<Options, Storage> {
/**
* 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<NodeConfig<Options>>["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 };

View File

@ -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;

View File

@ -208,6 +208,8 @@ class TableView implements NodeView {
}
const Table = TiptapTable.extend<ExtensionOptions & TableOptions>({
fakeSelection: false,
addExtensions() {
return [TableCell, TableRow, TableHeader];
},

View File

@ -2,6 +2,7 @@ import { TableRow as BuiltInTableRow } from "@tiptap/extension-table-row";
const TableRow = BuiltInTableRow.extend({
allowGapCursor: false,
fakeSelection: false,
addAttributes() {
return {

View File

@ -5,3 +5,4 @@
@import "./search.scss";
@import "./format-brush.scss";
@import "./node-select.scss";
@import "./range-selection.scss";

View File

@ -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;
}
}
}
}

View File

@ -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,