Compare commits

...

12 Commits

Author SHA1 Message Date
Palanikannan M a4835fc478 fix: change Appearance to Preferences 2025-01-16 14:02:03 +05:30
Palanikannan M 2d34ef201c fix: css of toggle 2025-01-15 17:51:18 +05:30
Palanikannan M 3122bd2e89 temp: remove later, workaround in store 2025-01-13 19:31:50 +05:30
Palanikannan M 4d92d6b919 fix: added configuration for smooth cursor behaviour 2025-01-13 19:31:15 +05:30
Palanikannan M f35083bc4f fix: refactored config screen for multiple options 2025-01-13 18:18:57 +05:30
Palanikannan M 557020cd47 fix: properly show smooth cursor along with fake cursor of the inline code block 2025-01-13 13:51:36 +05:30
Palanikannan M 5770cdeb5e fix: prosemirror codemark added for getting access to plugin state 2025-01-13 13:50:55 +05:30
Palanikannan M f6f48881b0 fix: add blinking while stationary 2024-11-28 17:17:02 +05:30
Palanikannan M d79883333c fix: hide cursor when editor not focused 2024-11-28 17:09:24 +05:30
Palanikannan M e206224746 Merge branch 'preview' into feat/smooth-cursor 2024-11-28 15:58:57 +05:30
Palanikannan M 85e87ce595 Merge branch 'preview' into feat/smooth-cursor 2024-11-26 08:53:04 +05:30
Palanikannan M 854eacdaa8 feat: add smooth cursor animation 2024-11-22 18:09:05 +05:30
36 changed files with 2300 additions and 141 deletions
-1
View File
@@ -62,7 +62,6 @@
"linkifyjs": "^4.1.3",
"lowlight": "^3.0.0",
"lucide-react": "^0.378.0",
"prosemirror-codemark": "^0.4.2",
"prosemirror-utils": "^1.2.2",
"react-moveable": "^0.54.2",
"tailwind-merge": "^1.14.0",
@@ -31,6 +31,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
serverHandler,
tabIndex,
user,
has_enabled_smooth_cursor = true,
} = props;
const extensions = [];
@@ -45,6 +46,7 @@ const CollaborativeDocumentEditor = (props: ICollaborativeDocumentEditor) => {
// use document editor
const { editor, hasServerConnectionFailed, hasServerSynced } = useCollaborativeEditor({
onTransaction,
has_enabled_smooth_cursor,
disabledExtensions,
editorClassName,
embedHandler,
@@ -13,6 +13,7 @@ import { EditorContentWrapper } from "./editor-content";
type Props = IEditorProps & {
children?: (editor: Editor) => React.ReactNode;
extensions: Extension<any, any>[];
has_enabled_smooth_cursor: boolean;
};
export const EditorWrapper: React.FC<Props> = (props) => {
@@ -26,10 +27,11 @@ export const EditorWrapper: React.FC<Props> = (props) => {
initialValue,
fileHandler,
forwardedRef,
handleEditorReady,
has_enabled_smooth_cursor,
mentionHandler,
onChange,
onTransaction,
handleEditorReady,
autofocus,
placeholder,
tabIndex,
@@ -42,12 +44,13 @@ export const EditorWrapper: React.FC<Props> = (props) => {
extensions,
fileHandler,
forwardedRef,
handleEditorReady,
has_enabled_smooth_cursor,
id,
initialValue,
mentionHandler,
onChange,
onTransaction,
handleEditorReady,
autofocus,
placeholder,
tabIndex,
@@ -7,7 +7,7 @@ import { EnterKeyExtension } from "@/extensions";
import { EditorRefApi, ILiteTextEditor } from "@/types";
const LiteTextEditor = (props: ILiteTextEditor) => {
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [] } = props;
const { onEnterKeyPress, disabledExtensions, extensions: externalExtensions = [], has_enabled_smooth_cursor } = props;
const extensions = useMemo(
() => [
@@ -17,7 +17,7 @@ const LiteTextEditor = (props: ILiteTextEditor) => {
[externalExtensions, disabledExtensions, onEnterKeyPress]
);
return <EditorWrapper {...props} extensions={extensions} />;
return <EditorWrapper {...props} extensions={extensions} has_enabled_smooth_cursor={has_enabled_smooth_cursor} />;
};
const LiteTextEditorWithRef = forwardRef<EditorRefApi, ILiteTextEditor>((props, ref) => (
@@ -13,6 +13,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
dragDropEnabled,
bubbleMenuEnabled = true,
extensions: externalExtensions = [],
has_enabled_smooth_cursor,
} = props;
const getExtensions = useCallback(() => {
@@ -31,7 +32,7 @@ const RichTextEditor = (props: IRichTextEditor) => {
}, [dragDropEnabled, disabledExtensions, externalExtensions]);
return (
<EditorWrapper {...props} extensions={getExtensions()}>
<EditorWrapper {...props} extensions={getExtensions()} has_enabled_smooth_cursor={has_enabled_smooth_cursor}>
{(editor) => <>{editor && bubbleMenuEnabled && <EditorBubbleMenu editor={editor} />}</>}
</EditorWrapper>
);
@@ -0,0 +1,206 @@
import type { MarkType } from "@tiptap/pm/model";
import { type EditorState, type Plugin, type Transaction, TextSelection } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import type { CodemarkState, CursorMetaTr } from "./types";
import { MAX_MATCH, safeResolve } from "./utils";
export function stepOutsideNextTrAndPass(view: EditorView, plugin: Plugin, action: "click" | "next" = "next"): boolean {
const meta: CursorMetaTr = { action };
view.dispatch(view.state.tr.setMeta(plugin, meta));
return false;
}
export function onBacktick(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
if (view.state.selection.empty) return false;
if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey) return false;
// Create a code mark!
const { from, to } = view.state.selection;
if (to - from >= MAX_MATCH || view.state.doc.rangeHasMark(from, to, markType)) return false;
const tr = view.state.tr.addMark(from, to, markType.create());
const selected = tr.setSelection(TextSelection.create(tr.doc, to)).removeStoredMark(markType);
view.dispatch(selected);
return true;
}
function onArrowRightInside(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
if (event.metaKey) return stepOutsideNextTrAndPass(view, plugin);
if (event.shiftKey || event.altKey || event.ctrlKey) return false;
const { selection, doc } = view.state;
if (!selection.empty) return false;
const pluginState = plugin.getState(view.state) as CodemarkState;
const pos = selection.$from;
const inCode = !!markType.isInSet(pos.marks());
const nextCode = !!markType.isInSet(pos.marksAcross(safeResolve(doc, selection.from + 1)) ?? []);
if (pos.pos === view.state.doc.nodeSize - 3 && pos.parentOffset === pos.parent.nodeSize - 2 && pluginState?.active) {
// Behaviour stops: `code`| at the end of the document
view.dispatch(view.state.tr.removeStoredMark(markType));
return true;
}
if (inCode === nextCode && pos.parentOffset !== 0) return false;
if (inCode && (!pluginState?.active || pluginState.side === -1) && pos.parentOffset !== 0) {
// `code|` --> `code`|
view.dispatch(view.state.tr.removeStoredMark(markType));
return true;
}
if (nextCode && pluginState?.side === -1) {
// |`code` --> `|code`
view.dispatch(view.state.tr.addStoredMark(markType.create()));
return true;
}
return false;
}
export function onArrowRight(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
const handled = onArrowRightInside(view, plugin, event, markType);
if (handled) return true;
const { selection } = view.state;
const pos = selection.$from;
if (selection.empty && pos.parentOffset === pos.parent.nodeSize - 2) {
return stepOutsideNextTrAndPass(view, plugin);
}
return false;
}
function onArrowLeftInside(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
if (event.metaKey) return stepOutsideNextTrAndPass(view, plugin);
if (event.shiftKey || event.altKey || event.ctrlKey) return false;
const { selection, doc } = view.state;
const pluginState = plugin.getState(view.state) as CodemarkState;
const inCode = !!markType.isInSet(selection.$from.marks());
const nextCode = !!markType.isInSet(
safeResolve(doc, selection.empty ? selection.from - 1 : selection.from + 1).marks() ?? []
);
if (inCode && pluginState?.side === -1 && selection.$from.parentOffset === 0) {
// New line!
// ^|`code` --> |^`code`
return false;
}
if (pluginState?.side === 0 && selection.$from.parentOffset === 0) {
// New line!
// ^`|code` --> ^|`code`
view.dispatch(view.state.tr.removeStoredMark(markType));
return true;
}
if (inCode && nextCode && pluginState?.side === 0) {
// `code`| --> `code|`
view.dispatch(view.state.tr.addStoredMark(markType.create()));
return true;
}
if (inCode && !nextCode && pluginState?.active && selection.$from.parentOffset === 0) {
// ^`|code` --> ^|`code`
view.dispatch(view.state.tr.removeStoredMark(markType));
return true;
}
if (!inCode && pluginState?.active && pluginState?.side === 0) {
// `|code` --> |`code`
view.dispatch(view.state.tr.removeStoredMark(markType));
return true;
}
if (inCode === nextCode) return false;
if (nextCode || (!selection.empty && inCode)) {
// `code`_|_ --> `code`| nextCode
// `code`███ --> `code`| !selection.empty && inCode
// `██de`___ --> `|code` !selection.empty && nextCode
const from = selection.empty ? selection.from - 1 : selection.from;
const selected = view.state.tr.setSelection(TextSelection.create(doc, from));
if (!selection.empty && nextCode) {
view.dispatch(selected.addStoredMark(markType.create()));
} else {
view.dispatch(selected.removeStoredMark(markType));
}
return true;
}
if ((nextCode || (!selection.empty && inCode)) && !pluginState?.active) {
// `code`_|_ --> `code`|
// `code`███ --> `code`|
const from = selection.empty ? selection.from - 1 : selection.from;
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, from)).removeStoredMark(markType));
return true;
}
if (inCode && !pluginState?.active && selection.$from.parentOffset > 0) {
// `c|ode` --> `|code`
view.dispatch(
view.state.tr.setSelection(TextSelection.create(doc, selection.from - 1)).addStoredMark(markType.create())
);
return true;
}
if (inCode && !nextCode && pluginState?.active && pluginState.side !== -1) {
// `x`| --> `x|` - Single character
view.dispatch(view.state.tr.addStoredMark(markType.create()));
return true;
}
if (inCode && !nextCode && pluginState?.active) {
// `x|` --> `|x` - Single character inside
const pos = selection.from - 1;
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, pos)).addStoredMark(markType.create()));
return true;
}
return false;
}
export function onArrowLeft(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
const handled = onArrowLeftInside(view, plugin, event, markType);
if (handled) return true;
const { selection } = view.state;
const pos = selection.$from;
const pluginState = plugin.getState(view.state) as CodemarkState;
if (pos.pos === 1 && pos.parentOffset === 0 && pluginState?.side === -1) {
return true;
}
if (selection.empty && pos.parentOffset === 0) {
return stepOutsideNextTrAndPass(view, plugin);
}
return false;
}
export function onBackspace(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey) return false;
const { selection, doc } = view.state;
const from = safeResolve(doc, selection.from - 1);
const fromCode = !!markType.isInSet(from.marks());
const startOfLine = from.parentOffset === 0;
const toCode = !!markType.isInSet(safeResolve(doc, selection.to + 1).marks());
if ((!fromCode || startOfLine) && !toCode) {
// `x|` → |
// `|████` → |
// `|███`█ → |
return stepOutsideNextTrAndPass(view, plugin);
}
// Firefox has difficulty with the decorations on -1.
const pluginState = plugin.getState(view.state) as CodemarkState;
if (selection.empty && pluginState?.side === -1) {
const tr = view.state.tr.delete(selection.from - 1, selection.from);
view.dispatch(tr);
return true;
}
return false;
}
export function onDelete(view: EditorView, plugin: Plugin, event: KeyboardEvent, markType: MarkType): boolean {
if (event.metaKey || event.shiftKey || event.altKey || event.ctrlKey) return false;
const { selection, doc } = view.state;
const fromCode = !!markType.isInSet(selection.$from.marks());
const startOfLine = selection.$from.parentOffset === 0;
const toCode = !!markType.isInSet(safeResolve(doc, selection.to + 2).marks());
if ((!fromCode || startOfLine) && !toCode) {
return stepOutsideNextTrAndPass(view, plugin);
}
return false;
}
export function stepOutside(state: EditorState, markType: MarkType): Transaction | null {
if (!state) return null;
const { selection, doc } = state;
if (!selection.empty) return null;
const stored = !!markType.isInSet(state.storedMarks ?? []);
const inCode = !!markType.isInSet(selection.$from.marks());
const nextCode = !!markType.isInSet(safeResolve(doc, selection.from + 1).marks() ?? []);
const startOfLine = selection.$from.parentOffset === 0;
// `code|` --> `code`|
// `|code` --> |`code`
// ^`|code` --> ^|`code`
if (inCode !== nextCode || (!inCode && stored !== inCode) || (inCode && startOfLine))
return state.tr.removeStoredMark(markType);
return null;
}
@@ -0,0 +1,116 @@
// taken from https://github.com/curvenote/editor/tree/main/packages/prosemirror-codemark
import { type PluginSpec, Plugin } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import type { CodemarkState, CursorMetaTr, Options } from "./types";
import { getMarkType, codeMarkPluginKey, safeResolve } from "./utils";
import { createInputRule } from "./input-rules";
import {
onArrowLeft,
onArrowRight,
onBackspace,
onBacktick,
onDelete,
stepOutside,
stepOutsideNextTrAndPass,
} from "./actions";
function toDom(): Node {
const span = document.createElement("span");
span.classList.add("fake-cursor");
return span;
}
export function getDecorationPlugin(opts?: Options) {
const plugin: Plugin<CodemarkState> = new Plugin({
key: codeMarkPluginKey,
appendTransaction: (trs, oldState, newState) => {
const prev = plugin.getState(oldState) as CodemarkState;
const meta = trs[0]?.getMeta(plugin) as CursorMetaTr | null;
if (prev?.next || meta?.action === "click") {
return stepOutside(newState, getMarkType(newState, opts));
}
return null;
},
state: {
init: () => null,
apply(tr, value, oldState, state): CodemarkState | null {
const meta = tr.getMeta(plugin) as CursorMetaTr | null;
if (meta?.action === "next") return { next: true };
const markType = getMarkType(state, opts);
const nextMark = markType.isInSet(state.storedMarks ?? state.doc.resolve(tr.selection.from).marks());
const inCode = markType.isInSet(state.doc.resolve(tr.selection.from).marks());
const nextCode = markType.isInSet(safeResolve(state.doc, tr.selection.from + 1).marks());
const startOfLine = tr.selection.$from.parentOffset === 0;
if (!tr.selection.empty) return null;
if (!nextMark && nextCode && (!inCode || startOfLine)) {
// |`code`
return { active: true, side: -1 };
}
if (nextMark && (!inCode || startOfLine)) {
// `|code`
return { active: true, side: 0 };
}
if (!nextMark && inCode && !nextCode) {
// `code`|
return { active: true, side: 0 };
}
if (nextMark && inCode && !nextCode) {
// `code|`
return { active: true, side: -1 };
}
return null;
},
},
props: {
attributes: (state) => {
const { active = false } = plugin.getState(state) ?? {};
return {
...(active ? { class: "no-cursor" } : {}),
};
},
decorations: (state) => {
const { active, side } = plugin.getState(state) ?? {};
if (!active) return DecorationSet.empty;
const deco = Decoration.widget(state.selection.from, toDom, { side });
return DecorationSet.create(state.doc, [deco]);
},
handleKeyDown(view, event) {
switch (event.key) {
case "`":
return onBacktick(view, plugin, event, getMarkType(view, opts));
case "ArrowRight":
return onArrowRight(view, plugin, event, getMarkType(view, opts));
case "ArrowLeft":
return onArrowLeft(view, plugin, event, getMarkType(view, opts));
case "Backspace":
return onBackspace(view, plugin, event, getMarkType(view, opts));
case "Delete":
return onDelete(view, plugin, event, getMarkType(view, opts));
case "ArrowUp":
case "ArrowDown":
case "Home":
case "End":
return stepOutsideNextTrAndPass(view, plugin);
case "e":
case "a":
if (!event.ctrlKey) return false;
return stepOutsideNextTrAndPass(view, plugin);
default:
return false;
}
},
handleClick(view) {
return stepOutsideNextTrAndPass(view, plugin, "click");
},
},
} as PluginSpec<CodemarkState>);
return plugin;
}
export function codemark(opts?: Options) {
const cursorPlugin = getDecorationPlugin(opts);
const inputRule = createInputRule(cursorPlugin, opts);
const rules: Plugin[] = [cursorPlugin, inputRule];
return rules;
}
@@ -0,0 +1,138 @@
import type { MarkType } from "@tiptap/pm/model";
import { type PluginSpec, type Transaction, Plugin, TextSelection } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import type { Options } from "./types";
import { getMarkType, MAX_MATCH } from "./utils";
type InputRuleState = {
transform: Transaction;
from: number;
to: number;
text: string;
} | null;
type Plugins = { input: Plugin; cursor: Plugin };
type Handler = (
markType: MarkType,
state: EditorView,
text: string,
match: RegExpExecArray,
from: number,
to: number,
plugin: Plugins
) => boolean;
type Rule = {
match: RegExp;
handler: Handler;
};
function stopMatch(markType: MarkType, view: EditorView, from: number, to: number): boolean {
const stored = markType.isInSet(view.state.storedMarks ?? view.state.doc.resolve(from).marks());
const range = view.state.doc.rangeHasMark(from, to, markType);
// Don't create it if there is code in between!
if (stored || range) return true;
return false;
}
const markBefore: Rule = {
match: /`((?:[^`\w]|[\w])+)`$/,
handler: (markType, view, text, match, from, to, plugins) => {
if (stopMatch(markType, view, from, to)) return false;
const code = match[1];
const mark = markType.create();
const pos = from + code.length;
const tr = view.state.tr.delete(from, to).insertText(code).addMark(from, pos, mark);
const selected = tr.setSelection(TextSelection.create(tr.doc, pos)).removeStoredMark(markType);
const withMeta = selected.setMeta(plugins.input, {
transform: selected,
from,
to,
text: `\`${code}${text}`,
});
view.dispatch(withMeta);
return true;
},
};
const markAfter: Rule = {
match: /^`((?:[^`\w]|[\w])+)`/,
handler: (markType, view, text, match, from, to, plugins) => {
if (stopMatch(markType, view, from, to)) return false;
const mark = markType.create();
const code = match[1];
const pos = from;
const tr = view.state.tr
.delete(from, to)
.insertText(code)
.addMark(from, from + code.length, mark);
const selected = tr.setSelection(TextSelection.create(tr.doc, pos)).addStoredMark(markType.create());
const withMeta = selected.setMeta(plugins.input, {
transform: selected,
from,
to,
text: `\`${code}${text}`,
});
view.dispatch(withMeta);
return true;
},
};
function run(markType: MarkType, view: EditorView, from: number, to: number, text: string, plugins: Plugins) {
if (view.composing) return false;
const { state } = view;
const $from = state.doc.resolve(from);
if ($from.parent.type.spec.code) return false;
const leafText = "\ufffc";
const textBefore =
$from.parent.textBetween(Math.max(0, $from.parentOffset - MAX_MATCH), $from.parentOffset, undefined, leafText) +
text;
const textAfter =
text +
$from.parent.textBetween(
$from.parentOffset,
Math.min($from.parent.nodeSize - 2, $from.parentOffset + MAX_MATCH),
undefined,
leafText
);
const matchB = markBefore.match.exec(textBefore);
const matchA = markAfter.match.exec(textAfter);
if (matchB) {
const handled = markBefore.handler(
markType,
view,
text,
matchB,
from - matchB[0].length + text.length,
to,
plugins
);
if (handled) return handled;
}
if (matchA)
return markAfter.handler(markType, view, text, matchA, from, to + matchA[0].length - text.length, plugins);
return false;
}
export function createInputRule(cursorPlugin: Plugin, opts?: Options) {
const plugin: Plugin<InputRuleState> = new Plugin({
isInputRules: true,
state: {
init: () => null,
apply(tr, prev) {
const meta = tr.getMeta(plugin);
if (meta) return meta;
return tr.selectionSet || tr.docChanged ? null : prev;
},
},
props: {
handleTextInput(view, from, to, text) {
const markType = getMarkType(view, opts);
return run(markType, view, from, to, text, { input: plugin, cursor: cursorPlugin });
},
},
} as PluginSpec<InputRuleState>);
return plugin;
}
@@ -0,0 +1,14 @@
import type { MarkType } from "prosemirror-model";
export type Options = {
markType?: MarkType;
};
export type CodemarkState = {
active?: boolean;
side?: -1 | 0;
next?: true; // Move outside of code after next transaction
click?: true; // When the editor is clicked on
} | null;
export type CursorMetaTr = { action: "next" } | { action: "click" };
@@ -0,0 +1,16 @@
import type { MarkType, Node } from "@tiptap/pm/model";
import { type EditorState, PluginKey } from "@tiptap/pm/state";
import type { EditorView } from "@tiptap/pm/view";
import type { Options } from "./types";
export const MAX_MATCH = 100;
export const codeMarkPluginKey = new PluginKey("codemark");
export function getMarkType(view: EditorView | EditorState, opts?: Options): MarkType {
if ("schema" in view) return opts?.markType ?? view.schema.marks.code;
return opts?.markType ?? view.state.schema.marks.code;
}
export function safeResolve(doc: Node, pos: number) {
return doc.resolve(Math.min(Math.max(1, pos), doc.nodeSize - 2));
}
@@ -1,5 +1,5 @@
import { Extension } from "@tiptap/core";
import codemark from "prosemirror-codemark";
import { codemark } from "@/extensions/code-mark";
export const CustomCodeMarkPlugin = Extension.create({
name: "codemarkPlugin",
@@ -24,6 +24,7 @@ import {
DropHandlerExtension,
ImageExtension,
ListKeymap,
SmoothCursorExtension,
Table,
TableCell,
TableHeader,
@@ -36,6 +37,7 @@ import { IMentionHighlight, IMentionSuggestion, TFileHandler } from "@/types";
type TArguments = {
enableHistory: boolean;
has_enabled_smooth_cursor: boolean;
fileHandler: TFileHandler;
mentionConfig: {
mentionSuggestions?: () => Promise<IMentionSuggestion[]>;
@@ -46,7 +48,7 @@ type TArguments = {
};
export const CoreEditorExtensions = (args: TArguments) => {
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex } = args;
const { enableHistory, fileHandler, mentionConfig, placeholder, tabIndex, has_enabled_smooth_cursor } = args;
return [
StarterKit.configure({
@@ -162,5 +164,6 @@ export const CoreEditorExtensions = (args: TArguments) => {
CustomTextAlignExtension,
CustomCalloutExtension,
CustomColorExtension,
has_enabled_smooth_cursor ? SmoothCursorExtension : null,
];
};
@@ -23,3 +23,5 @@ export * from "./quote";
export * from "./read-only-extensions";
export * from "./side-menu";
export * from "./text-align";
export * from "./smooth-cursor";
export * from "./code-mark";
@@ -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,166 @@
import { type Selection, Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { type EditorView, Decoration, DecorationSet } from "@tiptap/pm/view";
import { codeMarkPluginKey } from "@/extensions/code-mark/utils";
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 isCodemarkCursorActive(view: EditorView) {
const codemarkState = codeMarkPluginKey.getState(view.state);
return codemarkState?.active === true;
}
function updateCursor(view?: EditorView, cursor?: HTMLElement) {
if (!view || !view.dom || view.isDestroyed || !cursor) return;
if (!isEditorFocused || isCodemarkCursorActive(view)) {
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;
}
@@ -14,6 +14,7 @@ import { TCollaborativeEditorProps } from "@/types";
export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
const {
onTransaction,
has_enabled_smooth_cursor,
disabledExtensions,
editorClassName,
editorProps = {},
@@ -99,6 +100,7 @@ export const useCollaborativeEditor = (props: TCollaborativeEditorProps) => {
],
fileHandler,
handleEditorReady,
has_enabled_smooth_cursor,
forwardedRef,
mentionHandler,
placeholder,
@@ -26,6 +26,7 @@ export interface CustomEditorProps {
fileHandler: TFileHandler;
forwardedRef?: MutableRefObject<EditorRefApi | null>;
handleEditorReady?: (value: boolean) => void;
has_enabled_smooth_cursor?: boolean;
id?: string;
initialValue?: string;
mentionHandler: {
@@ -62,6 +63,7 @@ export const useEditor = (props: CustomEditorProps) => {
tabIndex,
value,
autofocus = false,
has_enabled_smooth_cursor = true,
} = props;
// states
@@ -79,6 +81,7 @@ export const useEditor = (props: CustomEditorProps) => {
},
extensions: [
...CoreEditorExtensions({
has_enabled_smooth_cursor,
enableHistory,
fileHandler,
mentionConfig: {
@@ -36,6 +36,7 @@ type TCollaborativeEditorHookProps = {
};
export type TCollaborativeEditorProps = TCollaborativeEditorHookProps & {
has_enabled_smooth_cursor: boolean;
onTransaction?: () => void;
embedHandler?: TEmbedConfig;
fileHandler: TFileHandler;
+3 -1
View File
@@ -117,11 +117,12 @@ export interface IEditorProps {
onChange?: (json: object, html: string) => void;
onTransaction?: () => void;
handleEditorReady?: (value: boolean) => void;
has_enabled_smooth_cursor?: boolean;
autofocus?: boolean;
onEnterKeyPress?: (e?: any) => void;
placeholder?: string | ((isFocused: boolean, value: string) => string);
tabIndex?: number;
value?: string | null;
value?: string | null;
}
export interface ILiteTextEditor extends IEditorProps {
extensions?: any[];
@@ -141,6 +142,7 @@ export interface ICollaborativeDocumentEditor
realtimeConfig: TRealtimeConfig;
serverHandler?: TServerHandler;
user: TUserDetails;
has_enabled_smooth_cursor?: boolean;
}
// read only editor props
+35 -1
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;
@@ -474,4 +478,34 @@ p + p {
[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;
pointer-events: none;
left: 0;
top: 0;
transition: transform 0.2s var(--ease-out-quart);
will-change: transform;
opacity: 0.8;
z-index: 1000;
}
.prosemirror-smooth-cursor--blinking {
animation: blink 1s step-end infinite;
}
@keyframes blink {
from,
to {
opacity: 1;
}
50% {
opacity: 0;
}
}
+1
View File
@@ -62,6 +62,7 @@ export type TUserProfile = {
billing_address_country: string | undefined;
billing_address: string | undefined;
has_billing_address: boolean;
has_enabled_smooth_cursor: boolean;
created_at: Date | string;
updated_at: Date | string;
};
-86
View File
@@ -1,86 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import { IUserTheme } from "@plane/types";
import { setPromiseToast } from "@plane/ui";
// components
import { LogoSpinner } from "@/components/common";
import { CustomThemeSelector, ThemeSwitch, PageHead } from "@/components/core";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
// constants
import { I_THEME_OPTION, THEME_OPTIONS } from "@/constants/themes";
// helpers
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
// hooks
import { useUserProfile } from "@/hooks/store";
const ProfileAppearancePage = observer(() => {
const { setTheme } = useTheme();
// states
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
// hooks
const { data: userProfile, updateUserTheme } = useUserProfile();
useEffect(() => {
if (userProfile?.theme?.theme) {
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile?.theme?.theme);
if (userThemeOption) {
setCurrentTheme(userThemeOption);
}
}
}, [userProfile?.theme?.theme]);
const handleThemeChange = (themeOption: I_THEME_OPTION) => {
applyThemeChange({ theme: themeOption.value });
const updateCurrentUserThemePromise = updateUserTheme({ theme: themeOption.value });
setPromiseToast(updateCurrentUserThemePromise, {
loading: "Updating theme...",
success: {
title: "Success!",
message: () => "Theme updated successfully!",
},
error: {
title: "Error!",
message: () => "Failed to Update the theme",
},
});
};
const applyThemeChange = (theme: Partial<IUserTheme>) => {
setTheme(theme?.theme || "system");
if (theme?.theme === "custom" && theme?.palette) {
applyTheme(theme?.palette !== ",,,," ? theme?.palette : "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5", false);
} else unsetCustomCssVariables();
};
return (
<>
<PageHead title="Profile - Appearance" />
{userProfile ? (
<ProfileSettingContentWrapper>
<ProfileSettingContentHeader title="Appearance" />
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">Theme</h4>
<p className="text-sm text-custom-text-200">Select or customize your interface color scheme.</p>
</div>
<div className="col-span-12 sm:col-span-6">
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
</div>
</div>
{userProfile?.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
</ProfileSettingContentWrapper>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
)}
</>
);
});
export default ProfileAppearancePage;
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { observer } from "mobx-react";
// components
import { LogoSpinner } from "@/components/common";
import { PageHead } from "@/components/core";
import { PreferencesList } from "@/components/preferences/list";
import { ProfileSettingContentHeader, ProfileSettingContentWrapper } from "@/components/profile";
// hooks
import { useUserProfile } from "@/hooks/store";
const ProfileAppearancePage = observer(() => {
// hooks
const { data: userProfile } = useUserProfile();
return (
<>
<PageHead title="Profile - Appearance" />
{userProfile ? (
<ProfileSettingContentWrapper>
<ProfileSettingContentHeader title="Preferences" />
<PreferencesList />
</ProfileSettingContentWrapper>
) : (
<div className="grid h-full w-full place-items-center px-4 sm:px-0">
<LogoSpinner />
</div>
)}
</>
);
});
export default ProfileAppearancePage;
@@ -19,7 +19,7 @@ export const ThemeSwitch: FC<Props> = (props) => {
value={value}
label={
value ? (
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 w-full">
<div
className="border-1 relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border"
style={{
@@ -12,7 +12,7 @@ import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
import { isCommentEmpty } from "@/helpers/string.helper";
// hooks
import { useMember, useMention, useUser } from "@/hooks/store";
import { useMember, useMention, useUser, useUserProfile } from "@/hooks/store";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
@@ -45,6 +45,9 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
} = props;
// store hooks
const { data: currentUser } = useUser();
const {
data: { has_enabled_smooth_cursor },
} = useUserProfile();
const {
getUserDetails,
project: { getProjectMemberIds },
@@ -79,6 +82,7 @@ export const LiteTextEditor = React.forwardRef<EditorRefApi, LiteTextEditorWrapp
workspaceId,
workspaceSlug,
})}
has_enabled_smooth_cursor={has_enabled_smooth_cursor}
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
@@ -7,7 +7,7 @@ import { IUserLite } from "@plane/types";
import { cn } from "@/helpers/common.helper";
import { getEditorFileHandlers } from "@/helpers/editor.helper";
// hooks
import { useMember, useMention, useUser } from "@/hooks/store";
import { useMember, useMention, useUser, useUserProfile } from "@/hooks/store";
// plane web hooks
import { useFileSize } from "@/plane-web/hooks/use-file-size";
@@ -22,6 +22,9 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
const { containerClassName, workspaceSlug, workspaceId, projectId, uploadFile, ...rest } = props;
// store hooks
const { data: currentUser } = useUser();
const {
data: { has_enabled_smooth_cursor },
} = useUserProfile();
const {
getUserDetails,
project: { getProjectMemberIds },
@@ -49,6 +52,7 @@ export const RichTextEditor = forwardRef<EditorRefApi, RichTextEditorWrapperProp
workspaceId,
workspaceSlug,
})}
has_enabled_smooth_cursor={has_enabled_smooth_cursor}
mentionHandler={{
highlights: mentionHighlights,
suggestions: mentionSuggestions,
@@ -23,7 +23,7 @@ import { cn, LIVE_BASE_PATH, LIVE_BASE_URL } from "@/helpers/common.helper";
import { getEditorFileHandlers, getReadOnlyEditorFileHandlers } from "@/helpers/editor.helper";
import { generateRandomColor } from "@/helpers/string.helper";
// hooks
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
import { useMember, useMention, useUser, useUserProfile, useWorkspace } from "@/hooks/store";
import { usePageFilters } from "@/hooks/use-page-filters";
// plane web components
import { EditorAIMenu } from "@/plane-web/components/pages";
@@ -64,6 +64,9 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
const { workspaceSlug, projectId } = useParams();
// store hooks
const { data: currentUser } = useUser();
const {
data: { has_enabled_smooth_cursor },
} = useUserProfile();
const { getWorkspaceBySlug } = useWorkspace();
const {
getUserDetails,
@@ -190,6 +193,7 @@ export const PageEditorBody: React.FC<Props> = observer((props) => {
</div>
{isContentEditable ? (
<CollaborativeDocumentEditorWithRef
has_enabled_smooth_cursor={has_enabled_smooth_cursor}
id={pageId}
fileHandler={getEditorFileHandlers({
maxFileSize,
+24
View File
@@ -0,0 +1,24 @@
import { SmoothCursorToggle } from "./smooth-cursor-toggle";
import { ThemeSwitcher } from "./theme-switcher";
export interface PreferenceOption {
id: string;
title: string;
description: string;
component: React.ComponentType<{ option: PreferenceOption }>;
}
export const PREFERENCE_OPTIONS: PreferenceOption[] = [
{
id: "theme",
title: "Theme",
description: "Select or customize your interface color scheme.",
component: ThemeSwitcher,
},
{
id: "smoothCursor",
title: "Smooth cursor movement",
description: "Select the cursor motion style that feels right for you",
component: SmoothCursorToggle,
},
];
+2
View File
@@ -0,0 +1,2 @@
export * from "./theme-switcher";
export * from "./section";
+23
View File
@@ -0,0 +1,23 @@
import { observer } from "mobx-react";
import { PREFERENCE_OPTIONS } from "./config";
import { PreferencesSection } from "./section";
export const PreferencesList = observer(() => {
console.log("list");
return (
<div className="space-y-6">
{PREFERENCE_OPTIONS.map((option) => {
const Component = option.component;
return <Component key={option.id} option={option} />;
return (
<PreferencesSection
key={option.id}
title={option.title}
description={option.description}
control={<Component />}
/>
);
})}
</div>
);
});
@@ -0,0 +1,15 @@
interface SettingsSectionProps {
title: string;
description: string;
control: React.ReactNode;
}
export const PreferencesSection = ({ title, description, control }: SettingsSectionProps) => (
<div className="grid grid-cols-12 gap-4 py-6 sm:gap-16">
<div className="col-span-12 sm:col-span-6">
<h4 className="text-lg font-semibold text-custom-text-100">{title}</h4>
<p className="text-sm text-custom-text-200">{description}</p>
</div>
<div className="col-span-12 sm:col-span-6">{control}</div>
</div>
);
@@ -0,0 +1,30 @@
import { observer } from "mobx-react";
import { ToggleSwitch } from "@plane/ui";
import { useUserProfile } from "@/hooks/store";
import { PreferenceOption } from "./config";
import { PreferencesSection } from "./section";
export const SmoothCursorToggle = observer((props: { option: PreferenceOption }) => {
const {
data: { has_enabled_smooth_cursor },
updateUserProfile,
} = useUserProfile();
return (
<PreferencesSection
title={props.option.title}
description={props.option.description}
control={
<div className="flex items-center justify-start sm:justify-end">
<ToggleSwitch
value={has_enabled_smooth_cursor}
onChange={(value) => {
updateUserProfile({ has_enabled_smooth_cursor: value });
}}
label={"smooth-cursor-toggle"}
size={"sm"}
/>
</div>
}
/>
);
});
@@ -0,0 +1,96 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { observer } from "mobx-react";
import { useTheme } from "next-themes";
import { IUserTheme } from "@plane/types";
import { setPromiseToast } from "@plane/ui";
// components
import { CustomThemeSelector, ThemeSwitch } from "@/components/core";
// constants
import { I_THEME_OPTION, THEME_OPTIONS } from "@/constants/themes";
// helpers
import { applyTheme, unsetCustomCssVariables } from "@/helpers/theme.helper";
// hooks
import { useUserProfile } from "@/hooks/store";
import { PreferenceOption } from "./config";
import { PreferencesSection } from ".";
export const ThemeSwitcher = observer((props: { option: PreferenceOption }) => {
// hooks
const { setTheme } = useTheme();
const { data: userProfile, updateUserTheme } = useUserProfile();
// states
const [currentTheme, setCurrentTheme] = useState<I_THEME_OPTION | null>(null);
// initialize theme
useEffect(() => {
if (!userProfile?.theme?.theme) return;
const userThemeOption = THEME_OPTIONS.find((t) => t.value === userProfile.theme.theme);
if (userThemeOption) {
setCurrentTheme(userThemeOption);
}
}, [userProfile?.theme?.theme]);
// handlers
const applyThemeChange = useCallback(
(theme: Partial<IUserTheme>) => {
const themeValue = theme?.theme || "system";
setTheme(themeValue);
if (theme?.theme === "custom" && theme?.palette) {
const defaultPalette = "#0d101b,#c5c5c5,#3f76ff,#0d101b,#c5c5c5";
const palette = theme.palette !== ",,,," ? theme.palette : defaultPalette;
applyTheme(palette, false);
} else {
unsetCustomCssVariables();
}
},
[setTheme]
);
const handleThemeChange = useCallback(
async (themeOption: I_THEME_OPTION) => {
try {
applyThemeChange({ theme: themeOption.value });
const updatePromise = updateUserTheme({ theme: themeOption.value });
setPromiseToast(updatePromise, {
loading: "Updating theme...",
success: {
title: "Success!",
message: () => "Theme updated successfully!",
},
error: {
title: "Error!",
message: () => "Failed to update the theme",
},
});
} catch (error) {
console.error("Error updating theme:", error);
}
},
[applyThemeChange, updateUserTheme]
);
if (!userProfile) return null;
return (
<>
<PreferencesSection
title={props.option.title}
description={props.option.description}
control={
<div className="w-[352px]">
<ThemeSwitch value={currentTheme} onChange={handleThemeChange} />
</div>
}
/>
{userProfile.theme?.theme === "custom" && <CustomThemeSelector applyThemeChange={applyThemeChange} />}
</>
);
});
+4 -4
View File
@@ -31,10 +31,10 @@ export const PROFILE_ACTION_LINKS: {
Icon: Activity,
},
{
key: "appearance",
label: "Appearance",
href: `/profile/appearance`,
highlight: (pathname: string) => pathname.includes("/profile/appearance"),
key: "preferences",
label: "Preferences",
href: `/profile/preferences`,
highlight: (pathname: string) => pathname.includes("/profile/preferences/"),
Icon: Settings2,
},
{
+5 -4
View File
@@ -50,6 +50,7 @@ export class ProfileStore implements IUserProfileStore {
workspace_create: false,
workspace_invite: false,
},
has_enabled_smooth_cursor: true,
is_onboarded: false,
is_tour_completed: false,
use_case: undefined,
@@ -101,7 +102,8 @@ export class ProfileStore implements IUserProfileStore {
const userProfile = await this.userService.getCurrentUserProfile();
runInAction(() => {
this.isLoading = false;
this.data = userProfile;
this.mutateUserProfile(userProfile);
// this.data = userProfile;
});
return userProfile;
} catch (error) {
@@ -127,7 +129,7 @@ export class ProfileStore implements IUserProfileStore {
if (currentUserProfileData) {
this.mutateUserProfile(data);
}
const userProfile = await this.userService.updateCurrentUserProfile(data);
// const userProfile = await this.userService.updateCurrentUserProfile(data);
return userProfile;
} catch {
if (currentUserProfileData) {
@@ -169,7 +171,6 @@ export class ProfileStore implements IUserProfileStore {
runInAction(() => {
this.mutateUserProfile({ ...dataToUpdate, is_onboarded: true });
});
} catch (error) {
runInAction(() => {
this.error = {
@@ -179,7 +180,7 @@ export class ProfileStore implements IUserProfileStore {
});
throw error;
}
}
};
/**
* @description updates the user tour completed status
+1324 -33
View File
File diff suppressed because it is too large Load Diff