Implement interactive Notion-style slash commands menu (/ for formatting)

This commit is contained in:
chrisf committed 2026-10-23 08:47:15 -06:00
1 parent 2eb283b9ec
commit 069a210bee
4 files changed
+492 -37

No files matched your search

+51 -36
View File
@@ -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",
+1
View File
@@ -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",
+110 -1
View File
@@ -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<SlashMenuState>(initialSlashMenuState);
const slashMenuRef = useRef<SlashMenuState>(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 || '<p></p>'),
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}
/>
<SlashCommandMenu
state={slashMenu}
editor={editor}
onClose={() => setSlashMenu(initialSlashMenuState)}
onSelect={(item) => {
if (slashMenu.range && editor) {
item.command(editor, slashMenu.range);
}
setSlashMenu(initialSlashMenuState);
}}
/>
</div>
);
}
+330
View File
@@ -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<HTMLDivElement>(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 (
<div
ref={menuRef}
style={{
position: 'fixed',
top: `${top}px`,
left: `${left}px`,
}}
className="z-50 w-72 max-h-80 overflow-y-auto bg-white rounded-xl shadow-2xl border border-gray-200 py-1.5 text-xs animate-in fade-in zoom-in-95 duration-100"
>
<div className="px-3 py-1 text-[10px] uppercase font-bold tracking-wider text-gray-400 border-b border-gray-100 mb-1">
Basic Blocks
</div>
{state.items.length === 0 ? (
<div className="px-3 py-3 text-center text-gray-400 text-xs">
No matching commands for &ldquo;{state.query}&rdquo;
</div>
) : (
state.items.map((item, idx) => {
const Icon = item.icon;
const isSelected = idx === state.selectedIndex;
return (
<button
key={item.id}
type="button"
onClick={() => onSelect(item)}
onMouseEnter={() => {
// optional: update hover
}}
className={`w-full flex items-center gap-2.5 px-3 py-2 text-left transition ${
isSelected
? 'bg-emerald-50 text-gray-900 font-medium'
: 'text-gray-700 hover:bg-gray-50'
}`}
>
<div
className={`w-7 h-7 rounded-lg flex items-center justify-center shrink-0 border ${
isSelected
? 'bg-white border-emerald-300 text-evernote-green shadow-xs'
: 'bg-gray-50 border-gray-200 text-gray-500'
}`}
>
<Icon className="w-3.5 h-3.5" />
</div>
<div className="min-w-0 flex-1">
<div className="text-xs font-semibold text-gray-900 leading-tight">
{item.title}
</div>
<div className="text-[11px] text-gray-500 truncate leading-tight mt-0.5">
{item.description}
</div>
</div>
</button>
);
})
)}
</div>
);
}