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,
|
||||
ExtensionClearFormat,
|
||||
ExtensionFormatBrush,
|
||||
ExtensionRangeSelection,
|
||||
} from "../index";
|
||||
|
||||
const content = useLocalStorage("content", "");
|
||||
|
@ -114,6 +115,7 @@ const editor = useEditor({
|
|||
ExtensionSearchAndReplace,
|
||||
ExtensionClearFormat,
|
||||
ExtensionFormatBrush,
|
||||
ExtensionRangeSelection,
|
||||
],
|
||||
parseOptions: {
|
||||
preserveWhitespace: true,
|
||||
|
|
|
@ -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?.(),
|
||||
|
|
|
@ -175,6 +175,7 @@ const Columns = Node.create({
|
|||
isolating: true,
|
||||
allowGapCursor: false,
|
||||
content: "column{1,}",
|
||||
fakeSelection: false,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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>({
|
||||
fakeSelection: false,
|
||||
|
||||
addExtensions() {
|
||||
return [TableCell, TableRow, TableHeader];
|
||||
},
|
||||
|
|
|
@ -2,6 +2,7 @@ import { TableRow as BuiltInTableRow } from "@tiptap/extension-table-row";
|
|||
|
||||
const TableRow = BuiltInTableRow.extend({
|
||||
allowGapCursor: false,
|
||||
fakeSelection: false,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
|
|
|
@ -5,3 +5,4 @@
|
|||
@import "./search.scss";
|
||||
@import "./format-brush.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,
|
||||
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,
|
||||
|
|
Loading…
Reference in New Issue