feat: add smooth cursor animation

This commit is contained in:
Palanikannan M
2024-11-22 18:09:05 +05:30
parent b72d18079f
commit 854eacdaa8
7 changed files with 356 additions and 0 deletions

View File

@@ -24,6 +24,7 @@ import {
DropHandlerExtension,
ImageExtension,
ListKeymap,
SmoothCursorExtension,
Table,
TableCell,
TableHeader,
@@ -162,5 +163,6 @@ export const CoreEditorExtensions = (args: TArguments) => {
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
SmoothCursorExtension,
];
};

View File

@@ -23,3 +23,4 @@ export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./text-align";
export * from "./smooth-cursor";

View File

@@ -0,0 +1,9 @@
import { Extension } from "@tiptap/core";
import { smoothCursorPlugin } from "./plugin";
export const SmoothCursorExtension = Extension.create({
name: "smoothCursorExtension",
addProseMirrorPlugins() {
return [smoothCursorPlugin()];
},
});

View File

@@ -0,0 +1,103 @@
import { type Selection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { type EditorView, Decoration, DecorationSet } from "@tiptap/pm/view";
export const PROSEMIRROR_SMOOTH_CURSOR_CLASS = "prosemirror-smooth-cursor";
export function smoothCursorPlugin(): Plugin {
let smoothCursor: HTMLElement | null = typeof document === "undefined" ? null : document.createElement("div");
return new Plugin({
key,
view: (view) => {
const doc = view.dom.ownerDocument;
smoothCursor = smoothCursor || document.createElement("div");
const cursor = smoothCursor;
const update = () => {
updateCursor(view, cursor);
};
let observer: ResizeObserver | undefined;
if (window.ResizeObserver) {
observer = new window.ResizeObserver(update);
observer?.observe(view.dom);
}
doc.addEventListener("selectionchange", update);
return {
update,
destroy: () => {
doc.removeEventListener("selectionchange", update);
observer?.unobserve(view.dom);
},
};
},
props: {
decorations: (state) => {
if (!smoothCursor || !isTextSelection(state.selection) || !state.selection.empty) return;
return DecorationSet.create(state.doc, [
Decoration.widget(0, smoothCursor, {
key: PROSEMIRROR_SMOOTH_CURSOR_CLASS,
}),
]);
},
attributes: {
class: "smooth-cursor-enabled",
},
},
});
}
const key = new PluginKey(PROSEMIRROR_SMOOTH_CURSOR_CLASS);
function getCursorRect(
view: EditorView,
toStart: boolean
): { left: number; right: number; top: number; bottom: number } | null {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
const range = selection?.getRangeAt(0)?.cloneRange();
if (!range) return null;
range.collapse(toStart);
const rects = range.getClientRects();
const rect = rects?.length ? rects[rects.length - 1] : null;
if (rect?.height) return rect;
return view.coordsAtPos(view.state.selection.head);
}
function isTextSelection(selection: Selection): selection is TextSelection {
return selection && typeof selection === "object" && "$cursor" in selection;
}
function updateCursor(view?: EditorView, cursor?: HTMLElement) {
if (!view || !view.dom || view.isDestroyed || !cursor) return;
const { state, dom } = view;
const { selection } = state;
if (!isTextSelection(selection)) return;
const cursorRect = getCursorRect(view, selection.$head === selection.$from);
if (!cursorRect) return cursor;
const editorRect = dom.getBoundingClientRect();
const className = PROSEMIRROR_SMOOTH_CURSOR_CLASS;
cursor.className = className;
cursor.style.height = `${cursorRect.bottom - cursorRect.top}px`;
// Calculate the exact position
const x = cursorRect.left - editorRect.left;
const y = cursorRect.top - editorRect.top;
requestAnimationFrame(() => {
cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`;
});
}

View File

@@ -0,0 +1,97 @@
import { type Selection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { type EditorView, Decoration, DecorationSet } from "@tiptap/pm/view";
export const PROSEMIRROR_SMOOTH_CURSOR_CLASS = "prosemirror-smooth-cursor";
export function smoothCursorPlugin(): Plugin {
let smoothCursor: HTMLElement | null = typeof document === "undefined" ? null : document.createElement("div");
return new Plugin({
key,
view: (view) => {
const doc = view.dom.ownerDocument;
smoothCursor = smoothCursor || document.createElement("div");
const cursor = smoothCursor;
const update = () => {
updateCursor(view, cursor);
};
let observer: ResizeObserver | undefined;
if (window.ResizeObserver) {
observer = new window.ResizeObserver(update);
observer?.observe(view.dom);
}
doc.addEventListener("selectionchange", update);
return {
update,
destroy: () => {
doc.removeEventListener("selectionchange", update);
observer?.unobserve(view.dom);
},
};
},
props: {
decorations: (state) => {
if (!smoothCursor || !isTextSelection(state.selection) || !state.selection.empty) return;
return DecorationSet.create(state.doc, [
Decoration.widget(0, smoothCursor, {
key: PROSEMIRROR_SMOOTH_CURSOR_CLASS,
}),
]);
},
attributes: {
class: "smooth-cursor-enabled",
},
},
});
}
const key = new PluginKey(PROSEMIRROR_SMOOTH_CURSOR_CLASS);
function getCursorRect(
view: EditorView,
toStart: boolean
): { left: number; right: number; top: number; bottom: number } | null {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
const range = selection?.getRangeAt(0)?.cloneRange();
if (!range) return null;
range.collapse(toStart);
const rects = range.getClientRects();
const rect = rects?.length ? rects[rects.length - 1] : null;
if (rect?.height) return rect;
return view.coordsAtPos(view.state.selection.head);
}
function isTextSelection(selection: Selection): selection is TextSelection {
return selection && typeof selection === "object" && "$cursor" in selection;
}
function updateCursor(view?: EditorView, cursor?: HTMLElement) {
if (!view || !view.dom || view.isDestroyed || !cursor) return;
const { state, dom } = view;
const { selection } = state;
if (!isTextSelection(selection)) return;
const cursorRect = getCursorRect(view, selection.$head === selection.$from);
if (!cursorRect) return cursor;
const editorRect = dom.getBoundingClientRect();
const className = PROSEMIRROR_SMOOTH_CURSOR_CLASS;
cursor.className = className;
cursor.style.height = `${cursorRect.bottom - cursorRect.top}px`;
cursor.style.left = `${cursorRect.left - editorRect.left}px`;
cursor.style.top = `${cursorRect.top - editorRect.top}px`;
}

View File

@@ -0,0 +1,113 @@
import { type Selection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { type EditorView, Decoration, DecorationSet } from "@tiptap/pm/view";
export const PROSEMIRROR_SMOOTH_CURSOR_CLASS = "prosemirror-smooth-cursor";
export function smoothCursorPlugin(): Plugin {
let smoothCursor: HTMLElement | null = typeof document === "undefined" ? null : document.createElement("div");
let rafId: number | undefined;
function updateCursor(view?: EditorView, cursor?: HTMLElement) {
if (!view || !view.dom || view.isDestroyed || !cursor) return;
const { state, dom } = view;
const { selection } = state;
if (!isTextSelection(selection)) return;
const cursorRect = getCursorRect(view, selection.$head === selection.$from);
if (!cursorRect) return cursor;
const editorRect = dom.getBoundingClientRect();
const className = PROSEMIRROR_SMOOTH_CURSOR_CLASS;
cursor.className = className;
cursor.style.height = `${cursorRect.bottom - cursorRect.top}px`;
// Calculate the exact position
const x = cursorRect.left - editorRect.left;
const y = cursorRect.top - editorRect.top;
rafId = requestAnimationFrame(() => {
cursor.style.transform = `translate3d(${x}px, ${y}px, 0)`;
});
}
return new Plugin({
key,
view: (view) => {
const doc = view.dom.ownerDocument;
smoothCursor = smoothCursor || document.createElement("div");
const cursor = smoothCursor;
const update = () => {
if (rafId !== undefined) {
console.log("cleaning up 1: " + rafId);
cancelAnimationFrame(rafId);
}
updateCursor(view, cursor);
};
let observer: ResizeObserver | undefined;
if (window.ResizeObserver) {
observer = new window.ResizeObserver(update);
observer?.observe(view.dom);
}
doc.addEventListener("selectionchange", update);
return {
update,
destroy: () => {
doc.removeEventListener("selectionchange", update);
observer?.unobserve(view.dom);
// Clean up any pending animation frame
if (rafId !== undefined) {
console.log("cleaning up 2: " + rafId);
cancelAnimationFrame(rafId);
}
},
};
},
props: {
decorations: (state) => {
if (!smoothCursor || !isTextSelection(state.selection) || !state.selection.empty) return;
return DecorationSet.create(state.doc, [
Decoration.widget(0, smoothCursor, {
key: PROSEMIRROR_SMOOTH_CURSOR_CLASS,
}),
]);
},
attributes: {
class: "smooth-cursor-enabled",
},
},
});
}
const key = new PluginKey(PROSEMIRROR_SMOOTH_CURSOR_CLASS);
function getCursorRect(
view: EditorView,
toStart: boolean
): { left: number; right: number; top: number; bottom: number } | null {
const selection = window.getSelection();
if (!selection || !selection.rangeCount) return null;
const range = selection?.getRangeAt(0)?.cloneRange();
if (!range) return null;
range.collapse(toStart);
const rects = range.getClientRects();
const rect = rects?.length ? rects[rects.length - 1] : null;
if (rect?.height) return rect;
return view.coordsAtPos(view.state.selection.head);
}
function isTextSelection(selection: Selection): selection is TextSelection {
return selection && typeof selection === "object" && "$cursor" in selection;
}

View File

@@ -1,3 +1,7 @@
:root {
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
}
.ProseMirror {
position: relative;
word-wrap: break-word;
@@ -475,3 +479,30 @@ p + p {
background-color: var(--editor-colors-purple-background);
}
/* end background colors */
@keyframes caret-flash-smooth {
0%,
100% {
opacity: 0;
}
50% {
opacity: 1;
}
}
.smooth-cursor-enabled {
caret-color: transparent;
}
.prosemirror-smooth-cursor {
position: absolute;
width: 2px;
background-color: currentColor;
pointer-events: none;
left: 0;
top: 0;
transition: transform 0.2s var(--ease-out-quart);
will-change: transform;
opacity: 0.8;
animation: cursorPulse 0.8s ease-in-out infinite;
}