mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 21:40:18 +02:00
feat: add smooth cursor animation
This commit is contained in:
@@ -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,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -23,3 +23,4 @@ export * from "./quote";
|
||||
export * from "./read-only-extensions";
|
||||
export * from "./side-menu";
|
||||
export * from "./text-align";
|
||||
export * from "./smooth-cursor";
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { smoothCursorPlugin } from "./plugin";
|
||||
|
||||
export const SmoothCursorExtension = Extension.create({
|
||||
name: "smoothCursorExtension",
|
||||
addProseMirrorPlugins() {
|
||||
return [smoothCursorPlugin()];
|
||||
},
|
||||
});
|
||||
@@ -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)`;
|
||||
});
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
113
packages/editor/src/core/extensions/smooth-cursor/plugin.ts
Normal file
113
packages/editor/src/core/extensions/smooth-cursor/plugin.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user