diff --git a/vanilla-slash-menu/.gitignore b/vanilla-slash-menu/.gitignore new file mode 100644 index 000000000..5d6225c6d --- /dev/null +++ b/vanilla-slash-menu/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.next +.svelte-kit diff --git a/vanilla-slash-menu/README.md b/vanilla-slash-menu/README.md new file mode 100644 index 000000000..0efff88c1 --- /dev/null +++ b/vanilla-slash-menu/README.md @@ -0,0 +1,15 @@ +# vanilla-slash-menu + +A [ProseKit](https://prosekit.dev) example. + +[![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/prosekit/examples/tree/master/vanilla-slash-menu) +[![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/p/sandbox/github/prosekit/examples/tree/master/vanilla-slash-menu) + +Run the example locally with: + +```bash +npx degit prosekit/examples/vanilla-slash-menu vanilla-slash-menu +cd vanilla-slash-menu +npm install +npm run dev +``` diff --git a/vanilla-slash-menu/index.html b/vanilla-slash-menu/index.html new file mode 100644 index 000000000..2d9080a6a --- /dev/null +++ b/vanilla-slash-menu/index.html @@ -0,0 +1,11 @@ + + + + + + ProseKit + Vanilla TypeScript + + + + + diff --git a/vanilla-slash-menu/package.json b/vanilla-slash-menu/package.json new file mode 100644 index 000000000..5d4ca7193 --- /dev/null +++ b/vanilla-slash-menu/package.json @@ -0,0 +1,24 @@ +{ + "name": "example-vanilla-slash-menu", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "preview": "vite preview" + }, + "dependencies": { + "prosekit": "^0.17.1" + }, + "devDependencies": { + "@egoist/tailwindcss-icons": "^1.9.0", + "@iconify-json/lucide": "^1.2.84", + "@tailwindcss/vite": "^4.1.18", + "postcss": "^8.5.6", + "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", + "typescript": "~5.9.3", + "vite": "7.3.1" + } +} diff --git a/vanilla-slash-menu/src/app.css b/vanilla-slash-menu/src/app.css new file mode 100644 index 000000000..5d0567ecf --- /dev/null +++ b/vanilla-slash-menu/src/app.css @@ -0,0 +1,13 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@plugin "@egoist/tailwindcss-icons"; + +body { + height: 100svh; + display: grid; + max-width: 900px; + padding: 16px; + margin-left: auto; + margin-right: auto; +} diff --git a/vanilla-slash-menu/src/components/editor/examples/slash-menu/editor.ts b/vanilla-slash-menu/src/components/editor/examples/slash-menu/editor.ts new file mode 100644 index 000000000..2e28e149c --- /dev/null +++ b/vanilla-slash-menu/src/components/editor/examples/slash-menu/editor.ts @@ -0,0 +1,39 @@ +import 'prosekit/basic/style.css' +import 'prosekit/basic/typography.css' + +import { createEditor } from 'prosekit/core' + +import { renderSlashMenu } from '../../ui/slash-menu' + +import { defineExtension } from './extension' + +export function setupVanillaEditor() { + const extension = defineExtension() + const editor = createEditor({ extension }) + + return { + render: () => { + const port = document.createElement('div') + port.className = + 'box-border h-full w-full min-h-36 overflow-y-hidden overflow-x-hidden rounded-md border border-solid border-gray-200 dark:border-gray-700 shadow-sm flex flex-col bg-white dark:bg-gray-950 text-black dark:text-white' + + const scrolling = document.createElement('div') + scrolling.className = 'relative w-full flex-1 box-border overflow-y-auto' + port.append(scrolling) + + const content = document.createElement('div') + content.className = + 'ProseMirror box-border min-h-full px-[max(4rem,calc(50%-20rem))] py-8 outline-hidden outline-0 [&_span[data-mention=user]]:text-blue-500 [&_span[data-mention=tag]]:text-violet-500' + scrolling.append(content) + + scrolling.append(renderSlashMenu(editor)) + + editor.mount(content) + + return port + }, + destroy: () => { + editor.unmount() + }, + } +} diff --git a/vanilla-slash-menu/src/components/editor/examples/slash-menu/extension.ts b/vanilla-slash-menu/src/components/editor/examples/slash-menu/extension.ts new file mode 100644 index 000000000..0edda6013 --- /dev/null +++ b/vanilla-slash-menu/src/components/editor/examples/slash-menu/extension.ts @@ -0,0 +1,12 @@ +import { defineBasicExtension } from 'prosekit/basic' +import { union } from 'prosekit/core' +import { definePlaceholder } from 'prosekit/extensions/placeholder' + +export function defineExtension() { + return union( + defineBasicExtension(), + definePlaceholder({ placeholder: 'Press / for commands...' }), + ) +} + +export type EditorExtension = ReturnType diff --git a/vanilla-slash-menu/src/components/editor/examples/slash-menu/index.ts b/vanilla-slash-menu/src/components/editor/examples/slash-menu/index.ts new file mode 100644 index 000000000..e54daaa4f --- /dev/null +++ b/vanilla-slash-menu/src/components/editor/examples/slash-menu/index.ts @@ -0,0 +1 @@ +export { setupVanillaEditor } from './editor' diff --git a/vanilla-slash-menu/src/components/editor/ui/slash-menu/index.ts b/vanilla-slash-menu/src/components/editor/ui/slash-menu/index.ts new file mode 100644 index 000000000..862a5dee5 --- /dev/null +++ b/vanilla-slash-menu/src/components/editor/ui/slash-menu/index.ts @@ -0,0 +1 @@ +export { renderSlashMenu } from './slash-menu' diff --git a/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-empty.ts b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-empty.ts new file mode 100644 index 000000000..ea2d45423 --- /dev/null +++ b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-empty.ts @@ -0,0 +1,17 @@ +import 'prosekit/web/autocomplete' + +import type { AutocompleteEmptyElement } from 'prosekit/web/autocomplete' + +export function renderSlashMenuEmpty() { + const empty = document.createElement( + 'prosekit-autocomplete-empty', + ) as AutocompleteEmptyElement + empty.className = + 'relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800' + + const span = document.createElement('span') + span.textContent = 'No results' + empty.append(span) + + return empty +} diff --git a/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-item.ts b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-item.ts new file mode 100644 index 000000000..7730603ff --- /dev/null +++ b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu-item.ts @@ -0,0 +1,34 @@ +import 'prosekit/web/autocomplete' + +import type { + AutocompleteItemElement, + AutocompleteItemEvents, +} from 'prosekit/web/autocomplete' + +export function renderSlashMenuItem(options: { + label: string + kbd?: string + onSelect: (event: AutocompleteItemEvents['select']) => void +}) { + const item = document.createElement( + 'prosekit-autocomplete-item', + ) as AutocompleteItemElement + item.className = + 'relative flex items-center justify-between min-w-32 scroll-my-1 rounded-sm px-3 py-1.5 box-border cursor-default select-none whitespace-nowrap outline-hidden data-focused:bg-gray-100 dark:data-focused:bg-gray-800' + item.addEventListener('select', (event) => + options.onSelect(event as AutocompleteItemEvents['select']), + ) + + const span = document.createElement('span') + span.textContent = options.label + item.append(span) + + if (options.kbd) { + const kbd = document.createElement('kbd') + kbd.className = 'text-xs font-mono text-gray-400 dark:text-gray-500' + kbd.textContent = options.kbd + item.append(kbd) + } + + return item +} diff --git a/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu.ts b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu.ts new file mode 100644 index 000000000..7868dc566 --- /dev/null +++ b/vanilla-slash-menu/src/components/editor/ui/slash-menu/slash-menu.ts @@ -0,0 +1,118 @@ +import 'prosekit/web/autocomplete' + +import type { BasicExtension } from 'prosekit/basic' +import type { Editor } from 'prosekit/core' +import { canUseRegexLookbehind } from 'prosekit/core' +import type { + AutocompleteListElement, + AutocompletePopoverElement, +} from 'prosekit/web/autocomplete' + +import { renderSlashMenuEmpty } from './slash-menu-empty' +import { renderSlashMenuItem } from './slash-menu-item' + +// Match inputs like "/", "/table", "/heading 1" etc. Do not match "/ heading". +const regex = canUseRegexLookbehind() ? /(?) { + const popover = document.createElement( + 'prosekit-autocomplete-popover', + ) as AutocompletePopoverElement + popover.className = + 'relative block max-h-100 min-w-60 select-none overflow-auto whitespace-nowrap p-1 z-10 box-border rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-950 shadow-lg [&:not([data-state])]:hidden' + popover.editor = editor + popover.regex = regex + + const list = document.createElement( + 'prosekit-autocomplete-list', + ) as AutocompleteListElement + list.editor = editor + popover.append(list) + + list.append( + renderSlashMenuItem({ + label: 'Text', + kbd: undefined, + onSelect: () => editor.commands.setParagraph(), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Heading 1', + kbd: '#', + onSelect: () => editor.commands.setHeading({ level: 1 }), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Heading 2', + kbd: '##', + onSelect: () => editor.commands.setHeading({ level: 2 }), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Heading 3', + kbd: '###', + onSelect: () => editor.commands.setHeading({ level: 3 }), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Bullet list', + kbd: '-', + onSelect: () => editor.commands.wrapInList({ kind: 'bullet' }), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Ordered list', + kbd: '1.', + onSelect: () => editor.commands.wrapInList({ kind: 'ordered' }), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Task list', + kbd: '[]', + onSelect: () => editor.commands.wrapInList({ kind: 'task' }), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Toggle list', + kbd: '>>', + onSelect: () => editor.commands.wrapInList({ kind: 'toggle' }), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Quote', + kbd: '>', + onSelect: () => editor.commands.setBlockquote(), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Table', + onSelect: () => editor.commands.insertTable({ row: 3, col: 3 }), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Divider', + kbd: '---', + onSelect: () => editor.commands.insertHorizontalRule(), + }), + ) + list.append( + renderSlashMenuItem({ + label: 'Code', + kbd: '```', + onSelect: () => editor.commands.setCodeBlock(), + }), + ) + list.append(renderSlashMenuEmpty()) + + return popover +} diff --git a/vanilla-slash-menu/src/editor.ts b/vanilla-slash-menu/src/editor.ts new file mode 100644 index 000000000..877138fee --- /dev/null +++ b/vanilla-slash-menu/src/editor.ts @@ -0,0 +1,5 @@ +import { setupVanillaEditor } from './components/editor/examples/slash-menu' + +export function renderEditor() { + return setupVanillaEditor().render() +} diff --git a/vanilla-slash-menu/src/main.ts b/vanilla-slash-menu/src/main.ts new file mode 100644 index 000000000..a4945ae00 --- /dev/null +++ b/vanilla-slash-menu/src/main.ts @@ -0,0 +1,11 @@ +import './app.css' +import { renderEditor } from './editor' + +let container = document.querySelector('#app') +if (!container) { + container = document.createElement('div') + container.id = 'app' + document.body.appendChild(container) +} + +container.replaceChildren(renderEditor()) diff --git a/vanilla-slash-menu/tsconfig.json b/vanilla-slash-menu/tsconfig.json new file mode 100644 index 000000000..4ba8dd95c --- /dev/null +++ b/vanilla-slash-menu/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/vanilla-slash-menu/vite.config.ts b/vanilla-slash-menu/vite.config.ts new file mode 100644 index 000000000..fb0cdf00b --- /dev/null +++ b/vanilla-slash-menu/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [tailwindcss()], +})