Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cad574ce06 | |||
| 85d437edba | |||
| 0aaf69076a | |||
| 7f84b39f6c |
@@ -1,7 +1,10 @@
|
||||
import { NodeType } from "@tiptap/pm/model";
|
||||
import { EditorState, Transaction } from "@tiptap/pm/state";
|
||||
import { Editor, findParentNode, getNodeType } from "@tiptap/react";
|
||||
import { Copy, LucideIcon, Repeat2, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import tippy, { Instance } from "tippy.js";
|
||||
import { Copy, LucideIcon, Trash2 } from "lucide-react";
|
||||
import { MultipleSelection } from "@/extensions/selection/multi-selection-tracker-new";
|
||||
|
||||
interface BlockMenuProps {
|
||||
editor: Editor;
|
||||
@@ -143,6 +146,26 @@ export const BlockMenu = (props: BlockMenuProps) => {
|
||||
popup.current?.hide();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "turn-into",
|
||||
icon: Repeat2,
|
||||
label: "Turn into Heading 1",
|
||||
isDisabled: !(editor.view.state.selection instanceof MultipleSelection),
|
||||
onClick: (e) => {
|
||||
editor.commands.toggleHeading({ level: 1 });
|
||||
// toggleBulletList(editor.view.state, editor.view.dispatch);
|
||||
// const toggleListType = (listType: NodeType) => (state: EditorState, dispatch?: (tr: Transaction) => void) => {
|
||||
// const { schema, selection } = state;
|
||||
// console.log("selection", selection instanceof MultipleSelection);
|
||||
// // apply on all the ranges
|
||||
// selection.ranges.forEach(({ $from, $to }) => {
|
||||
// editor.commands.toggleOrderedList();
|
||||
// });
|
||||
// };
|
||||
//
|
||||
// toggleListType(editor.schema.nodes.bullet_list)(editor.view.state, editor.view.dispatch);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -152,7 +175,7 @@ export const BlockMenu = (props: BlockMenuProps) => {
|
||||
>
|
||||
{MENU_ITEMS.map((item) => {
|
||||
// Skip rendering the button if it should be disabled
|
||||
if (item.isDisabled && item.key === "duplicate") {
|
||||
if (item.isDisabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -172,3 +195,56 @@ export const BlockMenu = (props: BlockMenuProps) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const toggleListType = (listType: NodeType) => (state: EditorState, dispatch?: (tr: Transaction) => void) => {
|
||||
console.log(listType);
|
||||
const { schema, selection } = state;
|
||||
|
||||
if (!(selection instanceof MultipleSelection)) {
|
||||
// Fall back to default behavior for non-MultipleSelection
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
const ranges = [...selection.ranges].sort((a, b) => a.$from.pos - b.$from.pos);
|
||||
|
||||
ranges.forEach(({ $from, $to }) => {
|
||||
state.tr.setNodeMarkup($from.pos + 1, getNodeType("orderedList", schema));
|
||||
|
||||
// state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||
// if (node.type === schema.nodes.paragraph) {
|
||||
// const parentList = findParentNode(
|
||||
// (p) => p.type === schema.nodes["bulletList"] || p.type === schema.nodes["orderedList"]
|
||||
// )(state.selection);
|
||||
//
|
||||
// if (parentList) {
|
||||
// if (parentList.node.type !== listType) {
|
||||
// // Change list type
|
||||
// tr.setNodeMarkup(parentList.pos, listType);
|
||||
// }
|
||||
// } else {
|
||||
// console.log("should not happen");
|
||||
// // Wrap in new list
|
||||
// const listItem = schema.nodes["listItem"].create(null, node);
|
||||
// const list = listType.create(null, [listItem]);
|
||||
// tr.replaceRangeWith(pos, pos + node.nodeSize, list);
|
||||
// }
|
||||
// }
|
||||
// return false;
|
||||
// });
|
||||
});
|
||||
|
||||
if (tr.docChanged) {
|
||||
dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const toggleBulletList = (state: EditorState, dispatch?: (tr: Transaction) => void) =>
|
||||
toggleListType(state.schema.nodes["bulletList"])(state, dispatch);
|
||||
const toggleNumberedList = (state: EditorState, dispatch?: (tr: Transaction) => void) =>
|
||||
toggleListType(state.schema.nodes["orderedList"])(state, dispatch);
|
||||
|
||||
@@ -33,9 +33,10 @@ import {
|
||||
// helpers
|
||||
import { isValidHttpUrl } from "@/helpers/common";
|
||||
// types
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
import { TExtensions, TFileHandler, TMentionHandler } from "@/types";
|
||||
// plane editor extensions
|
||||
import { CoreEditorAdditionalExtensions } from "@/plane-editor/extensions";
|
||||
import { multipleSelectionExtension } from "./selection/multi-selection-tracker-new";
|
||||
|
||||
type TArguments = {
|
||||
disabledExtensions: TExtensions[];
|
||||
@@ -177,5 +178,6 @@ export const CoreEditorExtensions = (args: TArguments): Extensions => {
|
||||
...CoreEditorAdditionalExtensions({
|
||||
disabledExtensions,
|
||||
}),
|
||||
multipleSelectionExtension,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Node } from "@tiptap/pm/model";
|
||||
import { Selection, SelectionRange } from "@tiptap/pm/state";
|
||||
import { Mappable } from "@tiptap/pm/transform";
|
||||
|
||||
export class MultiRangeSelection extends Selection {
|
||||
ranges: SelectionRange[];
|
||||
|
||||
constructor(ranges: SelectionRange[]) {
|
||||
// ProseMirror's `Selection` requires a "from" and a "to",
|
||||
// but in a multi-range scenario, we use the first and last positions.
|
||||
super(ranges[0].$from, ranges[ranges.length - 1].$to, ranges);
|
||||
this.ranges = ranges;
|
||||
}
|
||||
|
||||
map(doc: Node, mapping: Mappable): Selection {
|
||||
// You would typically map positions here if positions can change,
|
||||
// but for simplicity we return the same set of ranges.
|
||||
// For safer usage, you would re-map each range’s start and end.
|
||||
return new MultiRangeSelection(this.ranges);
|
||||
}
|
||||
|
||||
eq(other: Selection): boolean {
|
||||
if (!(other instanceof MultiRangeSelection)) return false;
|
||||
if (this.ranges.length !== other.ranges.length) return false;
|
||||
|
||||
for (let i = 0; i < this.ranges.length; i++) {
|
||||
if (
|
||||
this.ranges[i].$from.pos !== other.ranges[i].$from.pos ||
|
||||
this.ranges[i].$to.pos !== other.ranges[i].$to.pos
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
toJSON(): any {
|
||||
return {
|
||||
type: "multiRangeSelection",
|
||||
ranges: this.ranges.map((range) => ({
|
||||
from: range.$from.pos,
|
||||
to: range.$to.pos,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
static create(ranges: SelectionRange[]) {
|
||||
return new MultiRangeSelection(ranges);
|
||||
}
|
||||
|
||||
static fromJSON(doc: Node, json: any) {
|
||||
const ranges = json.ranges.map((r: any) => new SelectionRange(doc.resolve(r.from), doc.resolve(r.to)));
|
||||
return this.create(ranges);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
import { Dispatch, Extension } from "@tiptap/core";
|
||||
import { Node, ResolvedPos, Slice, Fragment } from "@tiptap/pm/model";
|
||||
import { EditorState, Plugin, PluginKey, Selection, TextSelection, Transaction } from "@tiptap/pm/state";
|
||||
import { EditorView, Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
const MultipleSelectionPluginKey = new PluginKey("multipleSelection");
|
||||
|
||||
interface BlockSelectionState {
|
||||
ranges: Array<[number, number]> | null;
|
||||
lastSelectedRange?: { from: number; to: number };
|
||||
}
|
||||
|
||||
let activeCursor: HTMLElement | null = null;
|
||||
const lastActiveSelection = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
|
||||
let isDragging = false;
|
||||
let selectNodesHandler: (event: MouseEvent) => void;
|
||||
let scrollAnimationFrame: number | null = null;
|
||||
let lastClientY = 0;
|
||||
let currentScrollSpeed = 0;
|
||||
const maxScrollSpeed = 20;
|
||||
const acceleration = 0.5;
|
||||
let cachedScrollParent = null;
|
||||
|
||||
function easeOutQuadAnimation(t: number) {
|
||||
return t * (2 - t);
|
||||
}
|
||||
|
||||
const scrollParentCache = new WeakMap();
|
||||
|
||||
const getScrollParent = (element) => {
|
||||
if (cachedScrollParent) {
|
||||
return cachedScrollParent;
|
||||
}
|
||||
|
||||
if (!element) return null;
|
||||
|
||||
let currentParent = element.parentElement;
|
||||
|
||||
while (currentParent) {
|
||||
if (isScrollable(currentParent)) {
|
||||
cachedScrollParent = currentParent;
|
||||
return cachedScrollParent;
|
||||
}
|
||||
currentParent = currentParent.parentElement;
|
||||
}
|
||||
|
||||
cachedScrollParent = document.scrollingElement || document.documentElement;
|
||||
return cachedScrollParent;
|
||||
};
|
||||
|
||||
const isScrollable = (node) => {
|
||||
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(node);
|
||||
return ["overflow", "overflow-y"].some((propertyName) => {
|
||||
const value = style.getPropertyValue(propertyName);
|
||||
return value === "auto" || value === "scroll";
|
||||
});
|
||||
};
|
||||
|
||||
const updateCursorPosition = (event: MouseEvent, view: EditorView) => {
|
||||
if (!activeCursor) return;
|
||||
|
||||
const x = event.pageX;
|
||||
const y = event.pageY;
|
||||
|
||||
let newHeight = y - lastActiveSelection.top;
|
||||
let newWidth = x - lastActiveSelection.left;
|
||||
|
||||
if (newHeight < 0) {
|
||||
activeCursor.style.marginTop = `${newHeight}px`;
|
||||
newHeight *= -1;
|
||||
} else {
|
||||
activeCursor.style.marginTop = "0px";
|
||||
}
|
||||
|
||||
if (newWidth < 0) {
|
||||
activeCursor.style.marginLeft = `${newWidth}px`;
|
||||
newWidth *= -1;
|
||||
} else {
|
||||
activeCursor.style.marginLeft = "0px";
|
||||
}
|
||||
|
||||
activeCursor.style.height = `${newHeight}px`;
|
||||
activeCursor.style.width = `${newWidth}px`;
|
||||
|
||||
lastClientY = event.clientY;
|
||||
|
||||
if (!scrollAnimationFrame) {
|
||||
scrollAnimationFrame = requestAnimationFrame(scroll);
|
||||
}
|
||||
};
|
||||
|
||||
function scroll() {
|
||||
if (!isDragging) {
|
||||
currentScrollSpeed = 0;
|
||||
scrollAnimationFrame = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const editorContainer = document.querySelector(".editor-container");
|
||||
const scrollableParent = getScrollParent(editorContainer);
|
||||
|
||||
if (!scrollableParent) {
|
||||
scrollAnimationFrame = requestAnimationFrame(scroll);
|
||||
return;
|
||||
}
|
||||
|
||||
const scrollRegionUp = 150;
|
||||
const scrollRegionDown = scrollableParent.clientHeight - 100;
|
||||
|
||||
let targetScrollAmount = 0;
|
||||
|
||||
if (lastClientY < scrollRegionUp) {
|
||||
const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / scrollRegionUp);
|
||||
targetScrollAmount = -maxScrollSpeed * ratio;
|
||||
} else if (lastClientY > scrollRegionDown) {
|
||||
const ratio = easeOutQuadAnimation(
|
||||
(lastClientY - scrollRegionDown) / (scrollableParent.clientHeight - scrollRegionDown)
|
||||
);
|
||||
targetScrollAmount = maxScrollSpeed * ratio;
|
||||
}
|
||||
|
||||
currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration;
|
||||
|
||||
if (Math.abs(currentScrollSpeed) > 0.1) {
|
||||
scrollableParent.scrollBy(0, currentScrollSpeed);
|
||||
}
|
||||
|
||||
scrollAnimationFrame = requestAnimationFrame(scroll);
|
||||
}
|
||||
|
||||
const getNodesInRect = (view: EditorView, rect: DOMRect) => {
|
||||
const ranges: Array<[number, number]> = [];
|
||||
const { doc } = view.state;
|
||||
|
||||
const isInRect = (pos: number) => {
|
||||
const coords = view.coordsAtPos(pos);
|
||||
return (
|
||||
coords &&
|
||||
coords.top < rect.bottom &&
|
||||
coords.bottom > rect.top &&
|
||||
coords.left < rect.right &&
|
||||
coords.right > rect.left
|
||||
);
|
||||
};
|
||||
|
||||
doc.nodesBetween(0, doc.content.size, (node, pos) => {
|
||||
const resolvedPos = view.state.doc.resolve(pos);
|
||||
|
||||
if (resolvedPos.depth === 1) {
|
||||
const start = resolvedPos.before(1);
|
||||
const end = resolvedPos.after(1);
|
||||
|
||||
if (isInRect(start) || isInRect(end)) {
|
||||
ranges.push([start, end]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return ranges;
|
||||
};
|
||||
|
||||
const removeActiveUser = (view?: EditorView) => {
|
||||
if (!activeCursor) return;
|
||||
|
||||
activeCursor.remove();
|
||||
activeCursor = null;
|
||||
document.removeEventListener("mousemove", selectNodesHandler);
|
||||
document.removeEventListener("mouseup", () => removeActiveUser(view));
|
||||
|
||||
if (scrollAnimationFrame) {
|
||||
cancelAnimationFrame(scrollAnimationFrame);
|
||||
scrollAnimationFrame = null;
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
};
|
||||
|
||||
export class MultipleSelection extends Selection {
|
||||
ranges: Array<{ $from: ResolvedPos; $to: ResolvedPos }>;
|
||||
|
||||
constructor(ranges: Array<{ $from: ResolvedPos; $to: ResolvedPos }>) {
|
||||
const $anchor = ranges[0].$from;
|
||||
const $head = ranges[ranges.length - 1].$to;
|
||||
super($anchor, $head);
|
||||
this.ranges = ranges;
|
||||
}
|
||||
|
||||
map(doc: Node, mapping: any) {
|
||||
const newRanges = this.ranges.map(({ $from, $to }) => ({
|
||||
$from: doc.resolve(mapping.map($from.pos)),
|
||||
$to: doc.resolve(mapping.map($to.pos)),
|
||||
}));
|
||||
return new MultipleSelection(newRanges);
|
||||
}
|
||||
|
||||
eq(other: Selection): boolean {
|
||||
if (!(other instanceof MultipleSelection) || other.ranges.length !== this.ranges.length) {
|
||||
return false;
|
||||
}
|
||||
return this.ranges.every(
|
||||
(range, i) => range.$from.pos === other.ranges[i].$from.pos && range.$to.pos === other.ranges[i].$to.pos
|
||||
);
|
||||
}
|
||||
|
||||
content() {
|
||||
const nodes: Node[] = [];
|
||||
this.ranges.forEach(({ $from, $to }) => {
|
||||
const slice = $from.doc.slice($from.pos, $to.pos);
|
||||
nodes.push(...slice.content.content);
|
||||
});
|
||||
return new Slice(Fragment.from(nodes), 0, 0);
|
||||
}
|
||||
|
||||
replace(tr: any, content: Slice = Slice.empty) {
|
||||
const ranges = this.ranges;
|
||||
for (let i = 0; i < ranges.length; i++) {
|
||||
const { $from, $to } = ranges[i];
|
||||
const mappedFrom = tr.mapping.map($from.pos);
|
||||
const mappedTo = tr.mapping.map($to.pos);
|
||||
tr.replace(mappedFrom, mappedTo, i ? Slice.empty : content);
|
||||
}
|
||||
const lastFrom = tr.mapping.map(ranges[ranges.length - 1].$from.pos);
|
||||
tr.setSelection(TextSelection.create(tr.doc, lastFrom));
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
type: "multiple",
|
||||
ranges: this.ranges.map(({ $from, $to }) => ({ from: $from.pos, to: $to.pos })),
|
||||
};
|
||||
}
|
||||
|
||||
static fromJSON(doc: Node, json: any) {
|
||||
if (json.type !== "multiple") throw new Error("Invalid input for MultipleSelection.fromJSON");
|
||||
const ranges = json.ranges.map(({ from, to }) => ({
|
||||
$from: doc.resolve(from),
|
||||
$to: doc.resolve(to),
|
||||
}));
|
||||
return new MultipleSelection(ranges);
|
||||
}
|
||||
|
||||
static create(doc: Node, ranges: Array<[number, number]>) {
|
||||
return new MultipleSelection(
|
||||
ranges.map(([from, to]) => ({
|
||||
$from: doc.resolve(from),
|
||||
$to: doc.resolve(to),
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Register the new selection type
|
||||
Selection.jsonID("multiple", MultipleSelection);
|
||||
|
||||
const createMultipleSelectionPlugin = () =>
|
||||
new Plugin<BlockSelectionState>({
|
||||
key: MultipleSelectionPluginKey,
|
||||
state: {
|
||||
init() {
|
||||
return { ranges: null };
|
||||
},
|
||||
apply(tr, state) {
|
||||
const action = tr.getMeta(MultipleSelectionPluginKey);
|
||||
if (action) {
|
||||
return action;
|
||||
}
|
||||
return state;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousedown(view: EditorView, event: MouseEvent) {
|
||||
if (!isDragging) {
|
||||
const pluginState = MultipleSelectionPluginKey.getState(view.state);
|
||||
if (pluginState?.ranges?.length || pluginState?.lastSelectedRange) {
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(MultipleSelectionPluginKey, {
|
||||
ranges: null,
|
||||
lastSelectedRange: undefined,
|
||||
})
|
||||
);
|
||||
console.log("view.state.selection instanceof MultipleSelection", view.state.selection);
|
||||
// if (view.state.selection instanceof MultipleSelection) {
|
||||
// let tr: Transaction;
|
||||
// view.state.selection.ranges.forEach(({ $from, $to }) => {
|
||||
// console.log("from", $from.pos, "to", $to.pos);
|
||||
// // Example: Delete content in each range
|
||||
// // tr = view.state.tr.delete($from.pos, $to.pos);
|
||||
// });
|
||||
// view.dispatch(tr);
|
||||
// }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.target !== view.dom) {
|
||||
return false;
|
||||
}
|
||||
removeActiveUser(view);
|
||||
|
||||
activeCursor = document.createElement("div");
|
||||
activeCursor.className = "multipleSelectionCursor";
|
||||
activeCursor.style.width = "0px";
|
||||
activeCursor.style.height = "0px";
|
||||
activeCursor.style.borderRadius = "2px";
|
||||
activeCursor.style.border = "1px solid rgba(var(--color-primary-100), 0.2)";
|
||||
activeCursor.style.background = "rgba(var(--color-primary-100), 0.2)";
|
||||
activeCursor.style.opacity = "0.5";
|
||||
activeCursor.style.position = "absolute";
|
||||
activeCursor.style.top = `${event.pageY}px`;
|
||||
activeCursor.style.left = `${event.pageX}px`;
|
||||
activeCursor.style.pointerEvents = "none";
|
||||
activeCursor.style.zIndex = "1000";
|
||||
|
||||
lastActiveSelection.top = event.pageY;
|
||||
lastActiveSelection.left = event.pageX;
|
||||
|
||||
document.body.appendChild(activeCursor);
|
||||
|
||||
isDragging = true;
|
||||
lastClientY = event.clientY;
|
||||
|
||||
selectNodesHandler = (e: MouseEvent) => {
|
||||
updateCursorPosition(e, view);
|
||||
if (activeCursor) {
|
||||
const rect = activeCursor.getBoundingClientRect();
|
||||
const ranges = getNodesInRect(view, rect);
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr
|
||||
.setMeta(MultipleSelectionPluginKey, { ranges })
|
||||
.setSelection(MultipleSelection.create(view.state.doc, ranges))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", selectNodesHandler);
|
||||
document.addEventListener("mouseup", () => removeActiveUser(view));
|
||||
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
decorations(state) {
|
||||
const pluginState = this.getState(state);
|
||||
|
||||
if (pluginState?.ranges?.length) {
|
||||
return DecorationSet.create(
|
||||
state.doc,
|
||||
pluginState.ranges.map(([from, to]) =>
|
||||
Decoration.node(from, to, {
|
||||
class: "ProseMirror-selectednode",
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
appendTransaction(transactions, oldState, newState) {
|
||||
const oldPluginState = this.getState(oldState);
|
||||
const newPluginState = this.getState(newState);
|
||||
|
||||
if (newPluginState?.ranges?.length && !oldPluginState?.ranges?.length) {
|
||||
return newState.tr.setSelection(MultipleSelection.create(newState.doc, newPluginState.ranges));
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
view() {
|
||||
return {
|
||||
destroy() {
|
||||
removeActiveUser();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const multipleSelectionExtension = Extension.create({
|
||||
name: "multipleSelection",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [createMultipleSelectionPlugin()];
|
||||
},
|
||||
});
|
||||
|
||||
export const deleteSelectedNodes = (state: EditorState, dispatch: Dispatch) => {
|
||||
const { selection } = state;
|
||||
if (!(selection instanceof MultipleSelection)) return false;
|
||||
|
||||
if (dispatch) {
|
||||
const tr = state.tr;
|
||||
|
||||
// Sort ranges in reverse order (from end to start)
|
||||
const sortedRanges = [...selection.ranges].sort((a, b) => b.$from.pos - a.$from.pos);
|
||||
|
||||
// Delete ranges from end to start to avoid position shifts
|
||||
sortedRanges.forEach(({ $from, $to }) => {
|
||||
tr.delete($from.pos, $to.pos);
|
||||
});
|
||||
|
||||
dispatch(tr);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { EditorView, Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
|
||||
const MultipleSelectionPluginKey = new PluginKey("multipleSelection");
|
||||
|
||||
interface BlockSelectionState {
|
||||
ranges: Array<[number, number]> | null;
|
||||
lastSelectedRange?: { from: number; to: number };
|
||||
}
|
||||
|
||||
let activeCursor: HTMLElement | null = null;
|
||||
const lastActiveSelection = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
|
||||
let isDragging = false;
|
||||
let selectNodesHandler: (event: MouseEvent) => void;
|
||||
let scrollAnimationFrame: number | null = null;
|
||||
let lastClientY = 0;
|
||||
let currentScrollSpeed = 0;
|
||||
const maxScrollSpeed = 20;
|
||||
const acceleration = 0.5;
|
||||
let cachedScrollParent = null;
|
||||
|
||||
function easeOutQuadAnimation(t: number) {
|
||||
return t * (2 - t);
|
||||
}
|
||||
|
||||
const scrollParentCache = new WeakMap();
|
||||
|
||||
const getScrollParent = (element) => {
|
||||
if (cachedScrollParent) {
|
||||
return cachedScrollParent;
|
||||
}
|
||||
|
||||
if (!element) return null;
|
||||
|
||||
let currentParent = element.parentElement;
|
||||
|
||||
while (currentParent) {
|
||||
if (isScrollable(currentParent)) {
|
||||
cachedScrollParent = currentParent;
|
||||
return cachedScrollParent;
|
||||
}
|
||||
currentParent = currentParent.parentElement;
|
||||
}
|
||||
|
||||
cachedScrollParent = document.scrollingElement || document.documentElement;
|
||||
return cachedScrollParent;
|
||||
};
|
||||
|
||||
const isScrollable = (node) => {
|
||||
if (!(node instanceof HTMLElement || node instanceof SVGElement)) {
|
||||
return false;
|
||||
}
|
||||
const style = getComputedStyle(node);
|
||||
return ["overflow", "overflow-y"].some((propertyName) => {
|
||||
const value = style.getPropertyValue(propertyName);
|
||||
return value === "auto" || value === "scroll";
|
||||
});
|
||||
};
|
||||
|
||||
const updateCursorPosition = (event: MouseEvent, view: EditorView) => {
|
||||
if (!activeCursor) return;
|
||||
|
||||
const x = event.pageX;
|
||||
const y = event.pageY;
|
||||
|
||||
let newHeight = y - lastActiveSelection.top;
|
||||
let newWidth = x - lastActiveSelection.left;
|
||||
|
||||
if (newHeight < 0) {
|
||||
activeCursor.style.marginTop = `${newHeight}px`;
|
||||
newHeight *= -1;
|
||||
} else {
|
||||
activeCursor.style.marginTop = "0px";
|
||||
}
|
||||
|
||||
if (newWidth < 0) {
|
||||
activeCursor.style.marginLeft = `${newWidth}px`;
|
||||
newWidth *= -1;
|
||||
} else {
|
||||
activeCursor.style.marginLeft = "0px";
|
||||
}
|
||||
|
||||
activeCursor.style.height = `${newHeight}px`;
|
||||
activeCursor.style.width = `${newWidth}px`;
|
||||
|
||||
lastClientY = event.clientY;
|
||||
|
||||
if (!scrollAnimationFrame) {
|
||||
scrollAnimationFrame = requestAnimationFrame(scroll);
|
||||
}
|
||||
};
|
||||
|
||||
function scroll() {
|
||||
if (!isDragging) {
|
||||
currentScrollSpeed = 0;
|
||||
scrollAnimationFrame = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const editorContainer = document.querySelector(".editor-container"); // Assuming the editor container has a class 'ProseMirror'
|
||||
const scrollableParent = getScrollParent(editorContainer);
|
||||
|
||||
if (!scrollableParent) {
|
||||
scrollAnimationFrame = requestAnimationFrame(scroll);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("scrollableParent", scrollableParent);
|
||||
const scrollRegionUp = 150;
|
||||
const scrollRegionDown = scrollableParent.clientHeight - 100;
|
||||
|
||||
let targetScrollAmount = 0;
|
||||
|
||||
if (lastClientY < scrollRegionUp) {
|
||||
const ratio = easeOutQuadAnimation((scrollRegionUp - lastClientY) / scrollRegionUp);
|
||||
targetScrollAmount = -maxScrollSpeed * ratio;
|
||||
} else if (lastClientY > scrollRegionDown) {
|
||||
const ratio = easeOutQuadAnimation(
|
||||
(lastClientY - scrollRegionDown) / (scrollableParent.clientHeight - scrollRegionDown)
|
||||
);
|
||||
targetScrollAmount = maxScrollSpeed * ratio;
|
||||
}
|
||||
|
||||
currentScrollSpeed += (targetScrollAmount - currentScrollSpeed) * acceleration;
|
||||
|
||||
if (Math.abs(currentScrollSpeed) > 0.1) {
|
||||
scrollableParent.scrollBy(0, currentScrollSpeed);
|
||||
}
|
||||
|
||||
scrollAnimationFrame = requestAnimationFrame(scroll);
|
||||
}
|
||||
|
||||
const getNodesInRect = (view: EditorView, rect: DOMRect) => {
|
||||
const ranges: Array<[number, number]> = [];
|
||||
const { doc } = view.state;
|
||||
|
||||
const isInRect = (pos: number) => {
|
||||
const coords = view.coordsAtPos(pos);
|
||||
return (
|
||||
coords &&
|
||||
coords.top < rect.bottom &&
|
||||
coords.bottom > rect.top &&
|
||||
coords.left < rect.right &&
|
||||
coords.right > rect.left
|
||||
);
|
||||
};
|
||||
|
||||
doc.nodesBetween(0, doc.content.size, (node, pos) => {
|
||||
const resolvedPos = view.state.doc.resolve(pos);
|
||||
|
||||
if (resolvedPos.depth === 1) {
|
||||
const start = resolvedPos.before(1);
|
||||
const end = resolvedPos.after(1);
|
||||
|
||||
if (isInRect(start) || isInRect(end)) {
|
||||
ranges.push([start, end]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return ranges;
|
||||
};
|
||||
|
||||
const removeActiveUser = (view?: EditorView) => {
|
||||
if (!activeCursor) return;
|
||||
|
||||
activeCursor.remove();
|
||||
activeCursor = null;
|
||||
document.removeEventListener("mousemove", selectNodesHandler);
|
||||
document.removeEventListener("mouseup", () => removeActiveUser(view));
|
||||
|
||||
if (scrollAnimationFrame) {
|
||||
cancelAnimationFrame(scrollAnimationFrame);
|
||||
scrollAnimationFrame = null;
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
};
|
||||
|
||||
const createMultipleSelectionPlugin = () =>
|
||||
new Plugin<BlockSelectionState>({
|
||||
key: MultipleSelectionPluginKey,
|
||||
state: {
|
||||
init() {
|
||||
return { ranges: null };
|
||||
},
|
||||
apply(tr, state) {
|
||||
const action = tr.getMeta(MultipleSelectionPluginKey);
|
||||
if (action) {
|
||||
return action;
|
||||
}
|
||||
return state;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousedown(view: EditorView, event: MouseEvent) {
|
||||
if (!isDragging) {
|
||||
const pluginState = MultipleSelectionPluginKey.getState(view.state);
|
||||
if (pluginState?.ranges?.length || pluginState?.lastSelectedRange) {
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(MultipleSelectionPluginKey, {
|
||||
ranges: null,
|
||||
lastSelectedRange: undefined,
|
||||
})
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (event.target !== view.dom) {
|
||||
return false;
|
||||
}
|
||||
removeActiveUser(view);
|
||||
|
||||
activeCursor = document.createElement("div");
|
||||
activeCursor.className = "multipleSelectionCursor";
|
||||
activeCursor.style.width = "0px";
|
||||
activeCursor.style.height = "0px";
|
||||
activeCursor.style.borderRadius = "2px";
|
||||
activeCursor.style.border = "1px solid rgba(var(--color-primary-100), 0.2)";
|
||||
activeCursor.style.background = "rgba(var(--color-primary-100), 0.2)";
|
||||
activeCursor.style.opacity = "0.5";
|
||||
activeCursor.style.position = "absolute";
|
||||
activeCursor.style.top = `${event.pageY}px`;
|
||||
activeCursor.style.left = `${event.pageX}px`;
|
||||
activeCursor.style.pointerEvents = "none";
|
||||
activeCursor.style.zIndex = "1000";
|
||||
|
||||
lastActiveSelection.top = event.pageY;
|
||||
lastActiveSelection.left = event.pageX;
|
||||
|
||||
document.body.appendChild(activeCursor);
|
||||
|
||||
isDragging = true;
|
||||
lastClientY = event.clientY;
|
||||
|
||||
selectNodesHandler = (e: MouseEvent) => {
|
||||
updateCursorPosition(e, view);
|
||||
if (activeCursor) {
|
||||
const rect = activeCursor.getBoundingClientRect();
|
||||
const ranges = getNodesInRect(view, rect);
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(MultipleSelectionPluginKey, {
|
||||
ranges,
|
||||
lastSelectedRange: undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", selectNodesHandler);
|
||||
document.addEventListener("mouseup", () => removeActiveUser(view));
|
||||
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
decorations(state) {
|
||||
const pluginState = this.getState(state);
|
||||
|
||||
if (pluginState?.ranges?.length) {
|
||||
return DecorationSet.create(
|
||||
state.doc,
|
||||
pluginState.ranges.map(([from, to]) =>
|
||||
Decoration.node(from, to, {
|
||||
class: "ProseMirror-selectednode",
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (pluginState?.lastSelectedRange) {
|
||||
const { from, to } = pluginState.lastSelectedRange;
|
||||
return DecorationSet.create(state.doc, [
|
||||
Decoration.node(from, to, {
|
||||
class: "ProseMirror-selectednode",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
view() {
|
||||
return {
|
||||
destroy() {
|
||||
removeActiveUser();
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const multipleSelectionExtension = Extension.create({
|
||||
name: "multipleSelection",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [createMultipleSelectionPlugin()];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { SelectionRange } from "@tiptap/pm/state";
|
||||
import { MultiRangeSelection } from "./MultiRangeSelection";
|
||||
import { createMultiSelectionDecorationPlugin } from "./plugin";
|
||||
|
||||
export const multipleSelectionExtension = Extension.create({
|
||||
name: "multiSelectionExtension",
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
selectedRanges: [] as SelectionRange[],
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [createMultiSelectionDecorationPlugin()];
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
const editorView = this.editor.view;
|
||||
const storage = this.storage;
|
||||
|
||||
// Listen for mousedown: If not holding ctrl, reset selection ranges
|
||||
editorView.dom.addEventListener("mousedown", (event) => {
|
||||
console.log("event.ctrlKey", event.metaKey);
|
||||
if (!event.metaKey) {
|
||||
storage.selectedRanges = [];
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for mouseup: If holding ctrl, add to ranges, else reset
|
||||
editorView.dom.addEventListener("mouseup", (event) => {
|
||||
const { state, dispatch } = editorView;
|
||||
const { selection } = state;
|
||||
|
||||
// Only proceed if it’s a normal TextSelection and user is holding ctrl
|
||||
if (event.metaKey) {
|
||||
const newRange = selection.ranges[0];
|
||||
|
||||
// Accumulate ranges
|
||||
storage.selectedRanges.push(newRange);
|
||||
|
||||
// Sort them by start position
|
||||
storage.selectedRanges.sort((r1, r2) => r1.$from.pos - r2.$from.pos);
|
||||
|
||||
// Construct a new multi-range selection
|
||||
const tr = state.tr;
|
||||
const multiSelection = MultiRangeSelection.create(storage.selectedRanges);
|
||||
tr.setSelection(multiSelection);
|
||||
dispatch(tr);
|
||||
} else {
|
||||
// Single selection: just store that range
|
||||
storage.selectedRanges = [selection.ranges[0]];
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Editor, Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
|
||||
const MultipleSelectionPluginKey = new PluginKey("multipleSelection");
|
||||
|
||||
let activeCursor: HTMLElement | null = null;
|
||||
const lastActiveSelection = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
|
||||
const getNodesInRect = (view: EditorView, rect: DOMRect) => {
|
||||
const nodes: number[] = [];
|
||||
const { doc } = view.state;
|
||||
|
||||
// Helper to check if a position is inside the selection rectangle
|
||||
const isInRect = (pos: number) => {
|
||||
const coords = view.coordsAtPos(pos);
|
||||
return (
|
||||
coords &&
|
||||
coords.top < rect.bottom &&
|
||||
coords.bottom > rect.top &&
|
||||
coords.left < rect.right &&
|
||||
coords.right > rect.left
|
||||
);
|
||||
};
|
||||
|
||||
// Iterate through the document to find nodes at depth 1
|
||||
doc.nodesBetween(0, doc.content.size, (node, pos) => {
|
||||
const resolvedPos = view.state.doc.resolve(pos);
|
||||
|
||||
// Only process nodes at depth 1 (direct children of the document)
|
||||
if (resolvedPos.depth === 1) {
|
||||
// Check both start and end of the node to ensure proper selection
|
||||
const start = resolvedPos.before(1);
|
||||
const end = resolvedPos.after(1);
|
||||
|
||||
// If either the start or end of the node is in the selection rectangle
|
||||
if (isInRect(start) || isInRect(end)) {
|
||||
nodes.push(start);
|
||||
}
|
||||
|
||||
// Don't descend into this node's children
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return nodes;
|
||||
};
|
||||
|
||||
const updateCursorPosition = (view: EditorView, editor: Editor) => (event: MouseEvent) => {
|
||||
if (!activeCursor) return;
|
||||
|
||||
let newHeight = event.y - lastActiveSelection.top;
|
||||
let newWidth = event.x - lastActiveSelection.left;
|
||||
|
||||
// Update cursor dimensions and position
|
||||
if (newHeight < 0) {
|
||||
activeCursor.style.marginTop = `${newHeight}px`;
|
||||
newHeight *= -1;
|
||||
} else {
|
||||
activeCursor.style.marginTop = "0px";
|
||||
}
|
||||
|
||||
if (newWidth < 0) {
|
||||
activeCursor.style.marginLeft = `${newWidth}px`;
|
||||
newWidth *= -1;
|
||||
} else {
|
||||
activeCursor.style.marginLeft = "0px";
|
||||
}
|
||||
|
||||
activeCursor.style.height = `${newHeight}px`;
|
||||
activeCursor.style.width = `${newWidth}px`;
|
||||
|
||||
// Get selection rectangle
|
||||
const selectionRect = activeCursor.getBoundingClientRect();
|
||||
|
||||
// Find nodes within rectangle
|
||||
const nodesToSelect = getNodesInRect(view, selectionRect);
|
||||
// __AUTO_GENERATED_PRINT_VAR_START__
|
||||
console.log("updateCursorPosition#(anon) nodesToSelect: ", nodesToSelect); // __AUTO_GENERATED_PRINT_VAR_END__
|
||||
|
||||
// Create node selections using Tiptap commands
|
||||
if (nodesToSelect.length > 0) {
|
||||
// Select each node in sequence
|
||||
nodesToSelect.forEach((pos) => {
|
||||
editor.commands.setNodeSelection(pos);
|
||||
});
|
||||
}
|
||||
};
|
||||
const removeActiveUser = () => {
|
||||
if (!activeCursor) return;
|
||||
|
||||
activeCursor.remove();
|
||||
activeCursor = null;
|
||||
document.removeEventListener("mouseup", removeActiveUser);
|
||||
};
|
||||
|
||||
const createMultipleSelectionPlugin = (editor: Editor) =>
|
||||
new Plugin({
|
||||
key: MultipleSelectionPluginKey,
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousedown(view: EditorView, event: MouseEvent) {
|
||||
if (event.target !== view.dom) return false;
|
||||
|
||||
removeActiveUser();
|
||||
|
||||
activeCursor = document.createElement("div");
|
||||
activeCursor.className = "multipleSelectionCursor";
|
||||
activeCursor.style.width = "0px";
|
||||
activeCursor.style.height = "0px";
|
||||
activeCursor.style.borderRadius = "2px";
|
||||
activeCursor.style.border = "1px solid rgba(var(--color-primary-100), 0.2)";
|
||||
activeCursor.style.background = "rgba(var(--color-primary-100), 0.2)";
|
||||
activeCursor.style.opacity = "0.5";
|
||||
activeCursor.style.position = "absolute";
|
||||
activeCursor.style.top = `${event.y}px`;
|
||||
activeCursor.style.left = `${event.x}px`;
|
||||
activeCursor.style.pointerEvents = "none";
|
||||
|
||||
lastActiveSelection.top = event.y;
|
||||
lastActiveSelection.left = event.x;
|
||||
|
||||
document.body.appendChild(activeCursor);
|
||||
|
||||
// Pass view to updateCursorPosition
|
||||
document.addEventListener("mousemove", updateCursorPosition(view, editor));
|
||||
document.addEventListener("mouseup", removeActiveUser);
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
},
|
||||
destroy() {
|
||||
removeActiveUser();
|
||||
},
|
||||
});
|
||||
|
||||
export const multipleSelectionExtension = Extension.create({
|
||||
name: "multipleSelection",
|
||||
addProseMirrorPlugins(this) {
|
||||
return [createMultipleSelectionPlugin(this.editor)];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Editor, Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey, Selection, TextSelection } from "prosemirror-state";
|
||||
import { EditorView, Decoration, DecorationSet } from "prosemirror-view";
|
||||
import { ResolvedPos } from "prosemirror-model";
|
||||
|
||||
const MultipleSelectionPluginKey = new PluginKey("multipleSelection");
|
||||
|
||||
// Custom Selection Class
|
||||
class MultipleNodeSelection extends Selection {
|
||||
constructor($from: ResolvedPos, $to: ResolvedPos) {
|
||||
super($from, $to);
|
||||
}
|
||||
|
||||
map(doc: any, mapping: any) {
|
||||
const $from = doc.resolve(mapping.map(this.$from.pos));
|
||||
const $to = doc.resolve(mapping.map(this.$to.pos));
|
||||
return new MultipleNodeSelection($from, $to);
|
||||
}
|
||||
|
||||
content() {
|
||||
return this.$from.node(0).slice(this.$from.pos, this.$to.pos, true);
|
||||
}
|
||||
|
||||
eq(other: Selection): boolean {
|
||||
return (
|
||||
other instanceof MultipleNodeSelection && other.$from.pos === this.$from.pos && other.$to.pos === this.$to.pos
|
||||
);
|
||||
}
|
||||
|
||||
static create(doc: any, from: number, to: number) {
|
||||
const $from = doc.resolve(from);
|
||||
const $to = doc.resolve(to);
|
||||
return new MultipleNodeSelection($from, $to);
|
||||
}
|
||||
|
||||
replaceWith(tr: any, node: any) {
|
||||
super.replaceWith(tr, node);
|
||||
}
|
||||
}
|
||||
|
||||
interface BlockSelectionState {
|
||||
ranges: Array<[number, number]> | null;
|
||||
lastSelectedRange?: { from: number; to: number };
|
||||
}
|
||||
|
||||
let activeCursor: HTMLElement | null = null;
|
||||
const lastActiveSelection = {
|
||||
top: 0,
|
||||
left: 0,
|
||||
};
|
||||
|
||||
const updateCursorPosition = (event: MouseEvent) => {
|
||||
if (!activeCursor) return;
|
||||
|
||||
let newHeight = event.y - lastActiveSelection.top;
|
||||
let newWidth = event.x - lastActiveSelection.left;
|
||||
|
||||
if (newHeight < 0) {
|
||||
activeCursor.style.marginTop = `${newHeight}px`;
|
||||
newHeight *= -1;
|
||||
} else {
|
||||
activeCursor.style.marginTop = "0px";
|
||||
}
|
||||
|
||||
if (newWidth < 0) {
|
||||
activeCursor.style.marginLeft = `${newWidth}px`;
|
||||
newWidth *= -1;
|
||||
} else {
|
||||
activeCursor.style.marginLeft = "0px";
|
||||
}
|
||||
|
||||
activeCursor.style.height = `${newHeight}px`;
|
||||
activeCursor.style.width = `${newWidth}px`;
|
||||
};
|
||||
|
||||
const getNodesInRect = (view: EditorView, rect: DOMRect) => {
|
||||
const ranges: Array<[number, number]> = [];
|
||||
const { doc } = view.state;
|
||||
|
||||
const isInRect = (pos: number) => {
|
||||
const coords = view.coordsAtPos(pos);
|
||||
return (
|
||||
coords &&
|
||||
coords.top < rect.bottom &&
|
||||
coords.bottom > rect.top &&
|
||||
coords.left < rect.right &&
|
||||
coords.right > rect.left
|
||||
);
|
||||
};
|
||||
|
||||
doc.nodesBetween(0, doc.content.size, (node, pos) => {
|
||||
const resolvedPos = view.state.doc.resolve(pos);
|
||||
|
||||
if (resolvedPos.depth === 1) {
|
||||
const start = resolvedPos.before(1);
|
||||
const end = resolvedPos.after(1);
|
||||
|
||||
if (isInRect(start) || isInRect(end)) {
|
||||
ranges.push([start, end]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return ranges;
|
||||
};
|
||||
|
||||
const removeActiveUser = () => {
|
||||
if (!activeCursor) return;
|
||||
|
||||
activeCursor.remove();
|
||||
activeCursor = null;
|
||||
document.removeEventListener("mousemove", selectNodesHandler);
|
||||
document.removeEventListener("mouseup", removeActiveUser);
|
||||
|
||||
if (lastView) {
|
||||
const pluginState = MultipleSelectionPluginKey.getState(lastView.state);
|
||||
const ranges = pluginState?.ranges;
|
||||
|
||||
if (ranges && ranges.length > 0) {
|
||||
// Sort ranges by position
|
||||
const sortedRanges = [...ranges].sort(([a], [b]) => a - b);
|
||||
const from = sortedRanges[0][0];
|
||||
const to = sortedRanges[sortedRanges.length - 1][1];
|
||||
|
||||
const tr = lastView.state.tr;
|
||||
|
||||
// Create our custom selection
|
||||
const selection = MultipleNodeSelection.create(lastView.state.doc, from, to);
|
||||
|
||||
tr.setSelection(selection).setMeta(MultipleSelectionPluginKey, {
|
||||
ranges: sortedRanges,
|
||||
lastSelectedRange: { from, to },
|
||||
});
|
||||
|
||||
lastView.dispatch(tr);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let selectNodesHandler: (event: MouseEvent) => void;
|
||||
let lastView: EditorView | null = null;
|
||||
|
||||
const createMultipleSelectionPlugin = () =>
|
||||
new Plugin<BlockSelectionState>({
|
||||
key: MultipleSelectionPluginKey,
|
||||
state: {
|
||||
init() {
|
||||
return { ranges: null };
|
||||
},
|
||||
apply(tr, state) {
|
||||
const action = tr.getMeta(MultipleSelectionPluginKey);
|
||||
if (action) {
|
||||
return action;
|
||||
}
|
||||
return state;
|
||||
},
|
||||
},
|
||||
props: {
|
||||
handleDOMEvents: {
|
||||
mousedown(view: EditorView, event: MouseEvent) {
|
||||
if (event.target !== view.dom) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastView = view;
|
||||
removeActiveUser();
|
||||
|
||||
activeCursor = document.createElement("div");
|
||||
activeCursor.className = "multipleSelectionCursor";
|
||||
activeCursor.style.width = "0px";
|
||||
activeCursor.style.height = "0px";
|
||||
activeCursor.style.borderRadius = "2px";
|
||||
activeCursor.style.border = "1px solid rgba(var(--color-primary-100), 0.2)";
|
||||
activeCursor.style.background = "rgba(var(--color-primary-100), 0.2)";
|
||||
activeCursor.style.opacity = "0.5";
|
||||
activeCursor.style.position = "absolute";
|
||||
activeCursor.style.top = `${event.y}px`;
|
||||
activeCursor.style.left = `${event.x}px`;
|
||||
activeCursor.style.pointerEvents = "none";
|
||||
activeCursor.style.zIndex = "1000";
|
||||
|
||||
lastActiveSelection.top = event.y;
|
||||
lastActiveSelection.left = event.x;
|
||||
|
||||
document.body.appendChild(activeCursor);
|
||||
|
||||
selectNodesHandler = (e: MouseEvent) => {
|
||||
updateCursorPosition(e);
|
||||
if (activeCursor) {
|
||||
const rect = activeCursor.getBoundingClientRect();
|
||||
const ranges = getNodesInRect(view, rect);
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(MultipleSelectionPluginKey, {
|
||||
ranges,
|
||||
lastSelectedRange: undefined,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", selectNodesHandler);
|
||||
document.addEventListener("mouseup", removeActiveUser);
|
||||
|
||||
event.preventDefault();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
decorations(state) {
|
||||
const pluginState = this.getState(state);
|
||||
|
||||
if (pluginState?.ranges?.length) {
|
||||
return DecorationSet.create(
|
||||
state.doc,
|
||||
pluginState.ranges.map(([from, to]) =>
|
||||
Decoration.node(from, to, {
|
||||
class: "custom-selection",
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
},
|
||||
view() {
|
||||
return {
|
||||
destroy() {
|
||||
removeActiveUser();
|
||||
lastView = null;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Register our custom selection with ProseMirror
|
||||
Selection.jsonID("multipleNode", MultipleNodeSelection);
|
||||
|
||||
export const multipleSelectionExtension = Extension.create({
|
||||
name: "multipleSelection",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [createMultipleSelectionPlugin()];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import { Decoration, DecorationSet } from "@tiptap/pm/view";
|
||||
import { MultiRangeSelection } from "./MultiRangeSelection";
|
||||
|
||||
export const multiSelectionDecorationPluginKey = new PluginKey("multiSelectionDecorationPlugin");
|
||||
|
||||
export function createMultiSelectionDecorationPlugin() {
|
||||
return new Plugin({
|
||||
key: multiSelectionDecorationPluginKey,
|
||||
props: {
|
||||
decorations(state) {
|
||||
const { doc, selection } = state;
|
||||
|
||||
// Only create decorations if we have a MultiRangeSelection
|
||||
if (!(selection instanceof MultiRangeSelection)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
// (1) Decorate all nodes so that their ::selection is transparent,
|
||||
// because we only want to highlight the actual selection range
|
||||
doc.nodesBetween(0, doc.nodeSize - 2, (node, pos) => {
|
||||
decorations.push(
|
||||
Decoration.inline(pos, pos + node.nodeSize, {
|
||||
class: "multiple-selection",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// (2) Decorate the actual selection ranges in a visible style
|
||||
selection.ranges.forEach((range) => {
|
||||
decorations.push(
|
||||
Decoration.inline(range.$from.pos, range.$to.pos, {
|
||||
class: "selected-text",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { replaceCodeWithText } from "@/extensions/code/utils/replace-code-block-
|
||||
import { findTableAncestor } from "@/helpers/common";
|
||||
// types
|
||||
import { InsertImageComponentProps } from "@/extensions";
|
||||
import { EditorState, NodeSelection, Transaction } from "@tiptap/pm/state";
|
||||
|
||||
export const setText = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).setNode("paragraph").run();
|
||||
@@ -42,10 +43,48 @@ export const toggleHeadingSix = (editor: Editor, range?: Range) => {
|
||||
};
|
||||
|
||||
export const toggleBold = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||
else editor.chain().focus().toggleBold().run();
|
||||
const { view } = editor;
|
||||
const { from, to } = view.state.selection;
|
||||
const tr = createNodeSelectionsAtDepth(editor.state, from, to, 1);
|
||||
if (tr) editor.view.dispatch(tr);
|
||||
// if (range) editor.chain().focus().deleteRange(range).toggleBold().run();
|
||||
// else editor.chain().focus().toggleBold().run();
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates multiple NodeSelections for nodes within a range at a specified depth
|
||||
*
|
||||
* @param state - The current EditorState
|
||||
* @param from - Starting position of the range
|
||||
* @param to - Ending position of the range
|
||||
* @param depth - The depth at which to select nodes (e.g. 1 for direct children of the parent)
|
||||
* @returns Transaction with the NodeSelections or null if invalid
|
||||
*/
|
||||
function createNodeSelectionsAtDepth(state: EditorState, from: number, to: number, depth: number): Transaction | null {
|
||||
const { doc, tr } = state;
|
||||
let hasSelections = false;
|
||||
console.log("asafdasf", doc, from, to);
|
||||
|
||||
// Collect positions of nodes at specified depth within range
|
||||
doc.nodesBetween(from, to, (node, pos, parent, index) => {
|
||||
console.log("node", node, parent, doc.resolve(pos).depth, depth);
|
||||
// Only process nodes at the specified depth
|
||||
if (parent) {
|
||||
console.log("node inside if", node);
|
||||
// Create a NodeSelection if the node is selectable
|
||||
if (NodeSelection.isSelectable(node)) {
|
||||
console.log("creating node selection", node);
|
||||
tr.setSelection(NodeSelection.create(doc, pos));
|
||||
hasSelections = true;
|
||||
}
|
||||
}
|
||||
// Return true to continue traversing
|
||||
return true;
|
||||
});
|
||||
|
||||
return hasSelections ? tr : null;
|
||||
}
|
||||
|
||||
export const toggleItalic = (editor: Editor, range?: Range) => {
|
||||
if (range) editor.chain().focus().deleteRange(range).toggleItalic().run();
|
||||
else editor.chain().focus().toggleItalic().run();
|
||||
|
||||
@@ -166,6 +166,7 @@ export const DragHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOp
|
||||
}
|
||||
|
||||
const scrollableParent = getScrollParent(dragHandleElement);
|
||||
console.log("scrollableParent drag hanlde", scrollableParent);
|
||||
if (!scrollableParent) return;
|
||||
|
||||
const scrollRegionUp = options.scrollThreshold.up;
|
||||
@@ -392,32 +393,32 @@ const handleNodeSelection = (
|
||||
draggedNodePos = Math.max(0, Math.min(draggedNodePos, docSize));
|
||||
|
||||
// Use NodeSelection to select the node at the calculated position
|
||||
const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||
// const nodeSelection = NodeSelection.create(view.state.doc, draggedNodePos);
|
||||
//
|
||||
// // Dispatch the transaction to update the selection
|
||||
// view.dispatch(view.state.tr.setSelection(nodeSelection));
|
||||
|
||||
// Dispatch the transaction to update the selection
|
||||
view.dispatch(view.state.tr.setSelection(nodeSelection));
|
||||
|
||||
if (isDragStart) {
|
||||
// Additional logic for drag start
|
||||
if (event instanceof DragEvent && !event.dataTransfer) return;
|
||||
|
||||
if (nodeSelection.node.type.name === "listItem" || nodeSelection.node.type.name === "taskItem") {
|
||||
listType = node.closest("ol, ul")?.tagName || "";
|
||||
}
|
||||
|
||||
const slice = view.state.selection.content();
|
||||
const { dom, text } = __serializeForClipboard(view, slice);
|
||||
|
||||
if (event instanceof DragEvent) {
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData("text/html", dom.innerHTML);
|
||||
event.dataTransfer.setData("text/plain", text);
|
||||
event.dataTransfer.effectAllowed = "copyMove";
|
||||
event.dataTransfer.setDragImage(node, 0, 0);
|
||||
}
|
||||
|
||||
view.dragging = { slice, move: event.ctrlKey };
|
||||
}
|
||||
// if (isDragStart) {
|
||||
// // Additional logic for drag start
|
||||
// if (event instanceof DragEvent && !event.dataTransfer) return;
|
||||
//
|
||||
// if (nodeSelection.node.type.name === "listItem" || nodeSelection.node.type.name === "taskItem") {
|
||||
// listType = node.closest("ol, ul")?.tagName || "";
|
||||
// }
|
||||
//
|
||||
// const slice = view.state.selection.content();
|
||||
// const { dom, text } = __serializeForClipboard(view, slice);
|
||||
//
|
||||
// if (event instanceof DragEvent) {
|
||||
// event.dataTransfer.clearData();
|
||||
// event.dataTransfer.setData("text/html", dom.innerHTML);
|
||||
// event.dataTransfer.setData("text/plain", text);
|
||||
// event.dataTransfer.effectAllowed = "copyMove";
|
||||
// event.dataTransfer.setDragImage(node, 0, 0);
|
||||
// }
|
||||
//
|
||||
// view.dragging = { slice, move: event.ctrlKey };
|
||||
// }
|
||||
|
||||
return { listType };
|
||||
};
|
||||
|
||||
@@ -51,9 +51,10 @@
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: calc(-1 * var(--horizontal-offset));
|
||||
height: 100%;
|
||||
height: calc(100% - 1.5px);
|
||||
width: calc(100% + (var(--horizontal-offset) * 2));
|
||||
background-color: rgba(var(--color-primary-100), 0.2);
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -488,3 +488,7 @@ p.editor-paragraph-block + p.editor-paragraph-block {
|
||||
background-color: var(--editor-colors-purple-background);
|
||||
}
|
||||
/* end background colors */
|
||||
|
||||
.custom-selection {
|
||||
padding: 10px !important;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user