Compare commits
5 Commits
alpha-v202
...
stable-v20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5ed863d5d | ||
|
|
4f7f5a31e4 | ||
|
|
439e2b7f66 | ||
|
|
048d27243d | ||
|
|
58e129cdbc |
137
.github/scripts/prefetch-tauri-nsis.ps1
vendored
Normal file
137
.github/scripts/prefetch-tauri-nsis.ps1
vendored
Normal 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."
|
||||
10
.github/workflows/release-stable.yml
vendored
10
.github/workflows/release-stable.yml
vendored
@@ -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
|
||||
|
||||
|
||||
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -562,7 +562,7 @@ 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.
|
||||
- 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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
32
docs/adr/0107-pointer-owned-editor-block-reordering.md
Normal file
32
docs/adr/0107-pointer-owned-editor-block-reordering.md
Normal 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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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() {
|
||||
|
||||
284
src-tauri/src/linux_appimage.rs
Normal file
284
src-tauri/src/linux_appimage.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
============================================= */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
|
||||
17
src/components/folder-tree/folderTreeLayout.ts
Normal file
17
src/components/folder-tree/folderTreeLayout.ts
Normal 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
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user