feat: migrate dashboard to svelte 5 (#577)
* 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]>
This commit is contained in:
@@ -0,0 +1,374 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user