Compare commits

...

11 Commits

Author SHA1 Message Date
lucaronin
e5ed863d5d fix: refine sidebar folder rows 2026-05-02 17:21:58 +02:00
lucaronin
4f7f5a31e4 fix(linux): choose matching wayland preload 2026-05-02 17:01:57 +02:00
lucaronin
439e2b7f66 fix: prefetch tauri nsis tools in release ci 2026-05-02 16:45:59 +02:00
lucaronin
048d27243d fix: refine slash command menu 2026-05-02 16:39:43 +02:00
lucaronin
58e129cdbc fix: stabilize editor block dragging 2026-05-02 12:18:59 +02:00
lucaronin
713b486750 fix: render release history tabs as pills 2026-05-02 11:25:39 +02:00
lucaronin
7d66e2f8e1 fix: guard table handle add selection 2026-05-02 11:16:09 +02:00
lucaronin
cd6847d59e feat: refine titlebar navigation controls 2026-05-02 10:56:36 +02:00
lucaronin
74c9841ad7 fix(ui): refine icon picker appearance 2026-05-02 10:16:24 +02:00
lucaronin
ba41854f0e fix: ignore malformed reload entries 2026-05-02 10:00:50 +02:00
lucaronin
6532ee125a fix(vault): prevent gitignored folder scan freezes 2026-05-02 09:28:51 +02:00
47 changed files with 2557 additions and 673 deletions

137
.github/scripts/prefetch-tauri-nsis.ps1 vendored Normal file
View File

@@ -0,0 +1,137 @@
$ErrorActionPreference = "Stop"
$nsisUrl = "https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip"
$nsisSha1 = "EF7FF767E5CBD9EDD22ADD3A32C9B8F4500BB10D"
$tauriUtilsUrl = "https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/nsis_tauri_utils.dll"
$tauriUtilsSha1 = "75197FEE3C6A814FE035788D1C34EAD39349B860"
$tauriUtilsRelativePath = "Plugins\x86-unicode\additional\nsis_tauri_utils.dll"
$nsisRequiredFiles = @(
"makensis.exe",
"Bin\makensis.exe",
"Stubs\lzma-x86-unicode",
"Stubs\lzma_solid-x86-unicode",
"Include\MUI2.nsh",
"Include\FileFunc.nsh",
"Include\x64.nsh",
"Include\nsDialogs.nsh",
"Include\WinMessages.nsh",
"Include\Win\COM.nsh",
"Include\Win\Propkey.nsh",
"Include\Win\RestartManager.nsh"
)
function Get-UpperSha1 {
param([Parameter(Mandatory = $true)][string]$Path)
return (Get-FileHash -Algorithm SHA1 -LiteralPath $Path).Hash.ToUpperInvariant()
}
function Test-FileSha1 {
param(
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$ExpectedSha1
)
return (Test-Path -LiteralPath $Path) -and ((Get-UpperSha1 -Path $Path) -eq $ExpectedSha1)
}
function Save-VerifiedDownload {
param(
[Parameter(Mandatory = $true)][string]$Uri,
[Parameter(Mandatory = $true)][string]$OutFile,
[Parameter(Mandatory = $true)][string]$ExpectedSha1
)
$parent = Split-Path -Parent $OutFile
New-Item -ItemType Directory -Force -Path $parent | Out-Null
$tempFile = "$OutFile.download"
for ($attempt = 1; $attempt -le 5; $attempt++) {
try {
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile
Invoke-WebRequest -Uri $Uri -OutFile $tempFile -TimeoutSec 120
$actualSha1 = Get-UpperSha1 -Path $tempFile
if ($actualSha1 -ne $ExpectedSha1) {
throw "SHA1 mismatch for $Uri. Expected $ExpectedSha1, got $actualSha1."
}
Move-Item -Force -LiteralPath $tempFile -Destination $OutFile
return
} catch {
Remove-Item -Force -ErrorAction SilentlyContinue -LiteralPath $tempFile
if ($attempt -eq 5) {
throw
}
$delaySeconds = [Math]::Min(30, 5 * $attempt)
Write-Warning "Download attempt ${attempt} failed: $($_.Exception.Message). Retrying in ${delaySeconds}s."
Start-Sleep -Seconds $delaySeconds
}
}
}
function Find-MissingFile {
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][string[]]$RelativePaths
)
foreach ($relativePath in $RelativePaths) {
if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath))) {
return $relativePath
}
}
return $null
}
if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) {
throw "LOCALAPPDATA is required to resolve Tauri's Windows tool cache."
}
$tauriToolsPath = Join-Path $env:LOCALAPPDATA "tauri"
$nsisPath = Join-Path $tauriToolsPath "NSIS"
$downloadRoot = if ([string]::IsNullOrWhiteSpace($env:RUNNER_TEMP)) {
[System.IO.Path]::GetTempPath()
} else {
$env:RUNNER_TEMP
}
New-Item -ItemType Directory -Force -Path $tauriToolsPath | Out-Null
$missingNsisFile = Find-MissingFile -Root $nsisPath -RelativePaths $nsisRequiredFiles
if ($missingNsisFile) {
Write-Host "Tauri NSIS cache is missing $missingNsisFile; downloading NSIS 3.11."
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath $nsisPath
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue -LiteralPath (Join-Path $tauriToolsPath "nsis-3.11")
$zipPath = Join-Path $downloadRoot "nsis-3.11.zip"
Save-VerifiedDownload -Uri $nsisUrl -OutFile $zipPath -ExpectedSha1 $nsisSha1
Expand-Archive -Force -LiteralPath $zipPath -DestinationPath $tauriToolsPath
$extractedNsisPath = Join-Path $tauriToolsPath "nsis-3.11"
if (-not (Test-Path -LiteralPath $extractedNsisPath)) {
throw "Downloaded NSIS archive did not contain the expected nsis-3.11 directory."
}
Move-Item -Force -LiteralPath $extractedNsisPath -Destination $nsisPath
} else {
Write-Host "Tauri NSIS cache already contains NSIS 3.11."
}
$tauriUtilsPath = Join-Path $nsisPath $tauriUtilsRelativePath
if (-not (Test-FileSha1 -Path $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1)) {
Write-Host "Downloading Tauri NSIS utility plugin."
Save-VerifiedDownload -Uri $tauriUtilsUrl -OutFile $tauriUtilsPath -ExpectedSha1 $tauriUtilsSha1
} else {
Write-Host "Tauri NSIS utility plugin is already cached."
}
$missingFile = Find-MissingFile -Root $nsisPath -RelativePaths ($nsisRequiredFiles + @($tauriUtilsRelativePath))
if ($missingFile) {
throw "Tauri NSIS toolchain is incomplete after prefetch; missing $missingFile."
}
Write-Host "Tauri NSIS toolchain ready at $nsisPath."

View File

@@ -401,6 +401,16 @@ jobs:
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Cache Tauri Windows tools
uses: actions/cache@v4
with:
path: ~\AppData\Local\tauri
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
- name: Prefetch Tauri NSIS toolchain
shell: pwsh
run: ./.github/scripts/prefetch-tauri-nsis.ps1
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile

View File

@@ -455,6 +455,16 @@ jobs:
restore-keys: |
${{ runner.os }}-release-cargo-x86_64-pc-windows-msvc-${{ env.RUST_TARGET_CACHE_VERSION }}-
- name: Cache Tauri Windows tools
uses: actions/cache@v4
with:
path: ~\AppData\Local\tauri
key: ${{ runner.os }}-tauri-tools-nsis-3.11-nsis-tauri-utils-0.5.3
- name: Prefetch Tauri NSIS toolchain
shell: pwsh
run: ./.github/scripts/prefetch-tauri-nsis.ps1
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile

View File

@@ -363,7 +363,7 @@ All Notes starts from Markdown notes and excludes Markdown files under `attachme
The folder tree hides the legacy `type/` directory, since those type documents already appear through the Types sidebar section. Default vault folders such as `attachments/` and `views/` remain visible alongside user-created folders under the synthetic vault-root row.
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, so negated and specific `.gitignore` patterns follow Git semantics as closely as the app can reasonably support.
Command-facing vault content is filtered through `vault::filter_gitignored_entries`, `vault::filter_gitignored_folders`, and `vault::filter_gitignored_paths` when the app setting `hide_gitignored_files` is enabled. The cache still stores the complete scan; `list_vault`, `reload_vault`, `list_vault_folders`, and search apply the visibility filter at the boundary before React consumes entries. The filter batches paths through `git check-ignore --no-index --stdin`, drains stdout while stdin is still being written, and short-circuits root `.gitignore` detection before walking for nested ignore files, so large ignored folder sets cannot deadlock the native UI while preserving Git semantics as closely as the app can reasonably support.
A `vault_health_check` command detects stray files in non-protected subfolders and filename-title mismatches. On vault load, a migration banner offers to flatten stray files to the root via `flatten_vault`.
@@ -562,8 +562,8 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, and list blocks. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the drag handle before the add-block button so the leftmost block affordance starts a reorder drag on macOS. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Browser and native table-drag regressions should exercise both row and column handles because the state is tracked per orientation.
- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the rendered text range for the hovered block, so H1/H2 typography, line-height, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation.
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader.
- `useTauriDragDropEvent()` owns the shared Tauri window drag/drop subscription and duplicate-unlisten cleanup used by native drop features.

View File

@@ -88,7 +88,7 @@ flowchart LR
3. **No orphan state updates**: Never call `updateEntry()` before the corresponding `handleUpdateFrontmatter()` or `handleDeleteProperty()` has resolved. The three functions in `useEntryActions` (`handleCustomizeType`, `handleRenameSection`, `handleToggleTypeVisibility`) follow this rule — disk write first, then state update.
4. **Recovery via reload**: If state ever diverges from disk (crash, external edit, race condition), `Reload Vault` (Cmd+K → "Reload Vault") invalidates the cache and does a full filesystem rescan via the `reload_vault` Tauri command, replacing all React state. The `reload_vault_entry` command can re-read a single file.
5. **Cache is disposable**: The `reload_vault` command deletes the cache file before rescanning, guaranteeing fresh data. The cache never contains data that doesn't exist on the filesystem.
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape.
6. **Visibility filters are command-boundary concerns**: Gitignored-content visibility is applied after scanning/caching, before entries, folders, or search results reach React. The cache remains complete so toggling the setting can show ignored content again without rebuilding a different cache shape. Large folder filtering runs on the blocking Tokio pool and drains `git check-ignore` output while feeding stdin so broad ignore matches cannot freeze the native UI thread.
#### External Change Detection
@@ -217,7 +217,7 @@ The main Tauri window also persists its last normal size and screen position in
Tauri setup keeps launch-time filesystem and subprocess work off the window creation critical path. Legacy `~/Laputa` housekeeping and the initial persisted-vault MCP bridge sync run on named background threads, so large legacy vaults, stale active-vault paths, or slow process startup cannot beachball the macOS app before React mounts. React still resyncs the bridge from `useVaultSwitcher` after the persisted selection loads, and no selected vault stops the bridge. The HTML bootstrap also installs a Tauri-only one-shot watchdog: React reports readiness from an effect after the root commits, and if that readiness signal never arrives the WebView reloads once instead of leaving macOS users in an inert rendered shell.
Linux uses custom React-rendered window chrome instead of the native Tauri menu bar. `setup_linux_window_chrome()` drops server-side decorations on the main window, `openNoteInNewWindow()` does the same for detached note windows, and `LinuxTitlebar`/`LinuxMenuButton` route both window controls and menu actions back through the same shared command pipeline that the desktop native menus use. The native app menu keeps macOS-only Services/Hide entries off Windows and Linux, while cross-platform custom items such as Check for Updates emit Tolaria command IDs and show visible updater feedback.
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first available system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, while the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER` before GTK/WebKit create the display.
When Tolaria is launched from a Linux AppImage, `run()` also applies AppImage-only WebKitGTK startup safeguards without changing native package installs. It injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` and `WEBKIT_DISABLE_COMPOSITING_MODE=1` independently unless the user already set either variable, and on Wayland sessions it re-execs once with the first architecture-matching system `libwayland-client.so` in `LD_PRELOAD` when the user has not provided their own preload. The candidate order prefers Fedora-style `lib64` and Debian-style `x86_64-linux-gnu` paths before generic `/usr/lib`, and the ELF header is checked so a 64-bit Tolaria process does not retry with a 32-bit Wayland client library. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, while the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER` before GTK/WebKit create the display.
## Multi-Window (Note Windows)
@@ -644,7 +644,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `parsing.rs` | Text processing: snippet extraction, markdown stripping, ISO date parsing, `extract_title` (H1 → legacy frontmatter → filename), `slug_to_title` |
| `title_sync.rs` | Legacy filename → `title` frontmatter sync helper; no longer used by the normal note-open flow |
| `cache.rs` | Git-based incremental vault caching (`scan_vault_cached`), git helpers |
| `ignored.rs` | Gitignored-content visibility filtering via batched `git check-ignore` |
| `ignored.rs` | Gitignored-content visibility filtering via batched, pipe-safe `git check-ignore` |
| `filename_rules.rs` | Cross-platform validation for note filenames, folder names, and custom view filenames |
| `rename.rs` | `rename_note` / `rename_note_filename` / `move_note_to_folder` — stage crash-safe file moves, update `title` frontmatter when needed, recover unfinished rename transactions, and report backlink rewrite failures |
| `image.rs` | `save_image` / `copy_image_to_vault` — save editor image attachments with sanitized filenames |
@@ -684,6 +684,7 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules:
| `rename_note` | Crash-safe note rename + `title` frontmatter update + cross-vault wikilinks + failed backlink counts |
| `move_note_to_folder` | Crash-safe folder move that preserves the filename, reloads the moved note, and rewrites path-based wikilinks |
| `create_vault_folder` | Create a folder relative to the active vault root |
| `list_vault_folders` | Build the folder tree on the blocking Tokio pool, then apply Gitignored-content visibility → `Vec<FolderNode>` |
| `rename_vault_folder` | Rename a folder relative to the active vault root and return old/new relative paths |
| `delete_vault_folder` | Permanently delete a folder subtree relative to the active vault root |
| `sync_note_title` | Legacy helper: rewrite `title` frontmatter from filename → `bool` (modified); not used by the normal note-open flow |

View File

