From 069a210bee61d7e36af849a1d69e5769060ee63c Mon Sep 17 00:00:00 2001 From: Chris Fuller Date: Fri, 23 Oct 2026 08:47:15 -0600 Subject: [PATCH] Implement interactive Notion-style slash commands menu (/ for formatting) --- package-lock.json | 87 ++++---- package.json | 1 + src/components/RichNoteEditor.tsx | 111 +++++++++- src/components/SlashCommands.tsx | 330 ++++++++++++++++++++++++++++++ 4 files changed, 492 insertions(+), 37 deletions(-) create mode 100644 src/components/SlashCommands.tsx diff --git a/package-lock.json b/package-lock.json index ef0a8f3..fae7d0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@tiptap/pm": "^2.11.5", "@tiptap/react": "^2.11.5", "@tiptap/starter-kit": "^2.11.5", + "@tiptap/suggestion": "^2.27.3", "clsx": "^2.1.1", "date-fns": "^4.1.0", "imapflow": "^1.0.171", @@ -1237,7 +1238,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.3.tgz", "integrity": "sha512-CBPT44BjlQxEt8kiMEauji2WHTDoVBOKl7UlewXmUgBPnr/oPRZC3psci5chJnYmH0ivEIog2OU9PGWoki3DLQ==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "c12": "3.1.0", @@ -1250,14 +1251,14 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.3.tgz", "integrity": "sha512-ljkJ+SgpXNktLG0Q/n4JGYCkKf0f8oYLyjImS2I8e2q2WCfdRRtWER062ZV/ixaNP2M2VKlWXVJiGzZaUgbKZw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.3.tgz", "integrity": "sha512-RSYxtlYFl5pJ8ZePgMv0lZ9IzVCOdTPOegrs2qcbAEFrBI1G33h6wyC9kjQvo0DnYEhEVY0X4LsuFHXLKQk88g==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1271,14 +1272,14 @@ "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7.tgz", "integrity": "sha512-03bgb1VD5gvuumNf+7fVGBzfpJPjmqV423l/WxsWk2cNQ42JD0/SsFBPhN6z8iAvdHs07/7ei77SKu7aZfq8bA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.3.tgz", "integrity": "sha512-tKtl/qco9Nt7LU5iKhpultD8O4vMCZcU2CHjNTnRrL1QvSUr5W/GcyFPjNL87GtRrwBc7ubXXD9xy4EvLvt8JA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3", @@ -1290,7 +1291,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.3.tgz", "integrity": "sha512-xFj1VcJ1N3MKooOQAGO0W5tsd0W2QzIvW7DD7c/8H14Zmp4jseeWAITm+w2LLoLrlhoHdPPh0NMZ8mfL6puoHA==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "6.19.3" @@ -1322,7 +1323,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@swc/helpers": { @@ -1962,6 +1963,20 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/suggestion": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.27.3.tgz", + "integrity": "sha512-j8mmf1JF48gKm7sGi0cmmYAnFHa2gXMYCYyaP0e4OaHzQyCEB6HBJbFiFCa8olbO0anZW0KdbA4H5y2FXLVy+Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/@types/jsdom": { "version": "21.1.7", "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", @@ -2273,7 +2288,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz", "integrity": "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "chokidar": "^4.0.3", @@ -2332,7 +2347,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "readdirp": "^4.0.1" @@ -2348,7 +2363,7 @@ "version": "0.1.6", "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "consola": "^3.2.3" @@ -2383,14 +2398,14 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": "^14.18.0 || >=16.10.0" @@ -2491,7 +2506,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", - "devOptional": true, + "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -2501,14 +2516,14 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -2606,7 +2621,7 @@ "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -2619,7 +2634,7 @@ "version": "3.21.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.21.0.tgz", "integrity": "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -2637,7 +2652,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=14" @@ -2700,14 +2715,14 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.3.tgz", "integrity": "sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/fast-check": { "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -2846,7 +2861,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", @@ -3157,7 +3172,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -3657,7 +3672,7 @@ "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/node-fetch/node_modules/tr46": { @@ -3721,7 +3736,7 @@ "version": "0.6.11", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.11.tgz", "integrity": "sha512-wrVBTLWDu7nTUKGWplQebWOXWnhaIwyKQhBxobH/CGxJHoVw27r9vG5ba7kdZnxG+f4rC6dZd6m2oHmpkOcO7g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "citty": "^0.2.2", @@ -3739,7 +3754,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/object-assign": { @@ -3766,7 +3781,7 @@ "version": "2.0.12", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/on-exit-leak-free": { @@ -3835,7 +3850,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/pdf-parse": { @@ -3883,7 +3898,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/picocolors": { @@ -3956,7 +3971,7 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.4.0.tgz", "integrity": "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "confbox": "^0.3.1", @@ -3968,7 +3983,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz", "integrity": "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/postcss": { @@ -4138,7 +4153,7 @@ "version": "6.19.3", "resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.3.tgz", "integrity": "sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==", - "devOptional": true, + "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -4399,7 +4414,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "devOptional": true, + "dev": true, "funding": [ { "type": "individual", @@ -4443,7 +4458,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "defu": "^6.1.4", @@ -4497,7 +4512,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">= 14.18.0" @@ -5026,7 +5041,7 @@ "version": "1.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5152,7 +5167,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index 7bd9ed0..669081a 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@tiptap/pm": "^2.11.5", "@tiptap/react": "^2.11.5", "@tiptap/starter-kit": "^2.11.5", + "@tiptap/suggestion": "^2.27.3", "clsx": "^2.1.1", "date-fns": "^4.1.0", "imapflow": "^1.0.171", diff --git a/src/components/RichNoteEditor.tsx b/src/components/RichNoteEditor.tsx index 822d691..cd9026f 100644 --- a/src/components/RichNoteEditor.tsx +++ b/src/components/RichNoteEditor.tsx @@ -1,7 +1,14 @@ 'use client'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useMemo } from 'react'; import LinkModal from './LinkModal'; +import { + SlashCommandsExtension, + SlashCommandMenu, + SlashMenuState, + initialSlashMenuState, + getSlashCommandItems, +} from './SlashCommands'; import { useEditor, EditorContent } from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import Underline from '@tiptap/extension-underline'; @@ -103,6 +110,13 @@ export default function RichNoteEditor({ const [linkModalUrl, setLinkModalUrl] = useState(''); const [linkModalText, setLinkModalText] = useState(''); + // Slash commands menu state + const [slashMenu, setSlashMenu] = useState(initialSlashMenuState); + const slashMenuRef = useRef(initialSlashMenuState); + slashMenuRef.current = slashMenu; + + const handleOpenLinkModalRef = useRef<() => void>(() => {}); + const editor = useEditor({ immediatelyRender: false, editable: !readOnly, @@ -152,6 +166,88 @@ export default function RichNoteEditor({ Placeholder.configure({ placeholder: 'Start writing, drag files here, or type / for formatting…', }), + SlashCommandsExtension.configure({ + suggestion: { + char: '/', + startOfLine: false, + items: ({ query }: { query: string }) => { + const items = getSlashCommandItems({ + onOpenLinkModal: () => handleOpenLinkModalRef.current(), + onTriggerUpload: () => fileInputRef.current?.click(), + }); + const q = query.toLowerCase().trim(); + if (!q) return items; + return items.filter( + (item) => + item.title.toLowerCase().includes(q) || + item.keywords.some((k) => k.toLowerCase().includes(q)) + ); + }, + render: () => { + return { + onStart: (props: any) => { + setSlashMenu({ + isOpen: true, + query: props.query, + range: props.range, + clientRect: props.clientRect, + items: props.items, + selectedIndex: 0, + }); + }, + onUpdate: (props: any) => { + setSlashMenu((prev) => ({ + ...prev, + query: props.query, + range: props.range, + clientRect: props.clientRect, + items: props.items, + selectedIndex: 0, + })); + }, + onKeyDown: (props: any) => { + if (props.event.key === 'Escape') { + setSlashMenu(initialSlashMenuState); + return true; + } + const current = slashMenuRef.current; + if (!current.isOpen || current.items.length === 0) return false; + + if (props.event.key === 'ArrowUp') { + props.event.preventDefault(); + setSlashMenu((prev) => ({ + ...prev, + selectedIndex: + (prev.selectedIndex + prev.items.length - 1) % prev.items.length, + })); + return true; + } + if (props.event.key === 'ArrowDown') { + props.event.preventDefault(); + setSlashMenu((prev) => ({ + ...prev, + selectedIndex: (prev.selectedIndex + 1) % prev.items.length, + })); + return true; + } + if (props.event.key === 'Enter') { + props.event.preventDefault(); + const selected = current.items[current.selectedIndex]; + if (selected && current.range && props.view.state) { + selected.command(props.editor, current.range); + } + setSlashMenu(initialSlashMenuState); + return true; + } + return false; + }, + onExit: () => { + setSlashMenu(initialSlashMenuState); + }, + }; + }, + }, + }), ], content: sanitizeOrUnescapeLinks(contentHtml || '

'), editorProps: { @@ -349,6 +445,7 @@ export default function RichNoteEditor({ setLinkModalText(selectedText); setLinkModalOpen(true); }; + handleOpenLinkModalRef.current = handleOpenLinkModal; const handleApplyLink = (url: string, text?: string) => { if (!editor) return; @@ -800,6 +897,18 @@ export default function RichNoteEditor({ onApplyLink={handleApplyLink} onUnlink={handleUnlink} /> + + setSlashMenu(initialSlashMenuState)} + onSelect={(item) => { + if (slashMenu.range && editor) { + item.command(editor, slashMenu.range); + } + setSlashMenu(initialSlashMenuState); + }} + /> ); } diff --git a/src/components/SlashCommands.tsx b/src/components/SlashCommands.tsx new file mode 100644 index 0000000..9705d3c --- /dev/null +++ b/src/components/SlashCommands.tsx @@ -0,0 +1,330 @@ +'use client'; + +import React, { useState, useEffect, useRef } from 'react'; +import { Extension } from '@tiptap/core'; +import Suggestion, { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion'; +import { Editor, Range } from '@tiptap/core'; +import { + Heading1, + Heading2, + Heading3, + Type, + CheckSquare, + List, + ListOrdered, + Quote, + Code, + Minus, + Table as TableIcon, + Link2, + Paperclip, +} from 'lucide-react'; + +export interface SlashCommandItem { + id: string; + title: string; + description: string; + icon: React.ComponentType<{ className?: string }>; + keywords: string[]; + command: (editor: Editor, range: Range) => void; +} + +export function getSlashCommandItems(handlers: { + onOpenLinkModal?: () => void; + onTriggerUpload?: () => void; +}): SlashCommandItem[] { + return [ + { + id: 'text', + title: 'Text', + description: 'Start writing with plain body text', + icon: Type, + keywords: ['text', 'paragraph', 'p', 'plain', 'normal'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).setParagraph().run(); + }, + }, + { + id: 'heading-1', + title: 'Heading 1', + description: 'Large section heading', + icon: Heading1, + keywords: ['h1', 'heading', 'title', 'large'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 1 }).run(); + }, + }, + { + id: 'heading-2', + title: 'Heading 2', + description: 'Medium section heading', + icon: Heading2, + keywords: ['h2', 'heading', 'subtitle', 'medium'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 2 }).run(); + }, + }, + { + id: 'heading-3', + title: 'Heading 3', + description: 'Small subsection heading', + icon: Heading3, + keywords: ['h3', 'heading', 'small'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).setHeading({ level: 3 }).run(); + }, + }, + { + id: 'todo', + title: 'To-do List', + description: 'Interactive checklist with task boxes', + icon: CheckSquare, + keywords: ['todo', 'task', 'check', 'checkbox', 'list'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleTaskList().run(); + }, + }, + { + id: 'bullet-list', + title: 'Bullet List', + description: 'Create an unordered bulleted list', + icon: List, + keywords: ['bullet', 'ul', 'list', 'unordered'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleBulletList().run(); + }, + }, + { + id: 'ordered-list', + title: 'Numbered List', + description: 'Create a sequential numbered list', + icon: ListOrdered, + keywords: ['ordered', 'ol', 'number', 'numbered', 'list'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleOrderedList().run(); + }, + }, + { + id: 'quote', + title: 'Quote', + description: 'Capture a stylized blockquote', + icon: Quote, + keywords: ['quote', 'blockquote', 'cite'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleBlockquote().run(); + }, + }, + { + id: 'code', + title: 'Code Block', + description: 'Syntax-friendly code snippet block', + icon: Code, + keywords: ['code', 'snippet', 'pre', 'block'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); + }, + }, + { + id: 'divider', + title: 'Divider', + description: 'Horizontal line separator', + icon: Minus, + keywords: ['divider', 'hr', 'line', 'separator', 'rule'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).setHorizontalRule().run(); + }, + }, + { + id: 'table', + title: 'Table', + description: 'Insert a 3x3 table with header row', + icon: TableIcon, + keywords: ['table', 'grid', 'rows', 'columns'], + command: (editor, range) => { + editor + .chain() + .focus() + .deleteRange(range) + .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) + .run(); + }, + }, + { + id: 'note-link', + title: 'Note Link', + description: 'Link to another Everclone note', + icon: Link2, + keywords: ['link', 'internal', 'note', 'evernote'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).run(); + if (handlers.onOpenLinkModal) handlers.onOpenLinkModal(); + }, + }, + { + id: 'upload', + title: 'Upload / Attach File', + description: 'Add photos, receipts, or PDF files', + icon: Paperclip, + keywords: ['upload', 'file', 'image', 'photo', 'pdf', 'receipt', 'attachment'], + command: (editor, range) => { + editor.chain().focus().deleteRange(range).run(); + if (handlers.onTriggerUpload) handlers.onTriggerUpload(); + }, + }, + ]; +} + +export interface SlashMenuState { + isOpen: boolean; + query: string; + range: Range | null; + clientRect: (() => DOMRect | null) | null; + items: SlashCommandItem[]; + selectedIndex: number; +} + +export const initialSlashMenuState: SlashMenuState = { + isOpen: false, + query: '', + range: null, + clientRect: null, + items: [], + selectedIndex: 0, +}; + +export const SlashCommandsExtension = Extension.create<{ + suggestion: any; +}>({ + name: 'slashCommands', + + addOptions() { + return { + suggestion: { + char: '/', + startOfLine: false, + command: ({ editor, range, props }: any) => { + props.command(editor, range); + }, + }, + }; + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestion, + }), + ]; + }, +}); + +interface SlashCommandMenuProps { + state: SlashMenuState; + editor: Editor | null; + onClose: () => void; + onSelect: (item: SlashCommandItem) => void; +} + +export function SlashCommandMenu({ + state, + onClose, + onSelect, +}: SlashCommandMenuProps) { + const menuRef = useRef(null); + + // Close on outside click + useEffect(() => { + if (!state.isOpen) return; + const handleOutsideClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + window.addEventListener('mousedown', handleOutsideClick); + return () => window.removeEventListener('mousedown', handleOutsideClick); + }, [state.isOpen, onClose]); + + if (!state.isOpen || !state.clientRect) return null; + + const rect = state.clientRect(); + if (!rect) return null; + + // Calculate smart floating position beneath cursor + const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : 1000; + const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : 800; + + const menuWidth = 280; + const menuHeight = 320; + + let left = rect.left; + if (left + menuWidth > viewportWidth - 16) { + left = viewportWidth - menuWidth - 16; + } + if (left < 16) left = 16; + + let top = rect.bottom + 6; + if (top + menuHeight > viewportHeight - 16) { + top = Math.max(16, rect.top - menuHeight - 6); + } + + return ( +
+
+ Basic Blocks +
+ + {state.items.length === 0 ? ( +
+ No matching commands for “{state.query}” +
+ ) : ( + state.items.map((item, idx) => { + const Icon = item.icon; + const isSelected = idx === state.selectedIndex; + return ( + + ); + }) + )} +
+ ); +}