mirror of https://github.com/halo-dev/halo
feat: add gap cursor for top-level block nodes in default editor (#6103)
#### What type of PR is this? /kind feature /area editor /milestone 2.17.x #### What this PR does / why we need it: 目前想对块级节点进行换行是一件比较困难的事情,尤其是两个相邻的块级节点之间的想插入额外的一行时更加困难。间隙光标可以解决这一问题。 为默认编辑器的顶级块节点(pos.depth = 1)增加间隙光标的功能。当 NodeType 属性 `allowGapCursor` 为 true 时,将会在目标节点上启用间隙光标的功能。间隙光标将可能出现在目标节点的左上方与右下方。 <img width="909" alt="image" src="https://github.com/halo-dev/halo/assets/31335418/fbbdc8fe-59c9-4ae3-a7c8-97a90607c785"> 已知问题: 1. 对于 inlineContent 的节点,点击生成间隙光标时,光标会先出现在对应的内容上,会出现闪动的问题。 2. 在间隙光标上使用组合输入(例如中文输入)时,首个字母会被新增至新的一行。 3. CodeBlock 无法使用间隙光标(CodeBlock 自身问题,待适配) ~~4. 首行空文本无法被删除(与 Gap Cursor 问题无关,待适配 Paragraph)~~ ~~5. 删除文本上方有可以添加间隙光标的块级节点时,无法触发间隙光标的 Backspace 事件(同 4,属于 Paragraph 适配问题)~~ 目前已经启用此功能的节点: - 表格 - 分栏卡片 - CodeBlock (无法生效) #### How to test it? 测试间隙光标是否能够在表格与分栏卡片上出现。 测试间隙光标出现后,输入文本、使用快捷键等操作是否符合逻辑。 测试使用方向键调整间隙光标位置。 #### Does this PR introduce a user-facing change? ```release-note 为默认编辑器的块级节点增加间隙光标的功能。 ```pull/6116/head
parent
a93479dc34
commit
73798e86c6
|
@ -51,7 +51,6 @@
|
|||
"@tiptap/extension-color": "^2.4.0",
|
||||
"@tiptap/extension-document": "^2.4.0",
|
||||
"@tiptap/extension-dropcursor": "^2.4.0",
|
||||
"@tiptap/extension-gapcursor": "^2.4.0",
|
||||
"@tiptap/extension-hard-break": "^2.4.0",
|
||||
"@tiptap/extension-heading": "^2.4.0",
|
||||
"@tiptap/extension-highlight": "^2.4.0",
|
||||
|
|
|
@ -100,6 +100,7 @@ const getRenderContainer = (node: HTMLElement) => {
|
|||
export default CodeBlockLowlight.extend<
|
||||
CustomCodeBlockLowlightOptions & CodeBlockLowlightOptions
|
||||
>({
|
||||
allowGapCursor: true,
|
||||
// It needs to have a higher priority than range-selection,
|
||||
// otherwise the Mod-a shortcut key will be overridden.
|
||||
priority: 110,
|
||||
|
|
|
@ -173,7 +173,7 @@ const Columns = Node.create({
|
|||
priority: 10,
|
||||
defining: true,
|
||||
isolating: true,
|
||||
allowGapCursor: false,
|
||||
allowGapCursor: true,
|
||||
content: "column{1,}",
|
||||
fakeSelection: false,
|
||||
|
||||
|
|
|
@ -0,0 +1,240 @@
|
|||
import type { PMNode } from "@/tiptap";
|
||||
import {
|
||||
NodeSelection,
|
||||
ResolvedPos,
|
||||
Selection,
|
||||
Slice,
|
||||
type Mappable,
|
||||
} from "@/tiptap/pm";
|
||||
|
||||
class GapCursorSelection extends Selection {
|
||||
private start: boolean | null = false;
|
||||
|
||||
constructor($pos: ResolvedPos) {
|
||||
super($pos, $pos);
|
||||
this.start = isNodeStart($pos);
|
||||
}
|
||||
|
||||
map(doc: PMNode, mapping: Mappable): Selection {
|
||||
const $pos = doc.resolve(mapping.map(this.head));
|
||||
return GapCursorSelection.valid($pos)
|
||||
? new GapCursorSelection($pos)
|
||||
: Selection.near($pos);
|
||||
}
|
||||
|
||||
content() {
|
||||
return Slice.empty;
|
||||
}
|
||||
|
||||
eq(other: Selection): boolean {
|
||||
return other instanceof GapCursorSelection && other.head == this.head;
|
||||
}
|
||||
|
||||
toJSON(): any {
|
||||
return { type: "node-gap-cursor", pos: this.head };
|
||||
}
|
||||
|
||||
get isStart() {
|
||||
return this.start;
|
||||
}
|
||||
|
||||
static fromJSON(doc: PMNode, json: any): GapCursorSelection {
|
||||
if (typeof json.pos != "number") {
|
||||
throw new RangeError("Invalid input for GapCursorSelection.fromJSON");
|
||||
}
|
||||
return new GapCursorSelection(doc.resolve(json.pos));
|
||||
}
|
||||
|
||||
getBookmark() {
|
||||
return new GapBookmark(this.anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates if a GapCursor can be placed at the given position
|
||||
*
|
||||
* This function checks whether a GapCursor can be placed at the specified position in the document.
|
||||
* It ensures that the position is not within a text block, and that the node at the position allows a GapCursor.
|
||||
*
|
||||
* @param {ResolvedPos} $pos - The resolved position in the document to validate.
|
||||
* @returns {boolean} - Returns true if a GapCursor can be placed at the given position, false otherwise.
|
||||
*/
|
||||
static valid($pos: ResolvedPos) {
|
||||
if ($pos.depth < 1) {
|
||||
return false;
|
||||
}
|
||||
// Get the node at the current position
|
||||
const nodeOffset = $pos.doc.childBefore($pos.pos);
|
||||
const root = nodeOffset.node;
|
||||
if (!root) {
|
||||
return false;
|
||||
}
|
||||
const parent = $pos.parent;
|
||||
if (parent.isTextblock || (!closedBefore($pos) && !closedAfter($pos))) {
|
||||
return false;
|
||||
}
|
||||
// Check if the node allows a GapCursor
|
||||
const override = root.type.spec.allowGapCursor;
|
||||
if (!override) {
|
||||
return false;
|
||||
}
|
||||
return !root.type.inlineContent;
|
||||
}
|
||||
|
||||
static findGapCursorFrom($pos: ResolvedPos, dir: number, mustMove = false) {
|
||||
let keepSearching = true;
|
||||
while (keepSearching) {
|
||||
if (!mustMove && GapCursorSelection.valid($pos)) {
|
||||
return $pos;
|
||||
}
|
||||
let pos = $pos.pos;
|
||||
let next: PMNode | null = null;
|
||||
|
||||
// Scan up from this position
|
||||
for (let d = $pos.depth; d >= 0; d--) {
|
||||
const parent = $pos.node(d);
|
||||
const index = dir > 0 ? $pos.indexAfter(d) : $pos.index(d) - 1;
|
||||
|
||||
if (dir > 0 ? index < parent.childCount : index >= 0) {
|
||||
next = parent.child(index);
|
||||
break;
|
||||
}
|
||||
|
||||
if (d == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
pos += dir;
|
||||
const $cur = $pos.doc.resolve(pos);
|
||||
if (GapCursorSelection.valid($cur)) {
|
||||
return $cur;
|
||||
}
|
||||
}
|
||||
|
||||
// And then down into the next node
|
||||
while (next) {
|
||||
const inside = dir > 0 ? next.firstChild : next.lastChild;
|
||||
if (!inside) {
|
||||
if (
|
||||
next.isAtom &&
|
||||
!next.isText &&
|
||||
!NodeSelection.isSelectable(next)
|
||||
) {
|
||||
$pos = $pos.doc.resolve(pos + next.nodeSize * dir);
|
||||
mustMove = false;
|
||||
break;
|
||||
}
|
||||
keepSearching = false;
|
||||
break;
|
||||
}
|
||||
next = inside;
|
||||
pos += dir;
|
||||
const $cur = $pos.doc.resolve(pos);
|
||||
if (GapCursorSelection.valid($cur)) {
|
||||
return $cur;
|
||||
}
|
||||
}
|
||||
|
||||
if (!next) {
|
||||
keepSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
GapCursorSelection.prototype.visible = false;
|
||||
(GapCursorSelection as any).findFrom = GapCursorSelection.findGapCursorFrom;
|
||||
|
||||
Selection.jsonID("node-gap-cursor", GapCursorSelection);
|
||||
|
||||
class GapBookmark {
|
||||
constructor(readonly pos: number) {}
|
||||
|
||||
map(mapping: Mappable) {
|
||||
return new GapBookmark(mapping.map(this.pos));
|
||||
}
|
||||
|
||||
resolve(doc: PMNode) {
|
||||
const $pos = doc.resolve(this.pos);
|
||||
return GapCursorSelection.valid($pos)
|
||||
? new GapCursorSelection($pos)
|
||||
: Selection.near($pos);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the position before the given resolved position is closed
|
||||
*
|
||||
* This function traverses up the document tree from the given resolved position and checks if the position
|
||||
* immediately before it is closed. A position is considered closed if the previous node is closed or
|
||||
* if the parent node is isolating.
|
||||
*
|
||||
* @param {ResolvedPos} $pos - The resolved position in the document to check.
|
||||
* @returns {boolean} - Returns true if the position before the given position is closed, false otherwise.
|
||||
*/
|
||||
export function closedBefore($pos: ResolvedPos) {
|
||||
for (let d = $pos.depth; d >= 0; d--) {
|
||||
const index = $pos.index(d);
|
||||
const parent = $pos.node(d);
|
||||
|
||||
if (index === 0) {
|
||||
if (parent.type.spec.isolating) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNodeClosed(parent.child(index - 1), false)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function closedAfter($pos: ResolvedPos) {
|
||||
for (let d = $pos.depth; d >= 0; d--) {
|
||||
const index = $pos.indexAfter(d);
|
||||
const parent = $pos.node(d);
|
||||
|
||||
if (index === parent.childCount) {
|
||||
if (parent.type.spec.isolating) {
|
||||
return true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isNodeClosed(parent.child(index), true)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isNodeClosed(node: PMNode, isAfter: boolean): boolean {
|
||||
while (node) {
|
||||
if (
|
||||
(node.childCount === 0 && !node.inlineContent) ||
|
||||
node.isAtom ||
|
||||
node.type.spec.isolating
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (node.inlineContent) {
|
||||
return false;
|
||||
}
|
||||
node = (isAfter ? node.firstChild : node.lastChild) as PMNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isNodeStart($pos: ResolvedPos) {
|
||||
if ($pos.depth < 1) {
|
||||
return null;
|
||||
}
|
||||
const startPos = $pos.start(1);
|
||||
const endPos = $pos.end(1);
|
||||
return $pos.pos < startPos + (endPos - startPos) / 2;
|
||||
}
|
||||
|
||||
export default GapCursorSelection;
|
|
@ -0,0 +1,394 @@
|
|||
import type {
|
||||
Dispatch,
|
||||
EditorState,
|
||||
EditorView,
|
||||
ResolvedPos,
|
||||
Transaction,
|
||||
} from "@/tiptap";
|
||||
import {
|
||||
Extension,
|
||||
callOrReturn,
|
||||
getExtensionField,
|
||||
isActive,
|
||||
type ParentConfig,
|
||||
} from "@/tiptap/core";
|
||||
import {
|
||||
AllSelection,
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
Plugin,
|
||||
PluginKey,
|
||||
TextSelection,
|
||||
keydownHandler,
|
||||
type Command,
|
||||
} from "@/tiptap/pm";
|
||||
import { deleteNodeByPos } from "@/utils";
|
||||
import { isEmpty } from "@/utils/isNodeEmpty";
|
||||
import GapCursorSelection from "./gap-cursor-selection";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface NodeConfig<Options, Storage> {
|
||||
allowGapCursor?:
|
||||
| boolean
|
||||
| null
|
||||
| ((this: {
|
||||
name: string;
|
||||
options: Options;
|
||||
storage: Storage;
|
||||
parent: ParentConfig<NodeConfig<Options>>["allowGapCursor"];
|
||||
}) => boolean | null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds GapCursor to top-level nodes
|
||||
*
|
||||
* When the top-level nodes (nodes with a depth of 1 relative to the doc) have the {@link NodeConfig#allowGapCursor} attribute set to true,
|
||||
* a GapCursor can be inserted before and after these nodes.
|
||||
*
|
||||
* This extension provides the ability to navigate between these nodes using the arrow keys.
|
||||
*
|
||||
* Note that some nodes and shortcuts may conflict with GapCursor due to their own behaviors, such as:
|
||||
* - CodeBlock nodes
|
||||
* - Backspace on an empty line
|
||||
* - Tab key
|
||||
*/
|
||||
const GapCursor = Extension.create({
|
||||
priority: 9999,
|
||||
name: "gapCursor",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("custom-gap-cursor"),
|
||||
props: {
|
||||
decorations: drawGapCursor,
|
||||
|
||||
// If a GapCursor can be created at the current position, use GapCursor instead of other selection types
|
||||
createSelectionBetween(_view, $anchor, $head) {
|
||||
// TODO: When clicking outside of a node, it will first generate a GapCursorSelection,
|
||||
// and then after handleClick returns false, it will turn into a TextSelection.
|
||||
// The reason for this issue is that createSelectionBetween is triggered first, at which point
|
||||
// GapCursorSelection.valid($head) validation fails, and then handleClick is triggered, at which point validation succeeds.
|
||||
return $anchor.pos == $head.pos && GapCursorSelection.valid($head)
|
||||
? new GapCursorSelection($head)
|
||||
: null;
|
||||
},
|
||||
|
||||
handleClick(view, pos, event) {
|
||||
if (!view || !view.editable) {
|
||||
return false;
|
||||
}
|
||||
const clickPos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
// Skip if the click position is inside a node.
|
||||
if (clickPos && clickPos.inside > -1) {
|
||||
return false;
|
||||
}
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
if (!GapCursorSelection.valid($pos)) {
|
||||
return false;
|
||||
}
|
||||
view.dispatch(
|
||||
view.state.tr.setSelection(new GapCursorSelection($pos))
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
handleKeyDown: keydownHandler({
|
||||
ArrowLeft: arrow("horiz", -1),
|
||||
ArrowRight: arrow("horiz", 1),
|
||||
ArrowUp: arrow("vert", -1),
|
||||
ArrowDown: arrow("vert", 1),
|
||||
Enter: (state, dispatch) => {
|
||||
const tr = createParagraphNearByGapCursor(state, false);
|
||||
if (tr && dispatch) {
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
Backspace: (state, dispatch) => {
|
||||
const { selection, tr } = state;
|
||||
if (
|
||||
isActive(state, "paragraph") &&
|
||||
isEmpty(state.selection.$from.parent) &&
|
||||
selection instanceof TextSelection &&
|
||||
selection.empty
|
||||
) {
|
||||
const { $from } = selection;
|
||||
deleteNodeByPos($from)(tr);
|
||||
if (dispatch) {
|
||||
const $found = arrowGapCursor(-1, "left", state)(tr);
|
||||
if ($found) {
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!(selection instanceof GapCursorSelection) || !dispatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { isStart, $from } = selection;
|
||||
const nodeOffset = state.doc.childBefore($from.pos);
|
||||
const index = nodeOffset.index;
|
||||
const pos = state.doc.resolve(0).posAtIndex(index);
|
||||
|
||||
if (isStart) {
|
||||
return handleBackspaceAtStart(pos, state, dispatch);
|
||||
} else if (
|
||||
nodeOffset.node &&
|
||||
deleteNodeByPos(state.doc.resolve(pos))(tr)
|
||||
) {
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
Tab: (state, dispatch) => {
|
||||
const tr = createParagraphNearByGapCursor(state);
|
||||
if (tr && dispatch) {
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
|
||||
handleTextInput(view) {
|
||||
const { state, dispatch } = view;
|
||||
const tr = createParagraphNearByGapCursor(state);
|
||||
if (tr && dispatch) {
|
||||
dispatch(tr);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
handleDOMEvents: {
|
||||
/**
|
||||
* Solve the issue of inserting text during composition input events, e.g., Chinese input
|
||||
*/
|
||||
beforeinput: (view, event) => {
|
||||
const { state, dispatch } = view;
|
||||
if (
|
||||
event.inputType != "insertCompositionText" ||
|
||||
!(state.selection instanceof GapCursorSelection)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// TODO: After creating a new node, due to the change in selection, the content of the composition input
|
||||
// will be inserted into the newly created node, causing the first character to be created directly.
|
||||
const tr = createParagraphNearByGapCursor(state);
|
||||
if (tr && dispatch) {
|
||||
dispatch(tr);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
extendNodeSchema(extension) {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
};
|
||||
|
||||
return {
|
||||
allowGapCursor:
|
||||
callOrReturn(getExtensionField(extension, "allowGapCursor", context)) ??
|
||||
null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function handleBackspaceAtStart(
|
||||
pos: number,
|
||||
state: EditorState,
|
||||
dispatch: Dispatch
|
||||
) {
|
||||
const { tr } = state;
|
||||
if (pos == 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $beforePos = state.doc.resolve(pos - 1);
|
||||
const parent = $beforePos.parent;
|
||||
|
||||
if (parent.inlineContent || parent.isTextblock) {
|
||||
return handleInlineContent($beforePos, state, dispatch);
|
||||
}
|
||||
|
||||
if (GapCursorSelection.valid($beforePos) && dispatch) {
|
||||
dispatch(tr.setSelection(new GapCursorSelection($beforePos)));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (deleteNodeByPos($beforePos)(tr) && dispatch) {
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function handleInlineContent(
|
||||
$beforePos: ResolvedPos,
|
||||
state: EditorState,
|
||||
dispatch: Dispatch
|
||||
) {
|
||||
if ($beforePos.parentOffset == 0 && $beforePos.pos > 1 && dispatch) {
|
||||
dispatch(state.tr.delete($beforePos.pos - 1, $beforePos.pos));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
dispatch(
|
||||
state.tr.setSelection(TextSelection.create(state.doc, $beforePos.pos))
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles arrow key navigation for GapCursor
|
||||
*
|
||||
* This function determines the direction (vertical or horizontal) and
|
||||
* the movement (positive or negative) based on the axis and direction parameters.
|
||||
*
|
||||
* @param {("vert" | "horiz")} axis - The axis of movement, either vertical ("vert") or horizontal ("horiz").
|
||||
* @param {number} dir - The direction of movement, positive (1) or negative (-1).
|
||||
*/
|
||||
export function arrow(axis: "vert" | "horiz", dir: number): Command {
|
||||
const dirStr =
|
||||
axis == "vert" ? (dir > 0 ? "down" : "up") : dir > 0 ? "right" : "left";
|
||||
return (state, dispatch, view) => {
|
||||
const { tr } = state;
|
||||
if (arrowGapCursor(dir, dirStr, state, view)(tr) && dispatch) {
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
export const arrowGapCursor = (
|
||||
dir: number,
|
||||
dirStr: any,
|
||||
state: EditorState,
|
||||
view?: EditorView
|
||||
) => {
|
||||
return (tr: Transaction) => {
|
||||
const sel = state.selection;
|
||||
let $start = dir > 0 ? sel.$to : sel.$from;
|
||||
let mustMove = sel.empty;
|
||||
if (sel instanceof TextSelection) {
|
||||
// Do nothing if the next node is not at the end of the document or is at the root node.
|
||||
if ($start.depth == 0) {
|
||||
return;
|
||||
}
|
||||
if (view && !view.endOfTextblock(dirStr)) {
|
||||
return;
|
||||
}
|
||||
mustMove = false;
|
||||
$start = state.doc.resolve(dir > 0 ? $start.after() : $start.before());
|
||||
// If inside a node, check if it has reached the boundary of the node
|
||||
if ($start.depth > 0) {
|
||||
const pos = $start.pos;
|
||||
const start = $start.start(1) + 1;
|
||||
const end = $start.end(1) - 1;
|
||||
if (pos != start && pos != end) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sel instanceof GapCursorSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const $found = GapCursorSelection.findGapCursorFrom($start, dir, mustMove);
|
||||
if (!$found) {
|
||||
return;
|
||||
}
|
||||
tr.setSelection(new GapCursorSelection($found));
|
||||
return $found;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new paragraph near the current GapCursor
|
||||
*
|
||||
* If the current selection is a GapCursorSelection, this function creates a new paragraph node
|
||||
* either before or after the current node depending on the selection direction.
|
||||
*
|
||||
* @param {EditorState} state - The current editor state.
|
||||
* @param {boolean} [changeSelection=true] - Whether to change the selection to the new paragraph.
|
||||
* @returns {Transaction|undefined} - The updated transaction or undefined if no changes are made.
|
||||
*/
|
||||
function createParagraphNearByGapCursor(
|
||||
state: EditorState,
|
||||
changeSelection = true
|
||||
) {
|
||||
const { tr } = state;
|
||||
if (!(state.selection instanceof GapCursorSelection)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { isStart, $from } = state.selection;
|
||||
// Create a new paragraph node before the current node
|
||||
if (state.selection instanceof AllSelection || $from.parent.inlineContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const docPos = state.doc.resolve(0);
|
||||
const nodeOffset = state.doc.childBefore($from.pos);
|
||||
const index = isStart ? nodeOffset.index : nodeOffset.index + 1;
|
||||
const pos = docPos.posAtIndex(index);
|
||||
tr.insert(pos, state.schema.nodes.paragraph.create());
|
||||
if (changeSelection || !isStart) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, pos + 1));
|
||||
tr.scrollIntoView();
|
||||
}
|
||||
|
||||
return tr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws a visual representation of the GapCursor
|
||||
*
|
||||
* If the current selection is a GapCursorSelection, this function creates a decoration set
|
||||
* to visually indicate the presence of a GapCursor at the appropriate position.
|
||||
*
|
||||
* @param {EditorState} state - The current editor state.
|
||||
* @returns {DecorationSet|null} - The decoration set for the GapCursor or null if not applicable.
|
||||
*/
|
||||
function drawGapCursor(state: EditorState) {
|
||||
if (!(state.selection instanceof GapCursorSelection)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const $head = state.selection.$head;
|
||||
if ($head.depth < 1) {
|
||||
return null;
|
||||
}
|
||||
const node = $head.node(1);
|
||||
const pos = $head.start(1) - 1;
|
||||
const isStart = state.selection.isStart;
|
||||
return DecorationSet.create(state.doc, [
|
||||
Decoration.node(pos, pos + node.nodeSize, {
|
||||
key: "node-gap-cursor",
|
||||
class: `card-gap-cursor ${isStart ? "start" : "end"}-card-gap-cursor`,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export default GapCursor;
|
|
@ -2,7 +2,6 @@
|
|||
import { i18n } from "@/locales";
|
||||
import ExtensionDocument from "@tiptap/extension-document";
|
||||
import ExtensionDropcursor from "@tiptap/extension-dropcursor";
|
||||
import ExtensionGapcursor from "@tiptap/extension-gapcursor";
|
||||
import ExtensionHardBreak from "@tiptap/extension-hard-break";
|
||||
import ExtensionHorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||
import ExtensionPlaceholder from "@tiptap/extension-placeholder";
|
||||
|
@ -37,6 +36,7 @@ import ExtensionClearFormat from "./clear-format";
|
|||
import { ExtensionColumn, ExtensionColumns } from "./columns";
|
||||
import ExtensionDraggable from "./draggable";
|
||||
import ExtensionFormatBrush from "./format-brush";
|
||||
import ExtensionGapcursor from "./gap-cursor";
|
||||
import ExtensionIframe from "./iframe";
|
||||
import ExtensionImage from "./image";
|
||||
import ExtensionIndent from "./indent";
|
||||
|
|
|
@ -1,8 +1,17 @@
|
|||
import ToolbarItem from "@/components/toolbar/ToolbarItem.vue";
|
||||
import ToolbarSubItem from "@/components/toolbar/ToolbarSubItem.vue";
|
||||
import { i18n } from "@/locales";
|
||||
import type { Editor } from "@/tiptap";
|
||||
import {
|
||||
EditorState,
|
||||
ResolvedPos,
|
||||
TextSelection,
|
||||
isActive,
|
||||
type Dispatch,
|
||||
type Editor,
|
||||
} from "@/tiptap";
|
||||
import type { ExtensionOptions, ToolbarItem as TypeToolbarItem } from "@/types";
|
||||
import { deleteNodeByPos } from "@/utils";
|
||||
import { isEmpty } from "@/utils/isNodeEmpty";
|
||||
import type { ParagraphOptions } from "@tiptap/extension-paragraph";
|
||||
import TiptapParagraph from "@tiptap/extension-paragraph";
|
||||
import { markRaw } from "vue";
|
||||
|
@ -85,6 +94,93 @@ const Paragraph = TiptapParagraph.extend<ExtensionOptions & ParagraphOptions>({
|
|||
},
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
Backspace: ({ editor }) => {
|
||||
const { state, view } = editor;
|
||||
const { selection } = state;
|
||||
|
||||
if (
|
||||
!isActive(state, Paragraph.name) ||
|
||||
!(selection instanceof TextSelection) ||
|
||||
!selection.empty
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { $from } = selection;
|
||||
|
||||
if ($from.parentOffset !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const beforePos = $from.before($from.depth);
|
||||
|
||||
if (isEmpty($from.parent)) {
|
||||
return deleteCurrentNodeAndSetSelection(
|
||||
$from,
|
||||
beforePos,
|
||||
state,
|
||||
view.dispatch
|
||||
);
|
||||
}
|
||||
|
||||
if (beforePos === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return handleDeletePreviousNode($from, beforePos, state, view.dispatch);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export function deleteCurrentNodeAndSetSelection(
|
||||
$from: ResolvedPos,
|
||||
beforePos: number,
|
||||
state: EditorState,
|
||||
dispatch: Dispatch
|
||||
) {
|
||||
const { tr } = state;
|
||||
if (deleteNodeByPos($from)(tr) && dispatch) {
|
||||
if (beforePos !== 0) {
|
||||
tr.setSelection(TextSelection.create(tr.doc, beforePos - 1));
|
||||
}
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function handleDeletePreviousNode(
|
||||
$from: ResolvedPos,
|
||||
beforePos: number,
|
||||
state: EditorState,
|
||||
dispatch: Dispatch
|
||||
) {
|
||||
const { tr } = state;
|
||||
if (!dispatch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $beforePos = $from.doc.resolve(beforePos);
|
||||
const nodeBefore = $beforePos.nodeBefore;
|
||||
|
||||
if (
|
||||
!nodeBefore ||
|
||||
!nodeBefore.type.isBlock ||
|
||||
nodeBefore.type.isText ||
|
||||
nodeBefore.type.name === Paragraph.name
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (deleteNodeByPos($from.doc.resolve(beforePos - 1))(tr)) {
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export default Paragraph;
|
||||
|
|
|
@ -208,6 +208,7 @@ class TableView implements NodeView {
|
|||
}
|
||||
|
||||
const Table = TiptapTable.extend<ExtensionOptions & TableOptions>({
|
||||
allowGapCursor: true,
|
||||
fakeSelection: false,
|
||||
|
||||
addExtensions() {
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
.halo-rich-text-editor {
|
||||
.ProseMirror {
|
||||
&.ProseMirror-focused {
|
||||
.card-gap-cursor {
|
||||
&::before {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
.card-gap-cursor {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-gap-cursor {
|
||||
&::before {
|
||||
content: "";
|
||||
display: none;
|
||||
position: absolute;
|
||||
min-width: 1px;
|
||||
height: 1em;
|
||||
font-size: 1em;
|
||||
border-left: 1px solid black;
|
||||
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.start-card-gap-cursor::before {
|
||||
top: -2px;
|
||||
left: -1px;
|
||||
}
|
||||
|
||||
.end-card-gap-cursor::before {
|
||||
bottom: -2px;
|
||||
right: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ProseMirror-cursor-blink {
|
||||
to {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
|
@ -4,5 +4,6 @@
|
|||
@import "./columns.scss";
|
||||
@import "./search.scss";
|
||||
@import "./format-brush.scss";
|
||||
@import "./gap-cursor.scss";
|
||||
@import "./node-select.scss";
|
||||
@import "./range-selection.scss";
|
||||
|
|
|
@ -1,9 +1,37 @@
|
|||
import { NodeSelection, Transaction, type ResolvedPos } from "@/tiptap/pm";
|
||||
import type { Editor } from "@/tiptap/vue-3";
|
||||
|
||||
export const deleteNodeByPos = ($pos: ResolvedPos) => {
|
||||
return (tr: Transaction) => {
|
||||
if ($pos.depth) {
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
tr.delete($pos.before(d), $pos.after(d)).scrollIntoView();
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
const node = $pos.parent;
|
||||
if (!node.isTextblock && node.nodeSize) {
|
||||
tr.setSelection(
|
||||
NodeSelection.create($pos.doc, $pos.pos)
|
||||
).deleteSelection();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const pos = $pos.pos;
|
||||
|
||||
if (pos) {
|
||||
tr.delete(pos, pos + $pos.node().nodeSize);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteNode = (nodeType: string, editor: Editor) => {
|
||||
const { state } = editor;
|
||||
const $pos = state.selection.$anchor;
|
||||
let done = false;
|
||||
const done = false;
|
||||
|
||||
if ($pos.depth) {
|
||||
for (let d = $pos.depth; d > 0; d--) {
|
||||
|
@ -15,7 +43,7 @@ export const deleteNode = (nodeType: string, editor: Editor) => {
|
|||
editor.dispatchTransaction(
|
||||
state.tr.delete($pos.before(d), $pos.after(d)).scrollIntoView()
|
||||
);
|
||||
done = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -23,7 +51,7 @@ export const deleteNode = (nodeType: string, editor: Editor) => {
|
|||
const node = state.selection.node;
|
||||
if (node && node.type.name === nodeType) {
|
||||
editor.chain().deleteSelection().run();
|
||||
done = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -38,7 +66,7 @@ export const deleteNode = (nodeType: string, editor: Editor) => {
|
|||
if (editor.dispatchTransaction)
|
||||
// @ts-ignore
|
||||
editor.dispatchTransaction(state.tr.delete(pos, pos + node.nodeSize));
|
||||
done = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,19 @@
|
|||
import { PMNode } from "@/tiptap";
|
||||
|
||||
export const isEmpty = (node: PMNode) => {
|
||||
return isNodeDefault(node) || isParagraphEmpty(node);
|
||||
};
|
||||
|
||||
export const isNodeDefault = (node: PMNode) => {
|
||||
const defaultContent = node.type.createAndFill()?.toJSON();
|
||||
const content = node.toJSON();
|
||||
return JSON.stringify(defaultContent) === JSON.stringify(content);
|
||||
};
|
||||
|
||||
export const isParagraphEmpty = (node: PMNode) => {
|
||||
if (node.type.name !== "paragraph") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return node.textContent.length === 0;
|
||||
};
|
|
@ -470,9 +470,6 @@ importers:
|
|||
'@tiptap/extension-dropcursor':
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)
|
||||
'@tiptap/extension-gapcursor':
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)
|
||||
'@tiptap/extension-hard-break':
|
||||
specifier: ^2.4.0
|
||||
version: 2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))
|
||||
|
@ -590,7 +587,7 @@ importers:
|
|||
version: 16.2.1(typescript@5.4.5)
|
||||
vite-plugin-dts:
|
||||
specifier: ^3.9.1
|
||||
version: 3.9.1(@types/node@20.14.2)(rollup@4.17.2)(typescript@5.4.5)(vite@5.2.11(@types/node@20.14.2)(less@4.2.0)(sass@1.60.0)(terser@5.31.0))
|
||||
version: 3.9.1(@types/node@18.13.0)(rollup@4.17.2)(typescript@5.4.5)(vite@5.2.11(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)(terser@5.31.0))
|
||||
|
||||
packages/shared:
|
||||
dependencies:
|
||||
|
@ -606,7 +603,7 @@ importers:
|
|||
devDependencies:
|
||||
vite-plugin-dts:
|
||||
specifier: ^3.9.1
|
||||
version: 3.9.1(@types/node@20.14.2)(rollup@4.17.2)(typescript@5.4.5)(vite@5.2.11(@types/node@20.14.2)(less@4.2.0)(sass@1.60.0)(terser@5.31.0))
|
||||
version: 3.9.1(@types/node@18.13.0)(rollup@4.17.2)(typescript@5.4.5)(vite@5.2.11(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)(terser@5.31.0))
|
||||
|
||||
packages/ui-plugin-bundler-kit:
|
||||
dependencies:
|
||||
|
@ -3681,12 +3678,6 @@ packages:
|
|||
'@tiptap/core': ^2.0.0
|
||||
'@tiptap/pm': ^2.0.0
|
||||
|
||||
'@tiptap/extension-gapcursor@2.4.0':
|
||||
resolution: {integrity: sha512-F4y/0J2lseohkFUw9P2OpKhrJ6dHz69ZScABUvcHxjznJLd6+0Zt7014Lw5PA8/m2d/w0fX8LZQ88pZr4quZPQ==}
|
||||
peerDependencies:
|
||||
'@tiptap/core': ^2.0.0
|
||||
'@tiptap/pm': ^2.0.0
|
||||
|
||||
'@tiptap/extension-hard-break@2.4.0':
|
||||
resolution: {integrity: sha512-3+Z6zxevtHza5IsDBZ4lZqvNR3Kvdqwxq/QKCKu9UhJN1DUjsg/l1Jn2NilSQ3NYkBYh2yJjT8CMo9pQIu776g==}
|
||||
peerDependencies:
|
||||
|
@ -13147,6 +13138,14 @@ snapshots:
|
|||
'@types/react': 18.2.41
|
||||
react: 18.2.0
|
||||
|
||||
'@microsoft/api-extractor-model@7.28.13(@types/node@18.13.0)':
|
||||
dependencies:
|
||||
'@microsoft/tsdoc': 0.14.2
|
||||
'@microsoft/tsdoc-config': 0.16.2
|
||||
'@rushstack/node-core-library': 4.0.2(@types/node@18.13.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
'@microsoft/api-extractor-model@7.28.13(@types/node@20.14.2)':
|
||||
dependencies:
|
||||
'@microsoft/tsdoc': 0.14.2
|
||||
|
@ -13155,6 +13154,24 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
'@microsoft/api-extractor@7.43.0(@types/node@18.13.0)':
|
||||
dependencies:
|
||||
'@microsoft/api-extractor-model': 7.28.13(@types/node@18.13.0)
|
||||
'@microsoft/tsdoc': 0.14.2
|
||||
'@microsoft/tsdoc-config': 0.16.2
|
||||
'@rushstack/node-core-library': 4.0.2(@types/node@18.13.0)
|
||||
'@rushstack/rig-package': 0.5.2
|
||||
'@rushstack/terminal': 0.10.0(@types/node@18.13.0)
|
||||
'@rushstack/ts-command-line': 4.19.1(@types/node@18.13.0)
|
||||
lodash: 4.17.21
|
||||
minimatch: 3.0.8
|
||||
resolve: 1.22.8
|
||||
semver: 7.5.4
|
||||
source-map: 0.6.1
|
||||
typescript: 5.4.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
'@microsoft/api-extractor@7.43.0(@types/node@20.14.2)':
|
||||
dependencies:
|
||||
'@microsoft/api-extractor-model': 7.28.13(@types/node@20.14.2)
|
||||
|
@ -13827,6 +13844,17 @@ snapshots:
|
|||
|
||||
'@rushstack/eslint-patch@1.3.2': {}
|
||||
|
||||
'@rushstack/node-core-library@4.0.2(@types/node@18.13.0)':
|
||||
dependencies:
|
||||
fs-extra: 7.0.1
|
||||
import-lazy: 4.0.0
|
||||
jju: 1.4.0
|
||||
resolve: 1.22.8
|
||||
semver: 7.5.4
|
||||
z-schema: 5.0.4
|
||||
optionalDependencies:
|
||||
'@types/node': 18.13.0
|
||||
|
||||
'@rushstack/node-core-library@4.0.2(@types/node@20.14.2)':
|
||||
dependencies:
|
||||
fs-extra: 7.0.1
|
||||
|
@ -13843,6 +13871,13 @@ snapshots:
|
|||
resolve: 1.22.8
|
||||
strip-json-comments: 3.1.1
|
||||
|
||||
'@rushstack/terminal@0.10.0(@types/node@18.13.0)':
|
||||
dependencies:
|
||||
'@rushstack/node-core-library': 4.0.2(@types/node@18.13.0)
|
||||
supports-color: 8.1.1
|
||||
optionalDependencies:
|
||||
'@types/node': 18.13.0
|
||||
|
||||
'@rushstack/terminal@0.10.0(@types/node@20.14.2)':
|
||||
dependencies:
|
||||
'@rushstack/node-core-library': 4.0.2(@types/node@20.14.2)
|
||||
|
@ -13850,6 +13885,15 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/node': 20.14.2
|
||||
|
||||
'@rushstack/ts-command-line@4.19.1(@types/node@18.13.0)':
|
||||
dependencies:
|
||||
'@rushstack/terminal': 0.10.0(@types/node@18.13.0)
|
||||
'@types/argparse': 1.0.38
|
||||
argparse: 1.0.10
|
||||
string-argv: 0.3.1
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
'@rushstack/ts-command-line@4.19.1(@types/node@20.14.2)':
|
||||
dependencies:
|
||||
'@rushstack/terminal': 0.10.0(@types/node@20.14.2)
|
||||
|
@ -14605,11 +14649,6 @@ snapshots:
|
|||
'@tiptap/pm': 2.4.0
|
||||
tippy.js: 6.3.7
|
||||
|
||||
'@tiptap/extension-gapcursor@2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))(@tiptap/pm@2.4.0)':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
|
||||
'@tiptap/pm': 2.4.0
|
||||
|
||||
'@tiptap/extension-hard-break@2.4.0(@tiptap/core@2.4.0(@tiptap/pm@2.4.0))':
|
||||
dependencies:
|
||||
'@tiptap/core': 2.4.0(@tiptap/pm@2.4.0)
|
||||
|
@ -21792,6 +21831,23 @@ snapshots:
|
|||
- supports-color
|
||||
- terser
|
||||
|
||||
vite-plugin-dts@3.9.1(@types/node@18.13.0)(rollup@4.17.2)(typescript@5.4.5)(vite@5.2.11(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)(terser@5.31.0)):
|
||||
dependencies:
|
||||
'@microsoft/api-extractor': 7.43.0(@types/node@18.13.0)
|
||||
'@rollup/pluginutils': 5.1.0(rollup@4.17.2)
|
||||
'@vue/language-core': 1.8.27(typescript@5.4.5)
|
||||
debug: 4.3.4(supports-color@8.1.1)
|
||||
kolorist: 1.8.0
|
||||
magic-string: 0.30.10
|
||||
typescript: 5.4.5
|
||||
vue-tsc: 1.8.27(typescript@5.4.5)
|
||||
optionalDependencies:
|
||||
vite: 5.2.11(@types/node@18.13.0)(less@4.2.0)(sass@1.60.0)(terser@5.31.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- rollup
|
||||
- supports-color
|
||||
|
||||
vite-plugin-dts@3.9.1(@types/node@20.14.2)(rollup@2.79.1)(typescript@5.4.5)(vite@5.2.11(@types/node@20.14.2)(less@4.2.0)(sass@1.60.0)(terser@5.31.0)):
|
||||
dependencies:
|
||||
'@microsoft/api-extractor': 7.43.0(@types/node@20.14.2)
|
||||
|
|
Loading…
Reference in New Issue