@@ -37,13 +37,13 @@ On some Wayland systems, the Linux AppImage may fail to launch with:
Could not create default EGL display: EGL_BAD_PARAMETER. Aborting...
```
Recent Tolaria AppImages automatically disable unstable WebKitGTK AppImage rendering paths and retry startup with the system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
Recent Tolaria AppImages automatically disable unstable WebKitGTK AppImage rendering paths and retry startup with an architecture-matching system Wayland client library when they detect this class of AppImage + Wayland environment. If you are running an older build, use this workaround:
```bash
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib/libwayland-client.so ./Tolaria*.AppImage
WEBKIT_DISABLE_COMPOSITING_MODE=1 WEBKIT_DISABLE_DMABUF_RENDERER=1 LD_PRELOAD=/usr/lib64/libwayland-client.so.0 ./Tolaria*.AppImage
```
If your distribution stores the library elsewhere, use that path instead, for example `/usr/lib64/libwayland-client.so.0` or `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`.
If your distribution stores the 64-bit library elsewhere, use that path instead, for example `/usr/lib/x86_64-linux-gnu/libwayland-client.so.0`. On 64-bit Fedora, avoid `/usr/lib/libwayland-client.so.0`; that path can point at a 32-bit library and be ignored by the loader with a wrong ELF class warning.
## Quick Start

View File

@@ -0,0 +1,32 @@
---
type: ADR
id: "0107"
title: "Pointer-owned editor block reordering"
status: active
date: 2026-05-02
---
## Context
Tolaria uses BlockNote for rich Markdown editing, Tauri for native desktop file drops, and custom editor drop handlers for images and wikilinks. BlockNote's default block drag path depends on HTML5 drag events and `DataTransfer`. On macOS inside the Tauri webview, that makes block reordering fragile because the same browser-level drag system also carries native file/image drops into the editor.
Regressions tended to oscillate: fixes that restored block dragging could break image drops, and fixes that protected native drops could make block reordering fail or lose visual feedback. The drag handle also needs editor-specific affordances: a moving block preview, an insertion separator, stale-block protection, and typography-aware positioning for the side-menu controls.
## Decision
**Tolaria owns editor block reordering as a pointer gesture that directly moves BlockNote blocks, and leaves HTML5/native drag data paths for file, image, and external drops.** The drag side menu is responsible for resolving live BlockNote blocks, computing pointer hit targets, rendering drag affordances, and aligning the hover controls to the rendered text range of the hovered block.
## Options considered
- **Pointer-owned block reordering** (chosen): separates internal block moves from native drop payloads, works without `DataTransfer`, gives Tolaria deterministic visual affordances, and can be tested with Playwright pointer/mouse actions. Cons: Tolaria now owns a small amount of hit-testing, block-move, and affordance code around BlockNote.
- **Continue using BlockNote's HTML5 drag handler**: keeps less local code, but ties internal block moves to the same unstable drag channel used by native file drops in Tauri.
- **Patch native file drops around BlockNote drag events**: might repair individual regressions, but preserves the root coupling between editor-internal reordering and external drag payload handling.
- **Disable block dragging on macOS/Tauri**: avoids the conflict, but removes an important editing workflow.
## Consequences
- Internal block reordering must not depend on `DataTransfer` or `draggable=true`.
- File/image/wikilink drop behavior remains owned by the existing editor drop abstractions and native Tauri file-drop bridge.
- Block reordering tests should use pointer or mouse movement in Playwright, and should assert the moving preview and insertion separator while the drag is in progress.
- The side menu should align to measured rendered content rather than heading-specific pixel offsets, so future theme font-size and line-height changes do not need drag-control retuning.
- Changes to BlockNote DOM structure around `.bn-block-content`, `.bn-inline-content`, `.bn-side-menu`, or block container IDs require rechecking the pointer hit-testing and side-menu alignment tests.

View File

@@ -159,3 +159,4 @@ proposed → active → superseded
| [0104](0104-tauri-frontend-readiness-watchdog.md) | Tauri frontend readiness watchdog | active |
| [0105](0105-editor-correctness-and-responsiveness-contract.md) | Editor correctness and responsiveness contract | active |
| [0106](0106-shared-app-command-manifest.md) | Shared app command manifest | active |
| [0107](0107-pointer-owned-editor-block-reordering.md) | Pointer-owned editor block reordering | active |

View File

@@ -1,5 +1,5 @@
diff --git a/dist/TrailingNode-CxM966vN.js b/dist/TrailingNode-CxM966vN.js
index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab1729752bc05 100644
index 3ac6fe16aa9ae605a59916f8343b8440befda89e..52df094addfedadcf5f7cee412fa637ddcd2604b 100644
--- a/dist/TrailingNode-CxM966vN.js
+++ b/dist/TrailingNode-CxM966vN.js
@@ -1183,6 +1183,10 @@ class Ft {
@@ -25,18 +25,18 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab172
if (!t || !this.editor.isEditable) {
- (r = this.state) != null && r.show && (this.state.show = !1, this.updateState(this.state));
+ this.hideMenu();
return;
}
- if (!((s = this.state) != null && s.show && ((i = this.hoveredBlock) != null && i.hasAttribute("data-id")) && ((a = this.hoveredBlock) == null ? void 0 : a.getAttribute("data-id")) === t.id) && (this.hoveredBlock = t.node, this.editor.isEditable)) {
- const c = t.node.getBoundingClientRect(), l = t.node.closest("[data-node-type=column]");
+ return;
+ }
+ if ((s = this.state) != null && s.show && ((i = this.hoveredBlock) != null && i.hasAttribute("data-id")) && ((a = this.hoveredBlock) == null ? void 0 : a.getAttribute("data-id")) === t.id)
+ return;
+ this.hoveredBlock = t.node;
+ const c = this.hoveredBlock.getAttribute("data-id"), l = c ? this.editor.getBlock(c) : void 0;
+ if (!l) {
+ this.hideMenu();
+ return;
+ }
return;
}
- if (!((s = this.state) != null && s.show && ((i = this.hoveredBlock) != null && i.hasAttribute("data-id")) && ((a = this.hoveredBlock) == null ? void 0 : a.getAttribute("data-id")) === t.id) && (this.hoveredBlock = t.node, this.editor.isEditable)) {
- const c = t.node.getBoundingClientRect(), l = t.node.closest("[data-node-type=column]");
+ if (this.editor.isEditable) {
+ const u = t.node.getBoundingClientRect(), h = t.node.closest("[data-node-type=column]");
this.state = {
@@ -65,7 +65,38 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab172
}, this.updateState(this.state);
}
});
@@ -1557,6 +1567,12 @@ class Kt {
@@ -1522,6 +1532,30 @@ function Ut(n) {
function M(n) {
return Array.prototype.indexOf.call(n.parentElement.childNodes, n);
}
+function isValidTablePosition(n) {
+ return Number.isInteger(n) && n >= 0;
+}
+function isValidCellIndex(n) {
+ return Number.isInteger(n) && n >= 0;
+}
+function isValidRelativeCell(n) {
+ return isValidCellIndex(n.row) && isValidCellIndex(n.col);
+}
+function resolveCellPosition(n, e, t) {
+ if (!isValidTablePosition(e) || !isValidRelativeCell(t))
+ return;
+ try {
+ const o = n.doc.resolve(e + 1);
+ if (t.row >= o.node().childCount)
+ return;
+ const r = n.doc.resolve(
+ o.posAtIndex(t.row) + 1
+ );
+ return t.col >= r.node().childCount ? void 0 : n.doc.resolve(r.posAtIndex(t.col));
+ } catch {
+ return;
+ }
+}
function _t(n) {
let e = n;
for (; e && e.nodeName !== "TD" && e.nodeName !== "TH" && !e.classList.contains("tableWrapper"); ) {
@@ -1557,6 +1591,12 @@ class Kt {
b(this, "menuFrozen", !1);
b(this, "mouseState", "up");
b(this, "prevWasEditable", null);
@@ -78,7 +109,7 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab172
b(this, "viewMousedownHandler", () => {
this.mouseState = "down";
});
@@ -1569,11 +1585,11 @@ class Kt {
@@ -1569,11 +1609,11 @@ class Kt {
return;
const t = _t(e.target);
if ((t == null ? void 0 : t.type) === "cell" && this.mouseState === "down" && !((l = this.state) != null && l.draggingState)) {
@@ -92,7 +123,7 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab172
return;
}
if (!t.tbodyNode)
@@ -1671,9 +1687,7 @@ class Kt {
@@ -1671,9 +1711,7 @@ class Kt {
if (this.mouseState = "up", this.state === void 0 || this.state.draggingState === void 0)
return !1;
if (this.state.rowIndex === void 0 || this.state.colIndex === void 0)
@@ -103,7 +134,7 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab172
e.preventDefault();
const { draggingState: t, colIndex: o, rowIndex: r } = this.state, s = this.state.block.content.columnWidths;
if (t.draggedCellOrientation === "row") {
@@ -1738,7 +1752,7 @@ class Kt {
@@ -1738,7 +1776,7 @@ class Kt {
if (this.state.block = this.editor.getBlock(this.state.block.id), !this.state.block || this.state.block.type !== "table" || // when collaborating, the table element might be replaced and out of date
// because yjs replaces the element when for example you change the color via the side menu
!((r = this.tableElement) != null && r.isConnected)) {
@@ -112,7 +143,7 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab172
return;
}
const { height: e, width: t } = Ve(
@@ -1746,10 +1760,10 @@ class Kt {
@@ -1746,10 +1784,10 @@ class Kt {
);
this.state.rowIndex !== void 0 && this.state.colIndex !== void 0 && (this.state.rowIndex >= e && (this.state.rowIndex = e - 1), this.state.colIndex >= t && (this.state.colIndex = t - 1));
const o = this.tableElement.querySelector("tbody");
@@ -127,7 +158,7 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab172
if (this.state.rowIndex !== void 0 && this.state.colIndex !== void 0) {
const i = o.children[this.state.rowIndex].children[this.state.colIndex];
i ? this.state.referencePosCell = i.getBoundingClientRect() : (this.state.rowIndex = void 0, this.state.colIndex = void 0);
@@ -1838,10 +1852,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
@@ -1838,10 +1876,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
* is used as the column drag handle.
*/
colDragStart(o) {
@@ -142,7 +173,7 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab172
e.state.draggingState = {
draggedCellOrientation: "col",
originalIndex: e.state.colIndex,
@@ -1860,10 +1874,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
@@ -1860,10 +1898,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
* is used as the row drag handle.
*/
rowDragStart(o) {
@@ -157,7 +188,7 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab172
e.state.draggingState = {
draggedCellOrientation: "row",
originalIndex: e.state.rowIndex,
@@ -1882,10 +1896,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
@@ -1882,10 +1920,10 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
* used as the row drag handle, and the one used as the column drag handle.
*/
dragEnd() {
@@ -172,6 +203,100 @@ index 3ac6fe16aa9ae605a59916f8343b8440befda89e..b8feb30cf1ddac140e17782a1e3ab172
e.state.draggingState = void 0, e.emitUpdate(), n.transact((o) => o.setMeta(D, null)), !n.headless && Ut(n.prosemirrorView.root);
},
/**
@@ -1918,30 +1956,37 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
setCellSelection(o, r, s = r) {
if (!e)
throw new Error("Table handles view not initialized");
- const i = o.doc.resolve(e.tablePos + 1), a = o.doc.resolve(
- i.posAtIndex(r.row) + 1
- ), c = o.doc.resolve(
- // No need for +1, since CellSelection expects the position before the cell
- a.posAtIndex(r.col)
- ), l = o.doc.resolve(
- i.posAtIndex(s.row) + 1
- ), u = o.doc.resolve(
- // No need for +1, since CellSelection expects the position before the cell
- l.posAtIndex(s.col)
- ), h = o.tr;
- return h.setSelection(
- new yt(c, u)
- ), o.apply(h);
+ const i = resolveCellPosition(
+ o,
+ e.tablePos,
+ r
+ ), a = resolveCellPosition(
+ o,
+ e.tablePos,
+ s
+ );
+ if (!i || !a)
+ return;
+ const c = o.tr;
+ return c.setSelection(
+ new yt(i, a)
+ ), o.apply(c);
},
/**
* Adds a row or column to the table using prosemirror-table commands
*/
addRowOrColumn(o, r) {
+ if (!e || !isValidTablePosition(e.tablePos) || !isValidCellIndex(o)) {
+ e == null || e.hideHandles({ resetCell: !0 });
+ return;
+ }
n.exec((s, i) => {
const a = this.setCellSelection(
s,
r.orientation === "row" ? { row: o, col: 0 } : { row: 0, col: o }
);
+ if (!a)
+ return e == null || e.hideHandles({ resetCell: !0 }), !1;
return r.orientation === "row" ? r.side === "above" ? pt(a, i) : ft(a, i) : r.side === "left" ? gt(a, i) : wt(a, i);
});
},
@@ -1949,17 +1994,23 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
* Removes a row or column from the table using prosemirror-table commands
*/
removeRowOrColumn(o, r) {
+ if (!isValidCellIndex(o))
+ return !1;
return r === "row" ? n.exec((s, i) => {
const a = this.setCellSelection(s, {
row: o,
col: 0
});
+ if (!a)
+ return !1;
return ht(a, i);
}) : n.exec((s, i) => {
const a = this.setCellSelection(s, {
row: 0,
col: o
});
+ if (!a)
+ return !1;
return mt(a, i);
});
},
@@ -1973,6 +2024,8 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
o.relativeStartCell,
o.relativeEndCell
) : r;
+ if (!i)
+ return !1;
return ut(i, s);
});
},
@@ -1983,6 +2036,8 @@ const D = new P("TableHandlesPlugin"), Po = k(({ editor: n }) => {
splitCell(o) {
return n.exec((r, s) => {
const i = o ? this.setCellSelection(r, o) : r;
+ if (!i)
+ return !1;
return dt(i, s);
});
},
diff --git a/dist/blocknote.js b/dist/blocknote.js
index 0f97dc48ddedfb9661b81c7469ce504dc974e284..66600e91f871ed86e4942b794d2bce8f96882ae0 100644
--- a/dist/blocknote.js
@@ -308,7 +433,7 @@ index dbb7fc33a9add7a96488349876bc56ad60111a3f..58c3cf181f25467d85cd8c3788b5b73d
@@ -134,6 +134,10 @@ export const createCodeBlockSpec = createBlockSpec(
const handleLanguageChange = (event: Event) => {
const language = (event.target as HTMLSelectElement).value;
+ if (!editor.getBlock(block.id)) {
+ return;
+ }
@@ -339,7 +464,7 @@ index 769e4a17154db2d88472696638db96e20131dfdb..a6c7f1a76eaa4db7b58774ae71ef47ab
@@ -194,6 +194,15 @@ export class SideMenuView<
this.emitUpdate(this.state);
};
+ private hideMenu = () => {
+ if (!this.state?.show) {
+ return;
@@ -363,9 +488,9 @@ index 769e4a17154db2d88472696638db96e20131dfdb..a6c7f1a76eaa4db7b58774ae71ef47ab
+ this.hideMenu();
return;
}
@@ -219,11 +225,7 @@ export class SideMenuView<
// Closes the menu if the mouse cursor is beyond the editor vertically.
if (!block || !this.editor.isEditable) {
- if (this.state?.show) {
@@ -376,10 +501,10 @@ index 769e4a17154db2d88472696638db96e20131dfdb..a6c7f1a76eaa4db7b58774ae71ef47ab
+ this.hideMenu();
return;
}
@@ -237,6 +239,15 @@ export class SideMenuView<
}
this.hoveredBlock = block.node;
+ const hoveredBlockId = this.hoveredBlock.getAttribute("data-id");
+ const hoveredEditorBlock = hoveredBlockId
@@ -390,7 +515,7 @@ index 769e4a17154db2d88472696638db96e20131dfdb..a6c7f1a76eaa4db7b58774ae71ef47ab
+ this.hideMenu();
+ return;
+ }
// Shows or updates elements.
if (this.editor.isEditable) {
@@ -258,9 +269,7 @@ export class SideMenuView<
@@ -409,18 +534,18 @@ index 029103600a98bf8ccd3054a5c43ffa2fd9115c06..d1678ed4761baca0b3495dafc8d75152
--- a/src/extensions/SuggestionMenu/SuggestionMenu.ts
+++ b/src/extensions/SuggestionMenu/SuggestionMenu.ts
@@ -32,7 +32,7 @@ class SuggestionMenuView {
this.emitUpdate = (menuName: string) => {
if (!this.state) {
- throw new Error("Attempting to update uninitialized suggestions menu");
+ return;
}
emitUpdate(menuName, {
@@ -64,6 +64,15 @@ class SuggestionMenuView {
}
};
+ private hideMenu = (menuName: string) => {
+ if (!this.state) {
+ return;
@@ -435,7 +560,7 @@ index 029103600a98bf8ccd3054a5c43ffa2fd9115c06..d1678ed4761baca0b3495dafc8d75152
suggestionMenuPluginKey.getState(prevState);
@@ -84,11 +93,7 @@ class SuggestionMenuView {
this.pluginState = stopped ? prev : next;
if (stopped || !this.editor.isEditable) {
- if (this.state) {
- this.state.show = false;
@@ -445,11 +570,11 @@ index 029103600a98bf8ccd3054a5c43ffa2fd9115c06..d1678ed4761baca0b3495dafc8d75152
+ this.hideMenu(this.pluginState!.triggerCharacter);
return;
}
@@ -96,17 +101,20 @@ class SuggestionMenuView {
`[data-decoration-id="${this.pluginState!.decorationId}"]`,
);
- if (this.editor.isEditable && decorationNode) {
- this.state = {
- show: true,
@@ -475,16 +600,63 @@ index 029103600a98bf8ccd3054a5c43ffa2fd9115c06..d1678ed4761baca0b3495dafc8d75152
+
+ this.emitUpdate(this.pluginState!.triggerCharacter!);
}
destroy() {
diff --git a/src/extensions/TableHandles/TableHandles.ts b/src/extensions/TableHandles/TableHandles.ts
index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49c47fd266 100644
index 30637742d517bcf136ca562a09a1544d7b03a20d..d8df74b7803efacd42e8cf3a9170e7ec37580f88 100644
--- a/src/extensions/TableHandles/TableHandles.ts
+++ b/src/extensions/TableHandles/TableHandles.ts
@@ -187,6 +187,32 @@ export class TableHandlesView implements PluginView {
@@ -99,6 +99,46 @@ function getChildIndex(node: Element) {
return Array.prototype.indexOf.call(node.parentElement!.childNodes, node);
}
+function isValidTablePosition(tablePos: number | undefined): tablePos is number {
+ return Number.isInteger(tablePos) && tablePos >= 0;
+}
+
+function isValidCellIndex(index: number | undefined): index is number {
+ return Number.isInteger(index) && index >= 0;
+}
+
+function isValidRelativeCell(cell: RelativeCellIndices) {
+ return isValidCellIndex(cell.row) && isValidCellIndex(cell.col);
+}
+
+function resolveCellPosition(
+ state: EditorState,
+ tablePos: number | undefined,
+ relativeCell: RelativeCellIndices,
+) {
+ if (!isValidTablePosition(tablePos) || !isValidRelativeCell(relativeCell)) {
+ return undefined;
+ }
+
+ try {
+ const tableResolvedPos = state.doc.resolve(tablePos + 1);
+ if (relativeCell.row >= tableResolvedPos.node().childCount) {
+ return undefined;
+ }
+
+ const rowResolvedPos = state.doc.resolve(
+ tableResolvedPos.posAtIndex(relativeCell.row) + 1,
+ );
+ if (relativeCell.col >= rowResolvedPos.node().childCount) {
+ return undefined;
+ }
+
+ return state.doc.resolve(rowResolvedPos.posAtIndex(relativeCell.col));
+ } catch {
+ return undefined;
+ }
+}
+
// Finds the DOM element corresponding to the table cell that the target element
// is currently in. If the target element is not in a table cell, returns null.
function domCellAround(target: Element) {
@@ -187,6 +227,32 @@ export class TableHandlesView implements PluginView {
);
}
+ hideHandles = ({
+ resetCell = false,
+ resetDragging = false,
@@ -514,10 +686,10 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
viewMousedownHandler = () => {
this.mouseState = "down";
};
@@ -222,22 +248,12 @@ export class TableHandlesView implements PluginView {
@@ -222,22 +288,12 @@ export class TableHandlesView implements PluginView {
// hide draghandles when selecting text as they could be in the way of the user
this.mouseState = "selecting";
- if (this.state?.show) {
- this.state.show = false;
- this.state.showAddOrRemoveRowsButton = false;
@@ -527,7 +699,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
+ this.hideHandles();
return;
}
if (!target || !this.editor.isEditable) {
- if (this.state?.show) {
- this.state.show = false;
@@ -538,8 +710,8 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
+ this.hideHandles();
return;
}
@@ -458,9 +474,9 @@ export class TableHandlesView implements PluginView {
@@ -458,9 +514,9 @@ export class TableHandlesView implements PluginView {
this.state.rowIndex === undefined ||
this.state.colIndex === undefined
) {
@@ -550,9 +722,9 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
+ this.hideHandles({ resetCell: true, resetDragging: true });
+ return false;
}
event.preventDefault();
@@ -541,11 +557,7 @@ export class TableHandlesView implements PluginView {
@@ -541,11 +597,7 @@ export class TableHandlesView implements PluginView {
// because yjs replaces the element when for example you change the color via the side menu
!this.tableElement?.isConnected
) {
@@ -564,10 +736,10 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
+ this.hideHandles();
return;
}
@@ -572,9 +584,8 @@ export class TableHandlesView implements PluginView {
@@ -572,9 +624,8 @@ export class TableHandlesView implements PluginView {
const tableBody = this.tableElement!.querySelector("tbody");
if (!tableBody) {
- throw new Error(
- "Table block does not contain a 'tbody' HTML element. This should never happen.",
@@ -575,9 +747,9 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
+ this.hideHandles({ resetCell: true });
+ return;
}
if (
@@ -796,11 +807,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -796,11 +847,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
if (
view === undefined ||
view.state === undefined ||
@@ -591,9 +763,9 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
+ view?.hideHandles({ resetCell: true, resetDragging: true });
+ return;
}
view.state.draggingState = {
@@ -837,26 +848,30 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -837,26 +888,30 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
dataTransfer: DataTransfer | null;
clientY: number;
}) {
@@ -610,7 +782,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
+ view?.hideHandles({ resetCell: true, resetDragging: true });
+ return;
}
- view!.state.draggingState = {
+ view.state.draggingState = {
draggedCellOrientation: "row",
@@ -620,7 +792,7 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
};
- view!.emitUpdate();
+ view.emitUpdate();
editor.transact((tr) =>
tr.setMeta(tableHandlesPluginKey, {
draggedCellOrientation:
@@ -634,8 +806,8 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
+ tablePos: view.tablePos,
}),
);
@@ -874,20 +889,21 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
@@ -874,14 +929,15 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
* used as the row drag handle, and the one used as the column drag handle.
*/
dragEnd() {
@@ -649,17 +821,126 @@ index 30637742d517bcf136ca562a09a1544d7b03a20d..f86cd930aa3834c3e26af2a979a39d49
+ }
+ return;
}
- view!.state.draggingState = undefined;
- view!.emitUpdate();
+ view.state.draggingState = undefined;
+ view.emitUpdate();
editor.transact((tr) => tr.setMeta(tableHandlesPluginKey, null));
if (editor.headless) {
return;
@@ -938,22 +994,21 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
throw new Error("Table handles view not initialized");
}
unsetHiddenDragImage(editor.prosemirrorView.root);
- const tableResolvedPos = state.doc.resolve(view.tablePos! + 1);
- const startRowResolvedPos = state.doc.resolve(
- tableResolvedPos.posAtIndex(relativeStartCell.row) + 1,
- );
- const startCellResolvedPos = state.doc.resolve(
- // No need for +1, since CellSelection expects the position before the cell
- startRowResolvedPos.posAtIndex(relativeStartCell.col),
- );
- const endRowResolvedPos = state.doc.resolve(
- tableResolvedPos.posAtIndex(relativeEndCell.row) + 1,
+ const startCellResolvedPos = resolveCellPosition(
+ state,
+ view.tablePos,
+ relativeStartCell,
);
- const endCellResolvedPos = state.doc.resolve(
- // No need for +1, since CellSelection expects the position before the cell
- endRowResolvedPos.posAtIndex(relativeEndCell.col),
+ const endCellResolvedPos = resolveCellPosition(
+ state,
+ view.tablePos,
+ relativeEndCell,
);
+ if (!startCellResolvedPos || !endCellResolvedPos) {
+ return undefined;
+ }
+
// Begin a new transaction to set the selection
const tr = state.tr;
@@ -975,6 +1030,15 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
| { orientation: "row"; side: "above" | "below" }
| { orientation: "column"; side: "left" | "right" },
) {
+ if (
+ !view ||
+ !isValidTablePosition(view.tablePos) ||
+ !isValidCellIndex(index)
+ ) {
+ view?.hideHandles({ resetCell: true });
+ return;
+ }
+
editor.exec((beforeState, dispatch) => {
const state = this.setCellSelection(
beforeState,
@@ -983,6 +1047,11 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
: { row: 0, col: index },
);
+ if (!state) {
+ view?.hideHandles({ resetCell: true });
+ return false;
+ }
+
if (direction.orientation === "row") {
if (direction.side === "above") {
return addRowBefore(state, dispatch);
@@ -1006,12 +1075,19 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
index: RelativeCellIndices["row"] | RelativeCellIndices["col"],
direction: "row" | "column",
) {
+ if (!isValidCellIndex(index)) {
+ return false;
+ }
+
if (direction === "row") {
return editor.exec((beforeState, dispatch) => {
const state = this.setCellSelection(beforeState, {
row: index,
col: 0,
});
+ if (!state) {
+ return false;
+ }
return deleteRow(state, dispatch);
});
} else {
@@ -1020,6 +1096,9 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
row: 0,
col: index,
});
+ if (!state) {
+ return false;
+ }
return deleteColumn(state, dispatch);
});
}
@@ -1041,6 +1120,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
)
: beforeState;
+ if (!state) {
+ return false;
+ }
+
return mergeCells(state, dispatch);
});
},
@@ -1055,6 +1138,10 @@ export const TableHandlesExtension = createExtension(({ editor }) => {
? this.setCellSelection(beforeState, relativeCellToSplit)
: beforeState;
+ if (!state) {
+ return false;
+ }
+
return splitCell(state, dispatch);
});
},

16
pnpm-lock.yaml generated
View File

@@ -6,7 +6,7 @@ settings:
patchedDependencies:
'@blocknote/core@0.46.2':
hash: eb41a5d55e2b0df60b0f947fab67d1e7168671d503e048a7123bd90cde80042a
hash: a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf
path: patches/@blocknote__core@0.46.2.patch
'@blocknote/react@0.46.2':
hash: e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76
@@ -27,10 +27,10 @@ importers:
version: 0.78.0(zod@4.3.6)
'@blocknote/code-block':
specifier: ^0.46.2
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=eb41a5d55e2b0df60b0f947fab67d1e7168671d503e048a7123bd90cde80042a)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
'@blocknote/core':
specifier: ^0.46.2
version: 0.46.2(patch_hash=eb41a5d55e2b0df60b0f947fab67d1e7168671d503e048a7123bd90cde80042a)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
version: 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/mantine':
specifier: ^0.46.2
version: 0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -5359,9 +5359,9 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=eb41a5d55e2b0df60b0f947fab67d1e7168671d503e048a7123bd90cde80042a)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=eb41a5d55e2b0df60b0f947fab67d1e7168671d503e048a7123bd90cde80042a)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/langs': 3.23.0
@@ -5369,7 +5369,7 @@ snapshots:
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=eb41a5d55e2b0df60b0f947fab67d1e7168671d503e048a7123bd90cde80042a)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
'@blocknote/core@0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@emoji-mart/data': 1.2.1
'@handlewithcare/prosemirror-inputrules': 0.1.4(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.6)
@@ -5421,7 +5421,7 @@ snapshots:
'@blocknote/mantine@0.46.2(@floating-ui/dom@1.7.5)(@mantine/core@8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@mantine/hooks@8.3.14(react@19.2.4))(@mantine/utils@6.0.22(react@19.2.4))(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=eb41a5d55e2b0df60b0f947fab67d1e7168671d503e048a7123bd90cde80042a)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/react': 0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/core': 8.3.14(@mantine/hooks@8.3.14(react@19.2.4))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@mantine/hooks': 8.3.14(react@19.2.4)
@@ -5443,7 +5443,7 @@ snapshots:
'@blocknote/react@0.46.2(patch_hash=e09f7011df33f4ff92c0d3fd8c9060e62f08997d076f3e438e1e82f8c1ab2f76)(@floating-ui/dom@1.7.5)(@types/hast@3.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(highlight.js@11.11.1)(lowlight@3.3.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=eb41a5d55e2b0df60b0f947fab67d1e7168671d503e048a7123bd90cde80042a)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@blocknote/core': 0.46.2(patch_hash=a72012129a27526bdd0ce50ab708872257576135b87550211baacbd0ff8e7daf)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@emoji-mart/data': 1.2.1
'@floating-ui/react': 0.27.17(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@floating-ui/utils': 0.2.10

View File

@@ -271,8 +271,12 @@ pub fn list_vault(path: PathBuf) -> Result<Vec<VaultEntry>, String> {
}
#[tauri::command]
pub fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
pub async fn list_vault_folders(path: PathBuf) -> Result<Vec<FolderNode>, String> {
tokio::task::spawn_blocking(move || {
with_expanded_vault_root(path.as_path(), scan_visible_vault_folders)
})
.await
.map_err(|e| format!("Task panicked: {e}"))?
}
#[cfg(test)]
@@ -348,8 +352,8 @@ mod tests {
);
}
#[test]
fn folder_and_listing_commands_use_expanded_vault_root() {
#[tokio::test]
async fn folder_and_listing_commands_use_expanded_vault_root() {
let dir = TempDir::new().unwrap();
let root = vault_root(&dir);
fs::write(dir.path().join("root.md"), "# Root\n").unwrap();
@@ -364,7 +368,7 @@ mod tests {
assert!(entries.iter().any(|entry| entry.filename == "root.md"));
assert!(entries.iter().any(|entry| entry.filename == "project.md"));
let folders = list_vault_folders(root).unwrap();
let folders = list_vault_folders(root).await.unwrap();
assert!(folders.iter().any(|folder| folder.name == "Projects"));
}

View File

@@ -9,6 +9,8 @@ pub mod gemini_cli;
mod gemini_config;
mod gemini_discovery;
pub mod git;
#[cfg(any(test, all(desktop, target_os = "linux")))]
mod linux_appimage;
pub mod mcp;
#[cfg(desktop)]
pub mod menu;
@@ -32,8 +34,6 @@ mod window_state;
use std::ffi::OsStr;
use std::process::Command;
#[cfg(all(desktop, target_os = "linux"))]
use std::os::unix::process::CommandExt;
#[cfg(desktop)]
use std::path::{Path, PathBuf};
#[cfg(desktop)]
@@ -65,136 +65,6 @@ struct WsBridgeChild(Mutex<Option<Child>>);
#[cfg(desktop)]
struct AllowedAssetScopeRoots(Mutex<Vec<PathBuf>>);
#[cfg(any(test, all(desktop, target_os = "linux")))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StartupEnvOverride {
key: &'static str,
value: &'static str,
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
const LINUX_APPIMAGE_WEBKIT_OVERRIDES: [StartupEnvOverride; 2] = [
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
},
];
#[cfg(any(test, all(desktop, target_os = "linux")))]
const WAYLAND_CLIENT_PRELOAD_CANDIDATES: [&str; 7] = [
"/usr/lib/libwayland-client.so",
"/usr/lib/libwayland-client.so.0",
"/usr/lib64/libwayland-client.so",
"/usr/lib64/libwayland-client.so.0",
"/lib64/libwayland-client.so.0",
"/lib/x86_64-linux-gnu/libwayland-client.so.0",
"/usr/lib/x86_64-linux-gnu/libwayland-client.so.0",
];
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn is_linux_appimage_launch<F>(mut get_var: F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
["APPIMAGE", "APPDIR"]
.into_iter()
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()))
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn is_wayland_session<F>(mut get_var: F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
get_var("WAYLAND_DISPLAY").is_some_and(|value| !value.trim().is_empty())
|| get_var("XDG_SESSION_TYPE")
.is_some_and(|value| value.trim().eq_ignore_ascii_case("wayland"))
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn linux_appimage_wayland_client_preload_path_with<F, E>(
mut get_var: F,
mut file_exists: E,
) -> Option<&'static str>
where
F: FnMut(&str) -> Option<String>,
E: FnMut(&str) -> bool,
{
if !is_linux_appimage_launch(&mut get_var) || !is_wayland_session(&mut get_var) {
return None;
}
if get_var("LD_PRELOAD").is_some_and(|value| !value.trim().is_empty())
|| get_var("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED").is_some_and(|value| value == "1")
{
return None;
}
WAYLAND_CLIENT_PRELOAD_CANDIDATES
.into_iter()
.find(|path| file_exists(path))
}
#[cfg(any(test, all(desktop, target_os = "linux")))]
fn linux_appimage_startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
where
F: FnMut(&str) -> Option<String>,
{
if !is_linux_appimage_launch(&mut get_var) {
return Vec::new();
}
LINUX_APPIMAGE_WEBKIT_OVERRIDES
.into_iter()
.filter(|env_override| {
!get_var(env_override.key).is_some_and(|value| !value.trim().is_empty())
})
.collect()
}
#[cfg(all(desktop, target_os = "linux"))]
fn apply_linux_appimage_startup_env_overrides() {
apply_linux_appimage_wayland_client_preload();
for env_override in linux_appimage_startup_env_overrides_with(|key| std::env::var(key).ok()) {
std::env::set_var(env_override.key, env_override.value);
}
}
#[cfg(all(desktop, target_os = "linux"))]
fn apply_linux_appimage_wayland_client_preload() {
let Some(preload_path) = linux_appimage_wayland_client_preload_path_with(
|key| std::env::var(key).ok(),
|path| std::path::Path::new(path).is_file(),
) else {
return;
};
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(e) => {
eprintln!(
"Tolaria AppImage Wayland preload skipped: failed to resolve executable ({e})"
);
return;
}
};
let error = Command::new(exe)
.args(std::env::args_os().skip(1))
.env("LD_PRELOAD", preload_path)
.env("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED", "1")
.exec();
eprintln!("Tolaria AppImage Wayland preload skipped: failed to re-exec ({error})");
}
#[cfg(not(all(desktop, target_os = "linux")))]
fn apply_linux_appimage_startup_env_overrides() {}
#[cfg(desktop)]
fn log_startup_result(label: &str, result: Result<usize, String>) {
match result {
@@ -612,7 +482,9 @@ fn handle_run_event(app_handle: &tauri::AppHandle, event: &tauri::RunEvent) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
apply_linux_appimage_startup_env_overrides();
#[cfg(all(desktop, target_os = "linux"))]
linux_appimage::apply_startup_env_overrides();
let builder = tauri::Builder::default();
#[cfg(desktop)]
@@ -634,9 +506,6 @@ pub fn run() {
#[cfg(test)]
mod tests {
use super::linux_appimage_startup_env_overrides_with;
use super::linux_appimage_wayland_client_preload_path_with;
use super::StartupEnvOverride;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_KEYS;
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
@@ -659,98 +528,6 @@ mod tests {
assert_eq!(MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS, ["L"]);
}
#[test]
fn linux_appimage_startup_env_overrides_are_empty_outside_appimage_launches() {
let overrides = linux_appimage_startup_env_overrides_with(|_| None);
assert!(overrides.is_empty());
}
#[test]
fn linux_appimage_startup_env_overrides_disable_unstable_webkit_rendering_for_appimages() {
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}
]
);
}
#[test]
fn linux_appimage_startup_env_overrides_preserve_explicit_user_setting_per_variable() {
let overrides = linux_appimage_startup_env_overrides_with(|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WEBKIT_DISABLE_DMABUF_RENDERER" => Some("0".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}]
);
}
#[test]
fn linux_appimage_wayland_preload_uses_first_available_system_library() {
let preload_path = linux_appimage_wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
_ => None,
},
|path| path == "/lib/x86_64-linux-gnu/libwayland-client.so.0",
);
assert_eq!(
preload_path,
Some("/lib/x86_64-linux-gnu/libwayland-client.so.0")
);
}
#[test]
fn linux_appimage_wayland_preload_preserves_explicit_ld_preload() {
let preload_path = linux_appimage_wayland_client_preload_path_with(
|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WAYLAND_DISPLAY" => Some("wayland-0".to_string()),
"LD_PRELOAD" => Some("/custom/libwayland-client.so".to_string()),
_ => None,
},
|_| true,
);
assert_eq!(preload_path, None);
}
#[test]
fn linux_appimage_wayland_preload_is_empty_for_x11_sessions() {
let preload_path = linux_appimage_wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("x11".to_string()),
_ => None,
},
|_| true,
);
assert_eq!(preload_path, None);
}
#[cfg(desktop)]
#[test]
fn selected_mcp_bridge_vault_path_uses_persisted_active_vault() {

View File

@@ -0,0 +1,284 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct StartupEnvOverride {
key: &'static str,
value: &'static str,
}
const LINUX_APPIMAGE_WEBKIT_OVERRIDES: [StartupEnvOverride; 2] = [
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
},
];
const WAYLAND_CLIENT_PRELOAD_CANDIDATES: [&str; 7] = [
"/usr/lib64/libwayland-client.so.0",
"/usr/lib64/libwayland-client.so",
"/lib64/libwayland-client.so.0",
"/usr/lib/x86_64-linux-gnu/libwayland-client.so.0",
"/lib/x86_64-linux-gnu/libwayland-client.so.0",
"/usr/lib/libwayland-client.so.0",
"/usr/lib/libwayland-client.so",
];
#[cfg(target_pointer_width = "64")]
const PROCESS_ELF_CLASS: u8 = 2;
#[cfg(target_pointer_width = "32")]
const PROCESS_ELF_CLASS: u8 = 1;
fn is_linux_appimage_launch<F>(mut get_var: F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
["APPIMAGE", "APPDIR"]
.into_iter()
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()))
}
fn is_wayland_session<F>(mut get_var: F) -> bool
where
F: FnMut(&str) -> Option<String>,
{
get_var("WAYLAND_DISPLAY").is_some_and(|value| !value.trim().is_empty())
|| get_var("XDG_SESSION_TYPE")
.is_some_and(|value| value.trim().eq_ignore_ascii_case("wayland"))
}
fn elf_library_matches_process(path: &std::path::Path) -> bool {
let Ok(mut file) = std::fs::File::open(path) else {
return false;
};
let mut header = [0; 5];
if std::io::Read::read_exact(&mut file, &mut header).is_err() {
return false;
}
header[..4] == *b"\x7FELF" && header[4] == PROCESS_ELF_CLASS
}
#[cfg(all(desktop, target_os = "linux"))]
fn wayland_preload_candidate_matches(path: &str) -> bool {
let path = std::path::Path::new(path);
path.is_file() && elf_library_matches_process(path)
}
fn wayland_client_preload_path_with<F, E>(
mut get_var: F,
mut candidate_matches: E,
) -> Option<&'static str>
where
F: FnMut(&str) -> Option<String>,
E: FnMut(&str) -> bool,
{
if !is_linux_appimage_launch(&mut get_var) || !is_wayland_session(&mut get_var) {
return None;
}
if get_var("LD_PRELOAD").is_some_and(|value| !value.trim().is_empty())
|| get_var("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED").is_some_and(|value| value == "1")
{
return None;
}
WAYLAND_CLIENT_PRELOAD_CANDIDATES
.into_iter()
.find(|path| candidate_matches(path))
}
fn startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
where
F: FnMut(&str) -> Option<String>,
{
if !is_linux_appimage_launch(&mut get_var) {
return Vec::new();
}
LINUX_APPIMAGE_WEBKIT_OVERRIDES
.into_iter()
.filter(|env_override| {
!get_var(env_override.key).is_some_and(|value| !value.trim().is_empty())
})
.collect()
}
#[cfg(all(desktop, target_os = "linux"))]
pub(crate) fn apply_startup_env_overrides() {
apply_wayland_client_preload();
for env_override in startup_env_overrides_with(|key| std::env::var(key).ok()) {
std::env::set_var(env_override.key, env_override.value);
}
}
#[cfg(all(desktop, target_os = "linux"))]
fn apply_wayland_client_preload() {
use std::os::unix::process::CommandExt;
let Some(preload_path) = wayland_client_preload_path_with(
|key| std::env::var(key).ok(),
wayland_preload_candidate_matches,
) else {
return;
};
let exe = match std::env::current_exe() {
Ok(exe) => exe,
Err(e) => {
eprintln!(
"Tolaria AppImage Wayland preload skipped: failed to resolve executable ({e})"
);
return;
}
};
let error = std::process::Command::new(exe)
.args(std::env::args_os().skip(1))
.env("LD_PRELOAD", preload_path)
.env("TOLARIA_APPIMAGE_WAYLAND_PRELOAD_ATTEMPTED", "1")
.exec();
eprintln!("Tolaria AppImage Wayland preload skipped: failed to re-exec ({error})");
}
#[cfg(test)]
mod tests {
use super::{
elf_library_matches_process, startup_env_overrides_with, wayland_client_preload_path_with,
StartupEnvOverride,
};
#[test]
fn startup_env_overrides_are_empty_outside_appimage_launches() {
let overrides = startup_env_overrides_with(|_| None);
assert!(overrides.is_empty());
}
#[test]
fn startup_env_overrides_disable_unstable_webkit_rendering_for_appimages() {
let overrides = startup_env_overrides_with(|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![
StartupEnvOverride {
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
value: "1",
},
StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}
]
);
}
#[test]
fn startup_env_overrides_preserve_explicit_user_setting_per_variable() {
let overrides = startup_env_overrides_with(|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WEBKIT_DISABLE_DMABUF_RENDERER" => Some("0".to_string()),
_ => None,
});
assert_eq!(
overrides,
vec![StartupEnvOverride {
key: "WEBKIT_DISABLE_COMPOSITING_MODE",
value: "1",
}]
);
}
#[test]
fn wayland_preload_uses_first_available_system_library() {
let preload_path = wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
_ => None,
},
|path| path == "/lib/x86_64-linux-gnu/libwayland-client.so.0",
);
assert_eq!(
preload_path,
Some("/lib/x86_64-linux-gnu/libwayland-client.so.0")
);
}
#[test]
fn wayland_preload_prefers_fedora_lib64_over_usr_lib() {
let preload_path = wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("wayland".to_string()),
_ => None,
},
|path| {
path == "/usr/lib/libwayland-client.so.0"
|| path == "/usr/lib64/libwayland-client.so.0"
},
);
assert_eq!(preload_path, Some("/usr/lib64/libwayland-client.so.0"));
}
#[test]
fn preload_library_rejects_wrong_elf_class() {
let dir = tempfile::tempdir().unwrap();
let matching = dir.path().join("matching-libwayland-client.so.0");
let mismatched = dir.path().join("mismatched-libwayland-client.so.0");
let matching_class = if cfg!(target_pointer_width = "64") {
2
} else {
1
};
let mismatched_class = if matching_class == 2 { 1 } else { 2 };
std::fs::write(&matching, [0x7F, b'E', b'L', b'F', matching_class]).unwrap();
std::fs::write(&mismatched, [0x7F, b'E', b'L', b'F', mismatched_class]).unwrap();
assert!(elf_library_matches_process(&matching));
assert!(!elf_library_matches_process(&mismatched));
assert!(!elf_library_matches_process(&dir.path().join("missing.so")));
}
#[test]
fn wayland_preload_preserves_explicit_ld_preload() {
let preload_path = wayland_client_preload_path_with(
|key| match key {
"APPDIR" => Some("/tmp/.mount_Tolaria".to_string()),
"WAYLAND_DISPLAY" => Some("wayland-0".to_string()),
"LD_PRELOAD" => Some("/custom/libwayland-client.so".to_string()),
_ => None,
},
|_| true,
);
assert_eq!(preload_path, None);
}
#[test]
fn wayland_preload_is_empty_for_x11_sessions() {
let preload_path = wayland_client_preload_path_with(
|key| match key {
"APPIMAGE" => Some("/tmp/Tolaria.AppImage".to_string()),
"XDG_SESSION_TYPE" => Some("x11".to_string()),
_ => None,
},
|_| true,
);
assert_eq!(preload_path, None);
}
}

View File

@@ -23,6 +23,10 @@ fn should_descend_for_gitignore(entry: &DirEntry) -> bool {
}
fn has_gitignore_file(vault_path: &Path) -> bool {
if vault_path.join(".gitignore").is_file() {
return true;
}
WalkDir::new(vault_path)
.follow_links(false)
.into_iter()
@@ -43,14 +47,17 @@ fn run_git_check_ignore(vault_path: &Path, relative_paths: &[String]) -> Option<
.spawn()
.ok()?;
{
let stdin = child.stdin.as_mut()?;
for path in relative_paths {
writeln!(stdin, "{path}").ok()?;
let mut stdin = child.stdin.take()?;
let paths = relative_paths.to_vec();
let writer = std::thread::spawn(move || -> std::io::Result<()> {
for path in paths {
writeln!(stdin, "{path}")?;
}
}
Ok(())
});
let output = child.wait_with_output().ok()?;
writer.join().ok()?.ok()?;
if output.status.success() || output.status.code() == Some(1) {
return Some(String::from_utf8_lossy(&output.stdout).to_string());
}
@@ -186,6 +193,8 @@ pub fn filter_gitignored_folders(
mod tests {
use super::*;
use std::fs;
use std::sync::mpsc;
use std::time::Duration;
use tempfile::TempDir;
fn write_file(root: &Path, relative: &str, content: &str) {
@@ -288,6 +297,34 @@ mod tests {
assert_eq!(filtered[0].path, "notes");
}
#[test]
fn filters_large_ignored_folder_sets_without_blocking_on_git_stdout() {
let dir = TempDir::new().unwrap();
init_git_repo(dir.path());
write_file(dir.path(), ".gitignore", "generated/\n");
let folders = (0..6_000)
.map(|index| FolderNode {
name: format!("package-{index}"),
path: format!("generated/package-{index}"),
children: vec![],
})
.collect::<Vec<_>>();
let vault_path = dir.path().to_path_buf();
let (sender, receiver) = mpsc::channel();
std::thread::spawn(move || {
let filtered = filter_gitignored_folders(vault_path.as_path(), folders, true);
let _ = sender.send(filtered);
drop(dir);
});
let filtered = receiver
.recv_timeout(Duration::from_secs(5))
.expect("large gitignored folder filtering should not block on child stdout");
assert!(filtered.is_empty());
}
#[test]
fn has_no_effect_without_gitignore_file() {
let dir = TempDir::new().unwrap();

View File

@@ -26,15 +26,23 @@
flex: 1;
}
.app:has(.app__note-list) .app__sidebar > * {
border-right-color: transparent;
}
.app__note-list {
flex: 0 0 auto;
min-width: 220px;
display: flex;
flex-direction: column;
position: relative;
z-index: 20;
background: var(--surface-sidebar);
}
.app__note-list > * {
flex: 1;
border-left: 1px solid var(--sidebar-border);
}
.app__editor {

View File

@@ -1230,6 +1230,10 @@ function App() {
updateMainWindowConstraints(mode === 'all', mode !== 'editor-only')
}, [setViewMode, updateMainWindowConstraints])
const handleCollapseSidebar = useCallback(() => {
handleSetViewMode('editor-list')
}, [handleSetViewMode])
const handleToggleInspector = useCallback(() => {
const nextInspectorCollapsed = !layout.inspectorCollapsed
layout.setInspectorCollapsed(nextInspectorCollapsed)
@@ -1664,7 +1668,7 @@ function App() {
{sidebarVisible && (
<>
<div className="app__sidebar" style={{ width: layout.sidebarWidth }}>
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} />
<Sidebar entries={vault.entries} folders={vault.folders} views={vault.views} selection={effectiveSelection} onSelect={handleSetSelection} onSelectNote={notes.handleSelectNote} onSelectFavorite={handleOpenFavorite} onReorderFavorites={entryActions.handleReorderFavorites} onCreateType={notes.handleCreateNoteImmediate} onCreateNewType={dialogs.openCreateType} onCustomizeType={entryActions.handleCustomizeType} onUpdateTypeTemplate={entryActions.handleUpdateTypeTemplate} onReorderSections={entryActions.handleReorderSections} onRenameSection={entryActions.handleRenameSection} onDeleteType={handleDeleteType} onToggleTypeVisibility={entryActions.handleToggleTypeVisibility} onCreateFolder={handleCreateFolder} onRenameFolder={folderActions.renameFolder} onDeleteFolder={folderActions.requestDeleteFolder} folderFileActions={fileActions.folderActions} renamingFolderPath={folderActions.renamingFolderPath} onStartRenameFolder={folderActions.startFolderRename} onCancelRenameFolder={folderActions.cancelFolderRename} onCreateView={dialogs.openCreateView} onEditView={handleEditView} onDeleteView={handleDeleteView} onUpdateViewDefinition={handleSidebarUpdateViewDefinition} onReorderViews={viewOrdering.onReorderViews} showInbox={explicitOrganizationEnabled} inboxCount={inboxCount} allNotesFileVisibility={allNotesFileVisibility} onCollapse={handleCollapseSidebar} onGoBack={handleGoBack} onGoForward={handleGoForward} canGoBack={canGoBack} canGoForward={canGoForward} locale={appLocale} loading={isVaultContentLoading} vaultRootPath={resolvedPath} />
</div>
<ResizeHandle onResize={layout.handleSidebarResize} />
</>

View File

@@ -179,16 +179,18 @@ describe('BreadcrumbBar — title in breadcrumb (always rendered, CSS-toggled)',
expect(screen.getByText('test')).toBeInTheDocument()
})
it('renders emoji note icons in the breadcrumb title', () => {
it('does not render emoji note icons in the breadcrumb filename', () => {
const entryWithEmoji = { ...baseEntry, icon: '🚀' }
render(<BreadcrumbBar entry={entryWithEmoji} {...defaultProps} />)
expect(screen.getByTestId('breadcrumb-note-icon')).toHaveTextContent('🚀')
expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent('test')
expect(screen.queryByText('🚀')).not.toBeInTheDocument()
})
it('renders Phosphor note icons in the breadcrumb title', () => {
it('does not render Phosphor note icons in the breadcrumb filename', () => {
const entryWithPhosphor = { ...baseEntry, icon: 'cooking-pot' }
render(<BreadcrumbBar entry={entryWithPhosphor} {...defaultProps} />)
expect(screen.getByTestId('breadcrumb-note-icon').tagName.toLowerCase()).toBe('svg')
expect(screen.getByTestId('breadcrumb-filename-trigger')).toHaveTextContent('test')
expect(screen.queryByTestId('breadcrumb-note-icon')).not.toBeInTheDocument()
})
it('falls back to "Note" when isA is null', () => {

View File

@@ -17,7 +17,7 @@ import {
GitBranch,
Code,
Sparkle,
SlidersHorizontal,
SidebarSimple,
Trash,
Archive,
ArrowUUpLeft,
@@ -30,7 +30,6 @@ import {
ArrowsOutLineHorizontal,
DotsThree,
} from '@phosphor-icons/react'
import { NoteTitleIcon } from './NoteTitleIcon'
import { slugify } from '../hooks/useNoteCreation'
import { useDragRegion } from '../hooks/useDragRegion'
@@ -436,7 +435,7 @@ function InspectorAction({
className="hover:text-foreground"
tooltipAlign="end"
>
<SlidersHorizontal size={16} className={BREADCRUMB_ICON_CLASS} />
<SidebarSimple size={16} weight="regular" className={BREADCRUMB_ICON_CLASS} style={{ transform: 'scaleX(-1)' }} />
</IconActionButton>
)
}
@@ -636,12 +635,10 @@ function FilenameInput({
}
function FilenameTrigger({
entry,
filenameStem,
locale = 'en',
onStartEditing,
}: {
entry: VaultEntry
filenameStem: string
locale?: AppLocale
onStartEditing: () => void
@@ -663,7 +660,6 @@ function FilenameTrigger({
data-testid="breadcrumb-filename-trigger"
aria-label={translate(locale, 'editor.filename.trigger', { filename: filenameStem })}
>
<NoteTitleIcon icon={entry.icon} size={15} testId="breadcrumb-note-icon" />
<span className="breadcrumb-bar__filename-text truncate">{filenameStem}</span>
</Button>
)
@@ -715,7 +711,7 @@ function FilenameDisplay({
}) {
return (
<div className="flex min-w-0 items-center gap-1">
<FilenameTrigger entry={entry} filenameStem={filenameStem} locale={locale} onStartEditing={onStartEditing} />
<FilenameTrigger filenameStem={filenameStem} locale={locale} onStartEditing={onStartEditing} />
<SyncFilenameButton entryPath={entry.path} syncStem={syncStem} locale={locale} onRenameFilename={onRenameFilename} />
</div>
)

View File

@@ -122,16 +122,16 @@
matches our vault theme instead of BlockNote's hardcoded light/dark defaults. */
--bn-colors-editor-background: var(--bg-primary);
--bn-colors-editor-text: var(--text-primary);
--bn-colors-menu-background: var(--bg-card);
--bn-colors-menu-background: var(--surface-popover);
--bn-colors-menu-text: var(--text-primary);
--bn-colors-tooltip-background: var(--bg-hover);
--bn-colors-tooltip-background: var(--state-hover-subtle);
--bn-colors-tooltip-text: var(--text-primary);
--bn-colors-hovered-background: var(--bg-hover);
--bn-colors-hovered-background: var(--state-hover);
--bn-colors-hovered-text: var(--text-primary);
--bn-colors-selected-background: var(--bg-selected);
--bn-colors-selected-text: var(--text-primary);
--bn-colors-border: var(--border-primary);
--bn-colors-shadow: var(--border-primary);
--bn-colors-border: var(--border-dialog);
--bn-colors-shadow: var(--shadow-dialog);
--bn-colors-side-menu: var(--text-muted);
}
@@ -140,6 +140,84 @@
padding: 20px 0;
}
.editor__blocknote-container .bn-suggestion-menu {
border: 1px solid var(--border-dialog) !important;
background: var(--surface-popover) !important;
box-shadow: 0 8px 32px var(--shadow-dialog) !important;
}
.editor__blocknote-container .bn-suggestion-menu-label {
color: var(--text-secondary) !important;
}
.editor__blocknote-container .bn-suggestion-menu-item {
color: var(--text-primary) !important;
height: 34px !important;
min-height: 34px !important;
padding: 3px 8px !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="left"] {
width: 28px;
min-width: 28px;
margin-inline-end: 8px !important;
padding: 0 !important;
background: transparent !important;
border-radius: 0 !important;
color: var(--text-primary) !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="right"] {
margin-inline-start: 12px !important;
color: var(--text-muted) !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="right"] .mantine-Badge-root {
min-width: 0 !important;
height: auto !important;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
color: var(--text-muted) !important;
font-size: 10px !important;
font-weight: 500 !important;
line-height: 1.2 !important;
text-transform: none !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-body {
justify-content: center !important;
padding-right: 0 !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-subtitle {
display: none !important;
}
.editor__blocknote-container .tolaria-slash-menu-icon {
position: relative;
display: inline-flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
}
.editor__blocknote-container .tolaria-slash-menu-icon__fill {
position: absolute;
inset: 0;
opacity: 0;
}
.editor__blocknote-container .bn-suggestion-menu-item:is(:hover, [aria-selected="true"]) .tolaria-slash-menu-icon__regular {
opacity: 0;
}
.editor__blocknote-container .bn-suggestion-menu-item:is(:hover, [aria-selected="true"]) .tolaria-slash-menu-icon__fill {
opacity: 1;
}
/* =============================================
Bear-style live preview: zero horizontal shift
============================================= */

View File

@@ -32,11 +32,19 @@
so they fit within a single text line for headings and paragraphs. */
.editor__blocknote-container .bn-side-menu {
--group-wrap: nowrap !important;
align-items: center !important;
flex-direction: row !important;
flex-wrap: nowrap !important;
gap: 0 !important;
}
.editor__blocknote-container .bn-side-menu button,
.editor__blocknote-container .tolaria-block-drag-handle {
display: inline-flex;
align-items: center;
justify-content: center;
}
.editor__blocknote-container .bn-side-menu [draggable="true"] * {
pointer-events: none;
}

View File

@@ -3,6 +3,7 @@ import { act, fireEvent, render, screen, within } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { FolderTree } from './FolderTree'
import { FOLDER_ROW_SINGLE_CLICK_DELAY_MS } from './folder-tree/useFolderRowInteractions'
import { FOLDER_ROW_NESTING_INDENT, getFolderConnectorLeft } from './folder-tree/folderTreeLayout'
import type { FolderNode, SidebarSelection } from '../types'
const mockFolders: FolderNode[] = [
@@ -67,7 +68,8 @@ describe('FolderTree', () => {
expect(screen.getByText('Laputa')).toBeInTheDocument()
})
it('lets the vault root collapse and expand its nested folders', () => {
it('lets the vault root collapse and expand from the row', () => {
vi.useFakeTimers()
render(
<FolderTree
folders={mockFolders}
@@ -77,19 +79,31 @@ describe('FolderTree', () => {
/>,
)
fireEvent.click(screen.getByLabelText('Collapse Laputa'))
fireEvent.click(screen.getByTestId('folder-row:'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.queryByText('projects')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Expand Laputa'))
fireEvent.click(screen.getByTestId('folder-row:'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.getByText('projects')).toBeInTheDocument()
vi.useRealTimers()
})
it('expands children when clicking the folder chevron', () => {
it('expands children when clicking a folder row', () => {
vi.useFakeTimers()
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
expect(screen.queryByText('laputa')).not.toBeInTheDocument()
fireEvent.click(screen.getByLabelText('Expand projects'))
fireEvent.click(screen.getByTestId('folder-row:projects'))
act(() => {
vi.advanceTimersByTime(FOLDER_ROW_SINGLE_CLICK_DELAY_MS)
})
expect(screen.getByText('laputa')).toBeInTheDocument()
expect(screen.getByText('portfolio')).toBeInTheDocument()
vi.useRealTimers()
})
it('calls onSelect with folder kind when clicking a folder row', () => {
@@ -182,32 +196,24 @@ describe('FolderTree', () => {
expect(onStartRenameFolder).toHaveBeenCalledWith('projects')
})
it('shows inline rename and delete actions for folders', () => {
const onDeleteFolder = vi.fn()
const onStartRenameFolder = vi.fn()
const onSelect = vi.fn()
it('keeps rename and delete out of row hover actions', () => {
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={onSelect}
onDeleteFolder={onDeleteFolder}
onSelect={vi.fn()}
onDeleteFolder={vi.fn()}
onRenameFolder={vi.fn().mockResolvedValue(true)}
onStartRenameFolder={onStartRenameFolder}
onStartRenameFolder={vi.fn()}
onCancelRenameFolder={vi.fn()}
/>,
)
fireEvent.click(screen.getByTestId('rename-folder-btn:projects'))
fireEvent.click(screen.getByTestId('delete-folder-btn:projects'))
expect(onSelect).toHaveBeenNthCalledWith(1, { kind: 'folder', path: 'projects' })
expect(onStartRenameFolder).toHaveBeenCalledWith('projects')
expect(onSelect).toHaveBeenNthCalledWith(2, { kind: 'folder', path: 'projects' })
expect(onDeleteFolder).toHaveBeenCalledWith('projects')
expect(screen.queryByTestId('rename-folder-btn:projects')).not.toBeInTheDocument()
expect(screen.queryByTestId('delete-folder-btn:projects')).not.toBeInTheDocument()
})
it('does not reserve a disclosure slot for leaf folders', () => {
it('does not render folder-level disclosure buttons', () => {
render(<FolderTree folders={mockFolders} selection={defaultSelection} onSelect={vi.fn()} />)
const leafRowContainer = screen.getByTestId('folder-row:areas').parentElement
@@ -216,7 +222,23 @@ describe('FolderTree', () => {
expect(leafRowContainer).not.toBeNull()
expect(parentRowContainer).not.toBeNull()
expect(within(leafRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(1)
expect(within(parentRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(2)
expect(within(parentRowContainer as HTMLElement).queryAllByRole('button')).toHaveLength(1)
expect(screen.queryByLabelText('Expand projects')).not.toBeInTheDocument()
})
it('aligns nested folders with the parent folder name and centers connectors on parent icons', () => {
render(
<FolderTree
folders={mockFolders}
selection={defaultSelection}
onSelect={vi.fn()}
vaultRootPath={vaultRootPath}
/>,
)
expect(screen.getByTestId('folder-row:').parentElement).toHaveStyle({ paddingLeft: '0px' })
expect(screen.getByTestId('folder-row:projects').parentElement).toHaveStyle({ paddingLeft: `${FOLDER_ROW_NESTING_INDENT}px` })
expect(screen.getByTestId('folder-connector:')).toHaveStyle({ left: `${getFolderConnectorLeft(0)}px` })
})
it('shows the rename input when a folder is being renamed', () => {

View File

@@ -1170,7 +1170,7 @@ describe('Sidebar', () => {
const favoritesHeader = screen.getByText('FAVORITES').closest('div') as HTMLElement
const countChip = within(favoritesHeader).getByTestId('sidebar-count-chip')
expect(favoritesHeader).toHaveStyle({ padding: '8px 8px 8px 16px' })
expect(favoritesHeader).toHaveStyle({ padding: '8px 8px 8px 12px' })
expect(countChip).toHaveStyle({
background: 'var(--muted)',
height: '18px',
@@ -1441,7 +1441,7 @@ describe('Sidebar', () => {
const navItem = viewLabel.closest('[class*="cursor-pointer"]') as HTMLElement
const countChip = within(navItem).getByTestId('view-count-chip')
expect(navItem).toHaveStyle({ padding: '6px 8px 6px 16px' })
expect(navItem).toHaveStyle({ padding: '6px 8px 6px 12px' })
expect(countChip).toHaveStyle({
background: 'var(--muted)',
height: '20px',
@@ -1460,8 +1460,8 @@ describe('Sidebar', () => {
const viewItem = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]') as HTMLElement
const viewCount = within(viewItem).getByTestId('view-count-chip')
expect(topNavItem).toHaveStyle({ padding: '6px 8px 6px 16px' })
expect(viewItem).toHaveStyle({ padding: '6px 8px 6px 16px' })
expect(topNavItem).toHaveStyle({ padding: '6px 8px 6px 12px' })
expect(viewItem).toHaveStyle({ padding: '6px 8px 6px 12px' })
expect(topNavCount).toHaveStyle({
background: 'var(--muted)',
height: '20px',

View File

@@ -68,6 +68,10 @@ interface SidebarProps {
allNotesFileVisibility?: AllNotesFileVisibility
locale?: AppLocale
onCollapse?: () => void
onGoBack?: () => void
onGoForward?: () => void
canGoBack?: boolean
canGoForward?: boolean
loading?: boolean
}
@@ -631,7 +635,14 @@ export const Sidebar = memo(function Sidebar(props: SidebarProps) {
return (
<aside className="flex h-full flex-col overflow-hidden border-r border-[var(--sidebar-border)] bg-sidebar text-sidebar-foreground">
<SidebarTitleBar locale={locale} onCollapse={props.onCollapse} />
<SidebarTitleBar
locale={locale}
onCollapse={props.onCollapse}
onGoBack={props.onGoBack}
onGoForward={props.onGoForward}
canGoBack={props.canGoBack}
canGoForward={props.canGoForward}
/>
<SidebarRuntimeNavigation props={props} runtime={runtime} />
<SidebarInteractionOverlays locale={locale} runtime={runtime} />
</aside>

View File

@@ -491,7 +491,7 @@ function SectionHeader({ label, type, Icon, sectionColor, sectionLightColor, ite
return (
<div
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", !isActive && "hover:bg-accent")}
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...getSectionHeaderBackground(isActive, sectionLightColor) }}
style={{ padding: SIDEBAR_ITEM_PADDING.withCount, borderRadius: 4, gap: 4, ...getSectionHeaderBackground(isActive, sectionLightColor) }}
{...dragHandleProps}
onClick={getSectionSelectHandler(isRenaming, onSelect)}
onContextMenu={getSectionContextMenuHandler(isRenaming, onContextMenu)}

View File

@@ -110,7 +110,7 @@ describe('TypeCustomizePopover', () => {
it('calls onChangeColor when a color is clicked', () => {
renderPopover()
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|teal|pink/i)
const colorButtons = screen.getAllByTitle(/red|blue|green|purple|yellow|orange|pink/i)
fireEvent.click(colorButtons[0])
expect(onChangeColor).toHaveBeenCalled()
@@ -123,6 +123,31 @@ describe('TypeCustomizePopover', () => {
expect(onChangeIcon).toHaveBeenCalledWith('wrench')
})
it('renders picker icons slightly larger than the sidebar icon size', () => {
renderPopover()
const icon = screen.getByTitle('wrench').querySelector('svg')
expect(icon).toHaveAttribute('width', '18')
expect(icon).toHaveAttribute('height', '18')
expect(icon).toHaveClass('size-[18px]')
})
it('orders color swatches by hue before neutral gray', () => {
renderPopover()
const colorRow = screen.getByTitle('Red').parentElement
expect(Array.from(colorRow?.children ?? []).map((element) => element.getAttribute('title'))).toEqual([
'Red',
'Orange',
'Yellow',
'Green',
'Blue',
'Purple',
'Pink',
'Gray',
])
})
it('calls onClose when Done is clicked', () => {
renderPopover()
@@ -130,10 +155,10 @@ describe('TypeCustomizePopover', () => {
expect(onClose).toHaveBeenCalled()
})
it('renders all color options including teal and pink', () => {
it('renders curated color options without the teal near-duplicate', () => {
renderPopover()
expect(screen.getByTitle('Teal')).toBeInTheDocument()
expect(screen.queryByTitle('Teal')).not.toBeInTheDocument()
expect(screen.getByTitle('Pink')).toBeInTheDocument()
})

View File

@@ -49,6 +49,20 @@ interface TemplateSectionProps {
onTemplateChange: (value: string) => void
}
const ICON_PICKER_ICON_SIZE = 18
const ICON_PICKER_ICON_CLASS_NAME = 'size-[18px]'
const COLOR_PICKER_ACCENT_COLORS = [
'red',
'orange',
'yellow',
'green',
'blue',
'purple',
'pink',
'gray',
].map((key) => ACCENT_COLORS.find((color) => color.key === key) ?? null)
.filter((color): color is typeof ACCENT_COLORS[number] => color !== null)
/** Debounce a callback by `delay` ms. Returns a stable ref-based wrapper. */
function useDebouncedCallback(fn: (v: string) => void, delay: number): (v: string) => void {
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
@@ -68,7 +82,7 @@ function ColorSection({ selectedColor, locale, onSelectColor }: ColorSectionProp
<>
<div className="font-mono-overline mb-2 text-muted-foreground">{translate(locale, 'customize.color')}</div>
<div className="flex gap-2 mb-3 flex-wrap">
{ACCENT_COLORS.map((color) => (
{COLOR_PICKER_ACCENT_COLORS.map((color) => (
<Button
key={color.key}
type="button"
@@ -118,7 +132,10 @@ function IconSection({
className="h-7 pl-7 pr-2 py-1 text-[12px]"
/>
</div>
<div className="flex flex-wrap gap-1 overflow-y-auto" style={{ maxHeight: 160 }}>
<div
className="grid gap-1 overflow-y-auto"
style={{ gridTemplateColumns: 'repeat(auto-fit, minmax(30px, 1fr))', maxHeight: 160 }}
>
{filteredIcons.length === 0 ? (
<div className="w-full py-6 text-center text-[12px] text-muted-foreground">
{translate(locale, 'customize.noIconsFound')}
@@ -131,7 +148,7 @@ function IconSection({
variant="ghost"
size="icon-xs"
className={cn(
'h-[30px] w-[30px] rounded p-0 transition-colors',
'h-[30px] w-[30px] justify-self-center rounded p-0 transition-colors',
selectedIcon === name
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
@@ -140,7 +157,7 @@ function IconSection({
title={name}
aria-label={name}
>
<Icon size={16} />
<Icon size={ICON_PICKER_ICON_SIZE} className={ICON_PICKER_ICON_CLASS_NAME} />
</Button>
))
)}

View File

@@ -1,17 +1,12 @@
import type { MouseEvent as ReactMouseEvent, ReactNode } from 'react'
import type { MouseEvent as ReactMouseEvent } from 'react'
import {
CaretDown,
CaretRight,
Folder,
FolderOpen,
PencilSimple,
Trash,
} from '@phosphor-icons/react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import type { FolderNode } from '../../types'
import { useFolderRowInteractions } from './useFolderRowInteractions'
import { translate, type AppLocale } from '../../lib/i18n'
interface FolderItemRowProps {
contentInset: number
@@ -19,12 +14,10 @@ interface FolderItemRowProps {
isExpanded: boolean
isSelected: boolean
node: FolderNode
onDeleteFolder?: (folderPath: string) => void
onOpenMenu: (node: FolderNode, event: ReactMouseEvent<HTMLDivElement>) => void
onSelect: () => void
onStartRenameFolder?: (folderPath: string) => void
onToggle: (path: string) => void
locale?: AppLocale
}
export function FolderItemRow({
@@ -33,16 +26,12 @@ export function FolderItemRow({
isExpanded,
isSelected,
node,
onDeleteFolder,
onOpenMenu,
onSelect,
onStartRenameFolder,
onToggle,
locale = 'en',
}: FolderItemRowProps) {
const hasChildren = node.children.length > 0
const expandLabel = translate(locale, isExpanded ? 'sidebar.folder.collapse' : 'sidebar.folder.expand', { name: node.name })
const hasActions = !!onStartRenameFolder || !!onDeleteFolder
const { handleRenameDoubleClick, handleSelectClick } = useFolderRowInteractions({
hasChildren,
onRenameFolder: onStartRenameFolder ? () => onStartRenameFolder(node.path) : undefined,
@@ -64,15 +53,8 @@ export function FolderItemRow({
onOpenMenu(node, event)
}}
>
<FolderToggleButton
expandLabel={expandLabel}
hasChildren={hasChildren}
isExpanded={isExpanded}
onToggle={() => onToggle(node.path)}
/>
<FolderSelectButton
contentInset={contentInset}
hasActions={hasActions}
hasChildren={hasChildren}
isExpanded={isExpanded}
isSelected={isSelected}
@@ -80,111 +62,12 @@ export function FolderItemRow({
onClick={handleSelectClick}
onDoubleClick={handleRenameDoubleClick}
/>
{hasActions && (
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onStartRenameFolder && (
<FolderActionButton
ariaLabel={translate(locale, 'sidebar.action.renameFolder')}
testId={`rename-folder-btn:${node.path}`}
title={translate(locale, 'sidebar.action.renameFolder')}
onClick={() => {
onSelect()
onStartRenameFolder(node.path)
}}
>
<PencilSimple size={12} />
</FolderActionButton>
)}
{onDeleteFolder && (
<FolderActionButton
ariaLabel={translate(locale, 'sidebar.action.deleteFolder')}
testId={`delete-folder-btn:${node.path}`}
title={translate(locale, 'sidebar.action.deleteFolder')}
destructive
onClick={() => {
onSelect()
onDeleteFolder(node.path)
}}
>
<Trash size={12} />
</FolderActionButton>
)}
</div>
)}
</div>
)
}
function FolderToggleButton({
expandLabel,
hasChildren,
isExpanded,
onToggle,
}: {
expandLabel: string
hasChildren: boolean
isExpanded: boolean
onToggle: () => void
}) {
if (!hasChildren) return null
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-4 shrink-0 p-0 text-muted-foreground hover:bg-transparent hover:text-foreground"
onClick={(event) => {
event.stopPropagation()
onToggle()
}}
aria-label={expandLabel}
>
{isExpanded ? <CaretDown size={12} /> : <CaretRight size={12} />}
</Button>
)
}
function FolderActionButton({
ariaLabel,
children,
destructive = false,
onClick,
testId,
title,
}: {
ariaLabel: string
children: ReactNode
destructive?: boolean
onClick: () => void
testId: string
title: string
}) {
return (
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={ariaLabel}
title={title}
className={cn(
'h-5 w-5 rounded p-0 text-muted-foreground',
destructive ? 'hover:text-destructive' : 'hover:text-foreground',
)}
data-testid={testId}
onClick={(event) => {
event.stopPropagation()
onClick()
}}
>
{children}
</Button>
)
}
function FolderSelectButton({
contentInset,
hasActions,
hasChildren,
isExpanded,
isSelected,
@@ -193,7 +76,6 @@ function FolderSelectButton({
onDoubleClick,
}: {
contentInset: number
hasActions: boolean
hasChildren: boolean
isExpanded: boolean
isSelected: boolean
@@ -212,10 +94,11 @@ function FolderSelectButton({
style={{
paddingTop: 6,
paddingBottom: 6,
paddingLeft: hasChildren ? 0 : contentInset,
paddingRight: hasActions ? 48 : 16,
paddingLeft: contentInset,
paddingRight: 16,
}}
title={node.path || node.name}
aria-expanded={hasChildren ? isExpanded : undefined}
onClick={(event) => onClick(event.detail)}
onDoubleClick={onDoubleClick}
data-testid={`folder-row:${node.path}`}

View File

@@ -2,6 +2,7 @@ import { memo, useCallback, type MouseEvent as ReactMouseEvent } from 'react'
import type { FolderNode, SidebarSelection } from '../../types'
import { FolderNameInput } from './FolderNameInput'
import { FolderItemRow } from './FolderItemRow'
import { FOLDER_ROW_CONTENT_INSET, getFolderConnectorLeft, getFolderDepthIndent } from './folderTreeLayout'
import { translate, type AppLocale } from '../../lib/i18n'
interface FolderTreeRowProps {
@@ -73,10 +74,11 @@ function FolderChildren({
if (!isExpanded || !hasChildren) return null
return (
<div className="relative" style={{ paddingLeft: 15 }}>
<div className="relative" data-testid={`folder-children:${node.path}`}>
<div
className="absolute top-0 bottom-0 bg-border"
style={{ left: 15 + depth * 16, width: 1, opacity: 0.3 }}
data-testid={`folder-connector:${node.path}`}
style={{ left: getFolderConnectorLeft(depth), width: 1 }}
/>
{node.children.map((child) => (
<FolderTreeRow
@@ -121,8 +123,8 @@ export const FolderTreeRow = memo(function FolderTreeRow({
const isRenaming = renamingFolderPath === node.path
const isSelected = selection.kind === 'folder' && selection.path === node.path
const canMutateFolder = node.path.length > 0
const depthIndent = depth * 16
const contentInset = 16
const depthIndent = getFolderDepthIndent(depth)
const contentInset = FOLDER_ROW_CONTENT_INSET
const selectFolder = useCallback(() => {
onSelect(node.path === '' ? { kind: 'folder', path: '', rootPath } : { kind: 'folder', path: node.path })
}, [node.path, onSelect, rootPath])
@@ -133,12 +135,10 @@ export const FolderTreeRow = memo(function FolderTreeRow({
isExpanded={isExpanded}
isSelected={isSelected}
node={node}
onDeleteFolder={canMutateFolder ? onDeleteFolder : undefined}
onOpenMenu={onOpenMenu}
onSelect={selectFolder}
onStartRenameFolder={canMutateFolder ? onStartRenameFolder : undefined}
onToggle={onToggle}
locale={locale}
/>
)

View File

@@ -0,0 +1,17 @@
export const FOLDER_ROW_CONTENT_INSET = 12
export const FOLDER_ROW_ICON_SIZE = 17
export const FOLDER_ROW_ICON_GAP = 8
export const FOLDER_ROW_NESTING_INDENT = FOLDER_ROW_ICON_SIZE + FOLDER_ROW_ICON_GAP
const FOLDER_CONNECTOR_WIDTH = 1
export function getFolderDepthIndent(depth: number) {
return depth * FOLDER_ROW_NESTING_INDENT
}
export function getFolderConnectorLeft(depth: number) {
return FOLDER_ROW_CONTENT_INSET
+ getFolderDepthIndent(depth)
+ FOLDER_ROW_ICON_SIZE / 2
- FOLDER_CONNECTOR_WIDTH / 2
}

View File

@@ -4,6 +4,7 @@ import { SlidersHorizontal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { translate, type AppLocale } from '../../lib/i18n'
import { SidebarGroupHeader } from './SidebarGroupHeader'
import { SIDEBAR_ITEM_PADDING } from './sidebarStyles'
interface SidebarLoadingSectionsProps {
collapsed: boolean
@@ -157,7 +158,7 @@ function SidebarLoadingRow({
return (
<div
className="flex select-none items-center gap-2 rounded"
style={{ padding: '6px 8px 6px 16px', borderRadius: 4 }}
style={{ padding: SIDEBAR_ITEM_PADDING.withCount, borderRadius: 4 }}
>
<SidebarLoadingIcon icon={icon} iconColor={iconColor} />
<div className="flex min-w-0 flex-1 items-center">

View File

@@ -1,5 +1,5 @@
import {
type Dispatch, type Ref, type RefObject, type SetStateAction,
type CSSProperties, type Dispatch, type ReactNode, type Ref, type RefObject, type SetStateAction,
} from 'react'
import type {
VaultEntry, SidebarSelection, ViewDefinition, ViewFile,
@@ -13,9 +13,12 @@ import {
import { CSS } from '@dnd-kit/utilities'
import { SlidersHorizontal } from 'lucide-react'
import {
CaretLeft, Palette, PencilSimple, Plus, Trash,
ArrowLeft, ArrowRight, Palette, PencilSimple, Plus, SidebarSimple, Trash,
} from '@phosphor-icons/react'
import { APP_COMMAND_IDS, getAppCommandShortcutDisplay } from '../../hooks/appCommandCatalog'
import { Button } from '@/components/ui/button'
import { ActionTooltip } from '@/components/ui/action-tooltip'
import { TooltipProvider } from '@/components/ui/tooltip'
import {
type SectionGroup, isSelectionActive, SectionContent, VisibilityPopover,
} from '../SidebarParts'
@@ -31,6 +34,13 @@ import { translate, type AppLocale } from '../../lib/i18n'
export { SidebarTopNav } from './SidebarTopNav'
export { FavoritesSection } from './FavoritesSection'
const SIDEBAR_TITLE_BAR_ACTION_CLASSNAME =
'!h-auto !w-auto !min-w-0 !rounded-none !p-0 text-muted-foreground hover:!bg-transparent hover:text-foreground [&_svg]:!size-4'
const SIDEBAR_COLLAPSE_SHORTCUT = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewEditorList)
const HISTORY_BACK_SHORTCUT = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoBack)
const HISTORY_FORWARD_SHORTCUT = getAppCommandShortcutDisplay(APP_COMMAND_IDS.viewGoForward)
export interface SidebarSectionProps {
entries: VaultEntry[]
selection: SidebarSelection
@@ -318,30 +328,102 @@ export function TypesSection({
)
}
export function SidebarTitleBar({ locale = 'en', onCollapse }: { locale?: AppLocale; onCollapse?: () => void }) {
const { onMouseDown } = useDragRegion()
function titleWithShortcut(label: string, shortcut?: string): string {
return shortcut ? `${label} (${shortcut})` : label
}
function SidebarTitleBarAction({
children,
disabled = false,
label,
onClick,
shortcut,
}: {
children: ReactNode
disabled?: boolean
label: string
onClick?: () => void
shortcut?: string
}) {
const title = titleWithShortcut(label, shortcut)
return (
<div
className="shrink-0 flex items-center justify-end border-b border-border"
style={{ height: 52, padding: '0 8px', paddingLeft: 80, cursor: 'default' }}
onMouseDown={onMouseDown}
>
{onCollapse && (
<ActionTooltip copy={{ label, shortcut }} side="bottom" sideOffset={8}>
<span className="inline-flex" title={title} data-no-drag>
<Button
type="button"
variant="ghost"
size="icon-xs"
className="h-6 w-6 rounded text-muted-foreground hover:text-foreground"
onClick={onCollapse}
aria-label={translate(locale, 'sidebar.action.collapse')}
title={translate(locale, 'sidebar.action.collapse')}
className={SIDEBAR_TITLE_BAR_ACTION_CLASSNAME}
onClick={(event) => { event.stopPropagation(); onClick?.() }}
disabled={disabled}
aria-label={label}
title={title}
data-no-drag
>
<CaretLeft size={14} weight="bold" />
{children}
</Button>
)}
</div>
</span>
</ActionTooltip>
)
}
export function SidebarTitleBar({
locale = 'en',
onCollapse,
onGoBack,
onGoForward,
canGoBack = false,
canGoForward = false,
}: {
locale?: AppLocale
onCollapse?: () => void
onGoBack?: () => void
onGoForward?: () => void
canGoBack?: boolean
canGoForward?: boolean
}) {
const { onMouseDown } = useDragRegion()
const collapseLabel = translate(locale, 'sidebar.action.collapse')
const backLabel = translate(locale, 'command.navigation.goBack')
const forwardLabel = translate(locale, 'command.navigation.goForward')
return (
<TooltipProvider>
<div
className="shrink-0 flex items-center border-b border-border"
style={{ height: 52, padding: '0 8px', paddingLeft: 90, cursor: 'default', justifyContent: 'flex-start' }}
onMouseDown={onMouseDown}
>
<div className="flex items-center gap-5" style={{ WebkitAppRegion: 'no-drag' } as CSSProperties}>
{onCollapse && (
<SidebarTitleBarAction label={collapseLabel} shortcut={SIDEBAR_COLLAPSE_SHORTCUT} onClick={onCollapse}>
<SidebarSimple size={16} weight="regular" />
</SidebarTitleBarAction>
)}
{onGoBack && (
<SidebarTitleBarAction
label={backLabel}
shortcut={HISTORY_BACK_SHORTCUT}
onClick={onGoBack}
disabled={!canGoBack}
>
<ArrowLeft size={16} weight="regular" />
</SidebarTitleBarAction>
)}
{onGoForward && (
<SidebarTitleBarAction
label={forwardLabel}
shortcut={HISTORY_FORWARD_SHORTCUT}
onClick={onGoForward}
disabled={!canGoForward}
>
<ArrowRight size={16} weight="regular" />
</SidebarTitleBarAction>
)}
</div>
</div>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,49 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { ComponentProps } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { SidebarTitleBar } from './SidebarSections'
function renderTitleBar(overrides: Partial<ComponentProps<typeof SidebarTitleBar>> = {}) {
return render(<SidebarTitleBar {...overrides} />)
}
describe('SidebarTitleBar', () => {
it('renders sidebar and history controls with shortcut tooltips', () => {
const onCollapse = vi.fn()
const onGoBack = vi.fn()
const onGoForward = vi.fn()
renderTitleBar({
onCollapse,
onGoBack,
onGoForward,
canGoBack: true,
canGoForward: false,
})
const collapse = screen.getByRole('button', { name: 'Collapse sidebar' })
const back = screen.getByRole('button', { name: 'Go Back' })
const forward = screen.getByRole('button', { name: 'Go Forward' })
expect(collapse).toHaveAttribute('title', expect.stringMatching(/^Collapse sidebar \((⌘|Ctrl\+)2\)$/))
expect(back).toHaveAttribute('title', expect.stringMatching(/^Go Back \((⌘←|Ctrl\+Left)\)$/))
expect(forward).toHaveAttribute('title', expect.stringMatching(/^Go Forward \((⌘→|Ctrl\+Right)\)$/))
expect(forward).toBeDisabled()
fireEvent.click(collapse)
fireEvent.click(back)
fireEvent.click(forward)
expect(onCollapse).toHaveBeenCalledTimes(1)
expect(onGoBack).toHaveBeenCalledTimes(1)
expect(onGoForward).not.toHaveBeenCalled()
})
it('omits controls when sidebar callbacks are absent', () => {
renderTitleBar()
expect(screen.queryByRole('button', { name: 'Collapse sidebar' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Go Back' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Go Forward' })).not.toBeInTheDocument()
})
})

View File

@@ -1,13 +1,13 @@
export const SIDEBAR_ITEM_PADDING = {
regular: '6px 16px',
withCount: '6px 8px 6px 16px',
compact: '4px 16px',
compactWithCount: '4px 8px 4px 16px',
regular: '6px 16px 6px 12px',
withCount: '6px 8px 6px 12px',
compact: '4px 16px 4px 12px',
compactWithCount: '4px 8px 4px 12px',
} as const
export const SIDEBAR_GROUP_HEADER_PADDING = {
regular: '8px 14px 8px 16px',
withCount: '8px 8px 8px 16px',
regular: '8px 14px 8px 12px',
withCount: '8px 8px 8px 12px',
} as const
export const SIDEBAR_SECTION_CONTENT_PADDING_BOTTOM = 8

View File

@@ -1,9 +1,10 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { DragEventHandler, PropsWithChildren, ReactNode } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
type MockBlock = {
children?: MockBlock[]
id: string
type: string
content?: unknown
@@ -25,11 +26,14 @@ type MenuItemProps = PropsWithChildren<{
}>
type MockEditor = {
domElement: HTMLElement
focus: ReturnType<typeof vi.fn>
getBlock: ReturnType<typeof vi.fn>
insertBlocks: ReturnType<typeof vi.fn>
removeBlocks: ReturnType<typeof vi.fn>
setTextCursorPosition: ReturnType<typeof vi.fn>
settings: { tables: { headers: boolean } }
transact: ReturnType<typeof vi.fn>
updateBlock: ReturnType<typeof vi.fn>
}
@@ -42,6 +46,27 @@ let mockSideMenu: {
}
let mockSuggestionMenu: { openSuggestionMenu: ReturnType<typeof vi.fn> }
let sideMenuBlock: MockBlock | undefined
const originalElementsFromPoint = document.elementsFromPoint
beforeAll(() => {
if (typeof globalThis.PointerEvent !== 'undefined') return
class TestPointerEvent extends MouseEvent {
readonly isPrimary: boolean
readonly pointerId: number
constructor(type: string, init: PointerEventInit = {}) {
super(type, init)
this.isPrimary = init.isPrimary ?? true
this.pointerId = init.pointerId ?? 1
}
}
Object.defineProperty(globalThis, 'PointerEvent', {
configurable: true,
value: TestPointerEvent,
})
})
function targetBlockId(block: MockBlock | string) {
return typeof block === 'string' ? block : block.id
@@ -111,36 +136,6 @@ vi.mock('@blocknote/react', () => ({
</div>
),
SideMenu: ({ children }: PropsWithChildren) => <div data-testid="side-menu">{children}</div>,
TableColumnHeaderItem: ({ children }: PropsWithChildren) => (
<div
role="menuitemcheckbox"
onClick={() => {
if (!sideMenuBlock) return
const liveBlock = requireLiveBlock(sideMenuBlock)
mockEditor.updateBlock(sideMenuBlock, {
content: { ...liveBlock.content, headerCols: 1 },
})
}}
>
{children}
</div>
),
TableRowHeaderItem: ({ children }: PropsWithChildren) => (
<div
role="menuitemcheckbox"
onClick={() => {
if (!sideMenuBlock) return
const liveBlock = requireLiveBlock(sideMenuBlock)
mockEditor.updateBlock(sideMenuBlock, {
content: { ...liveBlock.content, headerRows: 1 },
})
}}
>
{children}
</div>
),
useBlockNoteEditor: () => mockEditor,
useComponentsContext: () => ({
Generic: {
@@ -198,14 +193,81 @@ function renderSideMenuWithBlock(block: MockBlock | undefined) {
render(<TolariaSideMenu />)
}
function rect(left: number, top: number, width: number, height: number) {
return DOMRect.fromRect({ x: left, y: top, width, height })
}
function blockElement(id: string, bounds: DOMRect) {
const element = document.createElement('div')
element.dataset.id = id
element.dataset.nodeType = 'blockContainer'
element.getBoundingClientRect = vi.fn(() => bounds)
return element
}
function dispatchPointerEvent(
target: EventTarget,
type: 'pointerdown' | 'pointermove' | 'pointerup',
init: PointerEventInit,
) {
target.dispatchEvent(new PointerEvent(type, {
bubbles: true,
cancelable: true,
isPrimary: true,
pointerId: 1,
...init,
}))
}
function testBlock(id: string, type: string, content: unknown): MockBlock {
return { id, type, content, children: [] }
}
function dispatchHandlePointerReorder(dragHandle: HTMLElement) {
dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 80, clientY: 90 })
dispatchPointerEvent(document, 'pointermove', { clientX: 130, clientY: 122 })
dispatchPointerEvent(document, 'pointerup', { clientX: 130, clientY: 122 })
}
function renderPointerReorderFixture() {
const draggedBlock = testBlock('dragged-block', 'heading', ['Notes'])
const targetBlock = testBlock('target-block', 'paragraph', ['Paragraph'])
const draggedElement = blockElement(draggedBlock.id, rect(120, 80, 420, 40))
const targetElement = blockElement(targetBlock.id, rect(120, 120, 420, 40))
mockEditor.domElement.append(draggedElement, targetElement)
mockEditor.getBlock.mockImplementation((id: string) => (
id === draggedBlock.id ? draggedBlock
: id === targetBlock.id ? targetBlock
: undefined
))
document.elementsFromPoint = vi.fn(() => [targetElement, mockEditor.domElement])
renderSideMenuWithBlock(draggedBlock)
return {
draggedBlock,
draggedElement,
dragHandle: screen.getByRole('button', { name: 'Drag block' }),
targetBlock,
}
}
describe('TolariaSideMenu', () => {
beforeEach(() => {
const editorElement = document.createElement('div')
editorElement.className = 'bn-editor'
editorElement.getBoundingClientRect = vi.fn(() => rect(100, 50, 500, 400))
document.body.appendChild(editorElement)
sideMenuBlock = {
id: 'stale-block',
type: 'paragraph',
content: ['old text'],
children: [],
}
mockEditor = {
domElement: editorElement,
focus: vi.fn(),
getBlock: vi.fn(() => undefined),
insertBlocks: vi.fn((_blocks, block: MockBlock | string) => {
requireLiveBlock(block)
@@ -219,6 +281,7 @@ describe('TolariaSideMenu', () => {
requireLiveBlock(block)
}),
settings: { tables: { headers: true } },
transact: vi.fn((callback: () => void) => callback()),
updateBlock: vi.fn((block: MockBlock | string) => {
requireLiveBlock(block)
return block
@@ -235,14 +298,19 @@ describe('TolariaSideMenu', () => {
mockSuggestionMenu = { openSuggestionMenu: vi.fn() }
})
afterEach(() => {
document.elementsFromPoint = originalElementsFromPoint
document.body.innerHTML = ''
})
it('replaces BlockNote block colors with markdown-safe drag-handle items', () => {
mockEditor.getBlock.mockReturnValue(sideMenuBlock)
renderSideMenuWithBlock(sideMenuBlock)
expect(screen.getByTestId('side-menu')).toBeInTheDocument()
expect(screen.getAllByRole('button').map((button) => button.textContent)).toEqual([
'Drag block',
'Add block',
'Drag block',
])
expect(screen.getByText('Delete')).toBeInTheDocument()
@@ -305,4 +373,65 @@ describe('TolariaSideMenu', () => {
expect(() => fireEvent.dragStart(screen.getByRole('button', { name: 'Drag block' }))).not.toThrow()
expect(mockSideMenu.blockDragStart).not.toHaveBeenCalled()
})
it('reorders blocks with pointer movement instead of BlockNote HTML drag data', () => {
const { draggedBlock, dragHandle, targetBlock } = renderPointerReorderFixture()
dispatchHandlePointerReorder(dragHandle)
expect(mockSideMenu.blockDragStart).not.toHaveBeenCalled()
expect(mockEditor.focus).toHaveBeenCalled()
expect(mockEditor.transact).toHaveBeenCalled()
expect(mockEditor.removeBlocks).toHaveBeenCalledWith([draggedBlock.id])
expect(mockEditor.insertBlocks).toHaveBeenCalledWith([draggedBlock], targetBlock.id, 'before')
})
it('shows and clears pointer reorder affordances while dragging', () => {
const { draggedElement, dragHandle } = renderPointerReorderFixture()
dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 140, clientY: 90 })
dispatchPointerEvent(document, 'pointermove', { clientX: 180, clientY: 122 })
const preview = screen.getByTestId('editor-block-drag-preview')
const indicator = screen.getByTestId('editor-block-drop-indicator')
expect(preview).toHaveStyle({
left: '160px',
opacity: '0.72',
top: '112px',
})
expect(indicator).toHaveStyle({
display: 'block',
left: '120px',
top: '119px',
width: '420px',
})
expect(draggedElement).toHaveStyle({ opacity: '0.35' })
dispatchPointerEvent(document, 'pointerup', { clientX: 180, clientY: 122 })
expect(screen.queryByTestId('editor-block-drag-preview')).not.toBeInTheDocument()
expect(screen.queryByTestId('editor-block-drop-indicator')).not.toBeInTheDocument()
expect(draggedElement.style.opacity).toBe('')
})
it('keeps click-to-open menu behavior when the handle does not move', () => {
mockEditor.getBlock.mockReturnValue(sideMenuBlock)
renderSideMenuWithBlock(sideMenuBlock)
const dragHandle = screen.getByRole('button', { name: 'Drag block' })
dispatchPointerEvent(dragHandle.parentElement!, 'pointerdown', { button: 0, clientX: 80, clientY: 90 })
dispatchPointerEvent(document, 'pointerup', { clientX: 80, clientY: 90 })
fireEvent.click(dragHandle)
expect(mockSideMenu.freezeMenu).toHaveBeenCalled()
})
it('suppresses the follow-up menu click after a pointer reorder', () => {
const { dragHandle } = renderPointerReorderFixture()
dispatchHandlePointerReorder(dragHandle)
fireEvent.click(dragHandle)
expect(mockSideMenu.freezeMenu).not.toHaveBeenCalled()
})
})

View File

@@ -16,9 +16,18 @@ import {
type SideMenuProps,
} from '@blocknote/react'
import { GripVertical, Plus } from 'lucide-react'
import { useCallback, type ComponentType, type DragEvent, type ReactNode } from 'react'
import {
useCallback,
useLayoutEffect,
useRef,
type ComponentType,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react'
type TolariaBlockNoteEditor = BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>
type TolariaBlock = NonNullable<ReturnType<TolariaBlockNoteEditor['getBlock']>>
type SideMenuBlock = {
content?: unknown
id: string
@@ -28,6 +37,49 @@ type TableHeaderContent = Record<string, unknown> & {
headerCols?: unknown
headerRows?: unknown
}
type DropPlacement = 'before' | 'after'
type PointerReorderState = {
affordances?: ReorderAffordances
clearListeners: () => void
draggedBlockId: string
editorElement: HTMLElement
hasMoved: boolean
lastDropTarget?: DropTarget | null
ownerDocument: Document
pointerId: number
startX: number
startY: number
}
type ReorderAffordances = {
draggedElement: HTMLElement
dropIndicator: HTMLElement
pointerOffsetX: number
pointerOffsetY: number
preview: HTMLElement
previousDraggedOpacity: string
}
type DropTarget = {
blockId: string
element: HTMLElement
placement: DropPlacement
}
type SideMenuAlignmentState = {
attemptsRemaining: number
frame: number | null
hasObservedTargets: boolean
}
type SideMenuAlignmentContext = {
blockId: string
editorElement: HTMLElement
observeTargets: () => void
ownerWindow: Window
retry: () => void
state: SideMenuAlignmentState
}
const BLOCK_CONTAINER_SELECTOR = '[data-node-type="blockContainer"][data-id]'
const POINTER_REORDER_THRESHOLD_PX = 4
const SIDE_MENU_ALIGNMENT_ATTEMPTS = 8
function liveSideMenuBlock(editor: TolariaBlockNoteEditor, block: SideMenuBlock | undefined) {
if (!block) return undefined
@@ -55,6 +107,396 @@ function tableHeaderContent(block: unknown): TableHeaderContent | undefined {
return block.content
}
function hasChildBlock(block: TolariaBlock, blockId: string): boolean {
for (const child of block.children) {
if (child.id === blockId || hasChildBlock(child, blockId)) return true
}
return false
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max)
}
function editorBlockElement(editor: TolariaBlockNoteEditor): HTMLElement | null {
const element = editor.domElement
if (!(element instanceof HTMLElement)) return null
return element.matches('.bn-editor')
? element
: element.querySelector('.bn-editor')
}
function blockElementFromPoint({
editorElement,
ownerDocument,
x,
y,
}: {
editorElement: HTMLElement
ownerDocument: Document
x: number
y: number
}): HTMLElement | null {
if (typeof ownerDocument.elementsFromPoint !== 'function') return null
const editorRect = editorElement.getBoundingClientRect()
if (editorRect.width <= 0 || editorRect.height <= 0) return null
const hitX = clamp(x, editorRect.left + 10, editorRect.right - 10)
const hitY = clamp(y, editorRect.top + 1, editorRect.bottom - 1)
for (const element of ownerDocument.elementsFromPoint(hitX, hitY)) {
if (!editorElement.contains(element)) continue
const blockElement = element.closest(BLOCK_CONTAINER_SELECTOR)
if (blockElement instanceof HTMLElement && editorElement.contains(blockElement)) {
return blockElement
}
}
return null
}
function dropPlacementForPoint(blockElement: HTMLElement, y: number): DropPlacement {
const rect = blockElement.getBoundingClientRect()
return y < rect.top + rect.height / 2 ? 'before' : 'after'
}
function blockIdFromElement(blockElement: HTMLElement): string | null {
return blockElement.dataset.id ?? null
}
function blockElementById(editorElement: HTMLElement, blockId: string): HTMLElement | null {
for (const element of editorElement.querySelectorAll(BLOCK_CONTAINER_SELECTOR)) {
if (element instanceof HTMLElement && element.dataset.id === blockId) return element
}
return null
}
function sideMenuElementForEditor(editorElement: HTMLElement): HTMLElement | null {
const container = editorElement.closest('.editor__blocknote-container') ?? editorElement
const sideMenu = container.querySelector('.bn-side-menu')
return sideMenu instanceof HTMLElement ? sideMenu : null
}
function blockTextAnchorRect(blockElement: HTMLElement): DOMRect | null {
const content = blockElement.querySelector('.bn-block-content')
const inlineContent = content?.querySelector('.bn-inline-content') ?? content
if (!(inlineContent instanceof HTMLElement)) return null
const ownerDocument = inlineContent.ownerDocument
const range = ownerDocument.createRange()
range.selectNodeContents(inlineContent)
const textRect = range.getBoundingClientRect()
range.detach()
if (textRect.height > 0) return textRect
const fallbackRect = inlineContent.getBoundingClientRect()
return fallbackRect.height > 0 ? fallbackRect : null
}
function alignSideMenuWithBlockText(editorElement: HTMLElement, blockId: string): boolean {
const blockElement = blockElementById(editorElement, blockId)
const sideMenu = sideMenuElementForEditor(editorElement)
if (!blockElement || !sideMenu) return false
const anchorRect = blockTextAnchorRect(blockElement)
if (!anchorRect) return false
sideMenu.style.removeProperty('translate')
const sideMenuRect = sideMenu.getBoundingClientRect()
if (sideMenuRect.height <= 0) return false
const anchorCenter = anchorRect.top + anchorRect.height / 2
const sideMenuCenter = sideMenuRect.top + sideMenuRect.height / 2
sideMenu.style.setProperty('translate', `0 ${anchorCenter - sideMenuCenter}px`)
return true
}
function createSideMenuAlignmentState(): SideMenuAlignmentState {
return {
attemptsRemaining: SIDE_MENU_ALIGNMENT_ATTEMPTS,
frame: null,
hasObservedTargets: false,
}
}
function createSideMenuResizeObserver(onResize: () => void): ResizeObserver | null {
return typeof ResizeObserver === 'undefined'
? null
: new ResizeObserver(onResize)
}
function observeSideMenuAlignmentTargets({
blockId,
editorElement,
resizeObserver,
state,
}: {
blockId: string
editorElement: HTMLElement
resizeObserver: ResizeObserver | null
state: SideMenuAlignmentState
}) {
if (state.hasObservedTargets) return
const blockElement = blockElementById(editorElement, blockId)
const sideMenu = sideMenuElementForEditor(editorElement)
if (!resizeObserver || !blockElement || !sideMenu) return
resizeObserver.observe(blockElement)
resizeObserver.observe(sideMenu)
state.hasObservedTargets = true
}
function scheduleSideMenuTextAlignment(context: SideMenuAlignmentContext) {
const { blockId, editorElement, observeTargets, ownerWindow, retry, state } = context
if (state.frame !== null) return
state.frame = ownerWindow.requestAnimationFrame(() => {
state.frame = null
const aligned = alignSideMenuWithBlockText(editorElement, blockId)
observeTargets()
if (!aligned && state.attemptsRemaining > 0) {
state.attemptsRemaining -= 1
retry()
}
})
}
function createSideMenuAlignmentCleanup({
editorElement,
ownerWindow,
resizeObserver,
scheduleAlignment,
state,
}: {
editorElement: HTMLElement
ownerWindow: Window
resizeObserver: ResizeObserver | null
scheduleAlignment: () => void
state: SideMenuAlignmentState
}) {
return () => {
if (state.frame !== null) ownerWindow.cancelAnimationFrame(state.frame)
resizeObserver?.disconnect()
ownerWindow.removeEventListener('resize', scheduleAlignment)
sideMenuElementForEditor(editorElement)?.style.removeProperty('translate')
}
}
function createSideMenuAlignmentController(editor: TolariaBlockNoteEditor, blockId: string) {
const editorElement = editorBlockElement(editor)
const ownerWindow = editorElement?.ownerDocument.defaultView
if (!editorElement || !ownerWindow) return undefined
const state = createSideMenuAlignmentState()
let resizeObserver: ResizeObserver | null = null
const observeTargets = () => observeSideMenuAlignmentTargets({
blockId,
editorElement,
resizeObserver,
state,
})
const scheduleAlignment = () => scheduleSideMenuTextAlignment({
blockId,
editorElement,
observeTargets,
ownerWindow,
retry: scheduleAlignment,
state,
})
resizeObserver = createSideMenuResizeObserver(scheduleAlignment)
scheduleAlignment()
observeTargets()
ownerWindow.addEventListener('resize', scheduleAlignment)
return createSideMenuAlignmentCleanup({
editorElement,
ownerWindow,
resizeObserver,
scheduleAlignment,
state,
})
}
function useSideMenuTextAlignment(editor: TolariaBlockNoteEditor, block: SideMenuBlock | undefined) {
useLayoutEffect(() => {
if (!block) return
return createSideMenuAlignmentController(editor, block.id)
}, [block?.id, editor])
}
function styleDragPreview(preview: HTMLElement, rect: DOMRect) {
preview.setAttribute('data-testid', 'editor-block-drag-preview')
preview.setAttribute('aria-hidden', 'true')
preview.className = 'editor__blocknote-container'
preview.style.position = 'fixed'
preview.style.width = `${rect.width}px`
preview.style.maxHeight = `${Math.max(rect.height, 1)}px`
preview.style.overflow = 'hidden'
preview.style.pointerEvents = 'none'
preview.style.opacity = '0.72'
preview.style.zIndex = '14000'
preview.style.boxSizing = 'border-box'
preview.style.borderRadius = '6px'
preview.style.background = 'var(--bg-primary, white)'
preview.style.boxShadow = '0 10px 26px rgba(15, 23, 42, 0.18)'
}
function createDragPreview(draggedElement: HTMLElement, ownerDocument: Document): HTMLElement {
const preview = ownerDocument.createElement('div')
const clone = draggedElement.cloneNode(true)
const rect = draggedElement.getBoundingClientRect()
if (clone instanceof HTMLElement) {
clone.style.margin = '0'
clone.style.width = '100%'
clone.style.pointerEvents = 'none'
preview.appendChild(clone)
}
styleDragPreview(preview, rect)
ownerDocument.body.appendChild(preview)
return preview
}
function createDropIndicator(ownerDocument: Document): HTMLElement {
const indicator = ownerDocument.createElement('div')
indicator.setAttribute('data-testid', 'editor-block-drop-indicator')
indicator.style.position = 'fixed'
indicator.style.height = '2px'
indicator.style.pointerEvents = 'none'
indicator.style.background = 'var(--border-focus, #155dff)'
indicator.style.borderRadius = '999px'
indicator.style.boxShadow = '0 0 0 1px rgba(21, 93, 255, 0.12), 0 0 10px rgba(21, 93, 255, 0.28)'
indicator.style.zIndex = '14001'
indicator.style.display = 'none'
ownerDocument.body.appendChild(indicator)
return indicator
}
function createReorderAffordances(state: PointerReorderState): ReorderAffordances | undefined {
const draggedElement = blockElementById(state.editorElement, state.draggedBlockId)
if (!draggedElement) return undefined
const rect = draggedElement.getBoundingClientRect()
const previousDraggedOpacity = draggedElement.style.opacity
const preview = createDragPreview(draggedElement, state.ownerDocument)
draggedElement.style.opacity = '0.35'
return {
draggedElement,
dropIndicator: createDropIndicator(state.ownerDocument),
pointerOffsetX: state.startX - rect.left,
pointerOffsetY: state.startY - rect.top,
preview,
previousDraggedOpacity,
}
}
function cleanupReorderAffordances(affordances: ReorderAffordances | undefined) {
if (!affordances) return
affordances.draggedElement.style.opacity = affordances.previousDraggedOpacity
affordances.preview.remove()
affordances.dropIndicator.remove()
}
function updateDragPreview(affordances: ReorderAffordances, x: number, y: number) {
affordances.preview.style.left = `${x - affordances.pointerOffsetX}px`
affordances.preview.style.top = `${y - affordances.pointerOffsetY}px`
}
function hideDropIndicator(affordances: ReorderAffordances | undefined) {
if (affordances) affordances.dropIndicator.style.display = 'none'
}
function updateDropIndicator(affordances: ReorderAffordances | undefined, target: DropTarget | null) {
if (!affordances || !target) {
hideDropIndicator(affordances)
return
}
const rect = target.element.getBoundingClientRect()
affordances.dropIndicator.style.display = 'block'
affordances.dropIndicator.style.left = `${rect.left}px`
affordances.dropIndicator.style.top = `${target.placement === 'before' ? rect.top - 1 : rect.bottom - 1}px`
affordances.dropIndicator.style.width = `${rect.width}px`
}
function validDropTarget({
editor,
state,
x,
y,
}: {
editor: TolariaBlockNoteEditor
state: PointerReorderState
x: number
y: number
}): DropTarget | null {
const targetElement = blockElementFromPoint({
editorElement: state.editorElement,
ownerDocument: state.ownerDocument,
x,
y,
})
if (!targetElement) return null
const blockId = blockIdFromElement(targetElement)
if (!blockId || blockId === state.draggedBlockId) return null
const draggedBlock = editor.getBlock(state.draggedBlockId)
const targetBlock = editor.getBlock(blockId)
if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, blockId)) return null
return {
blockId,
element: targetElement,
placement: dropPlacementForPoint(targetElement, y),
}
}
function moveBlockByPointerDrop({
editor,
draggedBlockId,
targetBlockId,
placement,
}: {
editor: TolariaBlockNoteEditor
draggedBlockId: string
targetBlockId: string
placement: DropPlacement
}): boolean {
if (draggedBlockId === targetBlockId) return false
const draggedBlock = editor.getBlock(draggedBlockId)
const targetBlock = editor.getBlock(targetBlockId)
if (!draggedBlock || !targetBlock || hasChildBlock(draggedBlock, targetBlockId)) return false
let moved = false
editor.focus()
editor.transact(() => {
const currentDraggedBlock = editor.getBlock(draggedBlockId)
const currentTargetBlock = editor.getBlock(targetBlockId)
if (!currentDraggedBlock || !currentTargetBlock) return
if (hasChildBlock(currentDraggedBlock, targetBlockId)) return
editor.removeBlocks([currentDraggedBlock.id])
editor.insertBlocks([currentDraggedBlock], currentTargetBlock.id, placement)
moved = true
})
return moved
}
function useSideMenuBlock() {
const editor = useBlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>()
const block = useExtensionState(SideMenuExtension, {
@@ -116,18 +558,123 @@ function TolariaDragHandleButton({
const sideMenu = useExtension(SideMenuExtension)
const { block, editor } = useSideMenuBlock()
const MenuComponent: ComponentType<{ children?: ReactNode }> = dragHandleMenu ?? DragHandleMenu
const reorderStateRef = useRef<PointerReorderState | null>(null)
const suppressNextClickRef = useRef(false)
const clearReorderState = useCallback(() => {
const state = reorderStateRef.current
if (state) {
state.clearListeners()
cleanupReorderAffordances(state.affordances)
}
reorderStateRef.current = null
}, [])
const finishPointerReorder = useCallback((event: PointerEvent) => {
const state = reorderStateRef.current
if (!state || event.pointerId !== state.pointerId) return
clearReorderState()
if (!state.hasMoved) return
event.preventDefault()
suppressNextClickRef.current = true
const dropTarget = state.lastDropTarget ?? validDropTarget({
editor,
state,
x: event.clientX,
y: event.clientY,
})
if (!dropTarget) return
const moved = moveBlockByPointerDrop({
editor,
draggedBlockId: state.draggedBlockId,
targetBlockId: dropTarget.blockId,
placement: dropTarget.placement,
})
if (!moved) suppressNextClickRef.current = false
}, [clearReorderState, editor])
const onPointerDown = useCallback((event: ReactPointerEvent<HTMLElement>) => {
if ((typeof event.button === 'number' && event.button !== 0) || event.isPrimary === false) return
const onDragStart = useCallback((event: DragEvent) => {
runSideMenuAction(() => {
const liveBlock = liveSideMenuBlock(editor, block)
if (!liveBlock) {
const editorElement = editorBlockElement(editor)
if (!liveBlock || !editorElement) {
event.preventDefault()
return
}
sideMenu.blockDragStart(event, liveBlock)
clearReorderState()
const ownerDocument = event.currentTarget.ownerDocument
const pointerId = event.pointerId
const handlePointerMove = (nativeEvent: PointerEvent) => {
const state = reorderStateRef.current
if (!state || nativeEvent.pointerId !== state.pointerId) return
const distance = Math.hypot(
nativeEvent.clientX - state.startX,
nativeEvent.clientY - state.startY,
)
if (!state.hasMoved && distance < POINTER_REORDER_THRESHOLD_PX) return
state.hasMoved = true
suppressNextClickRef.current = true
state.affordances ??= createReorderAffordances(state)
if (!state.affordances) return
updateDragPreview(state.affordances, nativeEvent.clientX, nativeEvent.clientY)
state.lastDropTarget = validDropTarget({
editor,
state,
x: nativeEvent.clientX,
y: nativeEvent.clientY,
})
updateDropIndicator(state.affordances, state.lastDropTarget ?? null)
nativeEvent.preventDefault()
}
const handlePointerUp = (nativeEvent: PointerEvent) => finishPointerReorder(nativeEvent)
const handlePointerCancel = (nativeEvent: PointerEvent) => {
if (nativeEvent.pointerId !== pointerId) return
clearReorderState()
}
ownerDocument.addEventListener('pointermove', handlePointerMove, true)
ownerDocument.addEventListener('pointerup', handlePointerUp, true)
ownerDocument.addEventListener('pointercancel', handlePointerCancel, true)
reorderStateRef.current = {
clearListeners: () => {
ownerDocument.removeEventListener('pointermove', handlePointerMove, true)
ownerDocument.removeEventListener('pointerup', handlePointerUp, true)
ownerDocument.removeEventListener('pointercancel', handlePointerCancel, true)
},
draggedBlockId: liveBlock.id,
editorElement,
hasMoved: false,
ownerDocument,
pointerId,
startX: event.clientX,
startY: event.clientY,
}
try {
event.currentTarget.setPointerCapture?.(pointerId)
} catch {
// Document-level pointer listeners still complete the reorder gesture.
}
})
}, [block, editor, sideMenu])
}, [block, clearReorderState, editor, finishPointerReorder])
const onClickCapture = useCallback((event: ReactMouseEvent<HTMLElement>) => {
if (!suppressNextClickRef.current) return
suppressNextClickRef.current = false
event.preventDefault()
event.stopPropagation()
}, [])
if (!block) return null
@@ -140,14 +687,20 @@ function TolariaDragHandleButton({
position="left"
>
<Components.Generic.Menu.Trigger>
<Components.SideMenu.Button
label={dict.side_menu.drag_handle_label}
draggable
onDragStart={onDragStart}
onDragEnd={sideMenu.blockDragEnd}
className="bn-button"
icon={<GripVertical size={20} data-test="dragHandle" />}
/>
<span
className="tolaria-block-drag-handle"
onPointerDown={onPointerDown}
onClickCapture={onClickCapture}
>
<Components.SideMenu.Button
label={dict.side_menu.drag_handle_label}
draggable={false}
onDragStart={(event) => event.preventDefault()}
onDragEnd={sideMenu.blockDragEnd}
className="bn-button"
icon={<GripVertical size={20} data-test="dragHandle" />}
/>
</span>
</Components.Generic.Menu.Trigger>
<MenuComponent>{children}</MenuComponent>
</Components.Generic.Menu.Root>
@@ -231,10 +784,13 @@ function TolariaDragHandleMenu() {
}
export function TolariaSideMenu(props: SideMenuProps) {
const { block, editor } = useSideMenuBlock()
useSideMenuTextAlignment(editor, block)
return (
<SideMenu {...props}>
<TolariaDragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
<TolariaAddBlockButton />
<TolariaDragHandleButton dragHandleMenu={TolariaDragHandleMenu} />
</SideMenu>
)
}

View File

@@ -1,3 +1,4 @@
import { Children, isValidElement, type ReactElement } from 'react'
import { describe, expect, it } from 'vitest'
import { getFormattingToolbarItems } from '@blocknote/react'
import {
@@ -44,20 +45,24 @@ describe('tolariaEditorFormatting', () => {
])
})
it('filters unsupported toggle slash-menu variants and annotates supported markdown commands', () => {
it('filters unsupported toggle slash-menu variants and removes command descriptions', () => {
type TolariaSlashMenuTestItem = {
key: string
title: string
onItemClick: () => void
subtext?: string
icon?: ReactElement
}
const items = filterTolariaSlashMenuItems([
{ key: 'toggle_heading', title: 'Toggle heading', onItemClick: () => {} },
{ key: 'toggle_list', title: 'Toggle list', onItemClick: () => {} },
{ key: 'heading', title: 'Heading', onItemClick: () => {} },
{ key: 'bullet_list', title: 'Bullet List', onItemClick: () => {} },
{ key: 'code_block', title: 'Code Block', onItemClick: () => {} },
{ key: 'heading', title: 'Heading', subtext: 'Default heading copy', onItemClick: () => {} },
{ key: 'bullet_list', title: 'Bullet List', subtext: 'Default list copy', onItemClick: () => {} },
{ key: 'code_block', title: 'Code Block', subtext: 'Default code copy', onItemClick: () => {} },
{ key: 'heading_4', title: 'Heading 4', onItemClick: () => {} },
{ key: 'heading_5', title: 'Heading 5', onItemClick: () => {} },
{ key: 'heading_6', title: 'Heading 6', onItemClick: () => {} },
] satisfies TolariaSlashMenuTestItem[])
expect(items.map((item) => item.key)).toEqual([
@@ -65,14 +70,40 @@ describe('tolariaEditorFormatting', () => {
'bullet_list',
'code_block',
])
expect(items.find((item) => item.key === 'heading')?.subtext).toContain(
'Markdown-safe heading',
)
expect(items.find((item) => item.key === 'bullet_list')?.subtext).toContain(
'Markdown-safe bullet list',
)
expect(items.find((item) => item.key === 'code_block')?.subtext).toContain(
'Markdown-safe fenced code block',
)
expect(items.map((item) => item.subtext)).toEqual([
undefined,
undefined,
undefined,
])
})
it('wraps slash-menu icons so hover can swap Phosphor weights', () => {
type TolariaSlashMenuTestItem = {
key: string
title: string
onItemClick: () => void
icon?: ReactElement
}
const items = filterTolariaSlashMenuItems([
{ key: 'heading', title: 'Heading', onItemClick: () => {} },
] satisfies TolariaSlashMenuTestItem[])
const icon = items[0]?.icon
expect(isValidElement(icon)).toBe(true)
if (!isValidElement<{ className?: string; children?: ReactElement[] }>(icon)) return
const iconChildren = Children.toArray(icon.props.children) as Array<
ReactElement<{ className?: string; weight?: string }>
>
expect(icon.props.className).toBe('tolaria-slash-menu-icon')
expect(iconChildren.map((child) => child.props.className)).toEqual([
'tolaria-slash-menu-icon__regular',
'tolaria-slash-menu-icon__fill',
])
expect(iconChildren.map((child) => child.props.weight)).toEqual([
'regular',
'fill',
])
})
})

View File

@@ -3,29 +3,36 @@ import {
getDefaultReactSlashMenuItems,
type DefaultReactSuggestionItem,
} from '@blocknote/react'
import type { ReactElement } from 'react'
import { createElement, type ReactElement } from 'react'
import {
Code2,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
List,
CodeBlock,
File,
ImageSquare,
ListBullets,
ListChecks,
ListOrdered,
Pilcrow,
Quote,
type LucideIcon,
} from 'lucide-react'
ListNumbers,
Minus,
Paragraph,
Quotes,
Smiley,
SpeakerHigh,
Table,
TextHOne,
TextHTwo,
TextHThree,
TextHFour,
TextHFive,
TextHSix,
Video,
type Icon as PhosphorIcon,
} from '@phosphor-icons/react'
type TolariaSlashMenuItem = DefaultReactSuggestionItem & { key: string }
type TolariaBlockTypeSelectItem = {
name: string
type: string
props?: Record<string, boolean | number | string>
icon: LucideIcon
icon: PhosphorIcon
}
const UNSUPPORTED_FORMATTING_TOOLBAR_KEYS = new Set([
@@ -37,6 +44,9 @@ const UNSUPPORTED_FORMATTING_TOOLBAR_KEYS = new Set([
])
const UNSUPPORTED_SLASH_MENU_KEYS = new Set([
'heading_4',
'heading_5',
'heading_6',
'toggle_heading',
'toggle_heading_2',
'toggle_heading_3',
@@ -44,33 +54,60 @@ const UNSUPPORTED_SLASH_MENU_KEYS = new Set([
])
const TOLARIA_BLOCK_TYPE_SELECT_ITEMS: TolariaBlockTypeSelectItem[] = [
{ name: 'Paragraph', type: 'paragraph', icon: Pilcrow },
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: Heading1 },
{ name: 'Heading 2', type: 'heading', props: { level: 2 }, icon: Heading2 },
{ name: 'Heading 3', type: 'heading', props: { level: 3 }, icon: Heading3 },
{ name: 'Heading 4', type: 'heading', props: { level: 4 }, icon: Heading4 },
{ name: 'Heading 5', type: 'heading', props: { level: 5 }, icon: Heading5 },
{ name: 'Heading 6', type: 'heading', props: { level: 6 }, icon: Heading6 },
{ name: 'Quote', type: 'quote', icon: Quote },
{ name: 'Bullet List', type: 'bulletListItem', icon: List },
{ name: 'Numbered List', type: 'numberedListItem', icon: ListOrdered },
{ name: 'Paragraph', type: 'paragraph', icon: Paragraph },
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: TextHOne },
{ name: 'Heading 2', type: 'heading', props: { level: 2 }, icon: TextHTwo },
{ name: 'Heading 3', type: 'heading', props: { level: 3 }, icon: TextHThree },
{ name: 'Heading 4', type: 'heading', props: { level: 4 }, icon: TextHFour },
{ name: 'Heading 5', type: 'heading', props: { level: 5 }, icon: TextHFive },
{ name: 'Heading 6', type: 'heading', props: { level: 6 }, icon: TextHSix },
{ name: 'Quote', type: 'quote', icon: Quotes },
{ name: 'Bullet List', type: 'bulletListItem', icon: ListBullets },
{ name: 'Numbered List', type: 'numberedListItem', icon: ListNumbers },
{ name: 'Checklist', type: 'checkListItem', icon: ListChecks },
{ name: 'Code Block', type: 'codeBlock', icon: Code2 },
{ name: 'Code Block', type: 'codeBlock', icon: CodeBlock },
]
const TOLARIA_SLASH_MENU_SUPPORT_SUBTEXT: Partial<Record<string, string>> = {
heading: 'Markdown-safe heading (`#`). Persists after save and note switches.',
heading_2: 'Markdown-safe heading (`##`). Persists after save and note switches.',
heading_3: 'Markdown-safe heading (`###`). Persists after save and note switches.',
heading_4: 'Markdown-safe heading (`####`). Persists after save and note switches.',
heading_5: 'Markdown-safe heading (`#####`). Persists after save and note switches.',
heading_6: 'Markdown-safe heading (`######`). Persists after save and note switches.',
quote: 'Markdown-safe block quote (`>`). Persists after save and note switches.',
bullet_list: 'Markdown-safe bullet list (`-`). Persists after save and note switches.',
numbered_list: 'Markdown-safe numbered list (`1.`). Persists after save and note switches.',
check_list: 'Markdown-safe checklist (`- [ ]`). Persists after save and note switches.',
paragraph: 'Plain markdown paragraph text. Persists after save and note switches.',
code_block: 'Markdown-safe fenced code block (```...```). Persists after save and note switches.',
const TOLARIA_SLASH_MENU_ICONS: Partial<Record<string, PhosphorIcon>> = {
audio: SpeakerHigh,
bullet_list: ListBullets,
check_list: ListChecks,
code_block: CodeBlock,
divider: Minus,
emoji: Smiley,
file: File,
heading: TextHOne,
heading_2: TextHTwo,
heading_3: TextHThree,
image: ImageSquare,
numbered_list: ListNumbers,
paragraph: Paragraph,
quote: Quotes,
table: Table,
toggle_heading: TextHOne,
toggle_heading_2: TextHTwo,
toggle_heading_3: TextHThree,
toggle_list: ListBullets,
video: Video,
}
function createTolariaSlashMenuIcon(Icon: PhosphorIcon) {
return createElement(
'span',
{ className: 'tolaria-slash-menu-icon' },
createElement(Icon, {
'aria-hidden': true,
className: 'tolaria-slash-menu-icon__regular',
size: 18,
weight: 'regular',
}),
createElement(Icon, {
'aria-hidden': true,
className: 'tolaria-slash-menu-icon__fill',
size: 18,
weight: 'fill',
}),
)
}
export function getTolariaBlockTypeSelectItems() {
@@ -91,11 +128,12 @@ export function filterTolariaSlashMenuItems<T extends TolariaSlashMenuItem>(
return items
.filter((item) => !UNSUPPORTED_SLASH_MENU_KEYS.has(item.key))
.map((item) => {
const tolariaSubtext = TOLARIA_SLASH_MENU_SUPPORT_SUBTEXT[item.key]
if (!tolariaSubtext) return item
const TolariaIcon = TOLARIA_SLASH_MENU_ICONS[item.key]
return {
...item,
subtext: tolariaSubtext,
icon: TolariaIcon ? createTolariaSlashMenuIcon(TolariaIcon) : item.icon,
subtext: undefined,
}
}) as T[]
}

View File

@@ -22,4 +22,29 @@ describe('useBuildNumber', () => {
const { result } = renderHook(() => useBuildNumber())
await waitFor(() => expect(result.current).toBe('b?'))
})
it('ignores build number requests that settle after unmount', async () => {
const { mockInvoke } = await import('../mock-tauri')
let rejectBuildNumber!: (reason?: unknown) => void
vi.mocked(mockInvoke).mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectBuildNumber = reject
}),
)
const { result, unmount } = renderHook(() => useBuildNumber())
unmount()
const originalWindow = globalThis.window
try {
vi.stubGlobal('window', undefined)
rejectBuildNumber(new Error('late failure'))
await Promise.resolve()
await Promise.resolve()
} finally {
vi.stubGlobal('window', originalWindow)
}
expect(result.current).toBeUndefined()
})
})

