mirror of https://github.com/halo-dev/halo
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
parent
c1ba566e08
commit
ba2987b585
|
@ -46,6 +46,7 @@ import {
|
||||||
ExtensionSearchAndReplace,
|
ExtensionSearchAndReplace,
|
||||||
ExtensionClearFormat,
|
ExtensionClearFormat,
|
||||||
ExtensionFormatBrush,
|
ExtensionFormatBrush,
|
||||||
|
ExtensionRangeSelection,
|
||||||
} from "../index";
|
} from "../index";
|
||||||
|
|
||||||
const content = useLocalStorage("content", "");
|
const content = useLocalStorage("content", "");
|
||||||
|
@ -114,6 +115,7 @@ const editor = useEditor({
|
||||||
ExtensionSearchAndReplace,
|
ExtensionSearchAndReplace,
|
||||||
ExtensionClearFormat,
|
ExtensionClearFormat,
|
||||||
ExtensionFormatBrush,
|
ExtensionFormatBrush,
|
||||||
|
ExtensionRangeSelection,
|
||||||
],
|
],
|
||||||
parseOptions: {
|
parseOptions: {
|
||||||
preserveWhitespace: true,
|
preserveWhitespace: true,
|
||||||
|
|
|
@ -100,6 +100,9 @@ const getRenderContainer = (node: HTMLElement) => {
|
||||||
export default CodeBlockLowlight.extend<
|
export default CodeBlockLowlight.extend<
|
||||||
CustomCodeBlockLowlightOptions & CodeBlockLowlightOptions
|
CustomCodeBlockLowlightOptions & CodeBlockLowlightOptions
|
||||||
>({
|
>({
|
||||||
|
// It needs to have a higher priority than range-selection,
|
||||||
|
// otherwise the Mod-a shortcut key will be overridden.
|
||||||
|
priority: 110,
|
||||||
addCommands() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
...this.parent?.(),
|
...this.parent?.(),
|
||||||
|
|
|
@ -175,6 +175,7 @@ const Columns = Node.create({
|
||||||
isolating: true,
|
isolating: true,
|
||||||
allowGapCursor: false,
|
allowGapCursor: false,
|
||||||
content: "column{1,}",
|
content: "column{1,}",
|
||||||
|
fakeSelection: false,
|
||||||
|
|
||||||
addOptions() {
|
addOptions() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -45,6 +45,7 @@ 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";
|
import ExtensionFormatBrush from "./format-brush";
|
||||||
|
import { ExtensionRangeSelection, RangeSelection } from "./range-selection";
|
||||||
|
|
||||||
const allExtensions = [
|
const allExtensions = [
|
||||||
ExtensionBlockquote,
|
ExtensionBlockquote,
|
||||||
|
@ -104,6 +105,7 @@ const allExtensions = [
|
||||||
ExtensionSearchAndReplace,
|
ExtensionSearchAndReplace,
|
||||||
ExtensionClearFormat,
|
ExtensionClearFormat,
|
||||||
ExtensionFormatBrush,
|
ExtensionFormatBrush,
|
||||||
|
ExtensionRangeSelection,
|
||||||
];
|
];
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -153,4 +155,6 @@ export {
|
||||||
ExtensionSearchAndReplace,
|
ExtensionSearchAndReplace,
|
||||||
ExtensionClearFormat,
|
ExtensionClearFormat,
|
||||||
ExtensionFormatBrush,
|
ExtensionFormatBrush,
|
||||||
|
ExtensionRangeSelection,
|
||||||
|
RangeSelection,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 };
|
|
@ -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;
|
|
@ -208,6 +208,8 @@ class TableView implements NodeView {
|
||||||
}
|
}
|
||||||
|
|
||||||
const Table = TiptapTable.extend<ExtensionOptions & TableOptions>({
|
const Table = TiptapTable.extend<ExtensionOptions & TableOptions>({
|
||||||
|
fakeSelection: false,
|
||||||
|
|
||||||
addExtensions() {
|
addExtensions() {
|
||||||
return [TableCell, TableRow, TableHeader];
|
return [TableCell, TableRow, TableHeader];
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { TableRow as BuiltInTableRow } from "@tiptap/extension-table-row";
|
||||||
|
|
||||||
const TableRow = BuiltInTableRow.extend({
|
const TableRow = BuiltInTableRow.extend({
|
||||||
allowGapCursor: false,
|
allowGapCursor: false,
|
||||||
|
fakeSelection: false,
|
||||||
|
|
||||||
addAttributes() {
|
addAttributes() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -5,3 +5,4 @@
|
||||||
@import "./search.scss";
|
@import "./search.scss";
|
||||||
@import "./format-brush.scss";
|
@import "./format-brush.scss";
|
||||||
@import "./node-select.scss";
|
@import "./node-select.scss";
|
||||||
|
@import "./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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -49,6 +49,7 @@ import {
|
||||||
ToolboxItem,
|
ToolboxItem,
|
||||||
lowlight,
|
lowlight,
|
||||||
type AnyExtension,
|
type AnyExtension,
|
||||||
|
ExtensionRangeSelection,
|
||||||
} from "@halo-dev/richtext-editor";
|
} from "@halo-dev/richtext-editor";
|
||||||
// ui custom extension
|
// ui custom extension
|
||||||
import { i18n } from "@/locales";
|
import { i18n } from "@/locales";
|
||||||
|
@ -401,6 +402,7 @@ onMounted(async () => {
|
||||||
ExtensionSearchAndReplace,
|
ExtensionSearchAndReplace,
|
||||||
ExtensionClearFormat,
|
ExtensionClearFormat,
|
||||||
ExtensionFormatBrush,
|
ExtensionFormatBrush,
|
||||||
|
ExtensionRangeSelection,
|
||||||
],
|
],
|
||||||
parseOptions: {
|
parseOptions: {
|
||||||
preserveWhitespace: true,
|
preserveWhitespace: true,
|
||||||
|
|
Loading…
Reference in New Issue