diff --git a/packages/editor/src/core/extensions/extensions.tsx b/packages/editor/src/core/extensions/extensions.tsx index 959d20e2ba..24da24d853 100644 --- a/packages/editor/src/core/extensions/extensions.tsx +++ b/packages/editor/src/core/extensions/extensions.tsx @@ -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, ]; }; diff --git a/packages/editor/src/core/extensions/index.ts b/packages/editor/src/core/extensions/index.ts index d1fa0ce6db..4eb61d58db 100644 --- a/packages/editor/src/core/extensions/index.ts +++ b/packages/editor/src/core/extensions/index.ts @@ -23,3 +23,4 @@ export * from "./quote"; export * from "./read-only-extensions"; export * from "./side-menu"; export * from "./text-align"; +export * from "./smooth-cursor"; diff --git a/packages/editor/src/core/extensions/smooth-cursor/index.ts b/packages/editor/src/core/extensions/smooth-cursor/index.ts new file mode 100644 index 0000000000..54a8f97c13 --- /dev/null +++ b/packages/editor/src/core/extensions/smooth-cursor/index.ts @@ -0,0 +1,9 @@ +import { Extension } from "@tiptap/core"; +import { smoothCursorPlugin } from "./plugin"; + +export const SmoothCursorExtension = Extension.create({ + name: "smoothCursorExtension", + addProseMirrorPlugins() { + return [smoothCursorPlugin()]; + }, +}); diff --git a/packages/editor/src/core/extensions/smooth-cursor/plugin-better.ts b/packages/editor/src/core/extensions/smooth-cursor/plugin-better.ts new file mode 100644 index 0000000000..6fdd441474 --- /dev/null +++ b/packages/editor/src/core/extensions/smooth-cursor/plugin-better.ts @@ -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)`; + }); +} diff --git a/packages/editor/src/core/extensions/smooth-cursor/plugin-good.ts b/packages/editor/src/core/extensions/smooth-cursor/plugin-good.ts new file mode 100644 index 0000000000..12089cc3de --- /dev/null +++ b/packages/editor/src/core/extensions/smooth-cursor/plugin-good.ts @@ -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`; +} diff --git a/packages/editor/src/core/extensions/smooth-cursor/plugin.ts b/packages/editor/src/core/extensions/smooth-cursor/plugin.ts new file mode 100644 index 0000000000..581cf5dba0 --- /dev/null +++ b/packages/editor/src/core/extensions/smooth-cursor/plugin.ts @@ -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; +} diff --git a/packages/editor/src/styles/editor.css b/packages/editor/src/styles/editor.css index fff3b533e8..eea32f8b67 100644 --- a/packages/editor/src/styles/editor.css +++ b/packages/editor/src/styles/editor.css @@ -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; +}