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

Configure Feed

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

Merge pull request 'feat: document outline, table toolbar, link previews, zen mode' (#29) from feat/docs-features into main

scott c224a1cc edf4426d

+1676 -1
+274
src/css/app.css
··· 3436 3436 font-size: 0.6875rem; 3437 3437 font-weight: 500; 3438 3438 } 3439 + 3440 + /* ======================================================== 3441 + Document Outline Sidebar 3442 + ======================================================== */ 3443 + 3444 + .outline-sidebar { 3445 + position: relative; 3446 + width: 240px; 3447 + border-right: 1px solid var(--color-border); 3448 + background: var(--color-surface); 3449 + display: flex; 3450 + flex-direction: column; 3451 + overflow: hidden; 3452 + flex-shrink: 0; 3453 + transition: width var(--transition-med), opacity var(--transition-med); 3454 + } 3455 + 3456 + .outline-sidebar-header { 3457 + display: flex; 3458 + align-items: center; 3459 + justify-content: space-between; 3460 + padding: var(--space-sm) var(--space-md); 3461 + border-bottom: 1px solid var(--color-border); 3462 + } 3463 + 3464 + .outline-sidebar-header h3 { 3465 + margin: 0; 3466 + font-size: 0.875rem; 3467 + font-weight: 600; 3468 + font-family: var(--font-body); 3469 + color: var(--color-text); 3470 + } 3471 + 3472 + .outline-sidebar-close { 3473 + font-size: 1.25rem; 3474 + color: var(--color-text-muted); 3475 + } 3476 + 3477 + .outline-list { 3478 + flex: 1; 3479 + overflow-y: auto; 3480 + padding: var(--space-xs) 0; 3481 + } 3482 + 3483 + .outline-empty { 3484 + padding: var(--space-lg); 3485 + text-align: center; 3486 + color: var(--color-text-faint); 3487 + font-size: 0.8125rem; 3488 + } 3489 + 3490 + .outline-item { 3491 + display: block; 3492 + width: 100%; 3493 + padding: var(--space-xs) var(--space-md); 3494 + border: none; 3495 + background: none; 3496 + text-align: left; 3497 + cursor: pointer; 3498 + font-family: var(--font-body); 3499 + font-size: 0.8125rem; 3500 + color: var(--color-text-muted); 3501 + transition: background var(--transition-fast), color var(--transition-fast); 3502 + white-space: nowrap; 3503 + overflow: hidden; 3504 + text-overflow: ellipsis; 3505 + } 3506 + 3507 + .outline-item:hover { 3508 + background: var(--color-hover); 3509 + color: var(--color-text); 3510 + } 3511 + 3512 + .outline-item[data-level="1"] { 3513 + padding-left: var(--space-md); 3514 + font-weight: 600; 3515 + color: var(--color-text); 3516 + } 3517 + 3518 + .outline-item[data-level="2"] { 3519 + padding-left: calc(var(--space-md) + var(--space-md)); 3520 + font-weight: 500; 3521 + } 3522 + 3523 + .outline-item[data-level="3"] { 3524 + padding-left: calc(var(--space-md) + var(--space-md) + var(--space-md)); 3525 + font-weight: 400; 3526 + font-size: 0.75rem; 3527 + } 3528 + 3529 + /* ======================================================== 3530 + Table Toolbar (Floating) 3531 + ======================================================== */ 3532 + 3533 + .table-toolbar { 3534 + position: fixed; 3535 + z-index: 100; 3536 + display: flex; 3537 + align-items: center; 3538 + gap: 2px; 3539 + padding: 4px 6px; 3540 + background: var(--color-surface); 3541 + border: 1px solid var(--color-border); 3542 + border-radius: var(--radius-md); 3543 + box-shadow: var(--shadow-md); 3544 + font-family: var(--font-body); 3545 + } 3546 + 3547 + .table-tb-btn { 3548 + display: inline-flex; 3549 + align-items: center; 3550 + justify-content: center; 3551 + width: 28px; 3552 + height: 28px; 3553 + border: none; 3554 + border-radius: var(--radius-sm); 3555 + background: none; 3556 + color: var(--color-text-muted); 3557 + cursor: pointer; 3558 + transition: all var(--transition-fast); 3559 + } 3560 + 3561 + .table-tb-btn:hover { 3562 + background: var(--color-hover); 3563 + color: var(--color-text); 3564 + } 3565 + 3566 + .table-tb-btn .tb-icon { 3567 + width: 14px; 3568 + height: 14px; 3569 + } 3570 + 3571 + .table-tb-sep { 3572 + width: 1px; 3573 + height: 20px; 3574 + background: var(--color-border); 3575 + margin: 0 2px; 3576 + } 3577 + 3578 + .table-tb-color-wrap { 3579 + position: relative; 3580 + display: inline-flex; 3581 + align-items: center; 3582 + gap: 2px; 3583 + } 3584 + 3585 + .table-tb-color { 3586 + width: 24px; 3587 + height: 24px; 3588 + border: 1px solid var(--color-border); 3589 + border-radius: var(--radius-sm); 3590 + cursor: pointer; 3591 + padding: 0; 3592 + background: none; 3593 + } 3594 + 3595 + .table-tb-color-label { 3596 + font-size: 0.625rem; 3597 + color: var(--color-text-faint); 3598 + font-weight: 600; 3599 + pointer-events: none; 3600 + } 3601 + 3602 + /* ======================================================== 3603 + Link Preview Tooltip 3604 + ======================================================== */ 3605 + 3606 + .link-preview-tooltip { 3607 + position: fixed; 3608 + z-index: 200; 3609 + background: var(--color-surface); 3610 + border: 1px solid var(--color-border); 3611 + border-radius: var(--radius-md); 3612 + box-shadow: var(--shadow-md); 3613 + padding: var(--space-sm); 3614 + min-width: 200px; 3615 + max-width: 320px; 3616 + font-family: var(--font-body); 3617 + } 3618 + 3619 + .link-preview-url { 3620 + font-size: 0.75rem; 3621 + color: var(--color-teal); 3622 + margin-bottom: var(--space-xs); 3623 + word-break: break-all; 3624 + line-height: 1.3; 3625 + } 3626 + 3627 + .link-preview-actions { 3628 + display: flex; 3629 + gap: var(--space-xs); 3630 + } 3631 + 3632 + .link-preview-btn { 3633 + font-size: 0.75rem; 3634 + padding: 2px 8px; 3635 + } 3636 + 3637 + /* ======================================================== 3638 + Focus/Zen Mode 3639 + ======================================================== */ 3640 + 3641 + .zen-mode .app-topbar { 3642 + opacity: 0; 3643 + height: 0; 3644 + padding: 0; 3645 + overflow: hidden; 3646 + transition: all 200ms ease-out; 3647 + } 3648 + 3649 + .zen-mode .toolbar { 3650 + opacity: 0; 3651 + height: 0; 3652 + padding: 0; 3653 + overflow: hidden; 3654 + transition: all 200ms ease-out; 3655 + } 3656 + 3657 + .zen-mode .docs-footer { 3658 + opacity: 0; 3659 + height: 0; 3660 + padding: 0; 3661 + overflow: hidden; 3662 + transition: all 200ms ease-out; 3663 + } 3664 + 3665 + .zen-mode .editor-wrapper { 3666 + max-width: none; 3667 + transition: max-width 200ms ease-out; 3668 + } 3669 + 3670 + .zen-mode .editor-container { 3671 + padding: var(--space-xl) var(--space-2xl); 3672 + } 3673 + 3674 + .zen-mode .outline-sidebar { 3675 + display: none !important; 3676 + } 3677 + 3678 + .zen-mode .version-sidebar { 3679 + display: none !important; 3680 + } 3681 + 3682 + .zen-exit-btn { 3683 + position: fixed; 3684 + top: var(--space-md); 3685 + right: var(--space-md); 3686 + z-index: 300; 3687 + padding: 6px 14px; 3688 + border: 1px solid var(--color-border); 3689 + border-radius: var(--radius-md); 3690 + background: var(--color-surface); 3691 + color: var(--color-text-muted); 3692 + font-family: var(--font-body); 3693 + font-size: 0.75rem; 3694 + cursor: pointer; 3695 + opacity: 0.5; 3696 + transition: opacity var(--transition-fast); 3697 + box-shadow: var(--shadow-sm); 3698 + } 3699 + 3700 + .zen-exit-btn:hover { 3701 + opacity: 1; 3702 + color: var(--color-text); 3703 + background: var(--color-hover); 3704 + } 3705 + 3706 + /* Smooth transitions for entering/exiting zen */ 3707 + .app-topbar, 3708 + .toolbar, 3709 + .docs-footer, 3710 + .editor-wrapper { 3711 + transition: all 200ms ease-out; 3712 + }
+50
src/docs/index.html
··· 29 29 <span class="suggesting-toggle-label" id="suggesting-label">Editing</span> 30 30 </button> 31 31 </div> 32 + <!-- Outline toggle --> 33 + <button class="btn-icon" id="btn-outline" title="Document outline"> 34 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px"><line x1="2" y1="3" x2="14" y2="3"/><line x1="4" y1="6.5" x2="14" y2="6.5"/><line x1="6" y1="10" x2="14" y2="10"/><line x1="2" y1="13.5" x2="14" y2="13.5"/></svg> 35 + </button> 32 36 <!-- Version history --> 33 37 <button class="btn-icon" id="btn-history" title="Version history">&#128339;</button> 34 38 <div class="save-indicator saved" id="save-indicator"> ··· 257 261 </div> 258 262 </div> 259 263 264 + <!-- Table toolbar (floating, hidden by default) --> 265 + <div class="table-toolbar" id="table-toolbar" style="display:none"> 266 + <button class="table-tb-btn" data-cmd="addRowBefore" title="Add row above"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M2 6h12M2 10h12M6 6v4M10 6v4"/><path d="M8 2v2" stroke-width="2"/></svg></button> 267 + <button class="table-tb-btn" data-cmd="addRowAfter" title="Add row below"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M2 6h12M2 10h12M6 6v4M10 6v4"/><path d="M8 12v2" stroke-width="2"/></svg></button> 268 + <span class="table-tb-sep"></span> 269 + <button class="table-tb-btn" data-cmd="addColumnBefore" title="Add column left"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M6 2v12M10 2v12M6 6h4M6 10h4"/><path d="M2 8h2" stroke-width="2"/></svg></button> 270 + <button class="table-tb-btn" data-cmd="addColumnAfter" title="Add column right"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M6 2v12M10 2v12M6 6h4M6 10h4"/><path d="M12 8h2" stroke-width="2"/></svg></button> 271 + <span class="table-tb-sep"></span> 272 + <button class="table-tb-btn" data-cmd="deleteRow" title="Delete row"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="4" y1="8" x2="12" y2="8" stroke-width="2"/></svg></button> 273 + <button class="table-tb-btn" data-cmd="deleteColumn" title="Delete column"><svg class="tb-icon" viewBox="0 0 16 16"><line x1="4" y1="8" x2="12" y2="8" stroke-width="2"/></svg></button> 274 + <span class="table-tb-sep"></span> 275 + <button class="table-tb-btn" data-cmd="mergeCells" title="Merge cells"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="1"/><path d="M5.5 8h5M8 5.5v5"/></svg></button> 276 + <button class="table-tb-btn" data-cmd="splitCell" title="Split cell"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="1"/><line x1="8" y1="2" x2="8" y2="14"/></svg></button> 277 + <span class="table-tb-sep"></span> 278 + <button class="table-tb-btn" data-cmd="toggleHeaderRow" title="Toggle header row"><svg class="tb-icon" viewBox="0 0 16 16"><rect x="2" y="2" width="12" height="12" rx="1"/><line x1="2" y1="6" x2="14" y2="6" stroke-width="2"/></svg></button> 279 + <span class="table-tb-sep"></span> 280 + <div class="table-tb-color-wrap"> 281 + <input type="color" class="table-tb-color" id="table-cell-color" value="#ffffff" title="Cell background color"> 282 + <span class="table-tb-color-label">BG</span> 283 + </div> 284 + </div> 285 + 286 + <!-- Link preview tooltip (hidden by default) --> 287 + <div class="link-preview-tooltip" id="link-preview" style="display:none"> 288 + <div class="link-preview-url" id="link-preview-url"></div> 289 + <div class="link-preview-actions"> 290 + <button class="btn-ghost link-preview-btn" id="link-preview-open" title="Open link">Open</button> 291 + <button class="btn-ghost link-preview-btn" id="link-preview-edit" title="Edit link">Edit</button> 292 + <button class="btn-ghost link-preview-btn" id="link-preview-remove" title="Remove link">Remove</button> 293 + </div> 294 + </div> 295 + 296 + <!-- Zen mode exit button (hidden by default) --> 297 + <button class="zen-exit-btn" id="zen-exit" style="display:none" title="Exit zen mode (Cmd+Shift+F)">Exit Zen</button> 298 + 260 299 <!-- Editor + Version History Panel --> 261 300 <div class="editor-with-sidebar"> 301 + <!-- Outline sidebar (left) --> 302 + <div class="outline-sidebar" id="outline-sidebar" style="display:none"> 303 + <div class="outline-sidebar-header"> 304 + <h3>Outline</h3> 305 + <button class="btn-icon outline-sidebar-close" id="outline-sidebar-close" title="Close">&times;</button> 306 + </div> 307 + <div class="outline-list" id="outline-list"> 308 + <div class="outline-empty">No headings</div> 309 + </div> 310 + </div> 311 + 262 312 <div class="editor-container"> 263 313 <div class="editor-wrapper" id="editor"></div> 264 314 </div>
+288 -1
src/docs/main.js
··· 39 39 import { VersionManager, computeWordCount } from '../lib/version-history.js'; 40 40 import { SuggestionManager, createSuggestionAttrs } from '../lib/suggesting.js'; 41 41 import { OfflineManager } from '../lib/offline.js'; 42 + import { extractHeadings, OutlineState } from './outline.js'; 43 + import { TableToolbarState } from './table-toolbar.js'; 44 + import { LinkPreviewState, truncateUrl, computeTooltipPosition } from './link-preview.js'; 45 + import { ZenModeState, ZEN_STORAGE_KEY, ZEN_CLASS, ZEN_TRANSITION_MS } from './zen-mode.js'; 42 46 43 47 // --- Resolve document ID and encryption key --- 44 48 const pathParts = location.pathname.split('/').filter(Boolean); ··· 986 990 showShortcutModal(); 987 991 return; 988 992 } 989 - if (mod && e.key.toLowerCase() === 'f') { 993 + if (mod && !e.shiftKey && e.key.toLowerCase() === 'f') { 990 994 e.preventDefault(); 991 995 editor.commands.openSearch(); 992 996 updateFindBar(); ··· 1426 1430 statusText.textContent = 'Offline'; 1427 1431 } 1428 1432 } 1433 + 1434 + // ============================================= 1435 + // --- Document Outline Sidebar --- 1436 + // ============================================= 1437 + const outlineState = new OutlineState(); 1438 + const outlineSidebar = $('outline-sidebar'); 1439 + const outlineList = $('outline-list'); 1440 + 1441 + function renderOutline() { 1442 + const json = editor.getJSON(); 1443 + const headings = extractHeadings(json); 1444 + outlineState.updateHeadings(headings); 1445 + 1446 + if (headings.length === 0) { 1447 + outlineList.innerHTML = '<div class="outline-empty">No headings</div>'; 1448 + return; 1449 + } 1450 + 1451 + outlineList.innerHTML = ''; 1452 + for (const heading of headings) { 1453 + const item = document.createElement('button'); 1454 + item.className = 'outline-item'; 1455 + item.setAttribute('data-level', heading.level); 1456 + item.setAttribute('data-id', heading.id); 1457 + item.textContent = heading.text || '(empty heading)'; 1458 + item.title = heading.text || '(empty heading)'; 1459 + item.addEventListener('click', () => scrollToHeading(heading)); 1460 + outlineList.appendChild(item); 1461 + } 1462 + } 1463 + 1464 + function scrollToHeading(heading) { 1465 + // Find the heading node in the editor and scroll to it 1466 + const { doc } = editor.state; 1467 + let targetPos = null; 1468 + let headingIndex = 0; 1469 + 1470 + doc.descendants((node, pos) => { 1471 + if (node.type.name === 'heading' && node.attrs.level <= 3) { 1472 + const currentHeadings = extractHeadings(editor.getJSON()); 1473 + if (currentHeadings[headingIndex] && currentHeadings[headingIndex].id === heading.id) { 1474 + targetPos = pos; 1475 + return false; 1476 + } 1477 + headingIndex++; 1478 + } 1479 + }); 1480 + 1481 + if (targetPos !== null) { 1482 + editor.chain().focus().setTextSelection(targetPos + 1).run(); 1483 + // Scroll the heading into view 1484 + const domNode = editor.view.domAtPos(targetPos + 1); 1485 + if (domNode?.node) { 1486 + const el = domNode.node.nodeType === 1 ? domNode.node : domNode.node.parentElement; 1487 + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1488 + } 1489 + } 1490 + } 1491 + 1492 + function toggleOutline() { 1493 + outlineState.toggle(); 1494 + outlineSidebar.style.display = outlineState.isOpen ? '' : 'none'; 1495 + $('btn-outline').classList.toggle('active', outlineState.isOpen); 1496 + if (outlineState.isOpen) renderOutline(); 1497 + } 1498 + 1499 + $('btn-outline').addEventListener('click', toggleOutline); 1500 + $('outline-sidebar-close').addEventListener('click', () => { 1501 + outlineState.close(); 1502 + outlineSidebar.style.display = 'none'; 1503 + $('btn-outline').classList.remove('active'); 1504 + }); 1505 + 1506 + // Update outline on editor changes 1507 + editor.on('update', () => { 1508 + if (outlineState.isOpen) renderOutline(); 1509 + }); 1510 + 1511 + // ============================================= 1512 + // --- Table Toolbar (Floating) --- 1513 + // ============================================= 1514 + const tableToolbarState = new TableToolbarState(); 1515 + const tableToolbar = $('table-toolbar'); 1516 + 1517 + function updateTableToolbar() { 1518 + if (!editor.isActive('table')) { 1519 + if (tableToolbarState.visible) { 1520 + tableToolbarState.hide(); 1521 + tableToolbar.style.display = 'none'; 1522 + } 1523 + return; 1524 + } 1525 + 1526 + // Find the table DOM element 1527 + const { state } = editor; 1528 + const { $from } = state.selection; 1529 + let tableNode = null; 1530 + for (let d = $from.depth; d > 0; d--) { 1531 + if ($from.node(d).type.name === 'table') { 1532 + const domNode = editor.view.nodeDOM($from.before(d)); 1533 + tableNode = domNode; 1534 + break; 1535 + } 1536 + } 1537 + 1538 + if (tableNode) { 1539 + const rect = tableNode.getBoundingClientRect(); 1540 + const pos = { 1541 + top: rect.top - 40, 1542 + left: rect.left, 1543 + }; 1544 + tableToolbarState.show(pos); 1545 + tableToolbar.style.display = ''; 1546 + tableToolbar.style.top = `${Math.max(0, pos.top)}px`; 1547 + tableToolbar.style.left = `${pos.left}px`; 1548 + } 1549 + } 1550 + 1551 + // Wire up table toolbar buttons 1552 + tableToolbar.querySelectorAll('[data-cmd]').forEach(btn => { 1553 + btn.addEventListener('click', (e) => { 1554 + e.preventDefault(); 1555 + const cmd = btn.dataset.cmd; 1556 + if (editor.can()[cmd]?.()) { 1557 + editor.chain().focus()[cmd]().run(); 1558 + } else { 1559 + // Try running anyway (some commands don't have can() checks) 1560 + try { 1561 + editor.chain().focus()[cmd]().run(); 1562 + } catch {} 1563 + } 1564 + // Reposition after table structure change 1565 + requestAnimationFrame(updateTableToolbar); 1566 + }); 1567 + }); 1568 + 1569 + // Cell background color 1570 + const cellColorInput = $('table-cell-color'); 1571 + cellColorInput.addEventListener('input', (e) => { 1572 + editor.chain().focus().setCellAttribute('backgroundColor', e.target.value).run(); 1573 + }); 1574 + 1575 + editor.on('selectionUpdate', updateTableToolbar); 1576 + editor.on('transaction', () => { 1577 + requestAnimationFrame(updateTableToolbar); 1578 + }); 1579 + 1580 + // ============================================= 1581 + // --- Link Preview Tooltip --- 1582 + // ============================================= 1583 + const linkPreviewState = new LinkPreviewState(); 1584 + const linkPreview = $('link-preview'); 1585 + const linkPreviewUrl = $('link-preview-url'); 1586 + let linkPreviewTimeout = null; 1587 + 1588 + function showLinkPreview(linkEl) { 1589 + const href = linkEl.getAttribute('href'); 1590 + if (!href) return; 1591 + 1592 + const rect = linkEl.getBoundingClientRect(); 1593 + const pos = computeTooltipPosition( 1594 + rect, 1595 + window.innerWidth, 1596 + window.innerHeight, 1597 + 280 1598 + ); 1599 + 1600 + linkPreviewState.show({ href, position: pos }); 1601 + linkPreviewUrl.textContent = truncateUrl(href, 50); 1602 + linkPreviewUrl.title = href; 1603 + linkPreview.style.display = ''; 1604 + linkPreview.style.top = `${pos.top}px`; 1605 + linkPreview.style.left = `${pos.left}px`; 1606 + } 1607 + 1608 + function hideLinkPreview() { 1609 + linkPreviewState.hide(); 1610 + linkPreview.style.display = 'none'; 1611 + } 1612 + 1613 + // Mouse enter/leave on links in the editor 1614 + document.addEventListener('mouseover', (e) => { 1615 + const linkEl = e.target.closest('.tiptap a[href]'); 1616 + if (linkEl) { 1617 + clearTimeout(linkPreviewTimeout); 1618 + linkPreviewTimeout = setTimeout(() => showLinkPreview(linkEl), 200); 1619 + } 1620 + }); 1621 + 1622 + document.addEventListener('mouseout', (e) => { 1623 + const linkEl = e.target.closest('.tiptap a[href]'); 1624 + if (linkEl) { 1625 + clearTimeout(linkPreviewTimeout); 1626 + linkPreviewTimeout = setTimeout(() => { 1627 + // Only hide if mouse isn't over the tooltip itself 1628 + if (!linkPreview.matches(':hover')) { 1629 + hideLinkPreview(); 1630 + } 1631 + }, 300); 1632 + } 1633 + }); 1634 + 1635 + linkPreview.addEventListener('mouseleave', () => { 1636 + clearTimeout(linkPreviewTimeout); 1637 + hideLinkPreview(); 1638 + }); 1639 + 1640 + // Dismiss on Escape 1641 + document.addEventListener('keydown', (e) => { 1642 + if (e.key === 'Escape' && linkPreviewState.visible) { 1643 + hideLinkPreview(); 1644 + } 1645 + }); 1646 + 1647 + // Link preview actions 1648 + $('link-preview-open').addEventListener('click', () => { 1649 + if (linkPreviewState.href) { 1650 + window.open(linkPreviewState.href, '_blank', 'noopener'); 1651 + } 1652 + hideLinkPreview(); 1653 + }); 1654 + 1655 + $('link-preview-edit').addEventListener('click', () => { 1656 + const currentHref = linkPreviewState.href; 1657 + hideLinkPreview(); 1658 + const newHref = prompt('Edit URL:', currentHref || ''); 1659 + if (newHref !== null && newHref !== currentHref) { 1660 + editor.chain().focus().extendMarkRange('link').setLink({ href: newHref }).run(); 1661 + } 1662 + }); 1663 + 1664 + $('link-preview-remove').addEventListener('click', () => { 1665 + hideLinkPreview(); 1666 + editor.chain().focus().extendMarkRange('link').unsetLink().run(); 1667 + }); 1668 + 1669 + // ============================================= 1670 + // --- Focus/Zen Mode --- 1671 + // ============================================= 1672 + const zenState = ZenModeState.fromStored(localStorage.getItem(ZEN_STORAGE_KEY)); 1673 + const appShell = $('app'); 1674 + const zenExitBtn = $('zen-exit'); 1675 + 1676 + function applyZenMode() { 1677 + if (zenState.isActive) { 1678 + appShell.classList.add(ZEN_CLASS); 1679 + zenExitBtn.style.display = ''; 1680 + } else { 1681 + appShell.classList.remove(ZEN_CLASS); 1682 + zenExitBtn.style.display = 'none'; 1683 + } 1684 + localStorage.setItem(ZEN_STORAGE_KEY, zenState.serialize()); 1685 + } 1686 + 1687 + function toggleZenMode() { 1688 + zenState.toggle(); 1689 + applyZenMode(); 1690 + } 1691 + 1692 + // Keyboard shortcut: Cmd+Shift+F 1693 + document.addEventListener('keydown', (e) => { 1694 + const mod = e.metaKey || e.ctrlKey; 1695 + if (mod && e.shiftKey && e.key.toLowerCase() === 'f') { 1696 + e.preventDefault(); 1697 + toggleZenMode(); 1698 + } 1699 + }); 1700 + 1701 + zenExitBtn.addEventListener('click', () => { 1702 + zenState.deactivate(); 1703 + applyZenMode(); 1704 + }); 1705 + 1706 + // Apply on load if was previously active 1707 + applyZenMode(); 1708 + 1709 + // Add zen mode shortcut to cheatsheet 1710 + DOCS_SHORTCUTS.push({ 1711 + category: 'View', 1712 + shortcuts: [ 1713 + { keys: ['\u2318', '\u21e7', 'F'], label: 'Zen mode' }, 1714 + ], 1715 + });
+123
src/docs/outline.js
··· 1 + /** 2 + * Document Outline Sidebar 3 + * 4 + * Extracts headings (H1, H2, H3) from editor content and builds 5 + * a navigable tree for the outline sidebar panel. 6 + */ 7 + 8 + /** 9 + * Generate a URL-safe anchor ID from heading text. 10 + * Appends index suffix for uniqueness when index > 0. 11 + */ 12 + export function generateHeadingId(text, index) { 13 + let id = text 14 + .toLowerCase() 15 + .replace(/[^a-z0-9\s-]/g, '') 16 + .trim() 17 + .replace(/\s+/g, '-') 18 + .replace(/-{2,}/g, '-') 19 + .replace(/^-|-$/g, ''); 20 + 21 + if (!id) id = 'heading'; 22 + if (index && index > 0) id += `-${index}`; 23 + return id; 24 + } 25 + 26 + /** 27 + * Extract heading text from a heading node's content array. 28 + */ 29 + function getHeadingText(node) { 30 + if (!node.content || !Array.isArray(node.content)) return ''; 31 + return node.content 32 + .filter((child) => child.type === 'text') 33 + .map((child) => child.text || '') 34 + .join(''); 35 + } 36 + 37 + /** 38 + * Extract all H1, H2, H3 headings from editor JSON content. 39 + * Returns a flat array of { level, text, id } objects. 40 + */ 41 + export function extractHeadings(json) { 42 + if (!json || !json.content) return []; 43 + 44 + const headings = []; 45 + const idCounts = {}; 46 + 47 + for (const node of json.content) { 48 + if (node.type !== 'heading') continue; 49 + const level = node.attrs?.level; 50 + if (level < 1 || level > 3) continue; 51 + 52 + const text = getHeadingText(node); 53 + const baseId = generateHeadingId(text); 54 + const count = idCounts[baseId] || 0; 55 + idCounts[baseId] = count + 1; 56 + 57 + const id = count > 0 ? `${baseId}-${count}` : baseId; 58 + 59 + headings.push({ level, text, id }); 60 + } 61 + 62 + return headings; 63 + } 64 + 65 + /** 66 + * Build a nested tree from a flat list of headings. 67 + * H2 nests under preceding H1, H3 nests under preceding H2. 68 + */ 69 + export function buildOutlineTree(headings) { 70 + if (!headings || headings.length === 0) return []; 71 + 72 + const root = []; 73 + const stack = []; // stack of { node, level } 74 + 75 + for (const heading of headings) { 76 + const node = { ...heading, children: [] }; 77 + 78 + // Pop stack until we find a parent with a lower level 79 + while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) { 80 + stack.pop(); 81 + } 82 + 83 + if (stack.length === 0) { 84 + root.push(node); 85 + } else { 86 + stack[stack.length - 1].children.push(node); 87 + } 88 + 89 + stack.push(node); 90 + } 91 + 92 + return root; 93 + } 94 + 95 + /** 96 + * Manages outline sidebar state. 97 + */ 98 + export class OutlineState { 99 + constructor() { 100 + this.isOpen = false; 101 + this.headings = []; 102 + } 103 + 104 + toggle() { 105 + this.isOpen = !this.isOpen; 106 + } 107 + 108 + open() { 109 + this.isOpen = true; 110 + } 111 + 112 + close() { 113 + this.isOpen = false; 114 + } 115 + 116 + updateHeadings(headings) { 117 + this.headings = headings; 118 + } 119 + 120 + getTree() { 121 + return buildOutlineTree(this.headings); 122 + } 123 + }
+112
src/docs/table-toolbar.js
··· 1 + /** 2 + * Table Toolbar 3 + * 4 + * Floating toolbar that appears when the cursor is in a table, 5 + * providing commands for row/column manipulation, cell merging, 6 + * header toggling, and cell background color. 7 + */ 8 + 9 + /** 10 + * Command definitions for all table operations. 11 + * Each maps to a TipTap table extension command. 12 + */ 13 + export const TABLE_COMMANDS = { 14 + addRowBefore: { 15 + label: 'Add row above', 16 + icon: '&#x2B06;', 17 + command: 'addRowBefore', 18 + }, 19 + addRowAfter: { 20 + label: 'Add row below', 21 + icon: '&#x2B07;', 22 + command: 'addRowAfter', 23 + }, 24 + addColumnBefore: { 25 + label: 'Add column left', 26 + icon: '&#x2B05;', 27 + command: 'addColumnBefore', 28 + }, 29 + addColumnAfter: { 30 + label: 'Add column right', 31 + icon: '&#x27A1;', 32 + command: 'addColumnAfter', 33 + }, 34 + deleteRow: { 35 + label: 'Delete row', 36 + icon: '&#x1F5D1;', 37 + command: 'deleteRow', 38 + }, 39 + deleteColumn: { 40 + label: 'Delete column', 41 + icon: '&#x1F5D1;', 42 + command: 'deleteColumn', 43 + }, 44 + mergeCells: { 45 + label: 'Merge cells', 46 + icon: '&#x29C9;', 47 + command: 'mergeCells', 48 + }, 49 + splitCell: { 50 + label: 'Split cell', 51 + icon: '&#x29C8;', 52 + command: 'splitCell', 53 + }, 54 + toggleHeaderRow: { 55 + label: 'Toggle header row', 56 + icon: '&#x1D413;', 57 + command: 'toggleHeaderRow', 58 + }, 59 + }; 60 + 61 + /** 62 + * Manages visibility and position of the floating table toolbar. 63 + */ 64 + export class TableToolbarState { 65 + constructor() { 66 + this.visible = false; 67 + this.position = null; 68 + } 69 + 70 + show(pos) { 71 + this.visible = true; 72 + this.position = { ...pos }; 73 + } 74 + 75 + hide() { 76 + this.visible = false; 77 + this.position = null; 78 + } 79 + 80 + updatePosition(pos) { 81 + this.position = { ...pos }; 82 + } 83 + } 84 + 85 + /** 86 + * Parse cell background color from TipTap cell attributes. 87 + */ 88 + export function parseCellColor(attrs) { 89 + if (!attrs) return null; 90 + if (attrs.background) return attrs.background; 91 + if (attrs.backgroundColor) return attrs.backgroundColor; 92 + return null; 93 + } 94 + 95 + /** 96 + * Normalize a color string to lowercase hex. 97 + * Handles 3-digit shorthand (#f00 -> #ff0000). 98 + */ 99 + export function normalizeCellColor(color) { 100 + if (!color) return null; 101 + const trimmed = color.trim(); 102 + if (!trimmed) return null; 103 + 104 + let hex = trimmed.toLowerCase(); 105 + 106 + // Expand 3-digit hex to 6-digit 107 + if (/^#[0-9a-f]{3}$/.test(hex)) { 108 + hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]; 109 + } 110 + 111 + return hex; 112 + }
+51
src/docs/zen-mode.js
··· 1 + /** 2 + * Focus/Zen Mode 3 + * 4 + * Distraction-free editing: hides toolbar, minimizes topbar, 5 + * expands editor to full width. Toggled via Cmd+Shift+F. 6 + * Persists preference in localStorage. 7 + */ 8 + 9 + /** localStorage key for zen mode preference */ 10 + export const ZEN_STORAGE_KEY = 'tools-zen-mode'; 11 + 12 + /** CSS class applied to the app shell when zen mode is active */ 13 + export const ZEN_CLASS = 'zen-mode'; 14 + 15 + /** Transition duration in milliseconds */ 16 + export const ZEN_TRANSITION_MS = 200; 17 + 18 + /** 19 + * Manages zen mode state. 20 + */ 21 + export class ZenModeState { 22 + constructor(active = false) { 23 + this.isActive = active; 24 + this.toggleCount = 0; 25 + } 26 + 27 + toggle() { 28 + this.isActive = !this.isActive; 29 + this.toggleCount++; 30 + } 31 + 32 + activate() { 33 + this.isActive = true; 34 + } 35 + 36 + deactivate() { 37 + this.isActive = false; 38 + } 39 + 40 + getClassList() { 41 + return this.isActive ? [ZEN_CLASS] : []; 42 + } 43 + 44 + serialize() { 45 + return this.isActive ? 'true' : 'false'; 46 + } 47 + 48 + static fromStored(value) { 49 + return new ZenModeState(value === 'true'); 50 + } 51 + }
+266
tests/outline.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + 3 + /** 4 + * Tests for Document Outline Sidebar (src/docs/outline.js) 5 + * 6 + * The outline module extracts headings from editor content and builds 7 + * a hierarchical tree for navigation. Since we can't run TipTap in Node, 8 + * we test the pure logic: heading extraction, tree building, ID generation, 9 + * and state management. 10 + */ 11 + 12 + // --- extractHeadings: parse heading nodes from editor JSON --- 13 + import { 14 + extractHeadings, 15 + buildOutlineTree, 16 + generateHeadingId, 17 + OutlineState, 18 + } from '../src/docs/outline.js'; 19 + 20 + describe('extractHeadings — parse headings from editor JSON content', () => { 21 + it('extracts H1, H2, H3 headings with level and text', () => { 22 + const json = { 23 + type: 'doc', 24 + content: [ 25 + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Introduction' }] }, 26 + { type: 'paragraph', content: [{ type: 'text', text: 'Some text.' }] }, 27 + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Background' }] }, 28 + { type: 'heading', attrs: { level: 3 }, content: [{ type: 'text', text: 'Details' }] }, 29 + ], 30 + }; 31 + const headings = extractHeadings(json); 32 + expect(headings).toEqual([ 33 + { level: 1, text: 'Introduction', id: expect.any(String) }, 34 + { level: 2, text: 'Background', id: expect.any(String) }, 35 + { level: 3, text: 'Details', id: expect.any(String) }, 36 + ]); 37 + }); 38 + 39 + it('returns empty array when no headings exist', () => { 40 + const json = { 41 + type: 'doc', 42 + content: [ 43 + { type: 'paragraph', content: [{ type: 'text', text: 'Just a paragraph.' }] }, 44 + ], 45 + }; 46 + expect(extractHeadings(json)).toEqual([]); 47 + }); 48 + 49 + it('returns empty array for empty doc', () => { 50 + const json = { type: 'doc', content: [] }; 51 + expect(extractHeadings(json)).toEqual([]); 52 + }); 53 + 54 + it('returns empty array for null/undefined input', () => { 55 + expect(extractHeadings(null)).toEqual([]); 56 + expect(extractHeadings(undefined)).toEqual([]); 57 + }); 58 + 59 + it('handles headings with no content (empty headings)', () => { 60 + const json = { 61 + type: 'doc', 62 + content: [ 63 + { type: 'heading', attrs: { level: 1 }, content: [] }, 64 + { type: 'heading', attrs: { level: 2 } }, // no content key at all 65 + ], 66 + }; 67 + const headings = extractHeadings(json); 68 + expect(headings).toEqual([ 69 + { level: 1, text: '', id: expect.any(String) }, 70 + { level: 2, text: '', id: expect.any(String) }, 71 + ]); 72 + }); 73 + 74 + it('concatenates text from multiple inline nodes within a heading', () => { 75 + const json = { 76 + type: 'doc', 77 + content: [ 78 + { 79 + type: 'heading', 80 + attrs: { level: 1 }, 81 + content: [ 82 + { type: 'text', text: 'Hello ' }, 83 + { type: 'text', text: 'World', marks: [{ type: 'bold' }] }, 84 + ], 85 + }, 86 + ], 87 + }; 88 + const headings = extractHeadings(json); 89 + expect(headings[0].text).toBe('Hello World'); 90 + }); 91 + 92 + it('ignores heading levels beyond 3', () => { 93 + const json = { 94 + type: 'doc', 95 + content: [ 96 + { type: 'heading', attrs: { level: 4 }, content: [{ type: 'text', text: 'H4' }] }, 97 + { type: 'heading', attrs: { level: 5 }, content: [{ type: 'text', text: 'H5' }] }, 98 + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'H2' }] }, 99 + ], 100 + }; 101 + const headings = extractHeadings(json); 102 + expect(headings).toEqual([ 103 + { level: 2, text: 'H2', id: expect.any(String) }, 104 + ]); 105 + }); 106 + 107 + it('assigns unique IDs to each heading', () => { 108 + const json = { 109 + type: 'doc', 110 + content: [ 111 + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Same Title' }] }, 112 + { type: 'heading', attrs: { level: 1 }, content: [{ type: 'text', text: 'Same Title' }] }, 113 + ], 114 + }; 115 + const headings = extractHeadings(json); 116 + expect(headings[0].id).not.toBe(headings[1].id); 117 + }); 118 + }); 119 + 120 + describe('generateHeadingId — create anchor-safe IDs from heading text', () => { 121 + it('converts text to lowercase kebab-case', () => { 122 + expect(generateHeadingId('Hello World')).toBe('hello-world'); 123 + }); 124 + 125 + it('strips special characters', () => { 126 + expect(generateHeadingId('What is E2EE?')).toBe('what-is-e2ee'); 127 + }); 128 + 129 + it('collapses multiple hyphens', () => { 130 + expect(generateHeadingId('Hello---World')).toBe('hello-world'); 131 + }); 132 + 133 + it('trims leading/trailing hyphens', () => { 134 + expect(generateHeadingId(' Hello World ')).toBe('hello-world'); 135 + }); 136 + 137 + it('returns "heading" for empty string', () => { 138 + expect(generateHeadingId('')).toBe('heading'); 139 + }); 140 + 141 + it('appends index when provided for uniqueness', () => { 142 + expect(generateHeadingId('Title', 0)).toBe('title'); 143 + expect(generateHeadingId('Title', 1)).toBe('title-1'); 144 + expect(generateHeadingId('Title', 2)).toBe('title-2'); 145 + }); 146 + }); 147 + 148 + describe('buildOutlineTree — convert flat headings to nested tree', () => { 149 + it('builds a flat list for same-level headings', () => { 150 + const headings = [ 151 + { level: 1, text: 'A', id: 'a' }, 152 + { level: 1, text: 'B', id: 'b' }, 153 + ]; 154 + const tree = buildOutlineTree(headings); 155 + expect(tree).toEqual([ 156 + { level: 1, text: 'A', id: 'a', children: [] }, 157 + { level: 1, text: 'B', id: 'b', children: [] }, 158 + ]); 159 + }); 160 + 161 + it('nests H2 under preceding H1', () => { 162 + const headings = [ 163 + { level: 1, text: 'Intro', id: 'intro' }, 164 + { level: 2, text: 'Part A', id: 'part-a' }, 165 + { level: 2, text: 'Part B', id: 'part-b' }, 166 + ]; 167 + const tree = buildOutlineTree(headings); 168 + expect(tree).toEqual([ 169 + { 170 + level: 1, text: 'Intro', id: 'intro', 171 + children: [ 172 + { level: 2, text: 'Part A', id: 'part-a', children: [] }, 173 + { level: 2, text: 'Part B', id: 'part-b', children: [] }, 174 + ], 175 + }, 176 + ]); 177 + }); 178 + 179 + it('nests H3 under preceding H2', () => { 180 + const headings = [ 181 + { level: 1, text: 'Top', id: 'top' }, 182 + { level: 2, text: 'Mid', id: 'mid' }, 183 + { level: 3, text: 'Deep', id: 'deep' }, 184 + ]; 185 + const tree = buildOutlineTree(headings); 186 + expect(tree[0].children[0].children).toEqual([ 187 + { level: 3, text: 'Deep', id: 'deep', children: [] }, 188 + ]); 189 + }); 190 + 191 + it('handles orphaned H2 with no preceding H1 (appears at top level)', () => { 192 + const headings = [ 193 + { level: 2, text: 'Orphan', id: 'orphan' }, 194 + { level: 3, text: 'Sub', id: 'sub' }, 195 + ]; 196 + const tree = buildOutlineTree(headings); 197 + expect(tree.length).toBe(1); 198 + expect(tree[0].text).toBe('Orphan'); 199 + expect(tree[0].children.length).toBe(1); 200 + }); 201 + 202 + it('returns empty array for empty headings', () => { 203 + expect(buildOutlineTree([])).toEqual([]); 204 + }); 205 + 206 + it('handles mixed-level progression correctly', () => { 207 + const headings = [ 208 + { level: 1, text: 'A', id: 'a' }, 209 + { level: 2, text: 'B', id: 'b' }, 210 + { level: 1, text: 'C', id: 'c' }, 211 + { level: 2, text: 'D', id: 'd' }, 212 + { level: 3, text: 'E', id: 'e' }, 213 + ]; 214 + const tree = buildOutlineTree(headings); 215 + expect(tree.length).toBe(2); 216 + expect(tree[0].children.length).toBe(1); 217 + expect(tree[1].children.length).toBe(1); 218 + expect(tree[1].children[0].children.length).toBe(1); 219 + }); 220 + }); 221 + 222 + describe('OutlineState — toggle and heading management', () => { 223 + it('starts closed', () => { 224 + const state = new OutlineState(); 225 + expect(state.isOpen).toBe(false); 226 + }); 227 + 228 + it('toggle opens and closes', () => { 229 + const state = new OutlineState(); 230 + state.toggle(); 231 + expect(state.isOpen).toBe(true); 232 + state.toggle(); 233 + expect(state.isOpen).toBe(false); 234 + }); 235 + 236 + it('open/close methods work independently', () => { 237 + const state = new OutlineState(); 238 + state.open(); 239 + expect(state.isOpen).toBe(true); 240 + state.open(); // idempotent 241 + expect(state.isOpen).toBe(true); 242 + state.close(); 243 + expect(state.isOpen).toBe(false); 244 + }); 245 + 246 + it('updateHeadings stores the current headings', () => { 247 + const state = new OutlineState(); 248 + const headings = [ 249 + { level: 1, text: 'A', id: 'a' }, 250 + { level: 2, text: 'B', id: 'b' }, 251 + ]; 252 + state.updateHeadings(headings); 253 + expect(state.headings).toEqual(headings); 254 + }); 255 + 256 + it('getTree returns the tree from current headings', () => { 257 + const state = new OutlineState(); 258 + state.updateHeadings([ 259 + { level: 1, text: 'A', id: 'a' }, 260 + { level: 2, text: 'B', id: 'b' }, 261 + ]); 262 + const tree = state.getTree(); 263 + expect(tree.length).toBe(1); 264 + expect(tree[0].children.length).toBe(1); 265 + }); 266 + });
+153
tests/table-toolbar.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + 3 + /** 4 + * Tests for Table Toolbar (src/docs/table-toolbar.js) 5 + * 6 + * The table toolbar module provides toolbar state, command mapping, 7 + * and positioning logic for a floating toolbar that appears when the 8 + * cursor is inside a table. Since TipTap isn't available in Node, 9 + * we test the pure logic: command definitions, toolbar state, and 10 + * color parsing. 11 + */ 12 + 13 + import { 14 + TABLE_COMMANDS, 15 + TableToolbarState, 16 + parseCellColor, 17 + normalizeCellColor, 18 + } from '../src/docs/table-toolbar.js'; 19 + 20 + describe('TABLE_COMMANDS — command definitions', () => { 21 + it('has all required table commands', () => { 22 + const requiredCommands = [ 23 + 'addRowBefore', 24 + 'addRowAfter', 25 + 'addColumnBefore', 26 + 'addColumnAfter', 27 + 'deleteRow', 28 + 'deleteColumn', 29 + 'mergeCells', 30 + 'splitCell', 31 + 'toggleHeaderRow', 32 + ]; 33 + for (const cmd of requiredCommands) { 34 + expect(TABLE_COMMANDS).toHaveProperty(cmd); 35 + } 36 + }); 37 + 38 + it('each command has label, icon, and command name', () => { 39 + for (const [key, cmd] of Object.entries(TABLE_COMMANDS)) { 40 + expect(cmd).toHaveProperty('label'); 41 + expect(cmd).toHaveProperty('icon'); 42 + expect(cmd).toHaveProperty('command'); 43 + expect(typeof cmd.label).toBe('string'); 44 + expect(typeof cmd.icon).toBe('string'); 45 + expect(typeof cmd.command).toBe('string'); 46 + } 47 + }); 48 + 49 + it('command names match the key names', () => { 50 + for (const [key, cmd] of Object.entries(TABLE_COMMANDS)) { 51 + expect(cmd.command).toBe(key); 52 + } 53 + }); 54 + 55 + it('has grouped categories for row, column, and cell operations', () => { 56 + // Row operations 57 + expect(TABLE_COMMANDS.addRowBefore.label).toContain('above'); 58 + expect(TABLE_COMMANDS.addRowAfter.label).toContain('below'); 59 + expect(TABLE_COMMANDS.deleteRow.label).toContain('row'); 60 + 61 + // Column operations 62 + expect(TABLE_COMMANDS.addColumnBefore.label).toContain('left'); 63 + expect(TABLE_COMMANDS.addColumnAfter.label).toContain('right'); 64 + expect(TABLE_COMMANDS.deleteColumn.label).toContain('column'); 65 + 66 + // Cell operations 67 + expect(TABLE_COMMANDS.mergeCells.label).toContain('erge'); 68 + expect(TABLE_COMMANDS.splitCell.label).toContain('plit'); 69 + expect(TABLE_COMMANDS.toggleHeaderRow.label).toContain('header'); 70 + }); 71 + }); 72 + 73 + describe('TableToolbarState — visibility and position tracking', () => { 74 + it('starts hidden', () => { 75 + const state = new TableToolbarState(); 76 + expect(state.visible).toBe(false); 77 + }); 78 + 79 + it('show makes it visible with position', () => { 80 + const state = new TableToolbarState(); 81 + state.show({ top: 100, left: 200 }); 82 + expect(state.visible).toBe(true); 83 + expect(state.position).toEqual({ top: 100, left: 200 }); 84 + }); 85 + 86 + it('hide makes it invisible', () => { 87 + const state = new TableToolbarState(); 88 + state.show({ top: 100, left: 200 }); 89 + state.hide(); 90 + expect(state.visible).toBe(false); 91 + }); 92 + 93 + it('position defaults to null when hidden', () => { 94 + const state = new TableToolbarState(); 95 + expect(state.position).toBeNull(); 96 + }); 97 + 98 + it('updatePosition changes position without affecting visibility', () => { 99 + const state = new TableToolbarState(); 100 + state.show({ top: 100, left: 200 }); 101 + state.updatePosition({ top: 150, left: 250 }); 102 + expect(state.visible).toBe(true); 103 + expect(state.position).toEqual({ top: 150, left: 250 }); 104 + }); 105 + 106 + it('hide resets position to null', () => { 107 + const state = new TableToolbarState(); 108 + state.show({ top: 100, left: 200 }); 109 + state.hide(); 110 + expect(state.position).toBeNull(); 111 + }); 112 + }); 113 + 114 + describe('parseCellColor — extract background color from cell attributes', () => { 115 + it('returns the background color if set', () => { 116 + expect(parseCellColor({ background: '#ff0000' })).toBe('#ff0000'); 117 + }); 118 + 119 + it('returns the backgroundColor if set (alias)', () => { 120 + expect(parseCellColor({ backgroundColor: '#00ff00' })).toBe('#00ff00'); 121 + }); 122 + 123 + it('prefers background over backgroundColor', () => { 124 + expect(parseCellColor({ background: '#ff0000', backgroundColor: '#00ff00' })).toBe('#ff0000'); 125 + }); 126 + 127 + it('returns null for no color', () => { 128 + expect(parseCellColor({})).toBeNull(); 129 + expect(parseCellColor(null)).toBeNull(); 130 + expect(parseCellColor(undefined)).toBeNull(); 131 + }); 132 + }); 133 + 134 + describe('normalizeCellColor — convert color strings to hex', () => { 135 + it('passes through hex colors', () => { 136 + expect(normalizeCellColor('#ff0000')).toBe('#ff0000'); 137 + expect(normalizeCellColor('#FF0000')).toBe('#ff0000'); 138 + }); 139 + 140 + it('returns null for empty/null input', () => { 141 + expect(normalizeCellColor('')).toBeNull(); 142 + expect(normalizeCellColor(null)).toBeNull(); 143 + expect(normalizeCellColor(undefined)).toBeNull(); 144 + }); 145 + 146 + it('trims whitespace', () => { 147 + expect(normalizeCellColor(' #ff0000 ')).toBe('#ff0000'); 148 + }); 149 + 150 + it('handles 3-digit hex shorthand', () => { 151 + expect(normalizeCellColor('#f00')).toBe('#ff0000'); 152 + }); 153 + });
+136
tests/zen-mode.test.js
··· 1 + import { describe, it, expect } from 'vitest'; 2 + 3 + /** 4 + * Tests for Focus/Zen Mode (src/docs/zen-mode.js) 5 + * 6 + * Zen mode provides distraction-free editing by hiding the toolbar and 7 + * topbar, expanding the editor to full width. Since we can't run DOM 8 + * in Node, we test the pure logic: state management, CSS class names, 9 + * and localStorage persistence. 10 + */ 11 + 12 + import { 13 + ZenModeState, 14 + ZEN_STORAGE_KEY, 15 + ZEN_CLASS, 16 + ZEN_TRANSITION_MS, 17 + } from '../src/docs/zen-mode.js'; 18 + 19 + describe('ZenModeState — state management', () => { 20 + it('starts inactive by default', () => { 21 + const state = new ZenModeState(); 22 + expect(state.isActive).toBe(false); 23 + }); 24 + 25 + it('can be initialized as active', () => { 26 + const state = new ZenModeState(true); 27 + expect(state.isActive).toBe(true); 28 + }); 29 + 30 + it('toggle flips the state', () => { 31 + const state = new ZenModeState(); 32 + state.toggle(); 33 + expect(state.isActive).toBe(true); 34 + state.toggle(); 35 + expect(state.isActive).toBe(false); 36 + }); 37 + 38 + it('activate/deactivate are explicit', () => { 39 + const state = new ZenModeState(); 40 + state.activate(); 41 + expect(state.isActive).toBe(true); 42 + state.activate(); // idempotent 43 + expect(state.isActive).toBe(true); 44 + state.deactivate(); 45 + expect(state.isActive).toBe(false); 46 + state.deactivate(); // idempotent 47 + expect(state.isActive).toBe(false); 48 + }); 49 + 50 + it('tracks toggle count for debugging', () => { 51 + const state = new ZenModeState(); 52 + expect(state.toggleCount).toBe(0); 53 + state.toggle(); 54 + expect(state.toggleCount).toBe(1); 55 + state.toggle(); 56 + expect(state.toggleCount).toBe(2); 57 + }); 58 + }); 59 + 60 + describe('ZEN_STORAGE_KEY — localStorage key', () => { 61 + it('is a non-empty string', () => { 62 + expect(typeof ZEN_STORAGE_KEY).toBe('string'); 63 + expect(ZEN_STORAGE_KEY.length).toBeGreaterThan(0); 64 + }); 65 + 66 + it('is namespaced to tools', () => { 67 + expect(ZEN_STORAGE_KEY).toContain('tools'); 68 + }); 69 + }); 70 + 71 + describe('ZEN_CLASS — CSS class applied to body/app', () => { 72 + it('is a non-empty string', () => { 73 + expect(typeof ZEN_CLASS).toBe('string'); 74 + expect(ZEN_CLASS.length).toBeGreaterThan(0); 75 + }); 76 + 77 + it('contains "zen"', () => { 78 + expect(ZEN_CLASS).toContain('zen'); 79 + }); 80 + }); 81 + 82 + describe('ZEN_TRANSITION_MS — transition duration', () => { 83 + it('is a positive number', () => { 84 + expect(typeof ZEN_TRANSITION_MS).toBe('number'); 85 + expect(ZEN_TRANSITION_MS).toBeGreaterThan(0); 86 + }); 87 + 88 + it('is reasonable for CSS animation (100-500ms)', () => { 89 + expect(ZEN_TRANSITION_MS).toBeGreaterThanOrEqual(100); 90 + expect(ZEN_TRANSITION_MS).toBeLessThanOrEqual(500); 91 + }); 92 + }); 93 + 94 + describe('ZenModeState — getClassList returns correct classes', () => { 95 + it('returns zen class when active', () => { 96 + const state = new ZenModeState(); 97 + state.activate(); 98 + expect(state.getClassList()).toContain(ZEN_CLASS); 99 + }); 100 + 101 + it('returns empty array when inactive', () => { 102 + const state = new ZenModeState(); 103 + expect(state.getClassList()).toEqual([]); 104 + }); 105 + }); 106 + 107 + describe('ZenModeState — serialization for localStorage', () => { 108 + it('serialize returns "true" when active', () => { 109 + const state = new ZenModeState(true); 110 + expect(state.serialize()).toBe('true'); 111 + }); 112 + 113 + it('serialize returns "false" when inactive', () => { 114 + const state = new ZenModeState(false); 115 + expect(state.serialize()).toBe('false'); 116 + }); 117 + 118 + it('fromStored creates active state from "true"', () => { 119 + const state = ZenModeState.fromStored('true'); 120 + expect(state.isActive).toBe(true); 121 + }); 122 + 123 + it('fromStored creates inactive state from "false"', () => { 124 + const state = ZenModeState.fromStored('false'); 125 + expect(state.isActive).toBe(false); 126 + }); 127 + 128 + it('fromStored creates inactive state from null/empty', () => { 129 + const state = ZenModeState.fromStored(null); 130 + expect(state.isActive).toBe(false); 131 + const state2 = ZenModeState.fromStored(''); 132 + expect(state2.isActive).toBe(false); 133 + const state3 = ZenModeState.fromStored(undefined); 134 + expect(state3.isActive).toBe(false); 135 + }); 136 + });