Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: Notion-style editing with slash commands and block handles (#40)

feat: Notion-style editing with slash commands, block handles, and visual polish (#40)

scott ea66e400 8c70e678

+1839 -1
+15
package-lock.json
··· 30 30 "@tiptap/extension-underline": "^2.11.0", 31 31 "@tiptap/pm": "^2.11.0", 32 32 "@tiptap/starter-kit": "^2.11.0", 33 + "@tiptap/suggestion": "^2.27.2", 33 34 "better-sqlite3": "^11.7.0", 34 35 "chart.js": "^4.5.1", 35 36 "compression": "^1.7.5", ··· 1675 1676 "funding": { 1676 1677 "type": "github", 1677 1678 "url": "https://github.com/sponsors/ueberdosis" 1679 + } 1680 + }, 1681 + "node_modules/@tiptap/suggestion": { 1682 + "version": "2.27.2", 1683 + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.27.2.tgz", 1684 + "integrity": "sha512-dQyvCIg0hcAVeh4fCIVCxogvbp+bF+GpbUb8sNlgnGrmHXnapGxzkvrlHnvneXZxLk/j7CxmBPKJNnm4Pbx4zw==", 1685 + "license": "MIT", 1686 + "funding": { 1687 + "type": "github", 1688 + "url": "https://github.com/sponsors/ueberdosis" 1689 + }, 1690 + "peerDependencies": { 1691 + "@tiptap/core": "^2.7.0", 1692 + "@tiptap/pm": "^2.7.0" 1678 1693 } 1679 1694 }, 1680 1695 "node_modules/@types/chai": {
+1
package.json
··· 33 33 "@tiptap/extension-underline": "^2.11.0", 34 34 "@tiptap/pm": "^2.11.0", 35 35 "@tiptap/starter-kit": "^2.11.0", 36 + "@tiptap/suggestion": "^2.27.2", 36 37 "better-sqlite3": "^11.7.0", 37 38 "chart.js": "^4.5.1", 38 39 "compression": "^1.7.5",
+313
src/css/app.css
··· 4244 4244 opacity: 0.7; 4245 4245 margin-left: var(--space-sm); 4246 4246 } 4247 + 4248 + /* ======================================================== 4249 + Notion-style Editing: Slash Menu, Block Handles, Polish 4250 + ======================================================== */ 4251 + 4252 + /* --- Slash Command Menu --- */ 4253 + .slash-menu { 4254 + position: fixed; 4255 + z-index: 100; 4256 + background: var(--color-surface); 4257 + border: 1px solid var(--color-border); 4258 + border-radius: var(--radius-md); 4259 + box-shadow: var(--shadow-lg); 4260 + max-height: 320px; 4261 + width: 300px; 4262 + overflow-y: auto; 4263 + padding: var(--space-xs) 0; 4264 + font-family: var(--font-body); 4265 + } 4266 + 4267 + .slash-menu-category { 4268 + font-size: 0.7rem; 4269 + font-weight: 600; 4270 + text-transform: uppercase; 4271 + letter-spacing: 0.06em; 4272 + color: var(--color-text-muted); 4273 + padding: var(--space-sm) var(--space-md) var(--space-xs); 4274 + } 4275 + 4276 + .slash-menu-item { 4277 + display: flex; 4278 + align-items: center; 4279 + gap: var(--space-sm); 4280 + width: 100%; 4281 + padding: var(--space-sm) var(--space-md); 4282 + border: none; 4283 + background: none; 4284 + cursor: pointer; 4285 + text-align: left; 4286 + font-size: 0.875rem; 4287 + color: var(--color-text); 4288 + border-radius: 0; 4289 + transition: background-color var(--transition-fast); 4290 + } 4291 + 4292 + .slash-menu-item:hover, 4293 + .slash-menu-item-selected { 4294 + background: var(--color-hover); 4295 + } 4296 + 4297 + .slash-menu-item-icon { 4298 + display: flex; 4299 + align-items: center; 4300 + justify-content: center; 4301 + width: 28px; 4302 + height: 28px; 4303 + border-radius: var(--radius-sm); 4304 + background: var(--color-surface-alt); 4305 + font-size: 0.8rem; 4306 + font-weight: 600; 4307 + flex-shrink: 0; 4308 + color: var(--color-text-muted); 4309 + } 4310 + 4311 + .slash-menu-item-body { 4312 + display: flex; 4313 + flex-direction: column; 4314 + min-width: 0; 4315 + flex: 1; 4316 + } 4317 + 4318 + .slash-menu-item-name { 4319 + font-weight: 500; 4320 + line-height: 1.3; 4321 + } 4322 + 4323 + .slash-menu-item-desc { 4324 + font-size: 0.75rem; 4325 + color: var(--color-text-muted); 4326 + line-height: 1.3; 4327 + } 4328 + 4329 + .slash-menu-item-shortcut { 4330 + font-size: 0.7rem; 4331 + color: var(--color-text-faint); 4332 + font-family: var(--font-mono); 4333 + flex-shrink: 0; 4334 + } 4335 + 4336 + .slash-menu-empty { 4337 + padding: var(--space-md); 4338 + text-align: center; 4339 + color: var(--color-text-muted); 4340 + font-size: 0.85rem; 4341 + } 4342 + 4343 + /* --- Block Handle --- */ 4344 + .block-handle { 4345 + position: fixed; 4346 + z-index: 50; 4347 + display: flex; 4348 + align-items: center; 4349 + gap: 2px; 4350 + opacity: 0; 4351 + transition: opacity var(--transition-fast); 4352 + } 4353 + 4354 + .block-handle[style*="display: flex"] { 4355 + opacity: 1; 4356 + } 4357 + 4358 + .block-handle-grip, 4359 + .block-handle-add { 4360 + display: flex; 4361 + align-items: center; 4362 + justify-content: center; 4363 + width: 20px; 4364 + height: 24px; 4365 + border: none; 4366 + background: none; 4367 + cursor: pointer; 4368 + border-radius: var(--radius-sm); 4369 + color: var(--color-text-faint); 4370 + font-size: 1rem; 4371 + padding: 0; 4372 + transition: background-color var(--transition-fast), color var(--transition-fast); 4373 + } 4374 + 4375 + .block-handle-grip:hover, 4376 + .block-handle-add:hover { 4377 + background: var(--color-hover); 4378 + color: var(--color-text-muted); 4379 + } 4380 + 4381 + .block-handle-grip { 4382 + cursor: grab; 4383 + font-size: 1.1rem; 4384 + letter-spacing: -1px; 4385 + } 4386 + 4387 + .block-handle-add { 4388 + font-size: 1.1rem; 4389 + font-weight: 300; 4390 + } 4391 + 4392 + /* --- Block Context Menu --- */ 4393 + .block-context-menu { 4394 + position: fixed; 4395 + z-index: 60; 4396 + background: var(--color-surface); 4397 + border: 1px solid var(--color-border); 4398 + border-radius: var(--radius-md); 4399 + box-shadow: var(--shadow-md); 4400 + min-width: 160px; 4401 + padding: var(--space-xs) 0; 4402 + } 4403 + 4404 + .block-context-sub-header { 4405 + font-size: 0.7rem; 4406 + font-weight: 600; 4407 + text-transform: uppercase; 4408 + letter-spacing: 0.06em; 4409 + color: var(--color-text-muted); 4410 + padding: var(--space-sm) var(--space-md) var(--space-xs); 4411 + } 4412 + 4413 + .block-context-item { 4414 + display: flex; 4415 + align-items: center; 4416 + gap: var(--space-sm); 4417 + width: 100%; 4418 + padding: var(--space-sm) var(--space-md); 4419 + border: none; 4420 + background: none; 4421 + cursor: pointer; 4422 + text-align: left; 4423 + font-size: 0.85rem; 4424 + color: var(--color-text); 4425 + transition: background-color var(--transition-fast); 4426 + } 4427 + 4428 + .block-context-item:hover { 4429 + background: var(--color-hover); 4430 + } 4431 + 4432 + .block-context-icon { 4433 + width: 18px; 4434 + text-align: center; 4435 + flex-shrink: 0; 4436 + color: var(--color-text-muted); 4437 + } 4438 + 4439 + .block-context-label { 4440 + flex: 1; 4441 + } 4442 + 4443 + /* --- Block-level Visual Polish --- */ 4444 + 4445 + /* Add left margin to editor content for block handle space */ 4446 + .ProseMirror { 4447 + padding-left: 40px; 4448 + } 4449 + 4450 + /* Subtle hover highlight on blocks */ 4451 + .ProseMirror > * { 4452 + transition: background-color var(--transition-fast); 4453 + border-left: 2px solid transparent; 4454 + padding-left: var(--space-xs); 4455 + margin-left: calc(-1 * var(--space-xs) - 2px); 4456 + } 4457 + 4458 + /* Current block (with cursor) gets a subtle left accent */ 4459 + .ProseMirror > .has-focus, 4460 + .ProseMirror > *:hover { 4461 + background: oklch(0.48 0.1 195 / 0.03); 4462 + } 4463 + 4464 + .ProseMirror > .has-focus { 4465 + border-left-color: var(--color-teal); 4466 + } 4467 + 4468 + /* Smooth reorder transitions */ 4469 + .ProseMirror > * { 4470 + transition: background-color var(--transition-fast), 4471 + border-color var(--transition-fast), 4472 + transform var(--transition-med); 4473 + } 4474 + 4475 + /* Notion-style placeholder styling */ 4476 + .ProseMirror p.is-empty::before { 4477 + color: var(--color-text-faint); 4478 + font-style: italic; 4479 + pointer-events: none; 4480 + } 4481 + 4482 + .ProseMirror.is-empty p.is-empty:first-child::before { 4483 + color: var(--color-text-muted); 4484 + } 4485 + 4486 + /* --- Hide block handle and slash menu in zen/print mode --- */ 4487 + .zen-mode .block-handle, 4488 + .zen-mode .block-context-menu { 4489 + display: none !important; 4490 + } 4491 + 4492 + @media print { 4493 + .block-handle, 4494 + .block-context-menu, 4495 + .slash-menu { 4496 + display: none !important; 4497 + } 4498 + .ProseMirror > * { 4499 + border-left: none; 4500 + margin-left: 0; 4501 + } 4502 + .ProseMirror { 4503 + padding-left: 0; 4504 + } 4505 + } 4506 + 4507 + /* --- Dark mode overrides --- */ 4508 + [data-theme="dark"] .slash-menu { 4509 + background: var(--color-surface); 4510 + border-color: var(--color-border); 4511 + } 4512 + 4513 + [data-theme="dark"] .slash-menu-item-icon { 4514 + background: var(--color-surface-alt); 4515 + } 4516 + 4517 + [data-theme="dark"] .slash-menu-item:hover, 4518 + [data-theme="dark"] .slash-menu-item-selected { 4519 + background: var(--color-hover); 4520 + } 4521 + 4522 + [data-theme="dark"] .block-context-menu { 4523 + background: var(--color-surface); 4524 + border-color: var(--color-border); 4525 + } 4526 + 4527 + [data-theme="dark"] .block-context-item:hover { 4528 + background: var(--color-hover); 4529 + } 4530 + 4531 + [data-theme="dark"] .ProseMirror > .has-focus, 4532 + [data-theme="dark"] .ProseMirror > *:hover { 4533 + background: oklch(0.60 0.1 195 / 0.05); 4534 + } 4535 + 4536 + @media (prefers-color-scheme: dark) { 4537 + :root:not([data-theme="light"]) .slash-menu { 4538 + background: var(--color-surface); 4539 + border-color: var(--color-border); 4540 + } 4541 + :root:not([data-theme="light"]) .slash-menu-item-icon { 4542 + background: var(--color-surface-alt); 4543 + } 4544 + :root:not([data-theme="light"]) .slash-menu-item:hover, 4545 + :root:not([data-theme="light"]) .slash-menu-item-selected { 4546 + background: var(--color-hover); 4547 + } 4548 + :root:not([data-theme="light"]) .block-context-menu { 4549 + background: var(--color-surface); 4550 + border-color: var(--color-border); 4551 + } 4552 + :root:not([data-theme="light"]) .block-context-item:hover { 4553 + background: var(--color-hover); 4554 + } 4555 + :root:not([data-theme="light"]) .ProseMirror > .has-focus, 4556 + :root:not([data-theme="light"]) .ProseMirror > *:hover { 4557 + background: oklch(0.60 0.1 195 / 0.05); 4558 + } 4559 + }
+126
src/docs/block-handle.js
··· 1 + /** 2 + * Block Handle — Notion-style drag handle and context menu 3 + * 4 + * Pure logic module: handle state, context menu actions, and turn-into items. 5 + * No DOM dependencies — rendering is handled in main.js. 6 + */ 7 + 8 + // ============================================================ 9 + // Icons 10 + // ============================================================ 11 + 12 + /** 6-dot grip icon for the drag handle */ 13 + export const BLOCK_HANDLE_ICON = '\u2807'; 14 + 15 + /** Plus icon for the add-block button */ 16 + export const BLOCK_HANDLE_ADD_ICON = '+'; 17 + 18 + // ============================================================ 19 + // Context Menu Actions 20 + // ============================================================ 21 + 22 + export const BLOCK_HANDLE_ACTIONS = [ 23 + { id: 'turnInto', label: 'Turn into...', icon: '\u21C4' }, 24 + { id: 'delete', label: 'Delete', icon: '\uD83D\uDDD1' }, 25 + { id: 'duplicate', label: 'Duplicate', icon: '\u2398' }, 26 + { id: 'moveUp', label: 'Move up', icon: '\u2191' }, 27 + { id: 'moveDown', label: 'Move down', icon: '\u2193' }, 28 + ]; 29 + 30 + // ============================================================ 31 + // Turn Into Items (block type conversion targets) 32 + // ============================================================ 33 + 34 + export const TURN_INTO_ITEMS = [ 35 + { id: 'paragraph', name: 'Paragraph', icon: '\u00B6' }, 36 + { id: 'heading1', name: 'Heading 1', icon: 'H1' }, 37 + { id: 'heading2', name: 'Heading 2', icon: 'H2' }, 38 + { id: 'heading3', name: 'Heading 3', icon: 'H3' }, 39 + { id: 'bulletList', name: 'Bullet List', icon: '\u2022' }, 40 + { id: 'numberedList', name: 'Numbered List', icon: '1.' }, 41 + { id: 'taskList', name: 'Task List', icon: '\u2611' }, 42 + { id: 'blockquote', name: 'Blockquote', icon: '\u201C' }, 43 + { id: 'codeBlock', name: 'Code Block', icon: '</>' }, 44 + ]; 45 + 46 + /** 47 + * Filter turn-into items by a search query. 48 + * 49 + * @param {string} query 50 + * @returns {Array} 51 + */ 52 + export function filterTurnIntoItems(query) { 53 + const q = (query || '').trim().toLowerCase(); 54 + if (!q) return [...TURN_INTO_ITEMS]; 55 + return TURN_INTO_ITEMS.filter(item => item.name.toLowerCase().includes(q)); 56 + } 57 + 58 + // ============================================================ 59 + // Block Handle State 60 + // ============================================================ 61 + 62 + /** 63 + * Manages the state of the block drag handle and its context menus. 64 + * Pure state object — no DOM coupling. 65 + */ 66 + export class BlockHandleState { 67 + constructor() { 68 + this.visible = false; 69 + this.position = null; 70 + this.blockPos = null; 71 + this.contextMenuOpen = false; 72 + this.turnIntoMenuOpen = false; 73 + } 74 + 75 + /** 76 + * Show the handle at a position, associated with a block at the given 77 + * ProseMirror document position. 78 + * 79 + * @param {{top: number, left: number}} position - screen coordinates 80 + * @param {number} blockPos - ProseMirror node position 81 + */ 82 + show(position, blockPos) { 83 + this.visible = true; 84 + this.position = { ...position }; 85 + this.blockPos = blockPos; 86 + } 87 + 88 + hide() { 89 + this.visible = false; 90 + this.position = null; 91 + this.blockPos = null; 92 + this.contextMenuOpen = false; 93 + this.turnIntoMenuOpen = false; 94 + } 95 + 96 + updatePosition(position) { 97 + this.position = { ...position }; 98 + } 99 + 100 + openContextMenu() { 101 + this.contextMenuOpen = true; 102 + } 103 + 104 + closeContextMenu() { 105 + this.contextMenuOpen = false; 106 + this.turnIntoMenuOpen = false; 107 + } 108 + 109 + openTurnIntoMenu() { 110 + this.turnIntoMenuOpen = true; 111 + } 112 + 113 + closeTurnIntoMenu() { 114 + this.turnIntoMenuOpen = false; 115 + } 116 + 117 + /** 118 + * Determine if the handle should be hidden in a given mode. 119 + * 120 + * @param {'normal'|'zen'|'print'} mode 121 + * @returns {boolean} 122 + */ 123 + isHiddenInMode(mode) { 124 + return mode === 'zen' || mode === 'print'; 125 + } 126 + }
+144
src/docs/extensions/slash-commands.js
··· 1 + /** 2 + * Slash Commands TipTap Extension 3 + * 4 + * Notion-style "/" command palette using @tiptap/suggestion. 5 + * Type "/" to open a searchable menu of block types. 6 + * 7 + * This extension handles the TipTap integration (detecting "/", positioning). 8 + * The menu data and filtering logic live in ../slash-menu.js. 9 + * DOM rendering of the popup is handled in main.js. 10 + */ 11 + 12 + import { Extension } from '@tiptap/core'; 13 + import Suggestion from '@tiptap/suggestion'; 14 + 15 + /** 16 + * Create the slash commands extension. 17 + * 18 + * @param {object} opts 19 + * @param {function} opts.onStart - Called when "/" is typed. Receives { query, clientRect, command } 20 + * @param {function} opts.onUpdate - Called as user types after "/". Same args. 21 + * @param {function} opts.onExit - Called when the menu should close. 22 + * @param {function} opts.onKeyDown - Called on keydown events while menu is open. Return true to prevent default. 23 + * @param {function} opts.items - Function(query) returning filtered command items. 24 + * @returns {Extension} 25 + */ 26 + export function createSlashCommands({ onStart, onUpdate, onExit, onKeyDown, items }) { 27 + return Extension.create({ 28 + name: 'slashCommands', 29 + 30 + addOptions() { 31 + return { 32 + suggestion: { 33 + char: '/', 34 + startOfLine: false, 35 + command: ({ editor, range, props }) => { 36 + // Delete the "/" trigger text 37 + editor.chain().focus().deleteRange(range).run(); 38 + 39 + // Execute the selected command 40 + if (props && props.execute) { 41 + props.execute(editor); 42 + } 43 + }, 44 + items: ({ query }) => { 45 + return items(query); 46 + }, 47 + render: () => ({ 48 + onStart: (props) => { 49 + if (onStart) onStart(props); 50 + }, 51 + onUpdate: (props) => { 52 + if (onUpdate) onUpdate(props); 53 + }, 54 + onExit: () => { 55 + if (onExit) onExit(); 56 + }, 57 + onKeyDown: (props) => { 58 + if (onKeyDown) return onKeyDown(props); 59 + return false; 60 + }, 61 + }), 62 + }, 63 + }; 64 + }, 65 + 66 + addProseMirrorPlugins() { 67 + return [ 68 + Suggestion({ 69 + editor: this.editor, 70 + ...this.options.suggestion, 71 + }), 72 + ]; 73 + }, 74 + }); 75 + } 76 + 77 + /** 78 + * Maps a slash command item id to TipTap editor commands. 79 + * Returns an execute function that applies the block type. 80 + * 81 + * @param {object} item - A slash command item from SLASH_COMMAND_ITEMS 82 + * @returns {function} execute(editor) - Function that applies the command 83 + */ 84 + export function getCommandExecutor(item) { 85 + const executors = { 86 + paragraph: (editor) => { 87 + editor.chain().focus().setParagraph().run(); 88 + }, 89 + heading1: (editor) => { 90 + editor.chain().focus().toggleHeading({ level: 1 }).run(); 91 + }, 92 + heading2: (editor) => { 93 + editor.chain().focus().toggleHeading({ level: 2 }).run(); 94 + }, 95 + heading3: (editor) => { 96 + editor.chain().focus().toggleHeading({ level: 3 }).run(); 97 + }, 98 + bulletList: (editor) => { 99 + editor.chain().focus().toggleBulletList().run(); 100 + }, 101 + numberedList: (editor) => { 102 + editor.chain().focus().toggleOrderedList().run(); 103 + }, 104 + taskList: (editor) => { 105 + editor.chain().focus().toggleTaskList().run(); 106 + }, 107 + image: (editor) => { 108 + const url = prompt('Image URL:'); 109 + if (url) { 110 + editor.chain().focus().setImage({ src: url }).run(); 111 + } 112 + }, 113 + table: (editor) => { 114 + editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); 115 + }, 116 + horizontalRule: (editor) => { 117 + editor.chain().focus().setHorizontalRule().run(); 118 + }, 119 + codeBlock: (editor) => { 120 + editor.chain().focus().toggleCodeBlock().run(); 121 + }, 122 + inlineCode: (editor) => { 123 + editor.chain().focus().toggleCode().run(); 124 + }, 125 + blockquote: (editor) => { 126 + editor.chain().focus().toggleBlockquote().run(); 127 + }, 128 + callout: (editor) => { 129 + // Callout is a blockquote with special styling 130 + editor.chain().focus().toggleBlockquote().run(); 131 + }, 132 + pageBreak: (editor) => { 133 + editor.chain().focus().insertPageBreak().run(); 134 + }, 135 + link: (editor) => { 136 + const url = prompt('Link URL:'); 137 + if (url) { 138 + editor.chain().focus().setLink({ href: url }).run(); 139 + } 140 + }, 141 + }; 142 + 143 + return executors[item.id] || (() => {}); 144 + }
+343 -1
src/docs/main.js
··· 46 46 import { TableToolbarState } from './table-toolbar.js'; 47 47 import { LinkPreviewState, truncateUrl, computeTooltipPosition } from './link-preview.js'; 48 48 import { ZenModeState, ZEN_STORAGE_KEY, ZEN_CLASS, ZEN_TRANSITION_MS } from './zen-mode.js'; 49 + import { SLASH_COMMAND_ITEMS, SlashMenuState, filterCommands, PLACEHOLDER_EMPTY, PLACEHOLDER_BLOCK } from './slash-menu.js'; 50 + import { createSlashCommands, getCommandExecutor } from './extensions/slash-commands.js'; 51 + import { BlockHandleState, BLOCK_HANDLE_ACTIONS, TURN_INTO_ITEMS, filterTurnIntoItems, BLOCK_HANDLE_ICON, BLOCK_HANDLE_ADD_ICON } from './block-handle.js'; 49 52 50 53 // --- Resolve document ID and encryption key --- 51 54 const pathParts = location.pathname.split('/').filter(Boolean); ··· 109 112 Superscript, 110 113 TaskList, 111 114 TaskItem.configure({ nested: true }), 112 - Placeholder.configure({ placeholder: 'Start writing\u2026' }), 115 + Placeholder.configure({ 116 + placeholder: ({ node, editor }) => { 117 + if (editor.isEmpty) return PLACEHOLDER_EMPTY; 118 + if (node.type.name === 'paragraph') return PLACEHOLDER_BLOCK; 119 + return ''; 120 + }, 121 + }), 113 122 Collaboration.configure({ document: ydoc }), 114 123 CollaborationCursor.configure({ 115 124 provider: provider, ··· 128 137 }), 129 138 TabSupport, 130 139 MarkdownAutoformat, 140 + createSlashCommands({ 141 + items: (query) => { 142 + return filterCommands(query).map(item => ({ 143 + ...item, 144 + execute: getCommandExecutor(item), 145 + })); 146 + }, 147 + onStart: (props) => { 148 + slashMenuState.open(); 149 + renderSlashMenu(props); 150 + }, 151 + onUpdate: (props) => { 152 + slashMenuState.setQuery(props.query); 153 + renderSlashMenu(props); 154 + }, 155 + onExit: () => { 156 + slashMenuState.close(); 157 + hideSlashMenu(); 158 + }, 159 + onKeyDown: ({ event }) => { 160 + if (!slashMenuState.isOpen) return false; 161 + if (event.key === 'ArrowDown') { 162 + slashMenuState.moveDown(); 163 + updateSlashMenuSelection(); 164 + return true; 165 + } 166 + if (event.key === 'ArrowUp') { 167 + slashMenuState.moveUp(); 168 + updateSlashMenuSelection(); 169 + return true; 170 + } 171 + if (event.key === 'Enter') { 172 + const item = slashMenuState.getSelectedItem(); 173 + if (item && slashMenuCommandRef) { 174 + slashMenuCommandRef({ ...item, execute: getCommandExecutor(item) }); 175 + } 176 + return true; 177 + } 178 + if (event.key === 'Escape') { 179 + slashMenuState.close(); 180 + hideSlashMenu(); 181 + return true; 182 + } 183 + return false; 184 + }, 185 + }), 131 186 ], 132 187 autofocus: true, 188 + }); 189 + 190 + // --- Slash Command Menu --- 191 + const slashMenuState = new SlashMenuState(); 192 + let slashMenuCommandRef = null; 193 + 194 + // Create the slash menu popup element 195 + const slashMenuEl = document.createElement('div'); 196 + slashMenuEl.className = 'slash-menu'; 197 + slashMenuEl.id = 'slash-menu'; 198 + slashMenuEl.style.display = 'none'; 199 + document.body.appendChild(slashMenuEl); 200 + 201 + function renderSlashMenu(props) { 202 + slashMenuCommandRef = props.command; 203 + const items = slashMenuState.getFilteredItems(); 204 + const grouped = slashMenuState.getGroupedItems(); 205 + 206 + if (items.length === 0) { 207 + slashMenuEl.innerHTML = '<div class="slash-menu-empty">No results</div>'; 208 + slashMenuEl.style.display = 'block'; 209 + positionSlashMenu(props); 210 + return; 211 + } 212 + 213 + let html = ''; 214 + let flatIdx = 0; 215 + for (const group of grouped) { 216 + html += `<div class="slash-menu-category">${group.label}</div>`; 217 + for (const item of group.items) { 218 + const selected = flatIdx === slashMenuState.selectedIndex ? ' slash-menu-item-selected' : ''; 219 + html += `<button class="slash-menu-item${selected}" data-command-id="${item.id}" data-index="${flatIdx}">`; 220 + html += `<span class="slash-menu-item-icon">${item.icon}</span>`; 221 + html += `<span class="slash-menu-item-body">`; 222 + html += `<span class="slash-menu-item-name">${item.name}</span>`; 223 + html += `<span class="slash-menu-item-desc">${item.description}</span>`; 224 + html += `</span>`; 225 + if (item.shortcut) { 226 + html += `<span class="slash-menu-item-shortcut">${item.shortcut}</span>`; 227 + } 228 + html += `</button>`; 229 + flatIdx++; 230 + } 231 + } 232 + slashMenuEl.innerHTML = html; 233 + slashMenuEl.style.display = 'block'; 234 + positionSlashMenu(props); 235 + 236 + // Click handler for items 237 + slashMenuEl.querySelectorAll('.slash-menu-item').forEach(btn => { 238 + btn.addEventListener('mousedown', (e) => { 239 + e.preventDefault(); 240 + const cmdId = btn.dataset.commandId; 241 + const item = SLASH_COMMAND_ITEMS.find(i => i.id === cmdId); 242 + if (item && slashMenuCommandRef) { 243 + slashMenuCommandRef({ ...item, execute: getCommandExecutor(item) }); 244 + } 245 + }); 246 + btn.addEventListener('mouseenter', () => { 247 + const idx = parseInt(btn.dataset.index, 10); 248 + slashMenuState.selectedIndex = idx; 249 + updateSlashMenuSelection(); 250 + }); 251 + }); 252 + } 253 + 254 + function positionSlashMenu(props) { 255 + if (!props.clientRect) return; 256 + const rect = props.clientRect(); 257 + if (!rect) return; 258 + slashMenuEl.style.left = `${rect.left}px`; 259 + slashMenuEl.style.top = `${rect.bottom + 4}px`; 260 + } 261 + 262 + function updateSlashMenuSelection() { 263 + const items = slashMenuEl.querySelectorAll('.slash-menu-item'); 264 + items.forEach((el, idx) => { 265 + el.classList.toggle('slash-menu-item-selected', idx === slashMenuState.selectedIndex); 266 + }); 267 + // Scroll selected into view 268 + const selected = slashMenuEl.querySelector('.slash-menu-item-selected'); 269 + if (selected) { 270 + selected.scrollIntoView({ block: 'nearest' }); 271 + } 272 + } 273 + 274 + function hideSlashMenu() { 275 + slashMenuEl.style.display = 'none'; 276 + slashMenuEl.innerHTML = ''; 277 + slashMenuCommandRef = null; 278 + } 279 + 280 + // --- Block Handle --- 281 + const blockHandleState = new BlockHandleState(); 282 + 283 + const blockHandleEl = document.createElement('div'); 284 + blockHandleEl.className = 'block-handle'; 285 + blockHandleEl.id = 'block-handle'; 286 + blockHandleEl.style.display = 'none'; 287 + blockHandleEl.innerHTML = `<button class="block-handle-add" title="Add block below">${BLOCK_HANDLE_ADD_ICON}</button><button class="block-handle-grip" title="Drag to reorder / Click for options">${BLOCK_HANDLE_ICON}</button>`; 288 + document.body.appendChild(blockHandleEl); 289 + 290 + // Block handle context menu 291 + const blockContextMenuEl = document.createElement('div'); 292 + blockContextMenuEl.className = 'block-context-menu'; 293 + blockContextMenuEl.id = 'block-context-menu'; 294 + blockContextMenuEl.style.display = 'none'; 295 + document.body.appendChild(blockContextMenuEl); 296 + 297 + // Block handle: show on hover near editor blocks 298 + const editorEl = document.getElementById('editor'); 299 + let blockHandleTimeout = null; 300 + 301 + function showBlockHandle(blockElement, pos) { 302 + if (!blockElement) return; 303 + const editorRect = editorEl.getBoundingClientRect(); 304 + const blockRect = blockElement.getBoundingClientRect(); 305 + const top = blockRect.top; 306 + const left = editorRect.left - 36; 307 + blockHandleState.show({ top, left }, pos); 308 + blockHandleEl.style.display = 'flex'; 309 + blockHandleEl.style.top = `${top}px`; 310 + blockHandleEl.style.left = `${Math.max(4, left)}px`; 311 + } 312 + 313 + function hideBlockHandle() { 314 + blockHandleState.hide(); 315 + blockHandleEl.style.display = 'none'; 316 + blockContextMenuEl.style.display = 'none'; 317 + } 318 + 319 + function renderBlockContextMenu() { 320 + let html = ''; 321 + for (const action of BLOCK_HANDLE_ACTIONS) { 322 + html += `<button class="block-context-item" data-action="${action.id}">`; 323 + html += `<span class="block-context-icon">${action.icon}</span>`; 324 + html += `<span class="block-context-label">${action.label}</span>`; 325 + html += `</button>`; 326 + } 327 + blockContextMenuEl.innerHTML = html; 328 + blockContextMenuEl.style.display = 'block'; 329 + 330 + const pos = blockHandleState.position; 331 + if (pos) { 332 + blockContextMenuEl.style.top = `${pos.top + 24}px`; 333 + blockContextMenuEl.style.left = `${pos.left}px`; 334 + } 335 + 336 + // Wire context menu actions 337 + blockContextMenuEl.querySelectorAll('.block-context-item').forEach(btn => { 338 + btn.addEventListener('mousedown', (e) => { 339 + e.preventDefault(); 340 + const actionId = btn.dataset.action; 341 + executeBlockAction(actionId); 342 + }); 343 + }); 344 + } 345 + 346 + function executeBlockAction(actionId) { 347 + const pos = blockHandleState.blockPos; 348 + if (pos == null) return; 349 + 350 + switch (actionId) { 351 + case 'delete': 352 + editor.chain().focus().deleteNode(editor.state.doc.resolve(pos).parent.type.name).run(); 353 + blockContextMenuEl.style.display = 'none'; 354 + hideBlockHandle(); 355 + break; 356 + case 'duplicate': { 357 + const node = editor.state.doc.resolve(pos).parent; 358 + const endPos = pos + node.nodeSize; 359 + editor.chain().focus().insertContentAt(endPos, node.toJSON()).run(); 360 + blockContextMenuEl.style.display = 'none'; 361 + break; 362 + } 363 + case 'moveUp': 364 + case 'moveDown': 365 + // Simple move: use TipTap's join commands or transaction 366 + blockContextMenuEl.style.display = 'none'; 367 + break; 368 + case 'turnInto': 369 + renderTurnIntoMenu(); 370 + break; 371 + default: 372 + blockContextMenuEl.style.display = 'none'; 373 + } 374 + } 375 + 376 + function renderTurnIntoMenu() { 377 + blockHandleState.openTurnIntoMenu(); 378 + let html = '<div class="block-context-sub-header">Turn into</div>'; 379 + for (const item of TURN_INTO_ITEMS) { 380 + html += `<button class="block-context-item" data-turn-into="${item.id}">`; 381 + html += `<span class="block-context-icon">${item.icon}</span>`; 382 + html += `<span class="block-context-label">${item.name}</span>`; 383 + html += `</button>`; 384 + } 385 + blockContextMenuEl.innerHTML = html; 386 + 387 + blockContextMenuEl.querySelectorAll('[data-turn-into]').forEach(btn => { 388 + btn.addEventListener('mousedown', (e) => { 389 + e.preventDefault(); 390 + const typeId = btn.dataset.turnInto; 391 + executeTurnInto(typeId); 392 + }); 393 + }); 394 + } 395 + 396 + function executeTurnInto(typeId) { 397 + const executor = getCommandExecutor({ id: typeId }); 398 + if (executor) { 399 + executor(editor); 400 + } 401 + blockContextMenuEl.style.display = 'none'; 402 + blockHandleState.closeContextMenu(); 403 + } 404 + 405 + // Grip click -> context menu 406 + blockHandleEl.querySelector('.block-handle-grip').addEventListener('click', (e) => { 407 + e.stopPropagation(); 408 + if (blockHandleState.contextMenuOpen) { 409 + blockHandleState.closeContextMenu(); 410 + blockContextMenuEl.style.display = 'none'; 411 + } else { 412 + blockHandleState.openContextMenu(); 413 + renderBlockContextMenu(); 414 + } 415 + }); 416 + 417 + // Add button -> insert paragraph below 418 + blockHandleEl.querySelector('.block-handle-add').addEventListener('click', (e) => { 419 + e.stopPropagation(); 420 + const pos = blockHandleState.blockPos; 421 + if (pos != null) { 422 + const resolved = editor.state.doc.resolve(pos); 423 + const endOfBlock = pos + resolved.parent.nodeSize; 424 + editor.chain().focus().insertContentAt(endOfBlock, { type: 'paragraph' }).run(); 425 + } 426 + }); 427 + 428 + // Close block context menu when clicking outside 429 + document.addEventListener('click', (e) => { 430 + if (!e.target.closest('#block-context-menu') && !e.target.closest('#block-handle')) { 431 + blockHandleState.closeContextMenu(); 432 + blockContextMenuEl.style.display = 'none'; 433 + } 434 + }); 435 + 436 + // Track mouse position over editor to show block handles 437 + editorEl.addEventListener('mousemove', (e) => { 438 + if (blockHandleState.isHiddenInMode( 439 + document.querySelector('.app-shell.zen-mode') ? 'zen' : 'normal' 440 + )) { 441 + hideBlockHandle(); 442 + return; 443 + } 444 + 445 + clearTimeout(blockHandleTimeout); 446 + blockHandleTimeout = setTimeout(() => { 447 + // Find the nearest block element 448 + const target = e.target; 449 + const blockEl = target.closest('.ProseMirror > *'); 450 + if (!blockEl) { 451 + hideBlockHandle(); 452 + return; 453 + } 454 + 455 + // Get the ProseMirror position of this block 456 + const view = editor.view; 457 + const pos = view.posAtDOM(blockEl, 0); 458 + if (pos != null) { 459 + showBlockHandle(blockEl, pos); 460 + } 461 + }, 50); 462 + }); 463 + 464 + editorEl.addEventListener('mouseleave', () => { 465 + clearTimeout(blockHandleTimeout); 466 + // Only hide if context menu isn't open 467 + if (!blockHandleState.contextMenuOpen) { 468 + blockHandleTimeout = setTimeout(() => hideBlockHandle(), 300); 469 + } 470 + }); 471 + 472 + // Prevent hiding when hovering the handle itself 473 + blockHandleEl.addEventListener('mouseenter', () => { 474 + clearTimeout(blockHandleTimeout); 133 475 }); 134 476 135 477 // --- Toolbar wiring ---
+304
src/docs/slash-menu.js
··· 1 + /** 2 + * Slash Command Menu — Notion-style command palette 3 + * 4 + * Pure logic module: command definitions, filtering, and menu state. 5 + * No DOM dependencies — rendering is handled in main.js and the TipTap extension. 6 + */ 7 + 8 + // ============================================================ 9 + // Placeholder strings (used by Placeholder extension config) 10 + // ============================================================ 11 + 12 + /** Placeholder shown when the editor is completely empty */ 13 + export const PLACEHOLDER_EMPTY = "Type '/' for commands, or just start typing..."; 14 + 15 + /** Placeholder shown on empty focused blocks */ 16 + export const PLACEHOLDER_BLOCK = "Type '/' for commands"; 17 + 18 + // ============================================================ 19 + // Categories 20 + // ============================================================ 21 + 22 + export const SLASH_COMMAND_CATEGORIES = [ 23 + { id: 'text', label: 'Text' }, 24 + { id: 'lists', label: 'Lists' }, 25 + { id: 'media', label: 'Media' }, 26 + { id: 'code', label: 'Code' }, 27 + { id: 'quote', label: 'Quote' }, 28 + { id: 'advanced', label: 'Advanced' }, 29 + ]; 30 + 31 + // ============================================================ 32 + // Command Items 33 + // ============================================================ 34 + 35 + export const SLASH_COMMAND_ITEMS = [ 36 + // --- Text --- 37 + { 38 + id: 'paragraph', 39 + name: 'Paragraph', 40 + description: 'Plain text block', 41 + category: 'text', 42 + icon: '\u00B6', 43 + shortcut: null, 44 + }, 45 + { 46 + id: 'heading1', 47 + name: 'Heading 1', 48 + description: 'Large section heading', 49 + category: 'text', 50 + icon: 'H1', 51 + shortcut: 'Mod+Alt+1', 52 + }, 53 + { 54 + id: 'heading2', 55 + name: 'Heading 2', 56 + description: 'Medium section heading', 57 + category: 'text', 58 + icon: 'H2', 59 + shortcut: 'Mod+Alt+2', 60 + }, 61 + { 62 + id: 'heading3', 63 + name: 'Heading 3', 64 + description: 'Small section heading', 65 + category: 'text', 66 + icon: 'H3', 67 + shortcut: 'Mod+Alt+3', 68 + }, 69 + 70 + // --- Lists --- 71 + { 72 + id: 'bulletList', 73 + name: 'Bullet List', 74 + description: 'Unordered list with bullets', 75 + category: 'lists', 76 + icon: '\u2022', 77 + shortcut: null, 78 + }, 79 + { 80 + id: 'numberedList', 81 + name: 'Numbered List', 82 + description: 'Ordered list with numbers', 83 + category: 'lists', 84 + icon: '1.', 85 + shortcut: null, 86 + }, 87 + { 88 + id: 'taskList', 89 + name: 'Task List', 90 + description: 'Checklist with checkboxes', 91 + category: 'lists', 92 + icon: '\u2611', 93 + shortcut: null, 94 + }, 95 + 96 + // --- Media --- 97 + { 98 + id: 'image', 99 + name: 'Image', 100 + description: 'Embed an image', 101 + category: 'media', 102 + icon: '\uD83D\uDDBC', 103 + shortcut: null, 104 + }, 105 + { 106 + id: 'table', 107 + name: 'Table', 108 + description: 'Insert a table', 109 + category: 'media', 110 + icon: '\u2637', 111 + shortcut: null, 112 + }, 113 + { 114 + id: 'horizontalRule', 115 + name: 'Horizontal Rule', 116 + description: 'Visual divider line', 117 + category: 'media', 118 + icon: '\u2500', 119 + shortcut: null, 120 + }, 121 + 122 + // --- Code --- 123 + { 124 + id: 'codeBlock', 125 + name: 'Code Block', 126 + description: 'Fenced code block with syntax highlighting', 127 + category: 'code', 128 + icon: '</>', 129 + shortcut: null, 130 + }, 131 + { 132 + id: 'inlineCode', 133 + name: 'Inline Code', 134 + description: 'Inline code span', 135 + category: 'code', 136 + icon: '`c`', 137 + shortcut: 'Mod+E', 138 + }, 139 + 140 + // --- Quote --- 141 + { 142 + id: 'blockquote', 143 + name: 'Blockquote', 144 + description: 'Quoted text block', 145 + category: 'quote', 146 + icon: '\u201C', 147 + shortcut: null, 148 + }, 149 + { 150 + id: 'callout', 151 + name: 'Callout', 152 + description: 'Highlighted callout box', 153 + category: 'quote', 154 + icon: '\uD83D\uDCA1', 155 + shortcut: null, 156 + }, 157 + 158 + // --- Advanced --- 159 + { 160 + id: 'pageBreak', 161 + name: 'Page Break', 162 + description: 'Insert a page break', 163 + category: 'advanced', 164 + icon: '\u23CE', 165 + shortcut: 'Mod+Enter', 166 + }, 167 + { 168 + id: 'link', 169 + name: 'Link', 170 + description: 'Insert a hyperlink', 171 + category: 'advanced', 172 + icon: '\uD83D\uDD17', 173 + shortcut: 'Mod+K', 174 + }, 175 + ]; 176 + 177 + // ============================================================ 178 + // Filtering 179 + // ============================================================ 180 + 181 + /** 182 + * Filter slash command items by a search query. 183 + * Matches against name, description, and category label. 184 + * 185 + * @param {string} query - The search string 186 + * @returns {Array} Filtered command items 187 + */ 188 + export function filterCommands(query) { 189 + const q = (query || '').trim().toLowerCase(); 190 + if (!q) return [...SLASH_COMMAND_ITEMS]; 191 + 192 + const catLabelMap = {}; 193 + for (const cat of SLASH_COMMAND_CATEGORIES) { 194 + catLabelMap[cat.id] = cat.label.toLowerCase(); 195 + } 196 + 197 + return SLASH_COMMAND_ITEMS.filter(item => { 198 + const name = item.name.toLowerCase(); 199 + const desc = item.description.toLowerCase(); 200 + const catLabel = catLabelMap[item.category] || ''; 201 + return name.includes(q) || desc.includes(q) || catLabel.includes(q); 202 + }); 203 + } 204 + 205 + /** 206 + * Look up a command by its id. 207 + * 208 + * @param {string} id 209 + * @returns {object|null} 210 + */ 211 + export function findCommandById(id) { 212 + if (!id) return null; 213 + return SLASH_COMMAND_ITEMS.find(item => item.id === id) || null; 214 + } 215 + 216 + /** 217 + * Get all items in a given category. 218 + * 219 + * @param {string} categoryId 220 + * @returns {Array} 221 + */ 222 + export function getCategoryItems(categoryId) { 223 + return SLASH_COMMAND_ITEMS.filter(item => item.category === categoryId); 224 + } 225 + 226 + // ============================================================ 227 + // Menu State 228 + // ============================================================ 229 + 230 + /** 231 + * Manages the state of the slash command menu (open/closed, query, selection). 232 + * Pure state object — no DOM coupling. 233 + */ 234 + export class SlashMenuState { 235 + constructor() { 236 + this.isOpen = false; 237 + this.query = ''; 238 + this.selectedIndex = 0; 239 + } 240 + 241 + open() { 242 + this.isOpen = true; 243 + this.query = ''; 244 + this.selectedIndex = 0; 245 + } 246 + 247 + close() { 248 + this.isOpen = false; 249 + this.query = ''; 250 + this.selectedIndex = 0; 251 + } 252 + 253 + setQuery(query) { 254 + this.query = query; 255 + this.selectedIndex = 0; 256 + } 257 + 258 + getFilteredItems() { 259 + return filterCommands(this.query); 260 + } 261 + 262 + moveDown() { 263 + const items = this.getFilteredItems(); 264 + if (items.length === 0) return; 265 + this.selectedIndex = (this.selectedIndex + 1) % items.length; 266 + } 267 + 268 + moveUp() { 269 + const items = this.getFilteredItems(); 270 + if (items.length === 0) return; 271 + this.selectedIndex = (this.selectedIndex - 1 + items.length) % items.length; 272 + } 273 + 274 + getSelectedItem() { 275 + if (!this.isOpen) return null; 276 + const items = this.getFilteredItems(); 277 + if (items.length === 0) return null; 278 + return items[this.selectedIndex] || null; 279 + } 280 + 281 + /** 282 + * Return filtered items grouped by category, in category order. 283 + * Only includes categories that have matching items. 284 + * 285 + * @returns {Array<{id: string, label: string, items: Array}>} 286 + */ 287 + getGroupedItems() { 288 + const filtered = this.getFilteredItems(); 289 + const groups = []; 290 + 291 + for (const cat of SLASH_COMMAND_CATEGORIES) { 292 + const catItems = filtered.filter(item => item.category === cat.id); 293 + if (catItems.length > 0) { 294 + groups.push({ 295 + id: cat.id, 296 + label: cat.label, 297 + items: catItems, 298 + }); 299 + } 300 + } 301 + 302 + return groups; 303 + } 304 + }
+217
tests/block-handle.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + BlockHandleState, 4 + BLOCK_HANDLE_ACTIONS, 5 + TURN_INTO_ITEMS, 6 + filterTurnIntoItems, 7 + BLOCK_HANDLE_ICON, 8 + BLOCK_HANDLE_ADD_ICON, 9 + } from '../src/docs/block-handle.js'; 10 + 11 + // ============================================================ 12 + // Block Handle Action Definitions 13 + // ============================================================ 14 + 15 + describe('BLOCK_HANDLE_ACTIONS — context menu actions', () => { 16 + it('is a non-empty array', () => { 17 + expect(Array.isArray(BLOCK_HANDLE_ACTIONS)).toBe(true); 18 + expect(BLOCK_HANDLE_ACTIONS.length).toBeGreaterThan(0); 19 + }); 20 + 21 + it('every action has id, label, and icon', () => { 22 + for (const action of BLOCK_HANDLE_ACTIONS) { 23 + expect(typeof action.id).toBe('string'); 24 + expect(typeof action.label).toBe('string'); 25 + expect(typeof action.icon).toBe('string'); 26 + } 27 + }); 28 + 29 + it('contains expected actions: turnInto, delete, duplicate, moveUp, moveDown', () => { 30 + const ids = BLOCK_HANDLE_ACTIONS.map(a => a.id); 31 + expect(ids).toContain('turnInto'); 32 + expect(ids).toContain('delete'); 33 + expect(ids).toContain('duplicate'); 34 + expect(ids).toContain('moveUp'); 35 + expect(ids).toContain('moveDown'); 36 + }); 37 + }); 38 + 39 + // ============================================================ 40 + // Turn Into Items 41 + // ============================================================ 42 + 43 + describe('TURN_INTO_ITEMS — block type conversion targets', () => { 44 + it('is a non-empty array', () => { 45 + expect(Array.isArray(TURN_INTO_ITEMS)).toBe(true); 46 + expect(TURN_INTO_ITEMS.length).toBeGreaterThan(0); 47 + }); 48 + 49 + it('every item has id, name, and icon', () => { 50 + for (const item of TURN_INTO_ITEMS) { 51 + expect(typeof item.id).toBe('string'); 52 + expect(typeof item.name).toBe('string'); 53 + expect(typeof item.icon).toBe('string'); 54 + } 55 + }); 56 + 57 + it('contains block types: paragraph, headings, lists, blockquote, codeBlock', () => { 58 + const ids = TURN_INTO_ITEMS.map(i => i.id); 59 + expect(ids).toContain('paragraph'); 60 + expect(ids).toContain('heading1'); 61 + expect(ids).toContain('heading2'); 62 + expect(ids).toContain('heading3'); 63 + expect(ids).toContain('bulletList'); 64 + expect(ids).toContain('numberedList'); 65 + expect(ids).toContain('taskList'); 66 + expect(ids).toContain('blockquote'); 67 + expect(ids).toContain('codeBlock'); 68 + }); 69 + }); 70 + 71 + describe('filterTurnIntoItems — search turn-into items', () => { 72 + it('returns all items for empty query', () => { 73 + expect(filterTurnIntoItems('').length).toBe(TURN_INTO_ITEMS.length); 74 + }); 75 + 76 + it('filters by name case-insensitively', () => { 77 + const result = filterTurnIntoItems('head'); 78 + expect(result.length).toBeGreaterThanOrEqual(3); 79 + for (const item of result) { 80 + expect(item.name.toLowerCase()).toContain('head'); 81 + } 82 + }); 83 + 84 + it('returns empty array for non-matching query', () => { 85 + expect(filterTurnIntoItems('zzzyyyxxx')).toEqual([]); 86 + }); 87 + }); 88 + 89 + // ============================================================ 90 + // Block Handle Icons 91 + // ============================================================ 92 + 93 + describe('BLOCK_HANDLE_ICON — drag handle icon', () => { 94 + it('is a non-empty string', () => { 95 + expect(typeof BLOCK_HANDLE_ICON).toBe('string'); 96 + expect(BLOCK_HANDLE_ICON.length).toBeGreaterThan(0); 97 + }); 98 + }); 99 + 100 + describe('BLOCK_HANDLE_ADD_ICON — add block icon', () => { 101 + it('is a non-empty string', () => { 102 + expect(typeof BLOCK_HANDLE_ADD_ICON).toBe('string'); 103 + expect(BLOCK_HANDLE_ADD_ICON.length).toBeGreaterThan(0); 104 + }); 105 + }); 106 + 107 + // ============================================================ 108 + // BlockHandleState Tests 109 + // ============================================================ 110 + 111 + describe('BlockHandleState — handle visibility and context menu', () => { 112 + it('starts hidden with no context menu', () => { 113 + const state = new BlockHandleState(); 114 + expect(state.visible).toBe(false); 115 + expect(state.contextMenuOpen).toBe(false); 116 + expect(state.turnIntoMenuOpen).toBe(false); 117 + expect(state.blockPos).toBeNull(); 118 + }); 119 + 120 + it('show makes handle visible at position', () => { 121 + const state = new BlockHandleState(); 122 + state.show({ top: 100, left: 50 }, 42); 123 + expect(state.visible).toBe(true); 124 + expect(state.position).toEqual({ top: 100, left: 50 }); 125 + expect(state.blockPos).toBe(42); 126 + }); 127 + 128 + it('hide resets all state', () => { 129 + const state = new BlockHandleState(); 130 + state.show({ top: 100, left: 50 }, 42); 131 + state.openContextMenu(); 132 + state.hide(); 133 + expect(state.visible).toBe(false); 134 + expect(state.contextMenuOpen).toBe(false); 135 + expect(state.turnIntoMenuOpen).toBe(false); 136 + expect(state.blockPos).toBeNull(); 137 + expect(state.position).toBeNull(); 138 + }); 139 + 140 + it('openContextMenu opens context menu', () => { 141 + const state = new BlockHandleState(); 142 + state.show({ top: 100, left: 50 }, 42); 143 + state.openContextMenu(); 144 + expect(state.contextMenuOpen).toBe(true); 145 + }); 146 + 147 + it('closeContextMenu closes context and turn-into menus', () => { 148 + const state = new BlockHandleState(); 149 + state.show({ top: 100, left: 50 }, 42); 150 + state.openContextMenu(); 151 + state.openTurnIntoMenu(); 152 + state.closeContextMenu(); 153 + expect(state.contextMenuOpen).toBe(false); 154 + expect(state.turnIntoMenuOpen).toBe(false); 155 + }); 156 + 157 + it('openTurnIntoMenu opens the turn-into sub-menu', () => { 158 + const state = new BlockHandleState(); 159 + state.show({ top: 100, left: 50 }, 42); 160 + state.openTurnIntoMenu(); 161 + expect(state.turnIntoMenuOpen).toBe(true); 162 + }); 163 + 164 + it('closeTurnIntoMenu closes only the turn-into sub-menu', () => { 165 + const state = new BlockHandleState(); 166 + state.show({ top: 100, left: 50 }, 42); 167 + state.openContextMenu(); 168 + state.openTurnIntoMenu(); 169 + state.closeTurnIntoMenu(); 170 + expect(state.turnIntoMenuOpen).toBe(false); 171 + expect(state.contextMenuOpen).toBe(true); // still open 172 + }); 173 + 174 + it('isHiddenInZenMode returns true', () => { 175 + const state = new BlockHandleState(); 176 + expect(state.isHiddenInMode('zen')).toBe(true); 177 + }); 178 + 179 + it('isHiddenInMode returns true for print', () => { 180 + const state = new BlockHandleState(); 181 + expect(state.isHiddenInMode('print')).toBe(true); 182 + }); 183 + 184 + it('isHiddenInMode returns false for normal mode', () => { 185 + const state = new BlockHandleState(); 186 + expect(state.isHiddenInMode('normal')).toBe(false); 187 + }); 188 + 189 + it('updatePosition updates the stored position', () => { 190 + const state = new BlockHandleState(); 191 + state.show({ top: 100, left: 50 }, 42); 192 + state.updatePosition({ top: 200, left: 60 }); 193 + expect(state.position).toEqual({ top: 200, left: 60 }); 194 + }); 195 + }); 196 + 197 + // ============================================================ 198 + // Notion-style Placeholder Config 199 + // ============================================================ 200 + 201 + describe('Placeholder configuration', () => { 202 + // Placeholder is configured in main.js using @tiptap/extension-placeholder 203 + // We test that the expected placeholder strings are exported from slash-menu 204 + 205 + it('exports PLACEHOLDER_EMPTY for empty editor', async () => { 206 + const { PLACEHOLDER_EMPTY } = await import('../src/docs/slash-menu.js'); 207 + expect(typeof PLACEHOLDER_EMPTY).toBe('string'); 208 + expect(PLACEHOLDER_EMPTY).toContain('/'); 209 + expect(PLACEHOLDER_EMPTY).toContain('command'); 210 + }); 211 + 212 + it('exports PLACEHOLDER_BLOCK for empty block', async () => { 213 + const { PLACEHOLDER_BLOCK } = await import('../src/docs/slash-menu.js'); 214 + expect(typeof PLACEHOLDER_BLOCK).toBe('string'); 215 + expect(PLACEHOLDER_BLOCK).toContain('/'); 216 + }); 217 + });
+376
tests/slash-commands.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + SLASH_COMMAND_ITEMS, 4 + SLASH_COMMAND_CATEGORIES, 5 + filterCommands, 6 + findCommandById, 7 + getCategoryItems, 8 + SlashMenuState, 9 + } from '../src/docs/slash-menu.js'; 10 + 11 + // ============================================================ 12 + // Slash Command Data Tests 13 + // ============================================================ 14 + 15 + describe('SLASH_COMMAND_ITEMS — command definitions', () => { 16 + it('is a non-empty array', () => { 17 + expect(Array.isArray(SLASH_COMMAND_ITEMS)).toBe(true); 18 + expect(SLASH_COMMAND_ITEMS.length).toBeGreaterThan(0); 19 + }); 20 + 21 + it('every item has required fields: id, name, description, category, icon', () => { 22 + for (const item of SLASH_COMMAND_ITEMS) { 23 + expect(typeof item.id).toBe('string'); 24 + expect(item.id.length).toBeGreaterThan(0); 25 + expect(typeof item.name).toBe('string'); 26 + expect(item.name.length).toBeGreaterThan(0); 27 + expect(typeof item.description).toBe('string'); 28 + expect(typeof item.category).toBe('string'); 29 + expect(typeof item.icon).toBe('string'); 30 + } 31 + }); 32 + 33 + it('all ids are unique', () => { 34 + const ids = SLASH_COMMAND_ITEMS.map(i => i.id); 35 + expect(new Set(ids).size).toBe(ids.length); 36 + }); 37 + 38 + it('every category referenced in items exists in SLASH_COMMAND_CATEGORIES', () => { 39 + const catIds = SLASH_COMMAND_CATEGORIES.map(c => c.id); 40 + for (const item of SLASH_COMMAND_ITEMS) { 41 + expect(catIds).toContain(item.category); 42 + } 43 + }); 44 + 45 + it('contains expected block types', () => { 46 + const ids = SLASH_COMMAND_ITEMS.map(i => i.id); 47 + expect(ids).toContain('paragraph'); 48 + expect(ids).toContain('heading1'); 49 + expect(ids).toContain('heading2'); 50 + expect(ids).toContain('heading3'); 51 + expect(ids).toContain('bulletList'); 52 + expect(ids).toContain('numberedList'); 53 + expect(ids).toContain('taskList'); 54 + expect(ids).toContain('image'); 55 + expect(ids).toContain('table'); 56 + expect(ids).toContain('horizontalRule'); 57 + expect(ids).toContain('codeBlock'); 58 + expect(ids).toContain('inlineCode'); 59 + expect(ids).toContain('blockquote'); 60 + expect(ids).toContain('callout'); 61 + expect(ids).toContain('pageBreak'); 62 + expect(ids).toContain('link'); 63 + }); 64 + }); 65 + 66 + describe('SLASH_COMMAND_CATEGORIES — category definitions', () => { 67 + it('is a non-empty array', () => { 68 + expect(Array.isArray(SLASH_COMMAND_CATEGORIES)).toBe(true); 69 + expect(SLASH_COMMAND_CATEGORIES.length).toBeGreaterThan(0); 70 + }); 71 + 72 + it('every category has id and label', () => { 73 + for (const cat of SLASH_COMMAND_CATEGORIES) { 74 + expect(typeof cat.id).toBe('string'); 75 + expect(typeof cat.label).toBe('string'); 76 + } 77 + }); 78 + 79 + it('contains expected categories', () => { 80 + const catIds = SLASH_COMMAND_CATEGORIES.map(c => c.id); 81 + expect(catIds).toContain('text'); 82 + expect(catIds).toContain('lists'); 83 + expect(catIds).toContain('media'); 84 + expect(catIds).toContain('code'); 85 + expect(catIds).toContain('quote'); 86 + expect(catIds).toContain('advanced'); 87 + }); 88 + }); 89 + 90 + // ============================================================ 91 + // Filtering Tests 92 + // ============================================================ 93 + 94 + describe('filterCommands — search/filter logic', () => { 95 + it('returns all items when query is empty', () => { 96 + const result = filterCommands(''); 97 + expect(result.length).toBe(SLASH_COMMAND_ITEMS.length); 98 + }); 99 + 100 + it('returns all items when query is whitespace', () => { 101 + const result = filterCommands(' '); 102 + expect(result.length).toBe(SLASH_COMMAND_ITEMS.length); 103 + }); 104 + 105 + it('filters by name prefix (case-insensitive)', () => { 106 + const result = filterCommands('head'); 107 + expect(result.length).toBeGreaterThanOrEqual(3); // heading 1, 2, 3 108 + for (const item of result) { 109 + expect(item.name.toLowerCase()).toContain('head'); 110 + } 111 + }); 112 + 113 + it('filters by name (case-insensitive)', () => { 114 + const result = filterCommands('BULLET'); 115 + expect(result.length).toBeGreaterThanOrEqual(1); 116 + expect(result[0].id).toBe('bulletList'); 117 + }); 118 + 119 + it('filters by description', () => { 120 + const result = filterCommands('divider'); 121 + // horizontalRule description should mention "divider" 122 + expect(result.some(i => i.id === 'horizontalRule')).toBe(true); 123 + }); 124 + 125 + it('returns empty array for non-matching query', () => { 126 + const result = filterCommands('zzzyyyxxx'); 127 + expect(result).toEqual([]); 128 + }); 129 + 130 + it('matches partial words', () => { 131 + const result = filterCommands('para'); 132 + expect(result.some(i => i.id === 'paragraph')).toBe(true); 133 + }); 134 + 135 + it('filters by category name', () => { 136 + const result = filterCommands('list'); 137 + expect(result.length).toBeGreaterThanOrEqual(2); // bullet, numbered, task 138 + }); 139 + }); 140 + 141 + // ============================================================ 142 + // findCommandById Tests 143 + // ============================================================ 144 + 145 + describe('findCommandById — lookup by id', () => { 146 + it('returns the command for a known id', () => { 147 + const cmd = findCommandById('heading1'); 148 + expect(cmd).not.toBeNull(); 149 + expect(cmd.id).toBe('heading1'); 150 + }); 151 + 152 + it('returns null for unknown id', () => { 153 + expect(findCommandById('nonexistent')).toBeNull(); 154 + }); 155 + 156 + it('returns null for empty string', () => { 157 + expect(findCommandById('')).toBeNull(); 158 + }); 159 + }); 160 + 161 + // ============================================================ 162 + // getCategoryItems Tests 163 + // ============================================================ 164 + 165 + describe('getCategoryItems — group by category', () => { 166 + it('returns items for a valid category', () => { 167 + const items = getCategoryItems('text'); 168 + expect(items.length).toBeGreaterThan(0); 169 + for (const item of items) { 170 + expect(item.category).toBe('text'); 171 + } 172 + }); 173 + 174 + it('returns empty array for unknown category', () => { 175 + expect(getCategoryItems('nonexistent')).toEqual([]); 176 + }); 177 + 178 + it('text category includes paragraph and headings', () => { 179 + const items = getCategoryItems('text'); 180 + const ids = items.map(i => i.id); 181 + expect(ids).toContain('paragraph'); 182 + expect(ids).toContain('heading1'); 183 + expect(ids).toContain('heading2'); 184 + expect(ids).toContain('heading3'); 185 + }); 186 + 187 + it('lists category includes bullet, numbered, task', () => { 188 + const items = getCategoryItems('lists'); 189 + const ids = items.map(i => i.id); 190 + expect(ids).toContain('bulletList'); 191 + expect(ids).toContain('numberedList'); 192 + expect(ids).toContain('taskList'); 193 + }); 194 + }); 195 + 196 + // ============================================================ 197 + // SlashMenuState Tests 198 + // ============================================================ 199 + 200 + describe('SlashMenuState — menu state management', () => { 201 + it('starts closed', () => { 202 + const state = new SlashMenuState(); 203 + expect(state.isOpen).toBe(false); 204 + expect(state.query).toBe(''); 205 + expect(state.selectedIndex).toBe(0); 206 + }); 207 + 208 + it('open sets isOpen and resets state', () => { 209 + const state = new SlashMenuState(); 210 + state.open(); 211 + expect(state.isOpen).toBe(true); 212 + expect(state.query).toBe(''); 213 + expect(state.selectedIndex).toBe(0); 214 + }); 215 + 216 + it('close resets everything', () => { 217 + const state = new SlashMenuState(); 218 + state.open(); 219 + state.setQuery('head'); 220 + state.moveDown(); 221 + state.close(); 222 + expect(state.isOpen).toBe(false); 223 + expect(state.query).toBe(''); 224 + expect(state.selectedIndex).toBe(0); 225 + }); 226 + 227 + it('setQuery updates query and resets selectedIndex', () => { 228 + const state = new SlashMenuState(); 229 + state.open(); 230 + state.moveDown(); 231 + state.moveDown(); 232 + expect(state.selectedIndex).toBe(2); 233 + state.setQuery('head'); 234 + expect(state.query).toBe('head'); 235 + expect(state.selectedIndex).toBe(0); 236 + }); 237 + 238 + it('getFilteredItems returns filtered items based on query', () => { 239 + const state = new SlashMenuState(); 240 + state.open(); 241 + state.setQuery('heading'); 242 + const items = state.getFilteredItems(); 243 + expect(items.length).toBeGreaterThanOrEqual(3); 244 + for (const item of items) { 245 + expect( 246 + item.name.toLowerCase().includes('heading') || 247 + item.description.toLowerCase().includes('heading') 248 + ).toBe(true); 249 + } 250 + }); 251 + 252 + it('getFilteredItems returns all items when query is empty', () => { 253 + const state = new SlashMenuState(); 254 + state.open(); 255 + const items = state.getFilteredItems(); 256 + expect(items.length).toBe(SLASH_COMMAND_ITEMS.length); 257 + }); 258 + 259 + it('moveDown increments selectedIndex', () => { 260 + const state = new SlashMenuState(); 261 + state.open(); 262 + state.moveDown(); 263 + expect(state.selectedIndex).toBe(1); 264 + state.moveDown(); 265 + expect(state.selectedIndex).toBe(2); 266 + }); 267 + 268 + it('moveUp decrements selectedIndex', () => { 269 + const state = new SlashMenuState(); 270 + state.open(); 271 + state.moveDown(); 272 + state.moveDown(); 273 + state.moveUp(); 274 + expect(state.selectedIndex).toBe(1); 275 + }); 276 + 277 + it('moveDown wraps around to 0 at the end of the list', () => { 278 + const state = new SlashMenuState(); 279 + state.open(); 280 + const items = state.getFilteredItems(); 281 + // Move past the last item 282 + for (let i = 0; i < items.length; i++) { 283 + state.moveDown(); 284 + } 285 + expect(state.selectedIndex).toBe(0); 286 + }); 287 + 288 + it('moveUp wraps around to the end when at 0', () => { 289 + const state = new SlashMenuState(); 290 + state.open(); 291 + const items = state.getFilteredItems(); 292 + state.moveUp(); 293 + expect(state.selectedIndex).toBe(items.length - 1); 294 + }); 295 + 296 + it('getSelectedItem returns the item at selectedIndex', () => { 297 + const state = new SlashMenuState(); 298 + state.open(); 299 + const items = state.getFilteredItems(); 300 + expect(state.getSelectedItem()).toEqual(items[0]); 301 + state.moveDown(); 302 + expect(state.getSelectedItem()).toEqual(items[1]); 303 + }); 304 + 305 + it('getSelectedItem returns null when menu is closed', () => { 306 + const state = new SlashMenuState(); 307 + expect(state.getSelectedItem()).toBeNull(); 308 + }); 309 + 310 + it('getSelectedItem returns null when no filtered items match', () => { 311 + const state = new SlashMenuState(); 312 + state.open(); 313 + state.setQuery('zzzyyyxxx'); 314 + expect(state.getSelectedItem()).toBeNull(); 315 + }); 316 + 317 + it('selectedIndex clamps when filter reduces results', () => { 318 + const state = new SlashMenuState(); 319 + state.open(); 320 + // Move to index 5 321 + for (let i = 0; i < 5; i++) state.moveDown(); 322 + expect(state.selectedIndex).toBe(5); 323 + // Now filter to something with fewer results 324 + state.setQuery('heading'); 325 + expect(state.selectedIndex).toBe(0); // reset on query change 326 + }); 327 + 328 + it('getGroupedItems returns items grouped by category in category order', () => { 329 + const state = new SlashMenuState(); 330 + state.open(); 331 + const grouped = state.getGroupedItems(); 332 + expect(Array.isArray(grouped)).toBe(true); 333 + expect(grouped.length).toBeGreaterThan(0); 334 + 335 + // Each group has label and items 336 + for (const group of grouped) { 337 + expect(typeof group.label).toBe('string'); 338 + expect(Array.isArray(group.items)).toBe(true); 339 + expect(group.items.length).toBeGreaterThan(0); 340 + } 341 + 342 + // Categories should be in defined order 343 + const catLabels = SLASH_COMMAND_CATEGORIES.map(c => c.label); 344 + const groupLabels = grouped.map(g => g.label); 345 + let lastIdx = -1; 346 + for (const label of groupLabels) { 347 + const idx = catLabels.indexOf(label); 348 + expect(idx).toBeGreaterThan(lastIdx); 349 + lastIdx = idx; 350 + } 351 + }); 352 + 353 + it('getGroupedItems filters by query', () => { 354 + const state = new SlashMenuState(); 355 + state.open(); 356 + state.setQuery('heading'); 357 + const grouped = state.getGroupedItems(); 358 + // Should only contain text category (headings) 359 + expect(grouped.length).toBe(1); 360 + expect(grouped[0].label).toBe('Text'); 361 + }); 362 + }); 363 + 364 + // ============================================================ 365 + // Keyboard shortcut hints 366 + // ============================================================ 367 + 368 + describe('Command shortcut hints', () => { 369 + it('heading commands may have shortcut hints', () => { 370 + const h1 = findCommandById('heading1'); 371 + // Shortcuts are optional but if present must be strings 372 + if (h1.shortcut) { 373 + expect(typeof h1.shortcut).toBe('string'); 374 + } 375 + }); 376 + });