Compare commits

...

4 Commits

Author SHA1 Message Date
Palanikannan M cad574ce06 fix: perform actions on multi node selections 2024-12-30 09:02:56 +05:30
Palanikannan M 85d437edba fix: add multi node selection via decorations 2024-12-26 14:34:35 +05:30
Palanikannan M 0aaf69076a Merge branch 'preview' into feat/multinode-selection 2024-12-24 20:26:40 +05:30
Palanikannan M 7f84b39f6c wip: trying out multiple ways to perform multi node selection while dragging 2024-11-22 20:26:10 +05:30
13 changed files with 1431 additions and 33 deletions
@@ -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 ranges 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 its 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();
+26 -25
View File
@@ -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 };
};
+3 -2
View File
@@ -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;
+4
View File
@@ -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;
}