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,