Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4835fc478 | |||
| 2d34ef201c | |||
| 3122bd2e89 | |||
| 4d92d6b919 | |||
| f35083bc4f | |||
| 557020cd47 | |||
| 5770cdeb5e | |||
| f6f48881b0 | |||
| d79883333c | |||
| e206224746 | |||
| 85e87ce595 | |||
| 854eacdaa8 |
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./theme-switcher";
|
||||
export * from "./section";
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user