Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Smooth cursor in all the editors #6114

Draft
wants to merge 5 commits into
base: preview
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/editor/src/core/extensions/extensions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
DropHandlerExtension,
ImageExtension,
ListKeymap,
SmoothCursorExtension,
Table,
TableCell,
TableHeader,
Expand Down Expand Up @@ -162,5 +163,6 @@ export const CoreEditorExtensions = (args: TArguments) => {
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
SmoothCursorExtension,
];
};
1 change: 1 addition & 0 deletions packages/editor/src/core/extensions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./text-align";
export * from "./smooth-cursor";
9 changes: 9 additions & 0 deletions packages/editor/src/core/extensions/smooth-cursor/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { Extension } from "@tiptap/core";
import { smoothCursorPlugin } from "./plugin";

export const SmoothCursorExtension = Extension.create({
name: "smoothCursorExtension",
addProseMirrorPlugins() {
return [smoothCursorPlugin()];
},
});
160 changes: 160 additions & 0 deletions packages/editor/src/core/extensions/smooth-cursor/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
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";
const BLINK_DELAY = 750;

export function smoothCursorPlugin(): Plugin {
let smoothCursor: HTMLElement | null = typeof document === "undefined" ? null : document.createElement("div");
let rafId: number | undefined;
let blinkTimeoutId: number | undefined;
let isEditorFocused = false;
let lastCursorPosition = { x: 0, y: 0 };

function updateCursor(view?: EditorView, cursor?: HTMLElement) {
if (!view || !view.dom || view.isDestroyed || !cursor) return;

// Hide cursor if editor is not focused
if (!isEditorFocused) {
cursor.style.display = "none";
return;
}
cursor.style.display = "block";

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;

// Calculate the exact position
const x = cursorRect.left - editorRect.left;
const y = cursorRect.top - editorRect.top;

// Check if cursor position has changed
if (x !== lastCursorPosition.x || y !== lastCursorPosition.y) {
lastCursorPosition = { x, y };
cursor.classList.remove(`${className}--blinking`);

// Clear existing timeout
if (blinkTimeoutId) {
window.clearTimeout(blinkTimeoutId);
}

// Set new timeout for blinking
blinkTimeoutId = window.setTimeout(() => {
if (cursor && isEditorFocused) {
cursor.classList.add(`${className}--blinking`);
}
}, BLINK_DELAY);
}

cursor.className = className;
cursor.style.height = `${cursorRect.bottom - cursorRect.top}px`;

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) {
cancelAnimationFrame(rafId);
}
updateCursor(view, cursor);
};

const handleFocus = () => {
isEditorFocused = true;
update();
};

const handleBlur = () => {
isEditorFocused = false;
if (blinkTimeoutId) {
window.clearTimeout(blinkTimeoutId);
}
cursor.classList.remove(`${PROSEMIRROR_SMOOTH_CURSOR_CLASS}--blinking`);
update();
};

let observer: ResizeObserver | undefined;
if (window.ResizeObserver) {
observer = new window.ResizeObserver(update);
observer?.observe(view.dom);
}

doc.addEventListener("selectionchange", update);
view.dom.addEventListener("focus", handleFocus);
view.dom.addEventListener("blur", handleBlur);

return {
update,
destroy: () => {
doc.removeEventListener("selectionchange", update);
view.dom.removeEventListener("focus", handleFocus);
view.dom.removeEventListener("blur", handleBlur);
observer?.unobserve(view.dom);
if (rafId !== undefined) {
cancelAnimationFrame(rafId);
}
if (blinkTimeoutId) {
window.clearTimeout(blinkTimeoutId);
}
},
};
},
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: isEditorFocused ? "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;
}
35 changes: 34 additions & 1 deletion packages/editor/src/styles/editor.css
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
:root {
--ease-out-quart: cubic-bezier(0.165, 0.84, 0.44, 1);
}

.ProseMirror {
position: relative;
word-wrap: break-word;
Expand Down Expand Up @@ -474,4 +478,33 @@
[data-background-color="purple"] {
background-color: var(--editor-colors-purple-background);
}
/* end background colors */

.smooth-cursor-enabled {
caret-color: transparent;
}

.prosemirror-smooth-cursor {
position: absolute;
width: 2px;
background-color: currentColor;

Check notice on line 489 in packages/editor/src/styles/editor.css

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

packages/editor/src/styles/editor.css#L489

Expected "currentColor" to be "currentcolor" (value-keyword-case)
pointer-events: none;
left: 0;
top: 0;
transition: transform 0.2s var(--ease-out-quart);
will-change: transform;
opacity: 0.8;
}

.prosemirror-smooth-cursor--blinking {

Check notice on line 498 in packages/editor/src/styles/editor.css

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

packages/editor/src/styles/editor.css#L498

(selector) => `Expected class selector "${selector}" to be kebab-case`
animation: blink 1s step-end infinite;
}

@keyframes blink {
from,

Check notice on line 503 in packages/editor/src/styles/editor.css

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

packages/editor/src/styles/editor.css#L503

Expected "from" to be "0%" (keyframe-selector-notation)
to {
opacity: 1;
}
50% {
opacity: 0;
}
}