-
+ return (
+
+
+
-
+
+
+
-
- Edit{" "}
- src/routes/index.tsx to
- customize this page
-
+ {attachedFiles && attachedFiles.length > 0 && (
+
+ {Array.from(attachedFiles).map((file, index) => (
+ removeFile(index)}
+ />
+ ))}
+
+ )}
diff --git a/apps/www/src/style.css b/apps/www/src/style.css
index 0eb7dc5cc..e0e7ee3bd 100644
--- a/apps/www/src/style.css
+++ b/apps/www/src/style.css
@@ -1,2 +1,3 @@
-@import "@rectangular-labs/ui/styles.css";
+@import "@rectangular-labs/ui/style.css";
+@import "@rectangular-labs/editor/style.css";
@source "./**/*.{ts,tsx}";
diff --git a/apps/www/vite.config.ts b/apps/www/vite.config.ts
index e100f9aee..8ae8d5aac 100644
--- a/apps/www/vite.config.ts
+++ b/apps/www/vite.config.ts
@@ -3,6 +3,8 @@ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
import viteReact from "@vitejs/plugin-react";
import { createJiti } from "jiti";
import mkcert from "vite-plugin-mkcert";
+import topLevelAwait from "vite-plugin-top-level-await";
+import wasm from "vite-plugin-wasm";
import viteTsConfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vitest/config";
import type { serverEnv } from "~/lib/env";
@@ -19,6 +21,8 @@ const config = defineConfig({
}),
tailwindcss(),
mkcert(),
+ wasm(),
+ topLevelAwait(),
tanstackStart({
customViteReactPlugin: true,
}),
diff --git a/package.json b/package.json
index c52446d1e..1c7a543b1 100644
--- a/package.json
+++ b/package.json
@@ -12,13 +12,15 @@
"db:migrate-push": "bun run --filter @rectangular-labs/db migrate-push",
"db:push": "bun run --filter @rectangular-labs/db push --force",
"db:studio": "bun run --filter @rectangular-labs/db studio",
- "dev": "pnpm with-env-local pnpx sst dev",
+ "dev": "docker compose up -d && pnpm with-env-local turbo run dev",
"dev:packages": "docker compose up -d && turbo run dev --filter=\"./packages/*\"",
"deploy:local": "pnpm with-env-local sst deploy",
+ "deploy:personal": "pnpm with-env-preview sst deploy",
"deploy:preview": "pnpm with-env-preview sst deploy --stage preview",
"deploy:prod": "pnpm with-env-prod sst deploy --stage production",
"env:get": "bun x dotenvx get",
"env:set": "bun x dotenvx set",
+ "env:view": "bun x dotenvx decrypt --stdout",
"format": "turbo run format --continue",
"lint": "turbo run lint --continue",
"new:package": "turbo gen package",
diff --git a/packages/editor/package.json b/packages/editor/package.json
new file mode 100644
index 000000000..dc73ade82
--- /dev/null
+++ b/packages/editor/package.json
@@ -0,0 +1,56 @@
+{
+ "name": "@rectangular-labs/editor",
+ "version": "0.0.1",
+ "type": "module",
+ "private": true,
+ "sideEffects": false,
+ "files": [
+ "style.css"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./src/index.ts"
+ },
+ "./style.css": "./src/style.css"
+ },
+ "scripts": {
+ "clean": "git clean -xdf .turbo node_modules dist .cache",
+ "format": "bun x @biomejs/biome format . --write",
+ "lint": "bun x @biomejs/biome lint . --write",
+ "typecheck": "tsc --noEmit --emitDeclarationOnly false"
+ },
+ "peerDependencies": {
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0"
+ },
+ "dependencies": {
+ "@rectangular-labs/result": "workspace:*",
+ "@rectangular-labs/ui": "workspace:*",
+ "@tiptap/extension-highlight": "^2.11.7",
+ "@tiptap/extension-image": "^2.11.7",
+ "@tiptap/extension-link": "^2.11.7",
+ "@tiptap/extension-subscript": "^2.11.7",
+ "@tiptap/extension-superscript": "^2.11.7",
+ "@tiptap/extension-task-item": "^2.11.7",
+ "@tiptap/extension-task-list": "^2.11.7",
+ "@tiptap/extension-text-align": "^2.11.7",
+ "@tiptap/extension-typography": "^2.11.7",
+ "@tiptap/extension-underline": "^2.11.7",
+ "@tiptap/pm": "^2.11.7",
+ "@tiptap/react": "^2.11.7",
+ "@tiptap/starter-kit": "^2.11.7",
+ "loro-crdt": "^1.5.4",
+ "loro-prosemirror": "^0.2.1",
+ "lucide-react": "^0.542.0"
+ },
+ "devDependencies": {
+ "@rectangular-labs/typescript": "workspace:*",
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "tailwindcss": "^4.1.12",
+ "typescript": "^5.9.2"
+ }
+}
diff --git a/packages/editor/src/components/icons.tsx b/packages/editor/src/components/icons.tsx
new file mode 100644
index 000000000..d0ba0bd8c
--- /dev/null
+++ b/packages/editor/src/components/icons.tsx
@@ -0,0 +1,77 @@
+import {
+ AlignCenter as AlignCenterLucide,
+ AlignJustify as AlignJustifyLucide,
+ AlignLeft as AlignLeftLucide,
+ AlignRight as AlignRightLucide,
+ ArrowLeft as ArrowLeftLucide,
+ Ban as BanLucide,
+ Bold as BoldLucide,
+ ChevronDown as ChevronDownLucide,
+ Code2 as Code2Lucide,
+ CodeSquare as CodeSquareLucide,
+ CornerDownLeft as CornerDownLeftLucide,
+ ExternalLink as ExternalLinkLucide,
+ Heading1 as Heading1Lucide,
+ Heading2 as Heading2Lucide,
+ Heading3 as Heading3Lucide,
+ Heading4 as Heading4Lucide,
+ Heading5 as Heading5Lucide,
+ Heading6 as Heading6Lucide,
+ Heading as HeadingLucide,
+ Highlighter as HighlighterLucide,
+ ImagePlus as ImagePlusLucide,
+ Italic as ItalicLucide,
+ Link as LinkLucide,
+ List as ListLucide,
+ ListOrdered as ListOrderedLucide,
+ ListTodo as ListTodoLucide,
+ MoonStar as MoonStarLucide,
+ Quote as QuoteLucide,
+ Redo2 as Redo2Lucide,
+ Strikethrough as StrikethroughLucide,
+ Subscript as SubscriptLucide,
+ Sun as SunLucide,
+ Superscript as SuperscriptLucide,
+ Trash as TrashLucide,
+ Underline as UnderlineLucide,
+ Undo2 as Undo2Lucide,
+ X as XLucide,
+} from "lucide-react";
+
+export const AlignCenterIcon = AlignCenterLucide;
+export const AlignJustifyIcon = AlignJustifyLucide;
+export const AlignLeftIcon = AlignLeftLucide;
+export const AlignRightIcon = AlignRightLucide;
+export const ArrowLeftIcon = ArrowLeftLucide;
+export const BanIcon = BanLucide;
+export const BlockQuoteIcon = QuoteLucide;
+export const BoldIcon = BoldLucide;
+export const ChevronDownIcon = ChevronDownLucide;
+export const CloseIcon = XLucide;
+export const CodeBlockIcon = CodeSquareLucide;
+export const Code2Icon = Code2Lucide;
+export const CornerDownLeftIcon = CornerDownLeftLucide;
+export const ExternalLinkIcon = ExternalLinkLucide;
+export const HeadingFiveIcon = Heading5Lucide;
+export const HeadingFourIcon = Heading4Lucide;
+export const HeadingIcon = HeadingLucide;
+export const HeadingOneIcon = Heading1Lucide;
+export const HeadingSixIcon = Heading6Lucide;
+export const HeadingThreeIcon = Heading3Lucide;
+export const HeadingTwoIcon = Heading2Lucide;
+export const HighlighterIcon = HighlighterLucide;
+export const ImagePlusIcon = ImagePlusLucide;
+export const ItalicIcon = ItalicLucide;
+export const LinkIcon = LinkLucide;
+export const ListIcon = ListLucide;
+export const ListOrderedIcon = ListOrderedLucide;
+export const ListTodoIcon = ListTodoLucide;
+export const MoonStarIcon = MoonStarLucide;
+export const RedoIcon = Redo2Lucide;
+export const StrikeIcon = StrikethroughLucide;
+export const SubscriptIcon = SubscriptLucide;
+export const SunIcon = SunLucide;
+export const SuperscriptIcon = SuperscriptLucide;
+export const TrashIcon = TrashLucide;
+export const UnderlineIcon = UnderlineLucide;
+export const UndoIcon = Undo2Lucide;
diff --git a/packages/editor/src/components/tiptap-extension/link-extension.ts b/packages/editor/src/components/tiptap-extension/link-extension.ts
new file mode 100644
index 000000000..f99a6add3
--- /dev/null
+++ b/packages/editor/src/components/tiptap-extension/link-extension.ts
@@ -0,0 +1,67 @@
+import TiptapLink from "@tiptap/extension-link";
+import { Plugin, TextSelection } from "@tiptap/pm/state";
+import type { EditorView } from "@tiptap/pm/view";
+import { getMarkRange } from "@tiptap/react";
+
+export const Link = TiptapLink.extend({
+ inclusive: false,
+
+ parseHTML() {
+ return [
+ {
+ tag: 'a[href]:not([data-type="button"]):not([href *= "javascript:" i])',
+ },
+ ];
+ },
+
+ addProseMirrorPlugins() {
+ const { editor } = this;
+
+ return [
+ ...(this.parent?.() || []),
+ new Plugin({
+ props: {
+ handleKeyDown: (_: EditorView, event: KeyboardEvent) => {
+ const { selection } = editor.state;
+
+ if (event.key === "Escape" && selection.empty !== true) {
+ editor.commands.focus(selection.to, { scrollIntoView: false });
+ }
+
+ return false;
+ },
+ handleClick(view, pos) {
+ const { schema, doc, tr } = view.state;
+ let range: ReturnType
| undefined;
+
+ if (schema.marks.link) {
+ range = getMarkRange(doc.resolve(pos), schema.marks.link);
+ }
+
+ if (!range) {
+ return;
+ }
+
+ const { from, to } = range;
+ const start = Math.min(from, to);
+ const end = Math.max(from, to);
+
+ if (pos < start || pos > end) {
+ return;
+ }
+
+ const $start = doc.resolve(start);
+ const $end = doc.resolve(end);
+ const transaction = tr.setSelection(
+ new TextSelection($start, $end),
+ );
+
+ view.dispatch(transaction);
+ },
+ },
+ }),
+ ];
+ },
+});
+
+export default Link;
diff --git a/packages/editor/src/components/tiptap-extension/loro-extension.ts b/packages/editor/src/components/tiptap-extension/loro-extension.ts
new file mode 100644
index 000000000..d170cab39
--- /dev/null
+++ b/packages/editor/src/components/tiptap-extension/loro-extension.ts
@@ -0,0 +1,40 @@
+import { keymap } from "@tiptap/pm/keymap";
+import { Extension } from "@tiptap/react";
+import { LoroDoc, type LoroMap } from "loro-crdt";
+
+import {
+ CursorAwareness,
+ LoroCursorPlugin,
+ LoroSyncPlugin,
+ LoroUndoPlugin,
+ redo,
+ undo,
+} from "loro-prosemirror";
+
+const doc = new LoroDoc<{
+ doc: LoroMap;
+ data: LoroMap>;
+}>();
+doc.subscribeLocalUpdates((update) => {
+ console.log("update", update);
+});
+const awareness = new CursorAwareness(doc.peerIdStr);
+
+export const LoroCRDT = Extension.create({
+ name: "loro-crdt",
+ addProseMirrorPlugins() {
+ // Return the necessary Loro plugins for Tiptap integration
+ return [
+ // Specify the containerId to sync with the 'doc' map in LoroDoc
+ LoroSyncPlugin({ doc }),
+ LoroUndoPlugin({ doc }), // Provides collaborative undo/redo functionality
+ keymap({
+ // Maps keyboard shortcuts to Loro undo/redo actions
+ "Mod-z": undo,
+ "Mod-y": redo,
+ "Mod-Shift-z": redo,
+ }),
+ LoroCursorPlugin(awareness, {}), // Manages cursor awareness among collaborators
+ ];
+ },
+});
diff --git a/packages/editor/src/components/tiptap-extension/selection-extension.ts b/packages/editor/src/components/tiptap-extension/selection-extension.ts
new file mode 100644
index 000000000..56214ad98
--- /dev/null
+++ b/packages/editor/src/components/tiptap-extension/selection-extension.ts
@@ -0,0 +1,40 @@
+import { Extension, isNodeSelection } from "@tiptap/react";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+
+export const Selection = Extension.create({
+ name: "selection",
+
+ addProseMirrorPlugins() {
+ const { editor } = this;
+
+ return [
+ new Plugin({
+ key: new PluginKey("selection"),
+ props: {
+ decorations(state) {
+ if (state.selection.empty) {
+ return null;
+ }
+
+ if (editor.isFocused === true || !editor.isEditable) {
+ return null;
+ }
+
+ if (isNodeSelection(state.selection)) {
+ return null;
+ }
+
+ return DecorationSet.create(state.doc, [
+ Decoration.inline(state.selection.from, state.selection.to, {
+ class: "selection",
+ }),
+ ]);
+ },
+ },
+ }),
+ ];
+ },
+});
+
+export default Selection;
diff --git a/packages/editor/src/components/tiptap-extension/trailing-node-extension.ts b/packages/editor/src/components/tiptap-extension/trailing-node-extension.ts
new file mode 100644
index 000000000..0d76ace3d
--- /dev/null
+++ b/packages/editor/src/components/tiptap-extension/trailing-node-extension.ts
@@ -0,0 +1,82 @@
+import { Extension } from "@tiptap/react";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import type { Node, NodeType } from "@tiptap/pm/model";
+
+function nodeEqualsType({
+ types,
+ node,
+}: {
+ types: NodeType | NodeType[];
+ node: Node | null;
+}) {
+ if (!node) return false;
+
+ if (Array.isArray(types)) {
+ return types.includes(node.type);
+ }
+
+ return node.type === types;
+}
+
+export interface TrailingNodeOptions {
+ node: string;
+ notAfter: string[];
+}
+
+export const TrailingNode = Extension.create({
+ name: "trailingNode",
+
+ addOptions() {
+ return {
+ node: "paragraph",
+ notAfter: ["paragraph"],
+ };
+ },
+
+ addProseMirrorPlugins() {
+ const plugin = new PluginKey(this.name);
+ const disabledNodes = Object.entries(this.editor.schema.nodes)
+ .map(([, value]) => value)
+ .filter((node) => this.options.notAfter.includes(node.name));
+
+ return [
+ new Plugin({
+ key: plugin,
+ appendTransaction: (_, __, state) => {
+ const { doc, tr, schema } = state;
+ const shouldInsertNodeAtEnd = plugin.getState(state);
+ const endPosition = doc.content.size;
+ const type = schema.nodes[this.options.node];
+
+ if (!shouldInsertNodeAtEnd) {
+ return null;
+ }
+
+ if (type) {
+ return tr.insert(endPosition, type.create());
+ }
+
+ return null;
+ },
+ state: {
+ init: (_, state) => {
+ const lastNode = state.tr.doc.lastChild;
+
+ return !nodeEqualsType({ node: lastNode, types: disabledNodes });
+ },
+ apply: (tr, value) => {
+ if (!tr.docChanged) {
+ return value;
+ }
+
+ const lastNode = tr.doc.lastChild;
+
+ return !nodeEqualsType({ node: lastNode, types: disabledNodes });
+ },
+ },
+ }),
+ ];
+ },
+});
+
+export default TrailingNode;
diff --git a/packages/editor/src/components/tiptap-node/code-block-node/code-block-node.scss b/packages/editor/src/components/tiptap-node/code-block-node/code-block-node.scss
new file mode 100644
index 000000000..d31b312f6
--- /dev/null
+++ b/packages/editor/src/components/tiptap-node/code-block-node/code-block-node.scss
@@ -0,0 +1,54 @@
+.tiptap.ProseMirror {
+ --tt-inline-code-bg-color: var(--tt-gray-light-a-100);
+ --tt-inline-code-text-color: var(--tt-gray-light-a-700);
+ --tt-inline-code-border-color: var(--tt-gray-light-a-200);
+ --tt-codeblock-bg: var(--tt-gray-light-a-50);
+ --tt-codeblock-text: var(--tt-gray-light-a-800);
+ --tt-codeblock-border: var(--tt-gray-light-a-200);
+
+ .dark & {
+ --tt-inline-code-bg-color: var(--tt-gray-dark-a-100);
+ --tt-inline-code-text-color: var(--tt-gray-dark-a-700);
+ --tt-inline-code-border-color: var(--tt-gray-dark-a-200);
+ --tt-codeblock-bg: var(--tt-gray-dark-a-50);
+ --tt-codeblock-text: var(--tt-gray-dark-a-800);
+ --tt-codeblock-border: var(--tt-gray-dark-a-200);
+ }
+}
+
+/* =====================
+ CODE FORMATTING
+ ===================== */
+.tiptap.ProseMirror {
+ // Inline code
+ code {
+ background-color: var(--tt-inline-code-bg-color);
+ color: var(--tt-inline-code-text-color);
+ border: 1px solid var(--tt-inline-code-border-color);
+ font-family: "JetBrains Mono NL", monospace;
+ font-size: 0.875em;
+ line-height: 1.4;
+ border-radius: 6px/0.375rem;
+ padding: 0.1em 0.2em;
+ }
+
+ // Code blocks
+ pre {
+ background-color: var(--tt-codeblock-bg);
+ color: var(--tt-codeblock-text);
+ border: 1px solid var(--tt-codeblock-border);
+ margin-top: 1.5em;
+ margin-bottom: 1.5em;
+ padding: 1em;
+ font-size: 1rem;
+ border-radius: 6px/0.375rem;
+
+ code {
+ background-color: transparent;
+ border: none;
+ border-radius: 0;
+ -webkit-text-fill-color: inherit;
+ color: inherit;
+ }
+ }
+}
diff --git a/packages/editor/src/components/tiptap-node/image-node/image-node.scss b/packages/editor/src/components/tiptap-node/image-node/image-node.scss
new file mode 100644
index 000000000..55265bb1a
--- /dev/null
+++ b/packages/editor/src/components/tiptap-node/image-node/image-node.scss
@@ -0,0 +1,32 @@
+.tiptap.ProseMirror {
+ img {
+ max-width: 100%;
+ height: auto;
+ display: block;
+ }
+
+ > img:not([data-type="emoji"] img) {
+ margin: 2rem 0;
+ outline: 0.125rem solid transparent;
+ border-radius: var(--tt-radius-xs, 0.25rem);
+ }
+
+ &.ProseMirror-focused
+ img:not([data-type="emoji"] img).ProseMirror-selectednode {
+ outline-color: var(--tt-brand-color-500);
+ }
+
+ // Thread image handling
+ .tiptap-thread:has(> img) {
+ margin: 2rem 0;
+
+ img {
+ outline: 0.125rem solid transparent;
+ border-radius: var(--tt-radius-xs, 0.25rem);
+ }
+ }
+
+ .tiptap-thread img {
+ margin: 0;
+ }
+}
diff --git a/packages/editor/src/components/tiptap-node/image-upload-node/image-upload-node-extension.ts b/packages/editor/src/components/tiptap-node/image-upload-node/image-upload-node-extension.ts
new file mode 100644
index 000000000..f51b4f5c4
--- /dev/null
+++ b/packages/editor/src/components/tiptap-node/image-upload-node/image-upload-node-extension.ts
@@ -0,0 +1,147 @@
+import { Node, mergeAttributes } from "@tiptap/react";
+import { ReactNodeViewRenderer } from "@tiptap/react";
+import { ImageUploadNode as ImageUploadNodeComponent } from "./image-upload-node";
+
+export type UploadFunction = (
+ file: File,
+ onProgress?: (event: { progress: number }) => void,
+ abortSignal?: AbortSignal,
+) => Promise;
+
+export interface ImageUploadNodeOptions {
+ /**
+ * Acceptable file types for upload.
+ * @default 'image/*'
+ */
+ accept?: string;
+ /**
+ * Maximum number of files that can be uploaded.
+ * @default 1
+ */
+ limit?: number;
+ /**
+ * Maximum file size in bytes (0 for unlimited).
+ * @default 0
+ */
+ maxSize?: number;
+ /**
+ * Function to handle the upload process.
+ */
+ upload?: UploadFunction;
+ /**
+ * Callback for upload errors.
+ */
+ onError?: (error: Error) => void;
+ /**
+ * Callback for successful uploads.
+ */
+ onSuccess?: (url: string) => void;
+}
+
+declare module "@tiptap/react" {
+ interface Commands {
+ imageUpload: {
+ setImageUploadNode: (options?: ImageUploadNodeOptions) => ReturnType;
+ };
+ }
+}
+
+/**
+ * A TipTap node extension that creates an image upload component.
+ * @see registry/tiptap-node/image-upload-node/image-upload-node
+ */
+export const ImageUploadNode = Node.create({
+ name: "imageUpload",
+
+ group: "block",
+
+ draggable: true,
+
+ selectable: true,
+
+ atom: true,
+
+ addOptions() {
+ return {
+ accept: "image/*",
+ limit: 1,
+ maxSize: 0,
+ upload: undefined,
+ onError: undefined,
+ onSuccess: undefined,
+ };
+ },
+
+ addAttributes() {
+ return {
+ accept: {
+ default: this.options.accept,
+ },
+ limit: {
+ default: this.options.limit,
+ },
+ maxSize: {
+ default: this.options.maxSize,
+ },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: 'div[data-type="image-upload"]' }];
+ },
+
+ renderHTML({ HTMLAttributes }) {
+ return [
+ "div",
+ mergeAttributes({ "data-type": "image-upload" }, HTMLAttributes),
+ ];
+ },
+
+ addNodeView() {
+ return ReactNodeViewRenderer(ImageUploadNodeComponent);
+ },
+
+ addCommands() {
+ return {
+ setImageUploadNode:
+ (options = {}) =>
+ ({ commands }) => {
+ return commands.insertContent({
+ type: this.name,
+ attrs: options,
+ });
+ },
+ };
+ },
+
+ /**
+ * Adds Enter key handler to trigger the upload component when it's selected.
+ */
+ addKeyboardShortcuts() {
+ return {
+ Enter: ({ editor }) => {
+ const { selection } = editor.state;
+ const { nodeAfter } = selection.$from;
+
+ if (
+ nodeAfter &&
+ nodeAfter.type.name === "imageUpload" &&
+ editor.isActive("imageUpload")
+ ) {
+ const nodeEl = editor.view.nodeDOM(selection.$from.pos);
+ if (nodeEl && nodeEl instanceof HTMLElement) {
+ // Since NodeViewWrapper is wrapped with a div, we need to click the first child
+ const firstChild = nodeEl.firstChild;
+ if (firstChild && firstChild instanceof HTMLElement) {
+ firstChild.click();
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+ };
+ },
+});
+
+export default ImageUploadNode;
diff --git a/packages/editor/src/components/tiptap-node/image-upload-node/image-upload-node.scss b/packages/editor/src/components/tiptap-node/image-upload-node/image-upload-node.scss
new file mode 100644
index 000000000..44fd56421
--- /dev/null
+++ b/packages/editor/src/components/tiptap-node/image-upload-node/image-upload-node.scss
@@ -0,0 +1,213 @@
+:root {
+ --tt-button-default-icon-color: var(--tt-gray-light-a-600);
+
+ --tiptap-image-upload-active: var(--tt-brand-color-500);
+ --tiptap-image-upload-progress-bg: var(--tt-brand-color-50);
+ --tiptap-image-upload-icon-bg: var(--tt-brand-color-500);
+
+ --tiptap-image-upload-text-color: var(--tt-gray-light-a-700);
+ --tiptap-image-upload-subtext-color: var(--tt-gray-light-a-400);
+ --tiptap-image-upload-border: var(--tt-gray-light-a-300);
+ --tiptap-image-upload-border-hover: var(--tt-gray-light-a-400);
+ --tiptap-image-upload-border-active: var(--tt-brand-color-500);
+
+ --tiptap-image-upload-icon-doc-bg: var(--tt-gray-light-a-200);
+ --tiptap-image-upload-icon-doc-border: var(--tt-gray-light-300);
+ --tiptap-image-upload-icon-color: var(--white);
+}
+
+.dark {
+ --tt-button-default-icon-color: var(--tt-gray-dark-a-600);
+
+ --tiptap-image-upload-active: var(--tt-brand-color-400);
+ --tiptap-image-upload-progress-bg: var(--tt-brand-color-900);
+ --tiptap-image-upload-icon-bg: var(--tt-brand-color-400);
+
+ --tiptap-image-upload-text-color: var(--tt-gray-dark-a-700);
+ --tiptap-image-upload-subtext-color: var(--tt-gray-dark-a-400);
+ --tiptap-image-upload-border: var(--tt-gray-dark-a-300);
+ --tiptap-image-upload-border-hover: var(--tt-gray-dark-a-400);
+ --tiptap-image-upload-border-active: var(--tt-brand-color-400);
+
+ --tiptap-image-upload-icon-doc-bg: var(--tt-gray-dark-a-200);
+ --tiptap-image-upload-icon-doc-border: var(--tt-gray-dark-300);
+ --tiptap-image-upload-icon-color: var(--black);
+}
+
+.tiptap-image-upload {
+ margin: 2rem 0;
+
+ input[type="file"] {
+ display: none;
+ }
+
+ .tiptap-image-upload-dropzone {
+ position: relative;
+ width: 3.125rem;
+ height: 3.75rem;
+ display: inline-flex;
+ align-items: flex-start;
+ justify-content: center;
+ -webkit-user-select: none; /* Safari */
+ -ms-user-select: none; /* IE 10 and IE 11 */
+ user-select: none;
+ }
+
+ .tiptap-image-upload-icon-container {
+ position: absolute;
+ width: 1.75rem;
+ height: 1.75rem;
+ bottom: 0;
+ right: 0;
+ background-color: var(--tiptap-image-upload-icon-bg);
+ border-radius: var(--tt-radius-lg, 0.75rem);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .tiptap-image-upload-icon {
+ width: 0.875rem;
+ height: 0.875rem;
+ color: var(--tiptap-image-upload-icon-color);
+ }
+
+ .tiptap-image-upload-dropzone-rect-primary {
+ color: var(--tiptap-image-upload-icon-doc-bg);
+ position: absolute;
+ }
+
+ .tiptap-image-upload-dropzone-rect-secondary {
+ position: absolute;
+ top: 0;
+ right: 0.25rem;
+ bottom: 0;
+ color: var(--tiptap-image-upload-icon-doc-border);
+ }
+
+ .tiptap-image-upload-text {
+ color: var(--tiptap-image-upload-text-color);
+ font-weight: 500;
+ font-size: 0.875rem;
+ line-height: normal;
+
+ em {
+ font-style: normal;
+ text-decoration: underline;
+ }
+ }
+
+ .tiptap-image-upload-subtext {
+ color: var(--tiptap-image-upload-subtext-color);
+ font-weight: 600;
+ line-height: normal;
+ font-size: 0.75rem;
+ }
+
+ .tiptap-image-upload-preview {
+ position: relative;
+ border-radius: var(--tt-radius-md, 0.5rem);
+ overflow: hidden;
+
+ .tiptap-image-upload-progress {
+ position: absolute;
+ inset: 0;
+ background-color: var(--tiptap-image-upload-progress-bg);
+ transition: all 300ms ease-out;
+ }
+
+ .tiptap-image-upload-preview-content {
+ position: relative;
+ border: 1px solid var(--tiptap-image-upload-border);
+ border-radius: var(--tt-radius-md, 0.5rem);
+ padding: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .tiptap-image-upload-file-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ height: 2rem;
+
+ .tiptap-image-upload-file-icon {
+ padding: 0.5rem;
+ background-color: var(--tiptap-image-upload-icon-bg);
+ border-radius: var(--tt-radius-lg, 0.75rem);
+
+ svg {
+ width: 0.875rem;
+ height: 0.875rem;
+ color: var(--tiptap-image-upload-icon-color);
+ }
+ }
+ }
+
+ .tiptap-image-upload-details {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .tiptap-image-upload-actions {
+ display: flex;
+ align-items: center;
+
+ .tiptap-image-upload-progress-text {
+ font-size: 0.75rem;
+ color: var(--tiptap-image-upload-border-active);
+ }
+
+ .tiptap-image-upload-close-btn {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 2rem;
+ height: 2rem;
+ color: var(--tt-button-default-icon-color);
+ transition: color 200ms ease;
+
+ svg {
+ width: 1rem;
+ height: 1rem;
+ }
+ }
+ }
+ }
+
+ .tiptap-image-upload-dragger {
+ padding: 2rem 1.5rem;
+ border: 1.5px dashed var(--tiptap-image-upload-border);
+ border-radius: var(--tt-radius-md, 0.5rem);
+ text-align: center;
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+
+ &-active {
+ border-color: var(--tiptap-image-upload-border-active);
+ background-color: rgba(
+ var(--tiptap-image-upload-active-rgb, 0, 0, 255),
+ 0.05
+ );
+ }
+ }
+
+ .tiptap-image-upload-content {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 0.25rem;
+ -webkit-user-select: none; /* Safari */
+ -ms-user-select: none; /* IE 10 and IE 11 */
+ user-select: none;
+ }
+}
+
+.tiptap.ProseMirror.ProseMirror-focused {
+ .ProseMirror-selectednode .tiptap-image-upload-dragger {
+ border-color: var(--tiptap-image-upload-active);
+ }
+}
diff --git a/packages/editor/src/components/tiptap-node/image-upload-node/image-upload-node.tsx b/packages/editor/src/components/tiptap-node/image-upload-node/image-upload-node.tsx
new file mode 100644
index 000000000..fcb5ed350
--- /dev/null
+++ b/packages/editor/src/components/tiptap-node/image-upload-node/image-upload-node.tsx
@@ -0,0 +1,415 @@
+import type { NodeViewProps } from "@tiptap/react";
+import { NodeViewWrapper } from "@tiptap/react";
+import * as React from "react";
+import { CloseIcon } from "../../icons";
+import "./image-upload-node.scss";
+
+export interface FileItem {
+ id: string;
+ file: File;
+ progress: number;
+ status: "uploading" | "success" | "error";
+ url?: string;
+ abortController?: AbortController;
+}
+
+interface UploadOptions {
+ maxSize: number;
+ limit: number;
+ accept: string;
+ upload: (
+ file: File,
+ onProgress: (event: { progress: number }) => void,
+ signal: AbortSignal,
+ ) => Promise;
+ onSuccess?: (url: string) => void;
+ onError?: (error: Error) => void;
+}
+
+function useFileUpload(options: UploadOptions) {
+ const [fileItem, setFileItem] = React.useState(null);
+
+ const uploadFile = async (file: File): Promise => {
+ if (file.size > options.maxSize) {
+ const error = new Error(
+ `File size exceeds maximum allowed (${options.maxSize / 1024 / 1024}MB)`,
+ );
+ options.onError?.(error);
+ return null;
+ }
+
+ const abortController = new AbortController();
+
+ const newFileItem: FileItem = {
+ id: crypto.randomUUID(),
+ file,
+ progress: 0,
+ status: "uploading",
+ abortController,
+ };
+
+ setFileItem(newFileItem);
+
+ try {
+ if (!options.upload) {
+ throw new Error("Upload function is not defined");
+ }
+
+ const url = await options.upload(
+ file,
+ (event: { progress: number }) => {
+ setFileItem((prev) => {
+ if (!prev) return null;
+ return {
+ ...prev,
+ progress: event.progress,
+ };
+ });
+ },
+ abortController.signal,
+ );
+
+ if (!url) throw new Error("Upload failed: No URL returned");
+
+ if (!abortController.signal.aborted) {
+ setFileItem((prev) => {
+ if (!prev) return null;
+ return {
+ ...prev,
+ status: "success",
+ url,
+ progress: 100,
+ };
+ });
+ options.onSuccess?.(url);
+ return url;
+ }
+
+ return null;
+ } catch (error) {
+ if (!abortController.signal.aborted) {
+ setFileItem((prev) => {
+ if (!prev) return null;
+ return {
+ ...prev,
+ status: "error",
+ progress: 0,
+ };
+ });
+ options.onError?.(
+ error instanceof Error ? error : new Error("Upload failed"),
+ );
+ }
+ return null;
+ }
+ };
+
+ const uploadFiles = async (files: File[]): Promise => {
+ if (!files || files.length === 0) {
+ options.onError?.(new Error("No files to upload"));
+ return null;
+ }
+
+ if (options.limit && files.length > options.limit) {
+ options.onError?.(
+ new Error(
+ `Maximum ${options.limit} file${options.limit === 1 ? "" : "s"} allowed`,
+ ),
+ );
+ return null;
+ }
+
+ const file = files[0];
+ if (!file) {
+ options.onError?.(new Error("File is undefined"));
+ return null;
+ }
+
+ return await uploadFile(file);
+ };
+
+ const clearFileItem = () => {
+ if (!fileItem) return;
+
+ if (fileItem.abortController) {
+ fileItem.abortController.abort();
+ }
+ if (fileItem.url) {
+ URL.revokeObjectURL(fileItem.url);
+ }
+ setFileItem(null);
+ };
+
+ return {
+ fileItem,
+ uploadFiles,
+ clearFileItem,
+ };
+}
+
+const CloudUploadIcon: React.FC = () => (
+
+);
+
+const FileIcon: React.FC = () => (
+
+);
+
+const FileCornerIcon: React.FC = () => (
+
+);
+
+interface ImageUploadDragAreaProps {
+ onFile: (files: File[]) => void;
+ children?: React.ReactNode;
+}
+
+const ImageUploadDragArea: React.FC = ({
+ onFile,
+ children,
+}) => {
+ const [dragover, setDragover] = React.useState(false);
+
+ const onDrop = (e: React.DragEvent) => {
+ setDragover(false);
+ e.preventDefault();
+ e.stopPropagation();
+
+ const files = Array.from(e.dataTransfer.files);
+ onFile(files);
+ };
+
+ const onDragover = (e: React.DragEvent) => {
+ e.preventDefault();
+ setDragover(true);
+ };
+
+ const onDragleave = (e: React.DragEvent) => {
+ e.preventDefault();
+ setDragover(false);
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+interface ImageUploadPreviewProps {
+ file: File;
+ progress: number;
+ status: "uploading" | "success" | "error";
+ onRemove: () => void;
+}
+
+const ImageUploadPreview: React.FC = ({
+ file,
+ progress,
+ status,
+ onRemove,
+}) => {
+ const formatFileSize = (bytes: number) => {
+ if (bytes === 0) return "0 Bytes";
+ const k = 1024;
+ const sizes = ["Bytes", "KB", "MB", "GB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
+ };
+
+ return (
+
+ {status === "uploading" && (
+
+ )}
+
+
+
+
+
+
+
+ {file.name}
+
+ {formatFileSize(file.size)}
+
+
+
+
+ {status === "uploading" && (
+
+ {progress}%
+
+ )}
+
+
+
+
+ );
+};
+
+const DropZoneContent: React.FC<{ maxSize: number }> = ({ maxSize }) => (
+ <>
+
+
+
+
+ Click to upload or drag and drop
+
+
+ Maximum file size {maxSize / 1024 / 1024}MB.
+
+
+ >
+);
+
+export const ImageUploadNode: React.FC = (props) => {
+ const { accept, limit, maxSize } = props.node.attrs;
+ const inputRef = React.useRef(null);
+ const extension = props.extension;
+
+ const uploadOptions: UploadOptions = {
+ maxSize,
+ limit,
+ accept,
+ upload: extension.options.upload,
+ onSuccess: extension.options.onSuccess,
+ onError: extension.options.onError,
+ };
+
+ const { fileItem, uploadFiles, clearFileItem } = useFileUpload(uploadOptions);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const files = e.target.files;
+ if (!files || files.length === 0) {
+ extension.options.onError?.(new Error("No file selected"));
+ return;
+ }
+ handleUpload(Array.from(files));
+ };
+
+ const handleUpload = async (files: File[]) => {
+ const url = await uploadFiles(files);
+
+ if (url) {
+ const pos = props.getPos();
+ const filename = files[0]?.name.replace(/\.[^/.]+$/, "") || "unknown";
+
+ props.editor
+ .chain()
+ .focus()
+ .deleteRange({ from: pos, to: pos + 1 })
+ .insertContentAt(pos, [
+ {
+ type: "image",
+ attrs: { src: url, alt: filename, title: filename },
+ },
+ ])
+ .run();
+ }
+ };
+
+ const handleClick = () => {
+ if (inputRef.current && !fileItem) {
+ inputRef.current.value = "";
+ inputRef.current.click();
+ }
+ };
+
+ return (
+
+ {!fileItem && (
+
+
+
+ )}
+
+ {fileItem && (
+
+ )}
+
+ ) => e.stopPropagation()}
+ />
+
+ );
+};
diff --git a/packages/editor/src/components/tiptap-node/image-upload-node/index.tsx b/packages/editor/src/components/tiptap-node/image-upload-node/index.tsx
new file mode 100644
index 000000000..93f512da7
--- /dev/null
+++ b/packages/editor/src/components/tiptap-node/image-upload-node/index.tsx
@@ -0,0 +1 @@
+export * from "./image-upload-node-extension";
diff --git a/packages/editor/src/components/tiptap-node/list-node/list-node.scss b/packages/editor/src/components/tiptap-node/list-node/list-node.scss
new file mode 100644
index 000000000..4da72c818
--- /dev/null
+++ b/packages/editor/src/components/tiptap-node/list-node/list-node.scss
@@ -0,0 +1,159 @@
+.tiptap.ProseMirror {
+ --tt-checklist-bg-color: var(--tt-gray-light-a-100);
+ --tt-checklist-bg-active-color: var(--tt-gray-light-a-900);
+ --tt-checklist-border-color: var(--tt-gray-light-a-200);
+ --tt-checklist-border-active-color: var(--tt-gray-light-a-900);
+ --tt-checklist-check-icon-color: var(--white);
+ --tt-checklist-text-active: var(--tt-gray-light-a-500);
+
+ .dark & {
+ --tt-checklist-bg-color: var(--tt-gray-dark-a-100);
+ --tt-checklist-bg-active-color: var(--tt-gray-dark-a-900);
+ --tt-checklist-border-color: var(--tt-gray-dark-a-200);
+ --tt-checklist-border-active-color: var(--tt-gray-dark-a-900);
+ --tt-checklist-check-icon-color: var(--black);
+ --tt-checklist-text-active: var(--tt-gray-dark-a-500);
+ }
+}
+
+/* =====================
+ LISTS
+ ===================== */
+.tiptap.ProseMirror {
+ // Common list styles
+ ol,
+ ul {
+ margin-top: 1.5em;
+ margin-bottom: 1.5em;
+ padding-left: 1.5em;
+
+ &:first-child {
+ margin-top: 0;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ ol,
+ ul {
+ margin-top: 0;
+ margin-bottom: 0;
+ }
+ }
+
+ li {
+ p {
+ margin-top: 0;
+ }
+ }
+
+ // Ordered lists
+ ol {
+ list-style: decimal;
+
+ ol {
+ list-style: lower-alpha;
+
+ ol {
+ list-style: lower-roman;
+ }
+ }
+ }
+
+ // Unordered lists
+ ul:not([data-type="taskList"]) {
+ list-style: disc;
+
+ ul {
+ list-style: circle;
+
+ ul {
+ list-style: disc;
+ }
+ }
+ }
+
+ // Task lists
+ ul[data-type="taskList"] {
+ padding-left: 0.25em;
+
+ li {
+ display: flex;
+ flex-direction: row;
+ align-items: flex-start;
+
+ &:not(:has(> p:first-child)) {
+ list-style-type: none;
+ }
+
+ &[data-checked="true"] {
+ > div > p {
+ opacity: 0.5;
+ text-decoration: line-through;
+ }
+
+ > div > p span {
+ text-decoration: line-through;
+ }
+ }
+
+ label {
+ position: relative;
+ padding-top: 4px;
+ padding-right: 8px;
+
+ input[type="checkbox"] {
+ position: absolute;
+ opacity: 0;
+ width: 0;
+ height: 0;
+ }
+
+ span {
+ display: block;
+ width: 1em;
+ height: 1em;
+ border: 1px solid var(--tt-checklist-border-color);
+ border-radius: var(--tt-radius-xs, 0.25rem);
+ position: relative;
+ cursor: pointer;
+ background-color: var(--tt-checklist-bg-color);
+ transition:
+ background-color 80ms ease-out,
+ border-color 80ms ease-out;
+
+ &::before {
+ content: "";
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform: translate(-50%, -50%);
+ width: 0.75em;
+ height: 0.75em;
+ background-color: var(--tt-checklist-check-icon-color);
+ opacity: 0;
+ -webkit-mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E")
+ center/contain no-repeat;
+ mask: url("data:image/svg+xml,%3Csvg%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%20fill%3D%22currentColor%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20d%3D%22M21.4142%204.58579C22.1953%205.36683%2022.1953%206.63317%2021.4142%207.41421L10.4142%2018.4142C9.63317%2019.1953%208.36684%2019.1953%207.58579%2018.4142L2.58579%2013.4142C1.80474%2012.6332%201.80474%2011.3668%202.58579%2010.5858C3.36683%209.80474%204.63317%209.80474%205.41421%2010.5858L9%2014.1716L18.5858%204.58579C19.3668%203.80474%2020.6332%203.80474%2021.4142%204.58579Z%22%20fill%3D%22currentColor%22%2F%3E%3C%2Fsvg%3E")
+ center/contain no-repeat;
+ }
+ }
+
+ input[type="checkbox"]:checked + span {
+ background: var(--tt-checklist-bg-active-color);
+ border-color: var(--tt-checklist-border-active-color);
+
+ &::before {
+ opacity: 1;
+ }
+ }
+ }
+
+ div {
+ flex: 1 1 0%;
+ min-width: 0;
+ }
+ }
+ }
+}
diff --git a/packages/editor/src/components/tiptap-node/paragraph-node/paragraph-node.scss b/packages/editor/src/components/tiptap-node/paragraph-node/paragraph-node.scss
new file mode 100644
index 000000000..abb5a7e5c
--- /dev/null
+++ b/packages/editor/src/components/tiptap-node/paragraph-node/paragraph-node.scss
@@ -0,0 +1,402 @@
+.tiptap.ProseMirror {
+ --blockquote-bg-color: var(--tt-gray-light-900);
+ --link-text-color: var(--tt-brand-color-500);
+ --separator-color: var(--tt-gray-light-a-200);
+ --thread-text: var(--tt-gray-light-900);
+ --placeholder-color: var(--tt-gray-light-a-400);
+
+ // Highlight variables
+ --tt-highlight-green: #dcfce7;
+ --tt-highlight-green-contrast: #c7fad8;
+ --tt-highlight-blue: #e0f2fe;
+ --tt-highlight-blue-contrast: #ceeafd;
+ --tt-highlight-red: #ffe4e6;
+ --tt-highlight-red-contrast: #ffccd0;
+ --tt-highlight-purple: #f3e8ff;
+ --tt-highlight-purple-contrast: #e4ccff;
+ --tt-highlight-yellow: #fef9c3;
+ --tt-highlight-yellow-contrast: #fbe604;
+
+ // Mathematics variables
+ --tiptap-mathematics-bg-color: var(--tt-gray-light-a-200);
+ --tiptap-mathematics-border-color: var(--tt-brand-color-500);
+
+ .dark & {
+ --blockquote-bg-color: var(--tt-gray-dark-900);
+ --link-text-color: var(--tt-brand-color-400);
+ --separator-color: var(--tt-gray-dark-a-200);
+ --thread-text: var(--tt-gray-dark-900);
+ --placeholder-color: var(--tt-gray-dark-a-400);
+
+ --tt-highlight-green: #509568;
+ --tt-highlight-green-contrast: #47855d;
+ --tt-highlight-blue: #6e92aa;
+ --tt-highlight-blue-contrast: #5e86a1;
+ --tt-highlight-red: #743e42;
+ --tt-highlight-red-contrast: #643539;
+ --tt-highlight-purple: #583e74;
+ --tt-highlight-purple-contrast: #4c3564;
+ --tt-highlight-yellow: #6b6524;
+ --tt-highlight-yellow-contrast: #58531e;
+
+ --tiptap-mathematics-bg-color: var(--tt-gray-dark-a-200);
+ --tiptap-mathematics-border-color: var(--tt-brand-color-400);
+ }
+}
+
+/* =====================
+ CORE EDITOR STYLES
+ ===================== */
+.tiptap.ProseMirror {
+ white-space: pre-wrap;
+ outline: none;
+ caret-color: var(--tt-cursor-color);
+
+ // Paragraph spacing
+ p:not(:first-child) {
+ font-size: 1rem;
+ line-height: 1.6;
+ font-weight: normal;
+ margin-top: 20px;
+ }
+
+ // Selection styles
+ &:not(.readonly):not(.ProseMirror-hideselection) {
+ ::selection {
+ background-color: var(--tt-selection-color);
+ }
+
+ .selection::selection {
+ background: transparent;
+ }
+ }
+
+ .selection {
+ display: inline;
+ background-color: var(--tt-selection-color);
+ }
+
+ .ProseMirror-hideselection {
+ caret-color: transparent;
+ }
+
+ // Placeholder
+ > p.is-editor-empty::before {
+ content: attr(data-placeholder);
+ pointer-events: none;
+ color: var(--placeholder-color);
+ float: left;
+ height: 0;
+ }
+
+ // Resize cursor
+ &.resize-cursor {
+ cursor: ew-resize;
+ cursor: col-resize;
+ }
+}
+
+/* =====================
+ GAP CURSOR
+ ===================== */
+.tiptap.ProseMirror {
+ .ProseMirror-gapcursor {
+ display: none;
+ pointer-events: none;
+ position: absolute;
+
+ &:after {
+ content: "";
+ display: block;
+ position: absolute;
+ top: 1em;
+ width: 1.25em;
+ border-top: 1px solid black;
+ animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
+ }
+ }
+
+ &.ProseMirror-focused,
+ &.ProseMirror.ProseMirror-focused {
+ .ProseMirror-gapcursor {
+ display: block;
+ }
+ }
+}
+
+@keyframes ProseMirror-cursor-blink {
+ to {
+ visibility: hidden;
+ }
+}
+
+/* =====================
+ TEXT DECORATION
+ ===================== */
+.tiptap.ProseMirror {
+ // Text decoration inheritance for spans
+ a span {
+ text-decoration: underline;
+ }
+
+ s span {
+ text-decoration: line-through;
+ }
+
+ u span {
+ text-decoration: underline;
+ }
+}
+
+/* =====================
+ BLOCKQUOTE
+ ===================== */
+.tiptap.ProseMirror {
+ blockquote {
+ position: relative;
+ padding-left: 1em;
+ padding-top: 0.375em;
+ padding-bottom: 0.375em;
+ margin: 1.5rem 0;
+
+ p {
+ margin-top: 0;
+ }
+
+ &::before,
+ &.is-empty::before {
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ top: 0;
+ height: 100%;
+ width: 0.25em;
+ background-color: var(--blockquote-bg-color);
+ content: "";
+ border-radius: 0;
+ }
+ }
+}
+
+/* =====================
+ COLLABORATION
+ ===================== */
+.tiptap.ProseMirror {
+ .collaboration-cursor {
+ &__caret {
+ border-right: 1px solid transparent;
+ border-left: 1px solid transparent;
+ pointer-events: none;
+ margin-left: -1px;
+ margin-right: -1px;
+ position: relative;
+ word-break: normal;
+ }
+
+ &__label {
+ border-radius: 0.25rem;
+ border-bottom-left-radius: 0;
+ font-size: 0.75rem;
+ font-weight: 600;
+ left: -1px;
+ line-height: 1;
+ padding: 0.125rem 0.375rem;
+ position: absolute;
+ top: -1.3em;
+ user-select: none;
+ white-space: nowrap;
+ }
+ }
+}
+
+/* =====================
+ EMOJI
+ ===================== */
+.tiptap.ProseMirror [data-type="emoji"] img {
+ display: inline-block;
+ width: 1.25em;
+ height: 1.25em;
+ cursor: text;
+}
+
+/* =====================
+ HEADINGS
+ ===================== */
+.tiptap.ProseMirror {
+ h1,
+ h2,
+ h3,
+ h4 {
+ position: relative;
+ color: inherit;
+ font-style: inherit;
+
+ &:first-child {
+ margin-top: 0;
+ }
+ }
+
+ h1 {
+ font-size: 1.5em;
+ font-weight: 700;
+ margin-top: 3em;
+ }
+
+ h2 {
+ font-size: 1.25em;
+ font-weight: 700;
+ margin-top: 2.5em;
+ }
+
+ h3 {
+ font-size: 1.125em;
+ font-weight: 600;
+ margin-top: 2em;
+ }
+
+ h4 {
+ font-size: 1em;
+ font-weight: 600;
+ margin-top: 2em;
+ }
+}
+
+/* =====================
+ HORIZONTAL RULE
+ ===================== */
+.tiptap.ProseMirror {
+ hr {
+ margin-top: 3em;
+ margin-bottom: 3em;
+ border: none;
+ height: 1px;
+ background-color: var(--separator-color);
+ }
+
+ &.ProseMirror-focused {
+ hr.ProseMirror-selectednode {
+ border-radius: 9999px;
+ outline: 3px solid var(--tt-brand-color-500);
+ outline-offset: 2px;
+ }
+ }
+}
+
+/* =====================
+ LINKS
+ ===================== */
+.tiptap.ProseMirror {
+ a {
+ color: var(--link-text-color);
+ text-decoration: underline;
+ }
+}
+
+/* =====================
+ MENTION
+ ===================== */
+.tiptap.ProseMirror {
+ [data-type="mention"] {
+ display: inline-block;
+ color: var(--tt-brand-color-500);
+ }
+}
+
+/* =====================
+ THREADS
+ ===================== */
+.tiptap.ProseMirror {
+ // Base styles for inline threads
+ .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--inline {
+ transition:
+ color 0.2s ease-in-out,
+ background-color 0.2s ease-in-out;
+ color: var(--thread-text);
+ border-bottom: 2px dashed var(--tt-color-yellow-base);
+ font-weight: 600;
+
+ &.tiptap-thread--selected,
+ &.tiptap-thread--hovered {
+ background-color: var(--tt-color-yellow-inc-2);
+ border-bottom-color: transparent;
+ }
+ }
+
+ // Block thread styles with images
+ .tiptap-thread.tiptap-thread--unresolved.tiptap-thread--block {
+ &:has(img) {
+ outline: 0.125rem solid var(--tt-color-yellow-base);
+ border-radius: var(--tt-radius-xs, 0.25rem);
+ overflow: hidden;
+ width: fit-content;
+
+ &.tiptap-thread--selected {
+ outline-width: 0.25rem;
+ outline-color: var(--tt-color-yellow-base);
+ }
+
+ &.tiptap-thread--hovered {
+ outline-width: 0.25rem;
+ }
+ }
+
+ // Block thread styles without images
+ &:not(:has(img)) {
+ border-radius: 0.25rem;
+ border-bottom: 0.125rem dashed var(--tt-color-yellow-base);
+ padding-bottom: 0.5rem;
+ outline: 0.25rem solid transparent;
+
+ &.tiptap-thread--hovered,
+ &.tiptap-thread--selected {
+ background-color: var(--tt-color-yellow-base);
+ outline-color: var(--tt-color-yellow-base);
+ }
+ }
+ }
+
+ // Resolved thread styles
+ .tiptap-thread.tiptap-thread--resolved.tiptap-thread--inline.tiptap-thread--selected {
+ background-color: var(--tt-color-yellow-base);
+ border-color: transparent;
+ opacity: 0.5;
+ }
+
+ // React renderer specific styles
+ .tiptap-thread.tiptap-thread--block:has(.react-renderer) {
+ margin-top: 3rem;
+ margin-bottom: 3rem;
+ }
+}
+
+/* =====================
+ Mathematics
+ ===================== */
+.tiptap.ProseMirror {
+ .Tiptap-mathematics-editor {
+ padding: 0 0.25rem;
+ margin: 0 0.25rem;
+ border: 1px solid var(--tiptap-mathematics-border-color);
+ font-family: monospace;
+ font-size: 0.875rem;
+ }
+
+ .Tiptap-mathematics-render {
+ padding: 0 0.25rem;
+
+ &--editable {
+ cursor: pointer;
+ transition: background 0.2s;
+
+ &:hover {
+ background: var(--tiptap-mathematics-bg-color);
+ }
+ }
+ }
+
+ .Tiptap-mathematics-editor,
+ .Tiptap-mathematics-render {
+ border-radius: var(--tt-radius-xs);
+ display: inline-block;
+ }
+}
diff --git a/packages/editor/src/components/tiptap-templates/simple/data/content.json b/packages/editor/src/components/tiptap-templates/simple/data/content.json
new file mode 100644
index 000000000..b4e2ae918
--- /dev/null
+++ b/packages/editor/src/components/tiptap-templates/simple/data/content.json
@@ -0,0 +1,477 @@
+{
+ "type": "doc",
+ "content": [
+ {
+ "type": "heading",
+ "attrs": {
+ "textAlign": null,
+ "level": 1
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Getting started"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Welcome to the "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ },
+ {
+ "type": "highlight",
+ "attrs": {
+ "color": "var(--tt-highlight-yellow)"
+ }
+ }
+ ],
+ "text": "Simple Editor"
+ },
+ {
+ "type": "text",
+ "text": " template! This template integrates "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "open source"
+ },
+ {
+ "type": "text",
+ "text": " UI components and Tiptap extensions licensed under "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "MIT"
+ },
+ {
+ "type": "text",
+ "text": "."
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Integrate it by following the "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null
+ }
+ }
+ ],
+ "text": "Tiptap UI Components docs"
+ },
+ {
+ "type": "text",
+ "text": " or using our CLI tool."
+ }
+ ]
+ },
+ {
+ "type": "codeBlock",
+ "attrs": {
+ "language": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "npx @tiptap/cli init"
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "textAlign": null,
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Features"
+ }
+ ]
+ },
+ {
+ "type": "blockquote",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": null
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "A fully responsive rich text editor with built-in support for common formatting and layout tools. Type markdown "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "**"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": " or use keyboard shortcuts "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "⌘+B"
+ },
+ {
+ "type": "text",
+ "text": " for "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "strike"
+ }
+ ],
+ "text": "most"
+ },
+ {
+ "type": "text",
+ "text": " all common markdown marks. 🪄"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Add images, customize alignment, and apply "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "highlight",
+ "attrs": {
+ "color": "var(--tt-highlight-blue)"
+ }
+ }
+ ],
+ "text": "advanced formatting"
+ },
+ {
+ "type": "text",
+ "text": " to make your writing more engaging and professional."
+ }
+ ]
+ },
+ {
+ "type": "image",
+ "attrs": {
+ "src": "/images/placeholder-image.png",
+ "alt": "placeholder-image",
+ "title": "placeholder-image"
+ }
+ },
+ {
+ "type": "bulletList",
+ "content": [
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Superscript"
+ },
+ {
+ "type": "text",
+ "text": " (x"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "superscript"
+ }
+ ],
+ "text": "2"
+ },
+ {
+ "type": "text",
+ "text": ") and "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Subscript"
+ },
+ {
+ "type": "text",
+ "text": " (H"
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "subscript"
+ }
+ ],
+ "text": "2"
+ },
+ {
+ "type": "text",
+ "text": "O) for precision."
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "listItem",
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "Typographic conversion"
+ },
+ {
+ "type": "text",
+ "text": ": automatically convert to "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "code"
+ }
+ ],
+ "text": "->"
+ },
+ {
+ "type": "text",
+ "text": " an arrow "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "bold"
+ }
+ ],
+ "text": "→"
+ },
+ {
+ "type": "text",
+ "text": "."
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "italic"
+ }
+ ],
+ "text": "→ "
+ },
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor#features",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null
+ }
+ }
+ ],
+ "text": "Learn more"
+ }
+ ]
+ },
+ {
+ "type": "horizontalRule"
+ },
+ {
+ "type": "heading",
+ "attrs": {
+ "textAlign": "left",
+ "level": 2
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Make it your own"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Switch between light and dark modes, and tailor the editor's appearance with customizable CSS to match your style."
+ }
+ ]
+ },
+ {
+ "type": "taskList",
+ "content": [
+ {
+ "type": "taskItem",
+ "attrs": {
+ "checked": true
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "text": "Test template"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "taskItem",
+ "attrs": {
+ "checked": false
+ },
+ "content": [
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ },
+ "content": [
+ {
+ "type": "text",
+ "marks": [
+ {
+ "type": "link",
+ "attrs": {
+ "href": "https://tiptap.dev/docs/ui-components/templates/simple-editor",
+ "target": "_blank",
+ "rel": "noopener noreferrer nofollow",
+ "class": null
+ }
+ }
+ ],
+ "text": "Integrate the free template"
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "attrs": {
+ "textAlign": "left"
+ }
+ }
+ ]
+}
diff --git a/packages/editor/src/components/tiptap-templates/simple/simple-editor.scss b/packages/editor/src/components/tiptap-templates/simple/simple-editor.scss
new file mode 100644
index 000000000..8306b5a36
--- /dev/null
+++ b/packages/editor/src/components/tiptap-templates/simple/simple-editor.scss
@@ -0,0 +1,32 @@
+@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
+
+
+
+body {
+ font-family: "Inter", sans-serif;
+ font-optical-sizing: auto;
+}
+
+.tiptap.ProseMirror {
+ font-family: "DM Sans", sans-serif;
+}
+
+.content-wrapper {
+ &::-webkit-scrollbar {
+ display: block;
+ width: 0.5rem;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background-color: var(--tt-scrollbar-color);
+ border-radius: 4px;
+ }
+
+ /* Firefox scrollbar */
+ scrollbar-width: thin;
+ scrollbar-color: var(--tt-scrollbar-color) transparent;
+}
\ No newline at end of file
diff --git a/packages/editor/src/components/tiptap-templates/simple/simple-editor.tsx b/packages/editor/src/components/tiptap-templates/simple/simple-editor.tsx
new file mode 100644
index 000000000..a1856cc05
--- /dev/null
+++ b/packages/editor/src/components/tiptap-templates/simple/simple-editor.tsx
@@ -0,0 +1,294 @@
+import { EditorContent, EditorContext, useEditor } from "@tiptap/react";
+import * as React from "react";
+
+import { Highlight } from "@tiptap/extension-highlight";
+import { Image } from "@tiptap/extension-image";
+import { Subscript } from "@tiptap/extension-subscript";
+import { Superscript } from "@tiptap/extension-superscript";
+import { TaskItem } from "@tiptap/extension-task-item";
+import { TaskList } from "@tiptap/extension-task-list";
+import { TextAlign } from "@tiptap/extension-text-align";
+import { Typography } from "@tiptap/extension-typography";
+import { Underline } from "@tiptap/extension-underline";
+// --- Tiptap Core Extensions ---
+import { StarterKit } from "@tiptap/starter-kit";
+
+// --- Custom Extensions ---
+import { Link } from "../../tiptap-extension/link-extension";
+import { Selection } from "../../tiptap-extension/selection-extension";
+import { TrailingNode } from "../../tiptap-extension/trailing-node-extension";
+import "../../tiptap-node/code-block-node/code-block-node.scss";
+import "../../tiptap-node/list-node/list-node.scss";
+import "../../tiptap-node/image-node/image-node.scss";
+import "../../tiptap-node/paragraph-node/paragraph-node.scss";
+
+import { ThemeToggle } from "@rectangular-labs/ui/components/theme-provider";
+// --- Tiptap UI ---
+import { Button } from "@rectangular-labs/ui/components/ui/button";
+import { Spacer } from "@rectangular-labs/ui/components/ui/spacer";
+import { Toolbar } from "../../tiptap-ui-primitive/toolbar";
+import { HeadingDropdownMenu } from "../../tiptap-ui/heading/heading-dropdown-menu";
+import {
+ LinkButton,
+ LinkContent,
+ LinkPopover,
+} from "../../tiptap-ui/link-popover";
+import { ListDropdownMenu } from "../../tiptap-ui/list/list-dropdown-menu";
+import { MarkButton } from "../../tiptap-ui/mark-button";
+import { NodeButton } from "../../tiptap-ui/node-button";
+import { TextAlignButton } from "../../tiptap-ui/text-align-button";
+import { UndoRedoButton } from "../../tiptap-ui/undo-redo-button";
+
+// --- Icons ---
+import { ArrowLeftIcon, HighlighterIcon, LinkIcon } from "../../icons";
+
+// --- Hooks ---
+import { useIsMobile } from "@rectangular-labs/ui/hooks/use-is-mobile";
+import { useWindowSize } from "@rectangular-labs/ui/hooks/use-window-size";
+
+// --- Styles ---
+import "./simple-editor.scss";
+import { Separator } from "@rectangular-labs/ui/components/ui/separator";
+import { LoroCRDT } from "../../tiptap-extension/loro-extension";
+
+const MainToolbarContent = ({
+ onHighlighterClick,
+ onLinkClick,
+ isMobile,
+}: {
+ onHighlighterClick: () => void;
+ onLinkClick: () => void;
+ isMobile: boolean;
+}) => {
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* {isMobile ? (
+
+ ) : (
+
+ )} */}
+ {isMobile ? : }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+
+ */}
+
+
+
+ {isMobile && }
+
+
+
+
+ >
+ );
+};
+
+const MobileToolbarContent = ({
+ type,
+ onBack,
+}: {
+ type: "highlighter" | "link";
+ onBack: () => void;
+}) => (
+ <>
+
+
+
+
+
+
+
+ {/* {type === "highlighter" ? : } */}
+ >
+);
+
+export function SimpleEditor() {
+ const isMobile = useIsMobile();
+ const windowSize = useWindowSize();
+ const [mobileView, setMobileView] = React.useState<
+ "main" | "highlighter" | "link"
+ >("main");
+ const [rect, setRect] = React.useState<
+ Pick
+ >({
+ x: 0,
+ y: 0,
+ width: 0,
+ height: 0,
+ });
+ const toolbarRef = React.useRef(null);
+
+ React.useEffect(() => {
+ const updateRect = () => {
+ setRect(document.body.getBoundingClientRect());
+ };
+
+ updateRect();
+
+ const resizeObserver = new ResizeObserver(updateRect);
+ resizeObserver.observe(document.body);
+
+ window.addEventListener("scroll", updateRect);
+
+ return () => {
+ resizeObserver.disconnect();
+ window.removeEventListener("scroll", updateRect);
+ };
+ }, []);
+
+ const editor = useEditor({
+ immediatelyRender: false,
+ editorProps: {
+ attributes: {
+ autocomplete: "off",
+ autocorrect: "off",
+ autocapitalize: "off",
+ "aria-label": "Main content area, start typing to enter text.",
+ },
+ },
+ extensions: [
+ StarterKit,
+ TextAlign.configure({ types: ["heading", "paragraph"] }),
+ Underline,
+ TaskList,
+ TaskItem.configure({ nested: true }),
+ Highlight.configure({ multicolor: true }),
+ Image,
+ Typography,
+ Superscript,
+ Subscript,
+
+ Selection,
+ // ImageUploadNode.configure({
+ // accept: "image/*",
+ // maxSize: MAX_FILE_SIZE,
+ // limit: 3,
+ // upload: handleImageUpload,
+ // onError: (error) => console.error("Upload failed:", error),
+ // }),
+ TrailingNode,
+ Link.configure({ openOnClick: false }),
+ LoroCRDT,
+ ],
+ // content: content,
+ });
+
+ React.useEffect(() => {
+ const checkCursorVisibility = () => {
+ if (!editor || !toolbarRef.current) return;
+
+ const { state, view } = editor;
+ if (!view.hasFocus()) return;
+
+ const { from } = state.selection;
+ const cursorCoords = view.coordsAtPos(from);
+
+ if (windowSize.height < rect.height) {
+ if (cursorCoords && toolbarRef.current) {
+ const toolbarHeight =
+ toolbarRef.current.getBoundingClientRect().height;
+ const isEnoughSpace =
+ windowSize.height - cursorCoords.top - toolbarHeight > 0;
+
+ // If not enough space, scroll until the cursor is the middle of the screen
+ if (!isEnoughSpace) {
+ const scrollY =
+ cursorCoords.top - windowSize.height / 2 + toolbarHeight;
+ window.scrollTo({
+ top: scrollY,
+ behavior: "smooth",
+ });
+ }
+ }
+ }
+ };
+
+ checkCursorVisibility();
+ }, [editor, rect.height, windowSize.height]);
+
+ React.useEffect(() => {
+ if (!isMobile && mobileView !== "main") {
+ setMobileView("main");
+ }
+ }, [isMobile, mobileView]);
+
+ return (
+
+
+
+ {mobileView === "main" ? (
+ setMobileView("highlighter")}
+ onLinkClick={() => setMobileView("link")}
+ isMobile={isMobile}
+ />
+ ) : (
+ setMobileView("main")}
+ />
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/editor/src/components/tiptap-ui-primitive/toolbar/index.tsx b/packages/editor/src/components/tiptap-ui-primitive/toolbar/index.tsx
new file mode 100644
index 000000000..cd2701dba
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui-primitive/toolbar/index.tsx
@@ -0,0 +1 @@
+export * from "./toolbar";
diff --git a/packages/editor/src/components/tiptap-ui-primitive/toolbar/toolbar.scss b/packages/editor/src/components/tiptap-ui-primitive/toolbar/toolbar.scss
new file mode 100644
index 000000000..897be5733
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui-primitive/toolbar/toolbar.scss
@@ -0,0 +1,96 @@
+:root {
+ --tt-toolbar-height: 2.75rem;
+ --tt-safe-area-bottom: env(safe-area-inset-bottom, 0px);
+ --tt-toolbar-bg-color: var(--white);
+ --tt-toolbar-border-color: var(--tt-gray-light-a-100);
+}
+
+.dark {
+ --tt-toolbar-bg-color: var(--black);
+ --tt-toolbar-border-color: var(--tt-gray-dark-a-50);
+}
+
+.tiptap-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+
+ &-group {
+ display: flex;
+ align-items: center;
+ gap: 0.125rem;
+
+ &:empty {
+ display: none;
+ }
+
+ &:empty+.tiptap-separator,
+ .tiptap-separator+&:empty {
+ display: none;
+ }
+ }
+
+ &[data-variant="fixed"] {
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ width: 100%;
+ min-height: var(--tt-toolbar-height);
+ background: var(--tt-toolbar-bg-color);
+ border-bottom: 1px solid var(--tt-toolbar-border-color);
+ padding: 0 0.5rem;
+ overflow-x: auto;
+ overscroll-behavior-x: contain;
+ -webkit-overflow-scrolling: touch;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+
+ &::-webkit-scrollbar {
+ display: none;
+ }
+
+ @media (max-width: 480px) {
+ position: fixed;
+ top: auto;
+ bottom: 0;
+ height: calc(var(--tt-toolbar-height) + var(--tt-safe-area-bottom));
+ border-top: 1px solid var(--tt-toolbar-border-color);
+ border-bottom: none;
+ padding: 0 0.5rem var(--tt-safe-area-bottom);
+ flex-wrap: nowrap;
+ justify-content: flex-start;
+
+ .tiptap-toolbar-group {
+ flex: 0 0 auto;
+ }
+ }
+ }
+
+ &[data-variant="floating"] {
+ --tt-toolbar-padding: 0.25rem;
+ --tt-toolbar-border-width: 1px;
+
+ padding: 0.188rem;
+ border-radius: calc(var(--tt-toolbar-padding) + var(--tt-radius-lg) + var(--tt-toolbar-border-width));
+ border: var(--tt-toolbar-border-width) solid var(--tt-toolbar-border-color);
+ background-color: var(--tt-toolbar-bg-color);
+ box-shadow: var(--tt-shadow-elevated-md);
+ outline: none;
+ overflow: visible;
+
+ &[data-plain="true"] {
+ padding: 0;
+ border-radius: 0;
+ border: none;
+ box-shadow: none;
+ background-color: transparent;
+ }
+
+ @media screen and (max-width: 768px) {
+ width: 100%;
+ border-radius: 0;
+ border: none;
+ box-shadow: none;
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/editor/src/components/tiptap-ui-primitive/toolbar/toolbar.tsx b/packages/editor/src/components/tiptap-ui-primitive/toolbar/toolbar.tsx
new file mode 100644
index 000000000..3a25b6b52
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui-primitive/toolbar/toolbar.tsx
@@ -0,0 +1,118 @@
+import { cn } from "@rectangular-labs/ui/utils/cn";
+import * as React from "react";
+
+type BaseProps = React.HTMLAttributes;
+
+interface ToolbarProps extends BaseProps {
+ variant?: "floating" | "fixed";
+}
+
+const mergeRefs = (
+ refs: Array | React.Ref | null | undefined>,
+): React.RefCallback => {
+ return (value) => {
+ for (const ref of refs) {
+ if (typeof ref === "function") {
+ ref(value);
+ } else if (ref != null) {
+ (ref as React.MutableRefObject).current = value;
+ }
+ }
+ };
+};
+
+const useToolbarKeyboardNav = (
+ toolbarRef: React.RefObject,
+): void => {
+ React.useEffect(() => {
+ const toolbar = toolbarRef.current;
+ if (!toolbar) return;
+
+ const getFocusableElements = () =>
+ Array.from(
+ toolbar.querySelectorAll(
+ 'button:not([disabled]), [role="button"]:not([disabled]), [tabindex="0"]:not([disabled])',
+ ),
+ );
+
+ const navigateToIndex = (
+ e: KeyboardEvent,
+ targetIndex: number,
+ elements: HTMLElement[],
+ ) => {
+ e.preventDefault();
+ let nextIndex = targetIndex;
+
+ if (nextIndex >= elements.length) {
+ nextIndex = 0;
+ } else if (nextIndex < 0) {
+ nextIndex = elements.length - 1;
+ }
+
+ elements[nextIndex]?.focus();
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ const focusableElements = getFocusableElements();
+ if (!focusableElements.length) return;
+
+ const currentElement = document.activeElement as HTMLElement;
+ const currentIndex = focusableElements.indexOf(currentElement);
+
+ if (!toolbar.contains(currentElement)) return;
+
+ const keyActions: Record void> = {
+ ArrowRight: () =>
+ navigateToIndex(e, currentIndex + 1, focusableElements),
+ ArrowDown: () =>
+ navigateToIndex(e, currentIndex + 1, focusableElements),
+ ArrowLeft: () =>
+ navigateToIndex(e, currentIndex - 1, focusableElements),
+ ArrowUp: () => navigateToIndex(e, currentIndex - 1, focusableElements),
+ Home: () => navigateToIndex(e, 0, focusableElements),
+ End: () =>
+ navigateToIndex(e, focusableElements.length - 1, focusableElements),
+ };
+
+ const action = keyActions[e.key];
+ if (action) {
+ action();
+ }
+ };
+
+ toolbar.addEventListener("keydown", handleKeyDown);
+ return () => toolbar.removeEventListener("keydown", handleKeyDown);
+ }, [toolbarRef]);
+};
+
+export const Toolbar = React.forwardRef(
+ ({ children, className, variant = "floating", ...props }, ref) => {
+ const toolbarRef = React.useRef(null);
+
+ useToolbarKeyboardNav(toolbarRef);
+
+ return (
+
+ {children}
+
+ );
+ },
+);
+
+Toolbar.displayName = "Toolbar";
diff --git a/packages/editor/src/components/tiptap-ui/heading/heading-button.tsx b/packages/editor/src/components/tiptap-ui/heading/heading-button.tsx
new file mode 100644
index 000000000..724360c27
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/heading/heading-button.tsx
@@ -0,0 +1,176 @@
+import {
+ ShortcutDisplay,
+ type ShortcutKeys,
+} from "@rectangular-labs/ui/components/ui/shortcut";
+import * as React from "react";
+import {
+ HeadingFiveIcon,
+ HeadingFourIcon,
+ HeadingOneIcon,
+ HeadingSixIcon,
+ HeadingThreeIcon,
+ HeadingTwoIcon,
+} from "../../icons"; // Adjusted path
+
+// --- Hooks ---
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor"; // Adjusted path
+
+import {
+ Toggle,
+ type ToggleProps,
+} from "@rectangular-labs/ui/components/ui/toggle";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@rectangular-labs/ui/components/ui/tooltip";
+// --- Lib ---
+import { isNodeInSchema } from "../../../tiptap-utils"; // Corrected path
+
+export type Level = 1 | 2 | 3 | 4 | 5 | 6;
+
+// --- Constants ---
+export const HeadingOptions: Record<
+ Level,
+ {
+ icon: React.ElementType;
+ shortcutKey: ShortcutKeys;
+ label: string;
+ }
+> = {
+ 1: {
+ icon: HeadingOneIcon,
+ shortcutKey: "ctrl-alt-1",
+ label: "Heading 1",
+ },
+ 2: {
+ icon: HeadingTwoIcon,
+ shortcutKey: "ctrl-alt-2",
+ label: "Heading 2",
+ },
+ 3: {
+ icon: HeadingThreeIcon,
+ shortcutKey: "ctrl-alt-3",
+ label: "Heading 3",
+ },
+ 4: {
+ icon: HeadingFourIcon,
+ shortcutKey: "ctrl-alt-4",
+ label: "Heading 4",
+ },
+ 5: {
+ icon: HeadingFiveIcon,
+ shortcutKey: "ctrl-alt-5",
+ label: "Heading 5",
+ },
+ 6: {
+ icon: HeadingSixIcon,
+ shortcutKey: "ctrl-alt-6",
+ label: "Heading 6",
+ },
+};
+
+export function useHeading(level: Level, manuallyDisabled = false) {
+ const editor = useTiptapEditor();
+
+ const isDisabled = React.useMemo(() => {
+ const isHeadingInSchema = isNodeInSchema("heading", editor);
+ if (!isHeadingInSchema) {
+ console.warn(
+ `Heading ${level} node is not available in the editor schema. Make sure it is included in your editor configuration.`,
+ );
+ }
+ if (
+ !editor ||
+ !isHeadingInSchema ||
+ manuallyDisabled ||
+ editor.isActive("codeBlock")
+ ) {
+ return true;
+ }
+
+ try {
+ return !editor.can().toggleNode("heading", "paragraph", { level });
+ } catch (e) {
+ console.error("Error checking heading toggle", e);
+ return true;
+ }
+ }, [editor, level, manuallyDisabled]);
+
+ const isActive = React.useMemo(() => {
+ if (!editor) return false;
+ return editor.isActive("heading", { level });
+ }, [editor, level]);
+
+ const handleToggle = React.useCallback(() => {
+ if (!editor || isDisabled) return;
+ if (editor.isActive("heading", { level })) {
+ return editor.chain().focus().setNode("paragraph").run();
+ }
+ return editor
+ .chain()
+ .focus()
+ .toggleNode("heading", "paragraph", { level })
+ .run();
+ }, [editor, level, isDisabled]);
+
+ const displayOptions = HeadingOptions[level];
+
+ return {
+ isDisabled,
+ isActive,
+ handleToggle,
+ displayOptions,
+ };
+}
+
+// --- Component Props ---
+export interface HeadingButtonProps
+ extends Omit {
+ /**
+ * The heading level.
+ */
+ level: Level;
+ /**
+ * Whether to display the text label next to the icon.
+ * @default false
+ */
+ showText?: boolean;
+}
+
+export const HeadingButton = React.forwardRef<
+ HTMLButtonElement,
+ HeadingButtonProps
+>(({ level, showText = false, disabled, ...buttonProps }, ref) => {
+ const { isDisabled, isActive, handleToggle, displayOptions } = useHeading(
+ level,
+ disabled,
+ );
+
+ return (
+
+
+
+
+ {showText && {displayOptions.label}}
+
+
+
+ {displayOptions.label}
+
+
+
+ );
+});
+
+HeadingButton.displayName = "HeadingButton";
+
+export default HeadingButton;
diff --git a/packages/editor/src/components/tiptap-ui/heading/heading-dropdown-menu.tsx b/packages/editor/src/components/tiptap-ui/heading/heading-dropdown-menu.tsx
new file mode 100644
index 000000000..132526054
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/heading/heading-dropdown-menu.tsx
@@ -0,0 +1,76 @@
+import {
+ Button,
+ type ButtonProps,
+} from "@rectangular-labs/ui/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@rectangular-labs/ui/components/ui/dropdown-menu";
+import * as React from "react";
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor";
+import { ChevronDownIcon, HeadingIcon } from "../../icons";
+import HeadingButton, { HeadingOptions, type Level } from "./heading-button";
+
+interface HeadingDropdownMenuProps extends Omit {
+ /**
+ * The levels to display in the dropdown.
+ * @default [1, 2, 3, 4, 5, 6]
+ */
+ levels?: Level[];
+}
+
+export function HeadingDropdownMenu({
+ levels = [1, 2, 3, 4, 5, 6],
+ ...props
+}: HeadingDropdownMenuProps) {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const editor = useTiptapEditor();
+
+ const currentDisplayOption = React.useMemo(() => {
+ for (const level of levels) {
+ if (editor?.isActive("heading", { level })) {
+ return { ...HeadingOptions[level], isActive: true };
+ }
+ }
+ return {
+ icon: HeadingIcon,
+ label: "Heading",
+ isActive: false,
+ };
+ }, [editor, levels]);
+
+ return (
+
+
+
+
+
+
+
+ {levels.map((level) => (
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/packages/editor/src/components/tiptap-ui/highlight-popover/highlight-popover.scss b/packages/editor/src/components/tiptap-ui/highlight-popover/highlight-popover.scss
new file mode 100644
index 000000000..bd94961d1
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/highlight-popover/highlight-popover.scss
@@ -0,0 +1,72 @@
+:root {
+ --tt-highlight-green: #dcfce7;
+ --tt-highlight-blue: #e0f2fe;
+ --tt-highlight-red: #ffe4e6;
+ --tt-highlight-purple: #f3e8ff;
+ --tt-highlight-yellow: #fef9c3;
+}
+
+.dark {
+ --tt-highlight-green: #509568;
+ --tt-highlight-blue: #6e92aa;
+ --tt-highlight-red: #743e42;
+ --tt-highlight-purple: #583e74;
+ --tt-highlight-yellow: #6b6524;
+}
+
+.tiptap-highlight-content {
+ display: flex;
+ align-items: center;
+ gap: 0.25rem;
+ outline: none;
+}
+
+.tiptap-button-highlight {
+ position: relative;
+ width: 1.25rem;
+ height: 1.25rem;
+ margin: 0 -0.175rem;
+ border-radius: 100%;
+ background-color: var(--highlight-color);
+ transition: transform 0.2s ease;
+
+ &::after {
+ content: "";
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ left: 0;
+ top: 0;
+ border-radius: inherit;
+ box-sizing: border-box;
+ border: 1px solid var(--highlight-color);
+ filter: brightness(95%);
+ mix-blend-mode: multiply;
+
+ .dark & {
+ filter: brightness(140%);
+ mix-blend-mode: lighten;
+ }
+ }
+}
+
+.tiptap-button {
+ &[data-active-state="on"] {
+ .tiptap-button-highlight {
+ &::after {
+ filter: brightness(80%);
+ }
+ }
+ }
+
+ .dark & {
+ &[data-active-state="on"] {
+ .tiptap-button-highlight {
+ &::after {
+ // Andere Eigenschaft für .dark Kontext
+ filter: brightness(180%);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/editor/src/components/tiptap-ui/highlight-popover/highlight-popover.tsx b/packages/editor/src/components/tiptap-ui/highlight-popover/highlight-popover.tsx
new file mode 100644
index 000000000..16c9be584
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/highlight-popover/highlight-popover.tsx
@@ -0,0 +1,283 @@
+import { type Editor, isNodeSelection } from "@tiptap/react";
+import * as React from "react";
+// --- Icons ---
+import { BanIcon } from "@/components/tiptap-icons/ban-icon";
+import { HighlighterIcon } from "@/components/tiptap-icons/highlighter-icon";
+// --- UI Primitives ---
+import {
+ Button,
+ type ButtonProps,
+} from "@/components/tiptap-ui-primitive/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/tiptap-ui-primitive/popover";
+import { Separator } from "@/components/tiptap-ui-primitive/separator";
+// --- Hooks ---
+import { useMenuNavigation } from "@/hooks/use-menu-navigation";
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor";
+// --- Lib ---
+import { isMarkInSchema } from "@/lib/tiptap-utils";
+
+// --- Styles ---
+import "@/components/tiptap-ui/highlight-popover/highlight-popover.scss";
+
+export interface HighlightColor {
+ label: string;
+ value: string;
+ border?: string;
+}
+
+export interface HighlightContentProps {
+ editor?: Editor | null;
+ colors?: HighlightColor[];
+ activeNode?: number;
+}
+
+export const DEFAULT_HIGHLIGHT_COLORS: HighlightColor[] = [
+ {
+ label: "Green",
+ value: "var(--tt-highlight-green)",
+ border: "var(--tt-highlight-green-contrast)",
+ },
+ {
+ label: "Blue",
+ value: "var(--tt-highlight-blue)",
+ border: "var(--tt-highlight-blue-contrast)",
+ },
+ {
+ label: "Red",
+ value: "var(--tt-highlight-red)",
+ border: "var(--tt-highlight-red-contrast)",
+ },
+ {
+ label: "Purple",
+ value: "var(--tt-highlight-purple)",
+ border: "var(--tt-highlight-purple-contrast)",
+ },
+ {
+ label: "Yellow",
+ value: "var(--tt-highlight-yellow)",
+ border: "var(--tt-highlight-yellow-contrast)",
+ },
+];
+
+export const useHighlighter = (editor: Editor | null) => {
+ const markAvailable = isMarkInSchema("highlight", editor);
+
+ const getActiveColor = React.useCallback(() => {
+ if (!editor) return null;
+ if (!editor.isActive("highlight")) return null;
+ const attrs = editor.getAttributes("highlight");
+ return attrs.color || null;
+ }, [editor]);
+
+ const toggleHighlight = React.useCallback(
+ (color: string) => {
+ if (!markAvailable || !editor) return;
+ if (color === "none") {
+ editor.chain().focus().unsetMark("highlight").run();
+ } else {
+ editor.chain().focus().toggleMark("highlight", { color }).run();
+ }
+ },
+ [markAvailable, editor],
+ );
+
+ return {
+ markAvailable,
+ getActiveColor,
+ toggleHighlight,
+ };
+};
+
+export const HighlighterButton = React.forwardRef<
+ HTMLButtonElement,
+ ButtonProps
+>(({ className, children, ...props }, ref) => {
+ return (
+
+ );
+});
+
+export function HighlightContent({
+ editor: providedEditor,
+ colors = DEFAULT_HIGHLIGHT_COLORS,
+ onClose,
+}: {
+ editor?: Editor | null;
+ colors?: HighlightColor[];
+ onClose?: () => void;
+}) {
+ const editor = useTiptapEditor(providedEditor);
+
+ const containerRef = React.useRef(null);
+
+ const { getActiveColor, toggleHighlight } = useHighlighter(editor);
+ const activeColor = getActiveColor();
+
+ const menuItems = React.useMemo(
+ () => [...colors, { label: "Remove highlight", value: "none" }],
+ [colors],
+ );
+
+ const { selectedIndex } = useMenuNavigation({
+ containerRef,
+ items: menuItems,
+ orientation: "both",
+ onSelect: (item) => {
+ toggleHighlight(item.value);
+ onClose?.();
+ },
+ onClose,
+ autoSelectFirstItem: false,
+ });
+
+ return (
+
+
+ {colors.map((color, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+}
+
+export interface HighlightPopoverProps extends Omit {
+ /**
+ * The TipTap editor instance.
+ */
+ editor?: Editor | null;
+ /**
+ * The highlight colors to display in the popover.
+ */
+ colors?: HighlightColor[];
+ /**
+ * Whether to hide the highlight popover.
+ */
+ hideWhenUnavailable?: boolean;
+}
+
+export function HighlightPopover({
+ editor: providedEditor,
+ colors = DEFAULT_HIGHLIGHT_COLORS,
+ hideWhenUnavailable = false,
+ ...props
+}: HighlightPopoverProps) {
+ const editor = useTiptapEditor(providedEditor);
+
+ const { markAvailable } = useHighlighter(editor);
+ const [isOpen, setIsOpen] = React.useState(false);
+
+ const isDisabled = React.useMemo(() => {
+ if (!markAvailable || !editor) {
+ return true;
+ }
+
+ return (
+ editor.isActive("code") ||
+ editor.isActive("codeBlock") ||
+ editor.isActive("imageUpload")
+ );
+ }, [markAvailable, editor]);
+
+ const canSetMark = React.useMemo(() => {
+ if (!editor || !markAvailable) return false;
+
+ try {
+ return editor.can().setMark("highlight");
+ } catch {
+ return false;
+ }
+ }, [editor, markAvailable]);
+
+ const isActive = editor?.isActive("highlight") ?? false;
+
+ const show = React.useMemo(() => {
+ if (hideWhenUnavailable) {
+ if (isNodeSelection(editor?.state.selection) || !canSetMark) {
+ return false;
+ }
+ }
+
+ return true;
+ }, [hideWhenUnavailable, editor, canSetMark]);
+
+ if (!show || !editor || !editor.isEditable) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ setIsOpen(false)}
+ />
+
+
+ );
+}
+
+HighlighterButton.displayName = "HighlighterButton";
+
+export default HighlightPopover;
diff --git a/packages/editor/src/components/tiptap-ui/highlight-popover/index.tsx b/packages/editor/src/components/tiptap-ui/highlight-popover/index.tsx
new file mode 100644
index 000000000..1583a5dee
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/highlight-popover/index.tsx
@@ -0,0 +1 @@
+export * from "./highlight-popover";
diff --git a/packages/editor/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx b/packages/editor/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx
new file mode 100644
index 000000000..bf01bac71
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/image-upload-button/image-upload-button.tsx
@@ -0,0 +1,125 @@
+import type { Editor } from "@tiptap/react";
+import * as React from "react";
+// --- Icons ---
+import { ImagePlusIcon } from "@/components/tiptap-icons/image-plus-icon";
+// --- UI Primitives ---
+import {
+ Button,
+ type ButtonProps,
+} from "@/components/tiptap-ui-primitive/button";
+// --- Hooks ---
+import { useTiptapEditor } from "@/hooks/use-tiptap-editor";
+
+export interface ImageUploadButtonProps extends ButtonProps {
+ editor?: Editor | null;
+ text?: string;
+ extensionName?: string;
+}
+
+export function isImageActive(
+ editor: Editor | null,
+ extensionName: string,
+): boolean {
+ if (!editor) return false;
+ return editor.isActive(extensionName);
+}
+
+export function insertImage(
+ editor: Editor | null,
+ extensionName: string,
+): boolean {
+ if (!editor) return false;
+
+ return editor
+ .chain()
+ .focus()
+ .insertContent({
+ type: extensionName,
+ })
+ .run();
+}
+
+export function useImageUploadButton(
+ editor: Editor | null,
+ extensionName = "imageUpload",
+ disabled = false,
+) {
+ const isActive = isImageActive(editor, extensionName);
+ const handleInsertImage = React.useCallback(() => {
+ if (disabled) return false;
+ return insertImage(editor, extensionName);
+ }, [editor, extensionName, disabled]);
+
+ return {
+ isActive,
+ handleInsertImage,
+ };
+}
+
+export const ImageUploadButton = React.forwardRef<
+ HTMLButtonElement,
+ ImageUploadButtonProps
+>(
+ (
+ {
+ editor: providedEditor,
+ extensionName = "imageUpload",
+ text,
+ className = "",
+ disabled,
+ onClick,
+ children,
+ ...buttonProps
+ },
+ ref,
+ ) => {
+ const editor = useTiptapEditor(providedEditor);
+ const { isActive, handleInsertImage } = useImageUploadButton(
+ editor,
+ extensionName,
+ disabled,
+ );
+
+ const handleClick = React.useCallback(
+ (e: React.MouseEvent) => {
+ onClick?.(e);
+
+ if (!e.defaultPrevented && !disabled) {
+ handleInsertImage();
+ }
+ },
+ [onClick, disabled, handleInsertImage],
+ );
+
+ if (!editor || !editor.isEditable) {
+ return null;
+ }
+
+ return (
+
+ );
+ },
+);
+
+ImageUploadButton.displayName = "ImageUploadButton";
+
+export default ImageUploadButton;
diff --git a/packages/editor/src/components/tiptap-ui/image-upload-button/index.tsx b/packages/editor/src/components/tiptap-ui/image-upload-button/index.tsx
new file mode 100644
index 000000000..0a65ee9ca
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/image-upload-button/index.tsx
@@ -0,0 +1 @@
+export * from "./image-upload-button";
diff --git a/packages/editor/src/components/tiptap-ui/link-popover/index.tsx b/packages/editor/src/components/tiptap-ui/link-popover/index.tsx
new file mode 100644
index 000000000..7113a42ee
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/link-popover/index.tsx
@@ -0,0 +1 @@
+export * from "./link-popover";
diff --git a/packages/editor/src/components/tiptap-ui/link-popover/link-popover.scss b/packages/editor/src/components/tiptap-ui/link-popover/link-popover.scss
new file mode 100644
index 000000000..eb894ecda
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/link-popover/link-popover.scss
@@ -0,0 +1,27 @@
+.tiptap-input {
+ display: block;
+ width: 100%;
+ height: 2rem;
+ font-size: 1rem;
+ line-height: 1.5rem;
+ padding: 0.375rem 0.75rem;
+ border-radius: 0.375rem;
+ background: none;
+
+ &:focus {
+ outline: none;
+ }
+
+ &-clamp {
+ min-width: 12rem;
+ padding-right: 0;
+
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:focus {
+ text-overflow: clip;
+ overflow: visible;
+ }
+ }
+}
diff --git a/packages/editor/src/components/tiptap-ui/link-popover/link-popover.tsx b/packages/editor/src/components/tiptap-ui/link-popover/link-popover.tsx
new file mode 100644
index 000000000..109eca328
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/link-popover/link-popover.tsx
@@ -0,0 +1,311 @@
+import { type Editor, isNodeSelection } from "@tiptap/react";
+import * as React from "react";
+
+import "./link-popover.scss";
+import {
+ Button,
+ type ButtonProps,
+} from "@rectangular-labs/ui/components/ui/button";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@rectangular-labs/ui/components/ui/popover";
+import { Separator } from "@rectangular-labs/ui/components/ui/separator";
+import {
+ CornerDownLeftIcon,
+ ExternalLinkIcon,
+ LinkIcon,
+ TrashIcon,
+} from "lucide-react";
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor";
+import { isMarkInSchema } from "../../../tiptap-utils";
+
+const useLinkHandler = (props: {
+ editor: Editor | null;
+ onSetLink?: () => void;
+ onLinkActive?: () => void;
+}) => {
+ const { editor, onSetLink, onLinkActive } = props;
+ const [url, setUrl] = React.useState("");
+
+ React.useEffect(() => {
+ if (!editor) return;
+
+ // Get URL immediately on mount
+ const { href } = editor.getAttributes("link");
+
+ if (editor.isActive("link") && !url) {
+ setUrl(href || "");
+ onLinkActive?.();
+ }
+ }, [editor, onLinkActive, url]);
+
+ React.useEffect(() => {
+ if (!editor) return;
+
+ const updateLinkState = () => {
+ const { href } = editor.getAttributes("link");
+ setUrl(href || "");
+
+ if (editor.isActive("link") && !url) {
+ onLinkActive?.();
+ }
+ };
+
+ editor.on("selectionUpdate", updateLinkState);
+ return () => {
+ editor.off("selectionUpdate", updateLinkState);
+ };
+ }, [editor, onLinkActive, url]);
+
+ const setLink = React.useCallback(() => {
+ if (!url || !editor) return;
+
+ const { from, to } = editor.state.selection;
+ const text = editor.state.doc.textBetween(from, to);
+
+ editor
+ .chain()
+ .focus()
+ .extendMarkRange("link")
+ .insertContent({
+ type: "text",
+ text: text || url,
+ marks: [{ type: "link", attrs: { href: url } }],
+ })
+ .run();
+
+ onSetLink?.();
+ }, [editor, onSetLink, url]);
+
+ const removeLink = React.useCallback(() => {
+ if (!editor) return;
+ editor
+ .chain()
+ .focus()
+ .unsetMark("link", { extendEmptyMarkRange: true })
+ .setMeta("preventAutolink", true)
+ .run();
+ setUrl("");
+ }, [editor]);
+
+ return {
+ url,
+ setUrl,
+ setLink,
+ removeLink,
+ isActive: editor?.isActive("link") || false,
+ };
+};
+
+export const LinkButton = React.forwardRef(
+ ({ className, children, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+
+export function LinkContent() {
+ const editor = useTiptapEditor();
+
+ const linkHandler = useLinkHandler({
+ editor: editor,
+ });
+
+ return ;
+}
+
+interface LinkMainProps {
+ url: string;
+ setUrl: React.Dispatch>;
+ setLink: () => void;
+ removeLink: () => void;
+ isActive: boolean;
+}
+
+const LinkMain: React.FC = ({
+ url,
+ setUrl,
+ setLink,
+ removeLink,
+ isActive,
+}) => {
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ setLink();
+ }
+ };
+
+ return (
+ <>
+ setUrl(e.target.value)}
+ onKeyDown={handleKeyDown}
+ autoComplete="off"
+ autoCorrect="off"
+ autoCapitalize="off"
+ className="tiptap-input tiptap-input-clamp"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+};
+
+export interface LinkPopoverProps extends Omit {
+ /**
+ * The TipTap editor instance.
+ */
+ editor?: Editor | null;
+ /**
+ * Whether to hide the link popover.
+ * @default false
+ */
+ hideWhenUnavailable?: boolean;
+ /**
+ * Callback for when the popover opens or closes.
+ */
+ onOpenChange?: (isOpen: boolean) => void;
+ /**
+ * Whether to automatically open the popover when a link is active.
+ * @default true
+ */
+ autoOpenOnLinkActive?: boolean;
+}
+
+export function LinkPopover({
+ editor: providedEditor,
+ hideWhenUnavailable = false,
+ onOpenChange,
+ autoOpenOnLinkActive = true,
+ ...props
+}: LinkPopoverProps) {
+ const editor = useTiptapEditor(providedEditor);
+
+ const linkInSchema = isMarkInSchema("link", editor);
+
+ const [isOpen, setIsOpen] = React.useState(false);
+
+ const onSetLink = () => {
+ setIsOpen(false);
+ };
+
+ const onLinkActive = () => setIsOpen(autoOpenOnLinkActive);
+
+ const linkHandler = useLinkHandler({
+ editor: editor,
+ onSetLink,
+ onLinkActive,
+ });
+
+ const isDisabled = React.useMemo(() => {
+ if (!editor) return true;
+ if (editor.isActive("codeBlock")) return true;
+ return !editor.can().setLink?.({ href: "" });
+ }, [editor]);
+
+ const canSetLink = React.useMemo(() => {
+ if (!editor) return false;
+ try {
+ return editor.can().setMark("link");
+ } catch {
+ return false;
+ }
+ }, [editor]);
+
+ const isActive = editor?.isActive("link") ?? false;
+
+ const handleOnOpenChange = React.useCallback(
+ (nextIsOpen: boolean) => {
+ setIsOpen(nextIsOpen);
+ onOpenChange?.(nextIsOpen);
+ },
+ [onOpenChange],
+ );
+
+ const show = React.useMemo(() => {
+ if (!linkInSchema) {
+ return false;
+ }
+
+ if (hideWhenUnavailable) {
+ if (isNodeSelection(editor?.state.selection) || !canSetLink) {
+ return false;
+ }
+ }
+
+ return true;
+ }, [linkInSchema, hideWhenUnavailable, editor, canSetLink]);
+
+ if (!show || !editor || !editor.isEditable) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ );
+}
+
+LinkButton.displayName = "LinkButton";
diff --git a/packages/editor/src/components/tiptap-ui/list/list-button.tsx b/packages/editor/src/components/tiptap-ui/list/list-button.tsx
new file mode 100644
index 000000000..178310c7a
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/list/list-button.tsx
@@ -0,0 +1,154 @@
+"use client";
+import {
+ ShortcutDisplay,
+ type ShortcutKeys,
+} from "@rectangular-labs/ui/components/ui/shortcut";
+import {
+ Toggle,
+ type ToggleProps,
+} from "@rectangular-labs/ui/components/ui/toggle";
+import {
+ TooltipContent,
+ TooltipTrigger,
+} from "@rectangular-labs/ui/components/ui/tooltip";
+import { Tooltip } from "@rectangular-labs/ui/components/ui/tooltip";
+import * as React from "react";
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor";
+import { isNodeInSchema } from "../../../tiptap-utils";
+import { ListIcon, ListOrderedIcon, ListTodoIcon } from "../../icons";
+
+export type ListType = "bulletList" | "orderedList" | "taskList";
+
+export const ListOptions: Record<
+ ListType,
+ {
+ shortcutKey: ShortcutKeys;
+ label: string;
+ icon: React.ElementType;
+ }
+> = {
+ bulletList: {
+ shortcutKey: "ctrl-shift-7",
+ label: "Bullet List",
+ icon: ListIcon,
+ },
+ orderedList: {
+ shortcutKey: "ctrl-shift-8",
+ label: "Ordered List",
+ icon: ListOrderedIcon,
+ },
+ taskList: {
+ shortcutKey: "ctrl-shift-9",
+ label: "Task List",
+ icon: ListTodoIcon,
+ },
+};
+
+function useListState(type: ListType, manuallyDisabled = false) {
+ const editor = useTiptapEditor();
+
+ const isDisabled = React.useMemo(() => {
+ const listInSchema = isNodeInSchema(type, editor);
+
+ if (!listInSchema) {
+ console.warn(
+ `List type ${type} is not available. Make sure it is included in your editor configuration.`,
+ );
+ }
+ if (
+ !editor ||
+ !listInSchema ||
+ manuallyDisabled ||
+ editor.isActive("codeBlock")
+ )
+ return true;
+
+ try {
+ switch (type) {
+ case "bulletList":
+ return !editor.can().toggleBulletList();
+ case "orderedList":
+ return !editor.can().toggleOrderedList();
+ case "taskList":
+ return !editor.can().toggleList("taskList", "taskItem");
+ default:
+ return true;
+ }
+ } catch (error) {
+ console.error("Error checking mark toggle", error);
+ return true;
+ }
+ }, [editor, type, manuallyDisabled]);
+
+ const isActive = React.useMemo(() => {
+ if (!editor) return false;
+ return editor.isActive(type);
+ }, [editor, type]);
+
+ const toggleList = React.useCallback(() => {
+ if (!editor) return;
+ switch (type) {
+ case "bulletList":
+ editor.chain().focus().toggleBulletList().run();
+ break;
+ case "orderedList":
+ editor.chain().focus().toggleOrderedList().run();
+ break;
+ case "taskList":
+ editor.chain().focus().toggleList("taskList", "taskItem").run();
+ break;
+ }
+ }, [editor, type]);
+
+ const displayOptions = ListOptions[type];
+
+ return {
+ isDisabled,
+ isActive,
+ toggleList,
+ displayOptions,
+ };
+}
+
+interface ListToggleProps
+ extends Omit {
+ /**
+ * The type of list to toggle.
+ */
+ type: ListType;
+ showText?: boolean;
+}
+export const ListToggle = React.forwardRef(
+ ({ type, showText, ...buttonProps }, ref) => {
+ const { toggleList, isActive, displayOptions } = useListState(
+ type,
+ buttonProps.disabled,
+ );
+
+ return (
+
+
+
+
+ {showText && {displayOptions.label}}
+
+
+
+ {displayOptions.label}
+
+
+
+ );
+ },
+);
+
+ListToggle.displayName = "ListButton";
+
+export default ListToggle;
diff --git a/packages/editor/src/components/tiptap-ui/list/list-dropdown-menu.tsx b/packages/editor/src/components/tiptap-ui/list/list-dropdown-menu.tsx
new file mode 100644
index 000000000..072b33f5b
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/list/list-dropdown-menu.tsx
@@ -0,0 +1,75 @@
+import {
+ Button,
+ type ButtonProps,
+} from "@rectangular-labs/ui/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@rectangular-labs/ui/components/ui/dropdown-menu";
+import * as React from "react";
+import { useTiptapEditor } from "../../../hooks/use-tiptap-editor";
+import { ChevronDownIcon, ListIcon } from "../../icons";
+import ListToggle, { ListOptions, type ListType } from "./list-button";
+
+interface ListDropdownMenuProps extends Omit {
+ /**
+ * The list types to display in the dropdown.
+ */
+ types?: ListType[];
+}
+
+export function ListDropdownMenu({
+ types = ["bulletList", "orderedList", "taskList"],
+ ...props
+}: ListDropdownMenuProps) {
+ const editor = useTiptapEditor();
+ const [isOpen, setIsOpen] = React.useState(false);
+
+ const currentDisplayOption = React.useMemo(() => {
+ for (const listType of types) {
+ if (editor?.isActive(listType)) {
+ return { ...ListOptions[listType], isActive: true };
+ }
+ }
+ return {
+ icon: ListIcon,
+ label: "List",
+ isActive: false,
+ };
+ }, [editor, types]);
+
+ return (
+
+
+
+
+
+
+
+ {types.map((listType) => (
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/packages/editor/src/components/tiptap-ui/mark-button.tsx b/packages/editor/src/components/tiptap-ui/mark-button.tsx
new file mode 100644
index 000000000..e7f52b18e
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/mark-button.tsx
@@ -0,0 +1,171 @@
+import {
+ ShortcutDisplay,
+ type ShortcutKeys,
+} from "@rectangular-labs/ui/components/ui/shortcut";
+
+import {
+ Toggle,
+ type ToggleProps,
+} from "@rectangular-labs/ui/components/ui/toggle";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@rectangular-labs/ui/components/ui/tooltip";
+import * as React from "react";
+import { useTiptapEditor } from "../../hooks/use-tiptap-editor";
+import { isMarkInSchema } from "../../tiptap-utils";
+import {
+ BoldIcon,
+ Code2Icon,
+ ItalicIcon,
+ StrikeIcon,
+ SubscriptIcon,
+ SuperscriptIcon,
+ UnderlineIcon,
+} from "../icons";
+
+type Mark =
+ | "bold"
+ | "italic"
+ | "strike"
+ | "code"
+ | "underline"
+ | "superscript"
+ | "subscript";
+
+const markOptions: Record<
+ Mark,
+ {
+ icon: React.ElementType;
+ shortcutKey: ShortcutKeys;
+ label: string;
+ }
+> = {
+ bold: {
+ icon: BoldIcon,
+ shortcutKey: "ctrl-b",
+ label: "Bold",
+ },
+ italic: {
+ icon: ItalicIcon,
+ shortcutKey: "ctrl-i",
+ label: "Italic",
+ },
+ underline: {
+ icon: UnderlineIcon,
+ shortcutKey: "ctrl-u",
+ label: "Underline",
+ },
+ strike: {
+ icon: StrikeIcon,
+ shortcutKey: "ctrl-shift-s",
+ label: "Strike",
+ },
+ code: {
+ icon: Code2Icon,
+ shortcutKey: "ctrl-e",
+ label: "Code",
+ },
+ superscript: {
+ icon: SuperscriptIcon,
+ shortcutKey: "ctrl-.",
+ label: "Superscript",
+ },
+ subscript: {
+ icon: SubscriptIcon,
+ shortcutKey: "ctrl-,",
+ label: "Subscript",
+ },
+};
+
+export function useMark(type: Mark, manuallyDisabled = false) {
+ const editor = useTiptapEditor();
+
+ const isDisabled = (() => {
+ if (!editor) return true;
+ const markInSchema = isMarkInSchema(type, editor);
+
+ if (!markInSchema) {
+ console.warn(
+ `Mark type ${type} is not available. Make sure it is included in your editor configuration.`,
+ );
+ }
+ if (manuallyDisabled || !markInSchema || editor.isActive("codeBlock"))
+ return true;
+
+ try {
+ return !editor.can().toggleMark(type);
+ } catch (error) {
+ console.error("Error checking mark toggle", error);
+ return true;
+ }
+ })();
+
+ const isActive = (() => {
+ if (!editor) return false;
+ return editor.isActive(type);
+ })();
+
+ const handleToggleMark = React.useCallback(() => {
+ if (isDisabled || !editor) return false;
+ return editor.chain().focus().toggleMark(type).run();
+ }, [editor, type, isDisabled]);
+
+ const displayOptions = markOptions[type];
+
+ return {
+ isDisabled,
+ isActive,
+ handleToggleMark,
+ displayOptions,
+ };
+}
+
+interface MarkButtonProps
+ extends Omit {
+ /**
+ * The type of mark to toggle
+ */
+ type: Mark;
+ showText?: boolean;
+}
+
+export const MarkButton = React.forwardRef(
+ ({ type, showText, disabled, ...buttonProps }, ref) => {
+ const { isDisabled, isActive, handleToggleMark, displayOptions } = useMark(
+ type,
+ disabled,
+ );
+
+ return (
+
+
+
+
+
+ {showText && {displayOptions.label}}
+
+
+
+
+ {displayOptions.label}
+
+
+
+ );
+ },
+);
+
+MarkButton.displayName = "MarkButton";
+
+export default MarkButton;
diff --git a/packages/editor/src/components/tiptap-ui/node-button.tsx b/packages/editor/src/components/tiptap-ui/node-button.tsx
new file mode 100644
index 000000000..b51310c62
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/node-button.tsx
@@ -0,0 +1,130 @@
+import {
+ ShortcutDisplay,
+ type ShortcutKeys,
+} from "@rectangular-labs/ui/components/ui/shortcut";
+import {
+ Toggle,
+ type ToggleProps,
+} from "@rectangular-labs/ui/components/ui/toggle";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@rectangular-labs/ui/components/ui/tooltip";
+import * as React from "react";
+import { useTiptapEditor } from "../../hooks/use-tiptap-editor";
+import { isNodeInSchema } from "../../tiptap-utils";
+import { BlockQuoteIcon, CodeBlockIcon } from "../icons";
+
+type NodeType = "codeBlock" | "blockquote";
+
+const nodeOptions: Record<
+ NodeType,
+ {
+ icon: React.ElementType;
+ shortcutKey: ShortcutKeys;
+ label: string;
+ }
+> = {
+ codeBlock: {
+ icon: CodeBlockIcon,
+ shortcutKey: "ctrl-alt-c",
+ label: "Code Block",
+ },
+ blockquote: {
+ icon: BlockQuoteIcon,
+ shortcutKey: "ctrl-shift-b",
+ label: "Blockquote",
+ },
+};
+
+function useNode(type: NodeType, manuallyDisabled = false) {
+ const editor = useTiptapEditor();
+
+ const isDisabled = React.useMemo(() => {
+ const nodeInSchema = isNodeInSchema(type, editor);
+
+ if (!nodeInSchema) {
+ console.warn(
+ `Node type ${type} is not available. Make sure it is included in your editor configuration.`,
+ );
+ }
+ if (!editor || manuallyDisabled || !nodeInSchema) return true;
+
+ try {
+ return type === "codeBlock"
+ ? !editor.can().toggleNode("codeBlock", "paragraph")
+ : !editor.can().toggleWrap("blockquote");
+ } catch {
+ return true;
+ }
+ }, [editor, type, manuallyDisabled]);
+
+ const isActive = React.useMemo(() => {
+ if (!editor) return false;
+ return editor.isActive(type);
+ }, [editor, type]);
+
+ const handleToggle = React.useCallback(() => {
+ if (!editor || isDisabled) return false;
+
+ if (type === "codeBlock") {
+ return editor.chain().focus().toggleNode("codeBlock", "paragraph").run();
+ }
+ return editor.chain().focus().toggleWrap("blockquote").run();
+ }, [editor, type, isDisabled]);
+
+ const displayOptions = nodeOptions[type];
+
+ return {
+ isDisabled,
+ isActive,
+ handleToggle,
+ displayOptions,
+ };
+}
+
+interface NodeButtonProps
+ extends Omit {
+ /**
+ * The type of node to toggle.
+ */
+ type: NodeType;
+ showText?: boolean;
+}
+export const NodeButton = React.forwardRef(
+ ({ type, disabled, showText, ...buttonProps }, ref) => {
+ const { isDisabled, isActive, handleToggle, displayOptions } = useNode(
+ type,
+ disabled,
+ );
+
+ return (
+
+
+
+
+ {showText && {displayOptions.label}}
+
+
+
+ {displayOptions.label}
+
+
+
+ );
+ },
+);
+
+NodeButton.displayName = "NodeButton";
+
+export default NodeButton;
diff --git a/packages/editor/src/components/tiptap-ui/text-align-button.tsx b/packages/editor/src/components/tiptap-ui/text-align-button.tsx
new file mode 100644
index 000000000..2922617d2
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/text-align-button.tsx
@@ -0,0 +1,159 @@
+import {
+ ShortcutDisplay,
+ type ShortcutKeys,
+} from "@rectangular-labs/ui/components/ui/shortcut";
+import * as React from "react";
+
+import {
+ Toggle,
+ type ToggleProps,
+} from "@rectangular-labs/ui/components/ui/toggle";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@rectangular-labs/ui/components/ui/tooltip";
+import type { Editor } from "@tiptap/react";
+import { useTiptapEditor } from "../../hooks/use-tiptap-editor";
+import {
+ AlignCenterIcon,
+ AlignJustifyIcon,
+ AlignLeftIcon,
+ AlignRightIcon,
+} from "../icons";
+
+type TextAlign = "left" | "center" | "right" | "justify";
+
+const textAlignOptions: Record<
+ TextAlign,
+ {
+ icon: React.ElementType;
+ shortcutKey: ShortcutKeys;
+ label: string;
+ }
+> = {
+ left: {
+ icon: AlignLeftIcon,
+ shortcutKey: "ctrl-shift-l",
+ label: "Left",
+ },
+ center: {
+ icon: AlignCenterIcon,
+ shortcutKey: "ctrl-shift-c",
+ label: "Center",
+ },
+ right: {
+ icon: AlignRightIcon,
+ shortcutKey: "ctrl-shift-r",
+ label: "Right",
+ },
+ justify: {
+ icon: AlignJustifyIcon,
+ shortcutKey: "ctrl-shift-j",
+ label: "Justify",
+ },
+};
+
+function isTextAlignExtensionAvailable(editor: Editor | null) {
+ return editor?.extensionManager.extensions.some(
+ (extension) => extension.name === "textAlign",
+ );
+}
+
+function isTextAlignDisabled(
+ editor: Editor | null,
+ align: TextAlign,
+ manuallyDisabled = false,
+) {
+ const hasExtension = isTextAlignExtensionAvailable(editor);
+
+ if (!hasExtension) {
+ console.warn(
+ `TextAlign extension for ${align} is not available. Make sure it is included in your editor configuration.`,
+ );
+ }
+ if (!editor || !hasExtension || manuallyDisabled) return true;
+
+ try {
+ return !editor?.can().setTextAlign(align);
+ } catch (error) {
+ console.error("Error checking if text align is disabled", error);
+ return true;
+ }
+}
+
+function isTextAlignActive(editor: Editor | null, align: TextAlign) {
+ return editor?.isActive({ textAlign: align }) ?? false;
+}
+
+export function useTextAlign(align: TextAlign, manuallyDisabled = false) {
+ const editor = useTiptapEditor();
+
+ const isDisabled = isTextAlignDisabled(editor, align, manuallyDisabled);
+
+ const isActive = isTextAlignActive(editor, align);
+
+ const handleAlignment = React.useCallback(() => {
+ if (!editor || isDisabled) return false;
+ const chain = editor.chain().focus();
+ return chain.setTextAlign(align).run();
+ }, [editor, isDisabled, align]);
+
+ const displayOptions = textAlignOptions[align];
+
+ return {
+ isDisabled,
+ isActive,
+ handleAlignment,
+ displayOptions,
+ };
+}
+
+interface TextAlignButtonProps
+ extends Omit {
+ /**
+ * The text alignment to apply.
+ */
+ align: TextAlign;
+ showText?: boolean;
+}
+export const TextAlignButton = React.forwardRef<
+ HTMLButtonElement,
+ TextAlignButtonProps
+>(
+ (
+ { align, showText, className = "", disabled, onClick, ...buttonProps },
+ ref,
+ ) => {
+ const { isDisabled, isActive, handleAlignment, displayOptions } =
+ useTextAlign(align, disabled);
+
+ return (
+
+
+
+
+ {showText && {displayOptions.label}}
+
+
+
+ {displayOptions.label}
+
+
+
+ );
+ },
+);
+
+TextAlignButton.displayName = "TextAlignButton";
+
+export default TextAlignButton;
diff --git a/packages/editor/src/components/tiptap-ui/undo-redo-button.tsx b/packages/editor/src/components/tiptap-ui/undo-redo-button.tsx
new file mode 100644
index 000000000..34d26213d
--- /dev/null
+++ b/packages/editor/src/components/tiptap-ui/undo-redo-button.tsx
@@ -0,0 +1,120 @@
+import {
+ Button,
+ type ButtonProps,
+} from "@rectangular-labs/ui/components/ui/button";
+import type { ShortcutKeys } from "@rectangular-labs/ui/components/ui/shortcut";
+import type { Editor } from "@tiptap/react";
+import * as React from "react";
+import { useTiptapEditor } from "../../hooks/use-tiptap-editor";
+import { RedoIcon, UndoIcon } from "../icons";
+
+type HistoryAction = "undo" | "redo";
+
+const historyIcons = {
+ undo: UndoIcon,
+ redo: RedoIcon,
+};
+const historyShortcutKeys: Record = {
+ undo: "ctrl-z",
+ redo: "ctrl-shift-z",
+};
+const historyActionLabels = {
+ undo: "Undo",
+ redo: "Redo",
+};
+
+function isUndoRedoDisabled(
+ editor: Editor | null,
+ action: HistoryAction,
+ manuallyDisabled = false,
+) {
+ if (!editor || manuallyDisabled) return true;
+ if (action === "undo") {
+ return !editor.can().undo();
+ }
+ return !editor.can().redo();
+}
+
+/**
+ * Hook that provides all the necessary state and handlers for a history action.
+ *
+ * @param editor The TipTap editor instance
+ * @param action The history action to handle
+ * @returns Object containing state and handlers for the history action
+ */
+function useHistoryAction(action: HistoryAction, manuallyDisabled = false) {
+ const editor = useTiptapEditor();
+ const isDisabled = isUndoRedoDisabled(editor, action, manuallyDisabled);
+
+ const handleAction = React.useCallback(() => {
+ if (!editor || isDisabled) return;
+ const chain = editor.chain().focus();
+
+ if (action === "undo") {
+ chain.undo().run();
+ } else {
+ chain.redo().run();
+ }
+ }, [editor, action, isDisabled]);
+
+ const Icon = historyIcons[action];
+ const actionLabel = historyActionLabels[action];
+ const shortcutKey = historyShortcutKeys[action];
+
+ return {
+ isDisabled,
+ handleAction,
+ Icon,
+ actionLabel,
+ shortcutKey,
+ };
+}
+
+interface UndoRedoButtonProps extends ButtonProps {
+ action: HistoryAction;
+ showText?: boolean;
+}
+
+/**
+ * Button component for triggering undo/redo actions in a TipTap editor.
+ */
+export const UndoRedoButton = React.forwardRef<
+ HTMLButtonElement,
+ UndoRedoButtonProps
+>(({ action, showText, onClick, ...props }, ref) => {
+ const { isDisabled, handleAction, Icon, actionLabel, shortcutKey } =
+ useHistoryAction(action, props.disabled);
+
+ const onClickHandler = React.useCallback(
+ (e: React.MouseEvent) => {
+ onClick?.(e);
+ if (!e.defaultPrevented && !isDisabled) {
+ handleAction();
+ }
+ },
+ [onClick, handleAction, isDisabled],
+ );
+
+ return (
+
+ );
+});
+
+UndoRedoButton.displayName = "UndoRedoButton";
diff --git a/packages/editor/src/hooks/use-menu-navigation.ts b/packages/editor/src/hooks/use-menu-navigation.ts
new file mode 100644
index 000000000..9c893d73c
--- /dev/null
+++ b/packages/editor/src/hooks/use-menu-navigation.ts
@@ -0,0 +1,161 @@
+"use client";
+
+import type { Editor } from "@tiptap/react";
+import * as React from "react";
+
+type Orientation = "horizontal" | "vertical" | "both";
+
+interface MenuNavigationOptions {
+ editor?: Editor | null;
+ containerRef?: React.RefObject;
+ query?: string;
+ items: T[];
+ onSelect?: (item: T) => void;
+ onClose?: () => void;
+ orientation?: Orientation;
+ autoSelectFirstItem?: boolean;
+}
+
+export function useMenuNavigation({
+ editor,
+ containerRef,
+ query,
+ items,
+ onSelect,
+ onClose,
+ orientation = "vertical",
+ autoSelectFirstItem = true,
+}: MenuNavigationOptions) {
+ const [selectedIndex, setSelectedIndex] = React.useState(
+ autoSelectFirstItem ? 0 : -1,
+ );
+
+ React.useEffect(() => {
+ const handleKeyboardNavigation = (event: KeyboardEvent) => {
+ if (!items.length) return false;
+
+ const moveNext = () =>
+ setSelectedIndex((currentIndex) => {
+ if (currentIndex === -1) return 0;
+ return (currentIndex + 1) % items.length;
+ });
+
+ const movePrev = () =>
+ setSelectedIndex((currentIndex) => {
+ if (currentIndex === -1) return items.length - 1;
+ return (currentIndex - 1 + items.length) % items.length;
+ });
+
+ switch (event.key) {
+ case "ArrowUp": {
+ if (orientation === "horizontal") return false;
+ event.preventDefault();
+ movePrev();
+ return true;
+ }
+
+ case "ArrowDown": {
+ if (orientation === "horizontal") return false;
+ event.preventDefault();
+ moveNext();
+ return true;
+ }
+
+ case "ArrowLeft": {
+ if (orientation === "vertical") return false;
+ event.preventDefault();
+ movePrev();
+ return true;
+ }
+
+ case "ArrowRight": {
+ if (orientation === "vertical") return false;
+ event.preventDefault();
+ moveNext();
+ return true;
+ }
+
+ case "Tab": {
+ event.preventDefault();
+ if (event.shiftKey) {
+ movePrev();
+ } else {
+ moveNext();
+ }
+ return true;
+ }
+
+ case "Home": {
+ event.preventDefault();
+ setSelectedIndex(0);
+ return true;
+ }
+
+ case "End": {
+ event.preventDefault();
+ setSelectedIndex(items.length - 1);
+ return true;
+ }
+
+ case "Enter": {
+ if (event.isComposing) return false;
+ event.preventDefault();
+ if (selectedIndex !== -1 && items[selectedIndex]) {
+ onSelect?.(items[selectedIndex]);
+ }
+ return true;
+ }
+
+ case "Escape": {
+ event.preventDefault();
+ onClose?.();
+ return true;
+ }
+
+ default:
+ return false;
+ }
+ };
+
+ let targetElement: HTMLElement | null = null;
+
+ if (editor) {
+ targetElement = editor.view.dom;
+ } else if (containerRef?.current) {
+ targetElement = containerRef.current;
+ }
+
+ if (targetElement) {
+ targetElement.addEventListener("keydown", handleKeyboardNavigation, true);
+
+ return () => {
+ targetElement?.removeEventListener(
+ "keydown",
+ handleKeyboardNavigation,
+ true,
+ );
+ };
+ }
+
+ return undefined;
+ }, [
+ editor,
+ containerRef,
+ items,
+ selectedIndex,
+ onSelect,
+ onClose,
+ orientation,
+ ]);
+
+ React.useEffect(() => {
+ if (query) {
+ setSelectedIndex(autoSelectFirstItem ? 0 : -1);
+ }
+ }, [query, autoSelectFirstItem]);
+
+ return {
+ selectedIndex: items.length ? selectedIndex : undefined,
+ setSelectedIndex,
+ };
+}
diff --git a/packages/editor/src/hooks/use-tiptap-editor.ts b/packages/editor/src/hooks/use-tiptap-editor.ts
new file mode 100644
index 000000000..a24193201
--- /dev/null
+++ b/packages/editor/src/hooks/use-tiptap-editor.ts
@@ -0,0 +1,12 @@
+"use client";
+
+import { type Editor, useCurrentEditor } from "@tiptap/react";
+import * as React from "react";
+
+export function useTiptapEditor(providedEditor?: Editor | null): Editor | null {
+ const { editor: coreEditor } = useCurrentEditor();
+ return React.useMemo(
+ () => providedEditor || coreEditor,
+ [providedEditor, coreEditor],
+ );
+}
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
new file mode 100644
index 000000000..634407724
--- /dev/null
+++ b/packages/editor/src/index.ts
@@ -0,0 +1 @@
+export { SimpleEditor } from "./components/tiptap-templates/simple/simple-editor";
diff --git a/packages/editor/src/style.css b/packages/editor/src/style.css
new file mode 100644
index 000000000..943edc746
--- /dev/null
+++ b/packages/editor/src/style.css
@@ -0,0 +1,220 @@
+@import "tailwindcss";
+@source "./components/**/*.{ts,tsx}";
+@source "./hooks/**/*.{ts,tsx}";
+
+/* ----------------------------------------------------------------
+------------------- TIPTAP GLOBAL VARIABLES -----------------------
+---------------------------------------------------------------- */
+
+:root {
+ /******************
+ Basics
+ ******************/
+
+ overflow-wrap: break-word;
+ text-size-adjust: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+
+ /******************
+ Colors variables
+ ******************/
+
+ /* Gray alpha (light mode) */
+ --tt-gray-light-a-50: rgba(56, 56, 56, 0.04);
+ --tt-gray-light-a-100: rgba(15, 22, 36, 0.05);
+ --tt-gray-light-a-200: rgba(37, 39, 45, 0.1);
+ --tt-gray-light-a-300: rgba(47, 50, 55, 0.2);
+ --tt-gray-light-a-400: rgba(40, 44, 51, 0.42);
+ --tt-gray-light-a-500: rgba(52, 55, 60, 0.64);
+ --tt-gray-light-a-600: rgba(36, 39, 46, 0.78);
+ --tt-gray-light-a-700: rgba(35, 37, 42, 0.87);
+ --tt-gray-light-a-800: rgba(30, 32, 36, 0.95);
+ --tt-gray-light-a-900: rgba(29, 30, 32, 0.98);
+
+ /* Gray (light mode) */
+ --tt-gray-light-50: rgba(250, 250, 250, 1);
+ --tt-gray-light-100: rgba(244, 244, 245, 1);
+ --tt-gray-light-200: rgba(234, 234, 235, 1);
+ --tt-gray-light-300: rgba(213, 214, 215, 1);
+ --tt-gray-light-400: rgba(166, 167, 171, 1);
+ --tt-gray-light-500: rgba(125, 127, 130, 1);
+ --tt-gray-light-600: rgba(83, 86, 90, 1);
+ --tt-gray-light-700: rgba(64, 65, 69, 1);
+ --tt-gray-light-800: rgba(44, 45, 48, 1);
+ --tt-gray-light-900: rgba(34, 35, 37, 1);
+
+ /* Gray alpha (dark mode) */
+ --tt-gray-dark-a-50: rgba(232, 232, 253, 0.05);
+ --tt-gray-dark-a-100: rgba(231, 231, 243, 0.07);
+ --tt-gray-dark-a-200: rgba(238, 238, 246, 0.11);
+ --tt-gray-dark-a-300: rgba(239, 239, 245, 0.22);
+ --tt-gray-dark-a-400: rgba(244, 244, 255, 0.37);
+ --tt-gray-dark-a-500: rgba(236, 238, 253, 0.5);
+ --tt-gray-dark-a-600: rgba(247, 247, 253, 0.64);
+ --tt-gray-dark-a-700: rgba(251, 251, 254, 0.75);
+ --tt-gray-dark-a-800: rgba(253, 253, 253, 0.88);
+ --tt-gray-dark-a-900: rgba(255, 255, 255, 0.96);
+
+ /* Gray (dark mode) */
+ --tt-gray-dark-50: rgba(25, 25, 26, 1);
+ --tt-gray-dark-100: rgba(32, 32, 34, 1);
+ --tt-gray-dark-200: rgba(45, 45, 47, 1);
+ --tt-gray-dark-300: rgba(70, 70, 73, 1);
+ --tt-gray-dark-400: rgba(99, 99, 105, 1);
+ --tt-gray-dark-500: rgba(124, 124, 131, 1);
+ --tt-gray-dark-600: rgba(163, 163, 168, 1);
+ --tt-gray-dark-700: rgba(192, 192, 195, 1);
+ --tt-gray-dark-800: rgba(224, 224, 225, 1);
+ --tt-gray-dark-900: rgba(245, 245, 245, 1);
+
+ /* Brand colors */
+ --tt-brand-color-50: rgba(239, 238, 255, 1);
+ --tt-brand-color-100: rgba(222, 219, 255, 1);
+ --tt-brand-color-200: rgba(195, 189, 255, 1);
+ --tt-brand-color-300: rgba(157, 138, 255, 1);
+ --tt-brand-color-400: rgba(122, 82, 255, 1);
+ --tt-brand-color-500: rgba(98, 41, 255, 1);
+ --tt-brand-color-600: rgba(84, 0, 229, 1);
+ --tt-brand-color-700: rgba(75, 0, 204, 1);
+ --tt-brand-color-800: rgba(56, 0, 153, 1);
+ --tt-brand-color-900: rgba(43, 25, 102, 1);
+ --tt-brand-color-950: hsla(257, 100%, 9%, 1);
+
+ /* Green */
+ --tt-color-green-inc-5: hsla(129, 100%, 97%, 1);
+ --tt-color-green-inc-4: hsla(129, 100%, 92%, 1);
+ --tt-color-green-inc-3: hsla(131, 100%, 86%, 1);
+ --tt-color-green-inc-2: hsla(133, 98%, 78%, 1);
+ --tt-color-green-inc-1: hsla(137, 99%, 70%, 1);
+ --tt-color-green-base: hsla(147, 99%, 50%, 1);
+ --tt-color-green-dec-1: hsla(147, 97%, 41%, 1);
+ --tt-color-green-dec-2: hsla(146, 98%, 32%, 1);
+ --tt-color-green-dec-3: hsla(146, 100%, 24%, 1);
+ --tt-color-green-dec-4: hsla(144, 100%, 16%, 1);
+ --tt-color-green-dec-5: hsla(140, 100%, 9%, 1);
+
+ /* Yellow */
+ --tt-color-yellow-inc-5: hsla(50, 100%, 97%, 1);
+ --tt-color-yellow-inc-4: hsla(50, 100%, 91%, 1);
+ --tt-color-yellow-inc-3: hsla(50, 100%, 84%, 1);
+ --tt-color-yellow-inc-2: hsla(50, 100%, 77%, 1);
+ --tt-color-yellow-inc-1: hsla(50, 100%, 68%, 1);
+ --tt-color-yellow-base: hsla(52, 100%, 50%, 1);
+ --tt-color-yellow-dec-1: hsla(52, 100%, 41%, 1);
+ --tt-color-yellow-dec-2: hsla(52, 100%, 32%, 1);
+ --tt-color-yellow-dec-3: hsla(52, 100%, 24%, 1);
+ --tt-color-yellow-dec-4: hsla(51, 100%, 16%, 1);
+ --tt-color-yellow-dec-5: hsla(50, 100%, 9%, 1);
+
+ /* Red */
+ --tt-color-red-inc-5: hsla(11, 100%, 96%, 1);
+ --tt-color-red-inc-4: hsla(11, 100%, 88%, 1);
+ --tt-color-red-inc-3: hsla(10, 100%, 80%, 1);
+ --tt-color-red-inc-2: hsla(9, 100%, 73%, 1);
+ --tt-color-red-inc-1: hsla(7, 100%, 64%, 1);
+ --tt-color-red-base: hsla(7, 100%, 54%, 1);
+ --tt-color-red-dec-1: hsla(7, 100%, 41%, 1);
+ --tt-color-red-dec-2: hsla(5, 100%, 32%, 1);
+ --tt-color-red-dec-3: hsla(4, 100%, 24%, 1);
+ --tt-color-red-dec-4: hsla(3, 100%, 16%, 1);
+ --tt-color-red-dec-5: hsla(1, 100%, 9%, 1);
+
+ /* Basic colors */
+ --white: rgba(255, 255, 255, 1);
+ --black: rgba(14, 14, 17, 1);
+ --transparent: rgba(255, 255, 255, 0);
+
+ /******************
+ Shadow variables
+ ******************/
+
+ /* Shadows Light */
+ --tt-shadow-elevated-md:
+ 0px 16px 48px 0px rgba(17, 24, 39, 0.04),
+ 0px 12px 24px 0px rgba(17, 24, 39, 0.04),
+ 0px 6px 8px 0px rgba(17, 24, 39, 0.02),
+ 0px 2px 3px 0px rgba(17, 24, 39, 0.02);
+
+ /**************************************************
+ Radius variables
+ **************************************************/
+
+ --tt-radius-xxs: 0.125rem; /* 2px */
+ --tt-radius-xs: 0.25rem; /* 4px */
+ --tt-radius-sm: 0.375rem; /* 6px */
+ --tt-radius-md: 0.5rem; /* 8px */
+ --tt-radius-lg: 0.75rem; /* 12px */
+ --tt-radius-xl: 1rem; /* 16px */
+
+ /**************************************************
+ Transition variables
+ **************************************************/
+
+ --tt-transition-duration-short: 0.1s;
+ --tt-transition-duration-default: 0.2s;
+ --tt-transition-duration-long: 0.64s;
+ --tt-transition-easing-default: cubic-bezier(0.46, 0.03, 0.52, 0.96);
+ --tt-transition-easing-cubic: cubic-bezier(0.65, 0.05, 0.36, 1);
+ --tt-transition-easing-quart: cubic-bezier(0.77, 0, 0.18, 1);
+ --tt-transition-easing-circ: cubic-bezier(0.79, 0.14, 0.15, 0.86);
+ --tt-transition-easing-back: cubic-bezier(0.68, -0.55, 0.27, 1.55);
+
+ /******************
+ Contrast variables
+ ******************/
+
+ --tt-accent-contrast: 8%;
+ --tt-destructive-contrast: 8%;
+ --tt-foreground-contrast: 8%;
+
+ &,
+ *,
+ ::before,
+ ::after {
+ box-sizing: border-box;
+ transition: none var(--tt-transition-duration-default)
+ var(--tt-transition-easing-default);
+ }
+}
+
+/* Shadows Dark */
+@media (prefers-color-scheme: dark) {
+ :root {
+ --tt-shadow-elevated-md:
+ 0px 16px 48px 0px rgba(0, 0, 0, 0.5),
+ 0px 12px 24px 0px rgba(0, 0, 0, 0.24),
+ 0px 6px 8px 0px rgba(0, 0, 0, 0.22), 0px 2px 3px 0px rgba(0, 0, 0, 0.12);
+ }
+}
+
+:root {
+ /**************************************************
+ Global colors
+ **************************************************/
+
+ /* Global colors - Light mode */
+ --tt-bg-color: var(--white);
+ --tt-border-color: var(--tt-gray-light-a-200);
+ --tt-border-color-tint: var(--tt-gray-light-a-100);
+ --tt-sidebar-bg-color: var(--tt-gray-light-100);
+ --tt-scrollbar-color: var(--tt-gray-light-a-200);
+ --tt-cursor-color: var(--tt-brand-color-500);
+ --tt-selection-color: rgba(157, 138, 255, 0.2);
+ --tt-card-bg-color: var(--white);
+ --tt-card-border-color: var(--tt-gray-light-a-100);
+}
+
+/* Global colors - Dark mode */
+.dark {
+ --tt-bg-color: var(--black);
+ --tt-border-color: var(--tt-gray-dark-a-200);
+ --tt-border-color-tint: var(--tt-gray-dark-a-100);
+ --tt-sidebar-bg-color: var(--tt-gray-dark-100);
+ --tt-scrollbar-color: var(--tt-gray-dark-a-200);
+ --tt-cursor-color: var(--tt-brand-color-400);
+ --tt-selection-color: rgba(122, 82, 255, 0.2);
+ --tt-card-bg-color: var(--tt-gray-dark-50);
+ --tt-card-border-color: var(--tt-gray-dark-a-50);
+}
diff --git a/packages/editor/src/tiptap-utils.ts b/packages/editor/src/tiptap-utils.ts
new file mode 100644
index 000000000..305dc63a6
--- /dev/null
+++ b/packages/editor/src/tiptap-utils.ts
@@ -0,0 +1,74 @@
+import type { Editor } from "@tiptap/react";
+
+export const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
+
+/**
+ * Handles image upload with progress tracking and abort capability
+ */
+export const handleImageUpload = async (
+ _file: File,
+ onProgress?: (event: { progress: number }) => void,
+ abortSignal?: AbortSignal,
+): Promise => {
+ // Simulate upload progress
+ for (let progress = 0; progress <= 100; progress += 10) {
+ if (abortSignal?.aborted) {
+ throw new Error("Upload cancelled");
+ }
+ await new Promise((resolve) => setTimeout(resolve, 500));
+ onProgress?.({ progress });
+ }
+
+ return "/images/placeholder-image.png";
+
+ // Uncomment to use actual file conversion:
+ // return convertFileToBase64(file, abortSignal)
+};
+
+/**
+ * Converts a File to base64 string
+ */
+const _convertFileToBase64 = (
+ file: File,
+ abortSignal?: AbortSignal,
+): Promise => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+
+ const abortHandler = () => {
+ reader.abort();
+ reject(new Error("Upload cancelled"));
+ };
+
+ if (abortSignal) {
+ abortSignal.addEventListener("abort", abortHandler);
+ }
+
+ reader.onloadend = () => {
+ if (abortSignal) {
+ abortSignal.removeEventListener("abort", abortHandler);
+ }
+
+ if (typeof reader.result === "string") {
+ resolve(reader.result);
+ } else {
+ reject(new Error("Failed to convert File to base64"));
+ }
+ };
+
+ reader.onerror = reject;
+ reader.readAsDataURL(file);
+ });
+};
+
+/**
+ * Checks if a node exists in the editor schema
+ *
+ * @param nodeName - The name of the node to check
+ * @param editor - The editor instance
+ */
+export const isNodeInSchema = (nodeName: string, editor: Editor | null) =>
+ editor?.schema.spec.nodes.get(nodeName) !== undefined;
+
+export const isMarkInSchema = (markName: string, editor: Editor | null) =>
+ editor?.schema.spec.marks.get(markName) !== undefined;
diff --git a/packages/editor/tsconfig.json b/packages/editor/tsconfig.json
new file mode 100644
index 000000000..59872c7cb
--- /dev/null
+++ b/packages/editor/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "@rectangular-labs/typescript/tsconfig.internal-package.json",
+ "compilerOptions": {
+ "lib": ["ES2022", "dom", "dom.iterable"],
+ "jsx": "preserve",
+ "rootDir": "src"
+ },
+ "include": ["src"],
+ "exclude": ["node_modules"]
+}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 9e500c661..2dd1cbee8 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -5,12 +5,13 @@
"sideEffects": false,
"files": [
"tailwind.config.web.ts",
- "globals.css"
+ "style.css"
],
"exports": {
"./*": "./src/*.tsx",
- "./styles.css": "./src/styles.css",
- "./utils/*": "./src/utils/*.ts"
+ "./style.css": "./src/style.css",
+ "./utils/*": "./src/utils/*.ts",
+ "./hooks/*": "./src/hooks/*.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
@@ -33,6 +34,7 @@
"react-dom": "^19.1.0"
},
"dependencies": {
+ "@ark-ui/react": "^5.22.0",
"@hookform/resolvers": "^5.2.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
diff --git a/packages/ui/src/components/auth/schema/code.ts b/packages/ui/src/components/auth/schema/code.ts
index 2d5530fa9..67f5ec106 100644
--- a/packages/ui/src/components/auth/schema/code.ts
+++ b/packages/ui/src/components/auth/schema/code.ts
@@ -1,7 +1,7 @@
import { type } from "arktype";
export const CodeSchema = type("string").narrow((code, ctx) => {
- if (code.length !== 6 || Number.isNaN(parseInt(code))) {
+ if (code.length !== 6 || Number.isNaN(parseInt(code, 10))) {
return ctx.reject({ expected: "a valid code", actual: "" });
}
return true;
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index a61c6a8f9..4f0b569a4 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -7,8 +7,10 @@ import {
FileIcon,
Loader2,
MoonIcon,
+ PaperclipIcon,
SunIcon,
} from "lucide-react";
+
import type { SVGProps } from "react";
type IconProps = React.HTMLAttributes;
@@ -18,12 +20,13 @@ export const Sun = SunIcon;
export const EyeOn = EyeIcon;
export const EyeOff = EyeOffIcon;
export const Spinner = Loader2;
+export const Paperclip = PaperclipIcon;
+export const Dot = DotIcon;
+export const File = FileIcon;
export const ArrowUp = ArrowUpIcon;
export const ArrowDown = ArrowDownIcon;
-export const File = FileIcon;
-export const Dot = DotIcon;
-export const Logo = (props: IconProps) => (
+export const Logo = (props: SVGProps) => (