View File

@@ -10,9 +10,19 @@ export function useBuildNumber(): string | undefined {
const [buildNumber, setBuildNumber] = useState<string>()
useEffect(() => {
tauriCall<string>('get_build_number').then(setBuildNumber).catch(() => {
setBuildNumber('b?')
})
let mounted = true
tauriCall<string>('get_build_number')
.then((value) => {
if (mounted) setBuildNumber(value)
})
.catch(() => {
if (mounted) setBuildNumber('b?')
})
return () => {
mounted = false
}
}, [])
return buildNumber

View File

@@ -18,16 +18,44 @@ function createTableBlock() {
}
}
function createSelectionStateThatRejectsNaNPositions() {
const selectionTransaction = {
setSelection: vi.fn(),
}
const resolvedPosition = {
posAtIndex: vi.fn((index: number) => index),
}
return {
doc: {
resolve: vi.fn((position: number) => {
if (!Number.isFinite(position)) {
throw new Error(`Position ${position} out of range`)
}
return resolvedPosition
}),
},
tr: selectionTransaction,
apply: vi.fn(),
}
}
function mountTableHandlesExtension() {
const editorRoot = document.createElement('div')
document.body.appendChild(editorRoot)
const selectionState = createSelectionStateThatRejectsNaNPositions()
const dispatch = vi.fn()
const editor = {
headless: true,
isEditable: true,
prosemirrorView: {
root: document,
},
exec: vi.fn((command: (state: never, dispatch: never) => unknown) =>
command(selectionState as never, dispatch as never),
),
transact: vi.fn(),
}
@@ -43,7 +71,31 @@ function mountTableHandlesExtension() {
root: document,
} as never) as TableHandlesView
return { editor, extension, view }
return { editor, extension, view, selectionState }
}
function showTableHandles(view: TableHandlesView) {
view.state = {
block: createTableBlock(),
show: true,
showAddOrRemoveRowsButton: true,
showAddOrRemoveColumnsButton: true,
rowIndex: 0,
colIndex: 0,
draggingState: undefined,
} as never
}
function expectAddRowAndColumnActionsToStaySafe(
extension: ReturnType<typeof mountTableHandlesExtension>['extension'],
index: number,
) {
expect(() =>
extension.addRowOrColumn(index, { orientation: 'row', side: 'below' }),
).not.toThrow()
expect(() =>
extension.addRowOrColumn(index, { orientation: 'column', side: 'right' }),
).not.toThrow()
}
describe('BlockNote table handles regression', () => {
@@ -114,6 +166,23 @@ describe('BlockNote table handles regression', () => {
view.destroy()
})
it('ignores add row or column actions when the selection target is stale', () => {
const { editor, extension, view } = mountTableHandlesExtension()
showTableHandles(view)
view.tablePos = undefined
expectAddRowAndColumnActionsToStaySafe(extension, 0)
expect(editor.exec).not.toHaveBeenCalled()
view.tablePos = 0
expectAddRowAndColumnActionsToStaySafe(extension, Number.NaN)
expect(editor.exec).not.toHaveBeenCalled()
view.destroy()
})
it('cancels stale table drops instead of throwing when no hovered row or column is available', () => {
const block = createTableBlock()
const editorRoot = document.createElement('div')

View File

@@ -124,21 +124,18 @@ const RELEASE_HISTORY_PAGE_STYLES = `
.channel-tabs {
margin-bottom: 24px;
border-bottom: 1px solid var(--release-border-default);
}
.channel-tablist {
display: inline-flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: -1px;
}
.channel-tab {
appearance: none;
border: 1px solid transparent;
border-bottom: none;
border-radius: 12px 12px 0 0;
border-radius: 999px;
background: transparent;
color: var(--release-text-muted);
cursor: pointer;
@@ -161,7 +158,6 @@ const RELEASE_HISTORY_PAGE_STYLES = `
background: var(--release-surface-card);
border-color: var(--release-border-accent);
color: var(--release-accent);
box-shadow: 0 -1px 0 var(--release-surface-card) inset;
}
.tab-count {

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest'
import { normalizeVaultEntries } from './vaultMetadataNormalization'
describe('normalizeVaultEntries', () => {
it('repairs missing string metadata when the entry path is present', () => {
const entries = normalizeVaultEntries([
{
path: '/vault/alpha-project.md',
filename: undefined,
title: undefined,
aliases: undefined,
},
], '/vault')
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({
path: '/vault/alpha-project.md',
filename: 'alpha-project.md',
title: 'alpha-project',
aliases: [],
})
})
it('drops malformed reload entries that do not include a usable path', () => {
const entries = normalizeVaultEntries([
{ path: '/vault/valid.md', filename: 'valid.md', title: 'Valid' },
{ filename: 'missing-path.md', title: 'Missing Path' },
{ path: '', filename: 'empty-path.md', title: 'Empty Path' },
{ path: 42, filename: 'numeric-path.md', title: 'Numeric Path' },
null,
], '/vault')
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({
path: '/vault/valid.md',
filename: 'valid.md',
title: 'Valid',
})
})
})

View File

@@ -62,6 +62,11 @@ function stringArrayFrom(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}
function hasUsablePath(rawEntry: unknown): boolean {
const source = recordFrom(rawEntry)
return typeof source.path === 'string' && source.path.trim().length > 0
}
function filenameFromPath(path: string): string {
const normalizedPath = path.replace(/\\/g, '/')
return normalizedPath.split('/').filter(Boolean).pop() ?? ''
@@ -209,7 +214,9 @@ function normalizeViewFile({ rawView, index }: ViewNormalizationArgs): ViewFile
export function normalizeVaultEntries(rawEntries: unknown, vaultPath: string): VaultEntry[] {
if (!Array.isArray(rawEntries)) return []
return rawEntries.map((rawEntry, index) => normalizeVaultEntry(rawEntry, vaultPath, index))
return rawEntries
.filter(hasUsablePath)
.map((rawEntry, index) => normalizeVaultEntry(rawEntry, vaultPath, index))
}
export function normalizeVaultEntry(rawEntry: unknown, vaultPath = '', index = 0): VaultEntry {

View File

@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test'
import { test, expect, type Locator, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
let tempVaultDir: string
@@ -9,6 +9,104 @@ async function openAlphaProject(page: Page) {
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 10_000 })
}
async function openSlashMenu(page: Page) {
await page.locator('.bn-editor').click()
await page.keyboard.type('/')
const menu = page.locator('.bn-suggestion-menu')
await expect(menu).toBeVisible({ timeout: 5_000 })
return menu
}
async function readMenuStyles(menu: Locator) {
return menu.evaluate((node) => {
const style = getComputedStyle(node)
return {
backgroundColor: style.backgroundColor,
borderRadius: style.borderRadius,
boxShadow: style.boxShadow,
spacing: style.getPropertyValue('--mantine-spacing-sm').trim(),
radius: style.getPropertyValue('--mantine-radius-default').trim(),
shadow: style.getPropertyValue('--mantine-shadow-md').trim(),
}
})
}
function expectMenuChrome(styles: Awaited<ReturnType<typeof readMenuStyles>>) {
expect(styles.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
expect(styles.borderRadius).not.toBe('0px')
expect(styles.boxShadow).not.toBe('none')
expect(styles.spacing).not.toBe('')
expect(styles.radius).not.toBe('')
expect(styles.shadow).not.toBe('')
}
async function readSlashMenuItemStyles(item: Locator) {
return item.evaluate((node) => {
const item = node as HTMLElement
const leftSection = item.querySelector<HTMLElement>(
'.bn-mt-suggestion-menu-item-section[data-position="left"]',
)!
const shortcut = item.querySelector<HTMLElement>('.mantine-Badge-root')!
const shortcutLabel = item.querySelector<HTMLElement>('.mantine-Badge-label')!
const subtitle = item.querySelector<HTMLElement>('.bn-mt-suggestion-menu-item-subtitle')!
const regularIcon = item.querySelector<HTMLElement>('.tolaria-slash-menu-icon__regular')!
const fillIcon = item.querySelector<HTMLElement>('.tolaria-slash-menu-icon__fill')!
const probe = document.createElement('span')
probe.style.color = getComputedStyle(document.documentElement)
.getPropertyValue('--text-muted')
.trim()
document.body.appendChild(probe)
const textMuted = getComputedStyle(probe).color
probe.remove()
return {
fillOpacity: getComputedStyle(fillIcon).opacity,
itemHeight: getComputedStyle(item).height,
leftBackgroundColor: getComputedStyle(leftSection).backgroundColor,
leftBorderRadius: getComputedStyle(leftSection).borderRadius,
leftPadding: getComputedStyle(leftSection).padding,
regularOpacity: getComputedStyle(regularIcon).opacity,
shortcutBackgroundColor: getComputedStyle(shortcut).backgroundColor,
shortcutBorderRadius: getComputedStyle(shortcut).borderRadius,
shortcutColor: getComputedStyle(shortcutLabel).color,
shortcutFontSize: getComputedStyle(shortcutLabel).fontSize,
shortcutPadding: getComputedStyle(shortcut).padding,
subtitleDisplay: getComputedStyle(subtitle).display,
subtitleText: subtitle.textContent ?? '',
textMuted,
}
})
}
function expectSimplifiedItemStyles(
styles: Awaited<ReturnType<typeof readSlashMenuItemStyles>>,
) {
expect(styles.itemHeight).toBe('34px')
expect(styles.leftBackgroundColor).toBe('rgba(0, 0, 0, 0)')
expect(styles.leftBorderRadius).toBe('0px')
expect(styles.leftPadding).toBe('0px')
expect(styles.shortcutBackgroundColor).toBe('rgba(0, 0, 0, 0)')
expect(styles.shortcutBorderRadius).toBe('0px')
expect(styles.shortcutColor).toBe(styles.textMuted)
expect(styles.shortcutFontSize).toBe('10px')
expect(styles.shortcutPadding).toBe('0px')
expect(styles.subtitleDisplay).toBe('none')
expect(styles.subtitleText).toBe('')
}
async function readSlashMenuIconOpacities(item: Locator) {
return item.evaluate((node) => {
const item = node as HTMLElement
const regularIcon = item.querySelector<HTMLElement>('.tolaria-slash-menu-icon__regular')!
const fillIcon = item.querySelector<HTMLElement>('.tolaria-slash-menu-icon__fill')!
return {
fillOpacity: getComputedStyle(fillIcon).opacity,
regularOpacity: getComputedStyle(regularIcon).opacity,
}
})
}
test.describe('BlockNote slash menu styling', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
@@ -21,29 +119,22 @@ test.describe('BlockNote slash menu styling', () => {
test('slash menu keeps Mantine styling tokens in the editor', async ({ page }) => {
await openAlphaProject(page)
await page.locator('.bn-editor').click()
await page.keyboard.type('/')
const menu = await openSlashMenu(page)
expectMenuChrome(await readMenuStyles(menu))
await expect(menu.getByText('Subheadings', { exact: true })).toHaveCount(0)
await expect(menu.getByText(/^Heading [4-6]$/)).toHaveCount(0)
const secondItem = menu.locator('.bn-suggestion-menu-item').nth(1)
await expect(secondItem.locator('.tolaria-slash-menu-icon')).toBeVisible()
const menu = page.locator('.bn-suggestion-menu')
await expect(menu).toBeVisible({ timeout: 5_000 })
const itemStyles = await readSlashMenuItemStyles(secondItem)
expectSimplifiedItemStyles(itemStyles)
expect(itemStyles.regularOpacity).toBe('1')
expect(itemStyles.fillOpacity).toBe('0')
const menuStyles = await menu.evaluate((node) => {
const style = getComputedStyle(node)
return {
backgroundColor: style.backgroundColor,
borderRadius: style.borderRadius,
boxShadow: style.boxShadow,
spacing: style.getPropertyValue('--mantine-spacing-sm').trim(),
radius: style.getPropertyValue('--mantine-radius-default').trim(),
shadow: style.getPropertyValue('--mantine-shadow-md').trim(),
}
await secondItem.hover()
expect(await readSlashMenuIconOpacities(secondItem)).toEqual({
fillOpacity: '1',
regularOpacity: '0',
})
expect(menuStyles.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
expect(menuStyles.borderRadius).not.toBe('0px')
expect(menuStyles.boxShadow).not.toBe('none')
expect(menuStyles.spacing).not.toBe('')
expect(menuStyles.radius).not.toBe('')
expect(menuStyles.shadow).not.toBe('')
})
})

View File

@@ -22,25 +22,48 @@ async function blockOuterForText(page: Page, text: string): Promise<Locator> {
async function visibleLeftBlockHandle(page: Page, block: Locator): Promise<Locator> {
await block.hover()
const buttons = await page.locator('.bn-side-menu button').all()
expect(buttons.length).toBeGreaterThan(0)
let handle = buttons[0]
let leftEdge = Number.POSITIVE_INFINITY
for (const button of buttons) {
const box = await button.boundingBox()
expect(box).not.toBeNull()
if (box!.x < leftEdge) {
leftEdge = box!.x
handle = button
}
}
const addButton = page.locator('.bn-side-menu button:has([data-test="dragHandleAdd"])').first()
const handle = page.locator('.bn-side-menu button:has([data-test="dragHandle"])').first()
await expect(addButton).toBeVisible({ timeout: 5_000 })
await expect(handle).toBeVisible({ timeout: 5_000 })
await expect(handle).toHaveAttribute('draggable', 'true')
await expect(handle).not.toHaveAttribute('draggable', 'true')
const addBox = await addButton.boundingBox()
const handleBox = await handle.boundingBox()
expect(addBox).not.toBeNull()
expect(handleBox).not.toBeNull()
expect(addBox!.x).toBeLessThan(handleBox!.x)
expect(Math.abs((addBox!.y + addBox!.height / 2) - (handleBox!.y + handleBox!.height / 2))).toBeLessThanOrEqual(2)
return handle
}
async function expectSideMenuCenteredOnText(page: Page, text: string): Promise<void> {
const block = await blockOuterForText(page, text)
await block.hover()
await expect(page.locator('.bn-side-menu')).toBeVisible({ timeout: 5_000 })
const delta = await block.evaluate((blockElement) => {
const content = blockElement.querySelector('.bn-block-content')
const inlineContent = content?.querySelector('.bn-inline-content') ?? content
const sideMenu = document.querySelector('.bn-side-menu')
if (!inlineContent || !sideMenu) return Number.POSITIVE_INFINITY
const range = document.createRange()
range.selectNodeContents(inlineContent)
const textRect = range.getBoundingClientRect()
range.detach()
const sideMenuRect = sideMenu.getBoundingClientRect()
return Math.abs(
(sideMenuRect.top + sideMenuRect.height / 2) -
(textRect.top + textRect.height / 2),
)
})
expect(delta).toBeLessThanOrEqual(2)
}
async function dragHandleToBlock(page: Page, handle: Locator, targetBlock: Locator): Promise<void> {
const handleBox = await handle.boundingBox()
const targetBox = await targetBlock.boundingBox()
@@ -61,7 +84,16 @@ async function dragHandleToBlock(page: Page, handle: Locator, targetBlock: Locat
targetBox!.y + 2,
{ steps: 24 },
)
const dragPreview = page.getByTestId('editor-block-drag-preview')
const dropIndicator = page.getByTestId('editor-block-drop-indicator')
await expect(dragPreview).toBeVisible()
await expect(dragPreview).toHaveCSS('opacity', '0.72')
await expect(dropIndicator).toBeVisible()
await page.mouse.up()
await expect(dragPreview).toHaveCount(0)
await expect(dropIndicator).toHaveCount(0)
}
test('dragging the left block handle reorders editor blocks', async ({ page }) => {
@@ -73,6 +105,8 @@ test('dragging the left block handle reorders editor blocks', async ({ page }) =
const notesHeading = await blockOuterForText(page, 'Notes')
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*This is a test project[\s\S]*Notes/)
await expectSideMenuCenteredOnText(page, 'Alpha Project')
await expectSideMenuCenteredOnText(page, 'Notes')
const handle = await visibleLeftBlockHandle(page, notesHeading)
await dragHandleToBlock(page, handle, paragraph)

View File

@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test'
import { test, expect, type Locator, type Page } from '@playwright/test'
import {
createFixtureVaultCopy,
openFixtureVaultDesktopHarness,
@@ -47,12 +47,38 @@ function removeAlphaProjectStringMetadata(entries: Array<Record<string, unknown>
})
}
function appendMalformedReloadEntry(entries: Array<Record<string, unknown>>) {
return entries.concat({
filename: 'phantom-from-reload.md',
title: 'Phantom From Reload',
aliases: [],
outgoingLinks: [],
relationships: {},
properties: {},
snippet: '',
})
}
async function reloadVaultFromCommandPalette(page: Page): Promise<void> {
await openCommandPalette(page)
await executeCommand(page, 'Reload Vault')
await expect(page.locator('input[placeholder="Type a command..."]')).not.toBeVisible()
}
async function openNoteFromList(noteList: Locator, title: string): Promise<void> {
await noteList.getByText(title, { exact: true }).click()
}
async function expectAlphaProjectHeading(page: Page): Promise<void> {
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
}
async function switchFromNoteBBackToAlpha(page: Page, noteList: Locator): Promise<void> {
await openNoteFromList(noteList, 'Note B')
await openNoteFromList(noteList, 'alpha-project')
await expectAlphaProjectHeading(page)
}
test.beforeEach(async ({ page }, testInfo) => {
testInfo.setTimeout(60_000)
tempVaultDir = createFixtureVaultCopy()
@@ -64,9 +90,12 @@ test.beforeEach(async ({ page }, testInfo) => {
}
const response = await route.fetch()
const entries = await response.json() as Array<Record<string, unknown>>
const scrubbedEntries = removeAlphaProjectStringMetadata(entries)
await route.fulfill({
response,
json: removeAlphaProjectStringMetadata(entries),
json: requestUrl.searchParams.get('reload') === '1'
? appendMalformedReloadEntry(scrubbedEntries)
: scrubbedEntries,
})
})
await openFixtureVaultDesktopHarness(page, tempVaultDir, {
@@ -83,12 +112,9 @@ test('@smoke note open tolerates missing string metadata from the vault scan', a
const errors = collectMissingMetadataCrashes(page)
const noteList = page.getByTestId('note-list-container')
await noteList.getByText('alpha-project', { exact: true }).click()
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
await noteList.getByText('Note B', { exact: true }).click()
await noteList.getByText('alpha-project', { exact: true }).click()
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
await openNoteFromList(noteList, 'alpha-project')
await expectAlphaProjectHeading(page)
await switchFromNoteBBackToAlpha(page, noteList)
expect(errors).toHaveLength(0)
})
@@ -99,9 +125,8 @@ test('note open after vault reload tolerates missing suggestion metadata', async
await reloadVaultFromCommandPalette(page)
await noteList.getByText('Note B', { exact: true }).click()
await noteList.getByText('alpha-project', { exact: true }).click()
await expect(page.getByRole('heading', { name: 'Alpha Project', level: 1 })).toBeVisible({ timeout: 5_000 })
await expect(noteList.getByText('Phantom From Reload', { exact: true })).toHaveCount(0)
await switchFromNoteBBackToAlpha(page, noteList)
expect(errors).toHaveLength(0)
})

View File

@@ -102,6 +102,30 @@ async function dragTableHandle(
await page.mouse.up()
}
async function openTableHandleMenu(
page: Page,
orientation: 'row' | 'column',
source: { rowIndex: number; cellIndex: number },
): Promise<void> {
await tableCell(page, source.rowIndex, source.cellIndex).hover()
const handle = await visibleTableHandle(page, orientation)
await handle.click({ force: true })
}
async function clickTableHandleMenuItem(page: Page, name: string): Promise<void> {
await page.getByRole('menuitem', { name }).click()
}
async function addTableRowBelow(page: Page): Promise<void> {
await openTableHandleMenu(page, 'row', { rowIndex: 1, cellIndex: 0 })
await clickTableHandleMenuItem(page, 'Add row below')
}
async function addTableColumnRight(page: Page): Promise<void> {
await openTableHandleMenu(page, 'column', { rowIndex: 0, cellIndex: 1 })
await clickTableHandleMenuItem(page, 'Add column right')
}
test.describe('table hover crash regression', () => {
test.beforeEach(({ page }, testInfo) => {
void page
@@ -169,4 +193,36 @@ test.describe('table hover crash regression', () => {
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
test('adding table rows and columns from handle menus keeps selection valid', async ({ page }) => {
const errors = trackUnexpectedErrors(page)
await openFixtureVaultTauri(page, tempVaultDir)
await createUntitledNote(page)
await seedBlockNoteTable(page, [180, 120, 120])
await expect(page.locator('table tr')).toHaveCount(3, { timeout: 5_000 })
await expect(page.locator('table tr').first().locator('th,td')).toHaveCount(3)
await addTableRowBelow(page)
await expect(page.locator('table tr')).toHaveCount(4)
await addTableColumnRight(page)
await expect(page.locator('table tr').first().locator('th,td')).toHaveCount(4)
await addTableRowBelow(page)
await expect(page.locator('table tr')).toHaveCount(5)
await addTableColumnRight(page)
await expect(page.locator('table tr').first().locator('th,td')).toHaveCount(5)
const trailingParagraph = page.locator('.bn-editor [data-content-type="paragraph"]').last()
await trailingParagraph.click()
await page.keyboard.type('stable after table row and column adds')
const editor = page.getByRole('textbox').last()
await expect(editor).toContainText('stable after table row and column adds')
await expect(page.locator('table')).toHaveCount(1)
expect(errors).toEqual([])
})
})