* chore: migration to SvelteKit 2 * stage 1: migrate to svelte 5 and fix rerendering issues * stage 2: fix more $: reactivity across dashboard * fix merge conflicts * chore: migrate code in main to svelte 5 * chore: update preventDefault * chore: simplify tsconfig and remove unused mobileMenuRef in MobileMenu.svelte * chore: enforce .ts across dashboard * rename from classroomio-com to website * stage-3: Replace carbon icon svelte with lucide icon * update lock file * chore: finish icon replacement * fix: settings and setup page multiple rerender * stage 3: More icon and rerendering fixes * feat: checkpoint * feat: update editor * [not working] connect everything with ui build * storybook + ui + dashboard all working independently * add bg-black to +page.svelte * feat: add editor to storybook * chore: animate lucid icons (#590) * v1 animations * feat: change some course navigation icons & animate org navigation icons * chore: change settings icon * chore: use css instead of js for hover * rename animations --------- Co-authored-by: tunny17 <[email protected]> * chore: update path to node_modules types in api tsconfig * fix: update import path for DropdownMenu component * fix: add rollupOptions to build configuration in vite.config.ts * fix: remove rollupOptions from build configuration and add @floating-ui/dom dependency * install --------- Co-authored-by: tunny17 <[email protected]>
375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
import { Fragment, Node, Slice } from '@tiptap/pm/model';
|
|
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
|
|
|
|
import { EditorView } from '@tiptap/pm/view';
|
|
import { Extension } from '@tiptap/core';
|
|
import { serializeForClipboard } from './ClipboardSerializer';
|
|
|
|
export interface GlobalDragHandleOptions {
|
|
/**
|
|
* The width of the drag handle
|
|
*/
|
|
dragHandleWidth: number;
|
|
|
|
/**
|
|
* The treshold for scrolling
|
|
*/
|
|
scrollTreshold: number;
|
|
|
|
/*
|
|
* The css selector to query for the drag handle. (eg: '.custom-handle').
|
|
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
|
|
*/
|
|
dragHandleSelector?: string;
|
|
|
|
/**
|
|
* Tags to be excluded for drag handle
|
|
*/
|
|
excludedTags: string[];
|
|
|
|
/**
|
|
* Custom nodes to be included for drag handle
|
|
*/
|
|
customNodes: string[];
|
|
|
|
/**
|
|
* onNodeChange callback for drag handle
|
|
* @param data
|
|
* @returns
|
|
*/
|
|
onMouseMove?: (data: { node: Node; pos: number }) => void;
|
|
}
|
|
function absoluteRect(node: Element) {
|
|
const data = node.getBoundingClientRect();
|
|
const modal = node.closest('[role="dialog"]');
|
|
|
|
if (modal && window.getComputedStyle(modal).transform !== 'none') {
|
|
const modalRect = modal.getBoundingClientRect();
|
|
|
|
return {
|
|
top: data.top - modalRect.top,
|
|
left: data.left - modalRect.left,
|
|
width: data.width
|
|
};
|
|
}
|
|
return {
|
|
top: data.top,
|
|
left: data.left,
|
|
width: data.width
|
|
};
|
|
}
|
|
|
|
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
|
|
const selectors = [
|
|
'li',
|
|
'p:not(:first-child)',
|
|
'pre',
|
|
'blockquote',
|
|
'h1',
|
|
'h2',
|
|
'h3',
|
|
'h4',
|
|
'h5',
|
|
'h6',
|
|
...options.customNodes.map((node) => `[data-type=${node}]`)
|
|
].join(', ');
|
|
return document
|
|
.elementsFromPoint(coords.x, coords.y)
|
|
.find((elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors));
|
|
}
|
|
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
|
|
const boundingRect = node.getBoundingClientRect();
|
|
|
|
return view.posAtCoords({
|
|
left: boundingRect.left + 50 + options.dragHandleWidth,
|
|
top: boundingRect.top + 1
|
|
})?.inside;
|
|
}
|
|
|
|
function calcNodePos(pos: number, view: EditorView) {
|
|
const $pos = view.state.doc.resolve(pos);
|
|
if ($pos.depth > 1) return $pos.before($pos.depth);
|
|
return pos;
|
|
}
|
|
|
|
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
|
|
let listType = '';
|
|
function handleDragStart(event: DragEvent, view: EditorView) {
|
|
view.focus();
|
|
|
|
if (!event.dataTransfer) return;
|
|
|
|
const node = nodeDOMAtCoords(
|
|
{
|
|
x: event.clientX + 50 + options.dragHandleWidth,
|
|
y: event.clientY
|
|
},
|
|
options
|
|
);
|
|
|
|
if (!(node instanceof Element)) return;
|
|
|
|
let draggedNodePos = nodePosAtDOM(node, view, options);
|
|
if (draggedNodePos == null || draggedNodePos < 0) return;
|
|
draggedNodePos = calcNodePos(draggedNodePos, view);
|
|
|
|
const { from, to } = view.state.selection;
|
|
const diff = from - to;
|
|
|
|
const fromSelectionPos = calcNodePos(from, view);
|
|
let differentNodeSelected = false;
|
|
|
|
const nodePos = view.state.doc.resolve(fromSelectionPos);
|
|
|
|
// Check if nodePos points to the top level node
|
|
if (nodePos.node().type.name === 'doc') differentNodeSelected = true;
|
|
else {
|
|
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
|
|
|
|
// Check if the node where the drag event started is part of the current selection
|
|
differentNodeSelected = !(
|
|
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
|
|
);
|
|
}
|
|
let selection = view.state.selection;
|
|
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
|
|
const endSelection = NodeSelection.create(view.state.doc, to - 1);
|
|
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
|
|
} else {
|
|
selection = NodeSelection.create(view.state.doc, draggedNodePos);
|
|
|
|
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
|
|
// if table row is selected, go to the parent node to select the whole node
|
|
if (
|
|
(selection as NodeSelection).node.type.isInline ||
|
|
(selection as NodeSelection).node.type.name === 'tableRow'
|
|
) {
|
|
const $pos = view.state.doc.resolve(selection.from);
|
|
selection = NodeSelection.create(view.state.doc, $pos.before());
|
|
}
|
|
}
|
|
view.dispatch(view.state.tr.setSelection(selection));
|
|
|
|
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
|
|
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === 'listItem') {
|
|
listType = node.parentElement!.tagName;
|
|
}
|
|
|
|
const slice = view.state.selection.content();
|
|
const { dom, text } = serializeForClipboard(view, slice);
|
|
|
|
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 };
|
|
}
|
|
|
|
let dragHandleElement: HTMLElement | null = null;
|
|
|
|
function hideDragHandle() {
|
|
if (dragHandleElement) {
|
|
dragHandleElement.classList.add('hide');
|
|
}
|
|
}
|
|
|
|
function showDragHandle() {
|
|
if (dragHandleElement) {
|
|
dragHandleElement.classList.remove('hide');
|
|
}
|
|
}
|
|
|
|
function hideHandleOnEditorOut(event: MouseEvent) {
|
|
if (event.target instanceof Element) {
|
|
// Check if the relatedTarget class is still inside the editor
|
|
const relatedTarget = event.relatedTarget as HTMLElement;
|
|
const isInsideEditor =
|
|
relatedTarget?.classList.contains('tiptap') || relatedTarget?.classList.contains('drag-handle');
|
|
|
|
if (isInsideEditor) return;
|
|
}
|
|
hideDragHandle();
|
|
}
|
|
|
|
return new Plugin({
|
|
key: new PluginKey(options.pluginKey),
|
|
view: (view) => {
|
|
const handleBySelector = options.dragHandleSelector
|
|
? document.querySelector<HTMLElement>(options.dragHandleSelector)
|
|
: null;
|
|
dragHandleElement = handleBySelector ?? document.createElement('div');
|
|
dragHandleElement.draggable = true;
|
|
dragHandleElement.dataset.dragHandle = '';
|
|
dragHandleElement.classList.add('drag-handle');
|
|
|
|
function onDragHandleDragStart(e: DragEvent) {
|
|
handleDragStart(e, view);
|
|
}
|
|
|
|
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart);
|
|
|
|
function onDragHandleDrag(e: DragEvent) {
|
|
hideDragHandle();
|
|
const scrollY = window.scrollY;
|
|
if (e.clientY < options.scrollTreshold) {
|
|
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' });
|
|
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
|
|
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' });
|
|
}
|
|
}
|
|
|
|
dragHandleElement.addEventListener('drag', onDragHandleDrag);
|
|
|
|
hideDragHandle();
|
|
|
|
if (!handleBySelector) {
|
|
view?.dom?.parentElement?.appendChild(dragHandleElement);
|
|
}
|
|
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut);
|
|
|
|
return {
|
|
destroy: () => {
|
|
if (!handleBySelector) {
|
|
dragHandleElement?.remove?.();
|
|
}
|
|
dragHandleElement?.removeEventListener('drag', onDragHandleDrag);
|
|
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart);
|
|
dragHandleElement = null;
|
|
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut);
|
|
}
|
|
};
|
|
},
|
|
props: {
|
|
handleDOMEvents: {
|
|
mousemove: (view, event) => {
|
|
if (!view.editable) {
|
|
return;
|
|
}
|
|
|
|
const node = nodeDOMAtCoords(
|
|
{
|
|
x: event.clientX + 50 + options.dragHandleWidth,
|
|
y: event.clientY
|
|
},
|
|
options
|
|
);
|
|
|
|
const notDragging = node?.closest('.not-draggable');
|
|
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ');
|
|
|
|
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
|
|
hideDragHandle();
|
|
return;
|
|
}
|
|
|
|
const nodePos = nodePosAtDOM(node, view, options);
|
|
if (nodePos !== undefined) {
|
|
const currentNode = view.state.doc.nodeAt(nodePos);
|
|
if (currentNode !== null) {
|
|
options.onMouseMove?.({ node: currentNode, pos: nodePos });
|
|
}
|
|
}
|
|
|
|
const compStyle = window.getComputedStyle(node);
|
|
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
|
|
const lineHeight = isNaN(parsedLineHeight) ? parseInt(compStyle.fontSize) * 1.2 : parsedLineHeight;
|
|
const paddingTop = parseInt(compStyle.paddingTop, 10);
|
|
|
|
const rect = absoluteRect(node);
|
|
|
|
rect.top += (lineHeight - 24) / 2;
|
|
rect.top += paddingTop;
|
|
// Li markers
|
|
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
|
rect.left -= options.dragHandleWidth;
|
|
}
|
|
rect.width = options.dragHandleWidth;
|
|
|
|
if (!dragHandleElement) return;
|
|
|
|
dragHandleElement.style.left = `${rect.left - rect.width}px`;
|
|
dragHandleElement.style.top = `${rect.top}px`;
|
|
showDragHandle();
|
|
},
|
|
keydown: () => {
|
|
hideDragHandle();
|
|
},
|
|
mousewheel: () => {
|
|
hideDragHandle();
|
|
},
|
|
// dragging class is used for CSS
|
|
dragstart: (view) => {
|
|
view.dom.classList.add('dragging');
|
|
},
|
|
drop: (view, event) => {
|
|
view.dom.classList.remove('dragging');
|
|
hideDragHandle();
|
|
let droppedNode: Node | null = null;
|
|
const dropPos = view.posAtCoords({
|
|
left: event.clientX,
|
|
top: event.clientY
|
|
});
|
|
|
|
if (!dropPos) return;
|
|
|
|
if (view.state.selection instanceof NodeSelection) {
|
|
droppedNode = view.state.selection.node;
|
|
}
|
|
if (!droppedNode) return;
|
|
|
|
const resolvedPos = view.state.doc.resolve(dropPos.pos);
|
|
|
|
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem';
|
|
|
|
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
|
|
if (
|
|
view.state.selection instanceof NodeSelection &&
|
|
view.state.selection.node.type.name === 'listItem' &&
|
|
!isDroppedInsideList &&
|
|
listType == 'OL'
|
|
) {
|
|
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
|
|
const slice = new Slice(Fragment.from(newList), 0, 0);
|
|
view.dragging = { slice, move: event.ctrlKey };
|
|
}
|
|
},
|
|
dragend: (view) => {
|
|
view.dom.classList.remove('dragging');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
const GlobalDragHandle = Extension.create({
|
|
name: 'globalDragHandle',
|
|
|
|
addOptions() {
|
|
return {
|
|
dragHandleWidth: 20,
|
|
scrollTreshold: 100,
|
|
excludedTags: [],
|
|
customNodes: []
|
|
};
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
return [
|
|
DragHandlePlugin({
|
|
pluginKey: 'globalDragHandle',
|
|
dragHandleWidth: this.options.dragHandleWidth,
|
|
scrollTreshold: this.options.scrollTreshold,
|
|
dragHandleSelector: this.options.dragHandleSelector,
|
|
excludedTags: this.options.excludedTags,
|
|
customNodes: this.options.customNodes,
|
|
onMouseMove: this.options.onMouseMove
|
|
})
|
|
];
|
|
}
|
|
});
|
|
|
|
export default GlobalDragHandle;
|