Compare commits
5 Commits
codex/mobi
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64d961bd98 | ||
|
|
1b218f5250 | ||
|
|
dfb9f98b7a | ||
|
|
6a033cd2db | ||
|
|
b872b6148c |
@@ -336,7 +336,7 @@ The renderer uses `viewOrdering` helpers to convert drag or command-palette move
|
||||
|
||||
## Command Surface
|
||||
|
||||
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, and toggle state-dependent menu items from manifest groups.
|
||||
`src/shared/appCommandManifest.json` is the cross-runtime source for stable app command IDs, menu structure, display labels, accelerators, deterministic shortcut QA metadata, and native menu enablement groups. The renderer imports it through `src/hooks/appCommandCatalog.ts`, which derives `APP_COMMAND_IDS`, shortcut lookup maps, Linux titlebar menu sections, native-menu command membership, and test helpers. Tauri includes the same JSON in `src-tauri/src/menu.rs` and uses it to build custom menu items, emit overridden menu item IDs such as the quick-open alias as their primary command IDs, register the Windows main-window menu event bridge, and toggle state-dependent menu items from manifest groups.
|
||||
|
||||
Domain command builders still own context-sensitive command-palette entries, availability, and execution callbacks. The manifest owns metadata that must stay identical across native menus, renderer shortcuts, deterministic QA bridges, and the Linux fallback menu; OS-native menu items such as Undo, Copy/Paste, Services, Quit, and Window controls remain local to the native menu implementation.
|
||||
|
||||
@@ -570,7 +570,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
|
||||
- `SingleEditorView` disables BlockNote's default formatting toolbar, `/` menu, and side menu, then mounts Tolaria-owned controllers so the visible formatting surface matches Tolaria's markdown round-trip guarantees.
|
||||
- The formatting toolbar only exposes inline controls that persist through `blocksToMarkdownLossy()` in Tolaria's save pipeline: bold, italic, strike, nesting, and link creation. Controls that BlockNote can render temporarily but Tolaria cannot faithfully persist, such as underline, color, alignment, and the block-type dropdown, are hidden instead of appearing to work and later disappearing.
|
||||
- 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.
|
||||
- `useEditorComposing` tracks `compositionstart` and `compositionend` events that target the BlockNote editor and closes the floating formatting toolbar while IME text is being composed, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior.
|
||||
- `useEditorComposing` tracks editor-owned IME composition events and closes the floating formatting toolbar during composition plus a short post-composition settle window, keeping CJK candidate windows unobstructed without changing normal selection toolbar behavior.
|
||||
- `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, list blocks, Mermaid diagrams, and whiteboards. 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 add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, 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.
|
||||
|
||||
@@ -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.
|
||||
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, registers a window-scoped menu event handler on Windows where Tauri delivers menu clicks through the main `WebviewWindow`, and cross-platform custom items such as Check for Updates emit Tolaria command IDs with 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 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 same AppImage path checks whether `fc-match` resolves the default emoji font to `Noto-COLRv1.ttf`; when the user has not provided `FONTCONFIG_FILE` or `FONTCONFIG_PATH`, Tolaria writes a cache-local fontconfig file that rejects only that matched font file and exports it before WebKit starts. The rendering overrides keep AppImage WebViews from blanking after accelerated compositing/DMA-BUF failures, the re-exec addresses AppImage library-order failures that can surface as `Could not create default EGL display: EGL_BAD_PARAMETER`, and the fontconfig guard avoids known WebKit crashes in COLRv1 emoji font rendering while leaving other emoji fonts available.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
@@ -869,7 +869,7 @@ Shortcut routing is explicit:
|
||||
- macOS browser-reserved chords such as `Cmd+O`, `Cmd+F`, and `Cmd+Shift+L` are unblocked at webview init via `tauri-plugin-prevent-default`, then continue through the same renderer-first command path
|
||||
- `Cmd+Shift+V` uses the same command path for "Paste without Formatting"; `plainTextPaste.ts` reads text through the native clipboard command in Tauri and inserts it through the active rich/raw editor target or the focused browser text control
|
||||
- `Cmd+F` is surface-aware: editor focus opens current-note find/replace in raw CodeMirror, note-list focus preserves note-list search, and native menu enablement follows focus availability events so only one `Cmd+F` menu item is active
|
||||
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions
|
||||
- `menu.rs`, `useMenuEvents`, and Linux's `LinuxMenuButton` emit the same manifest-derived command IDs for native menu clicks, accelerators, and custom titlebar menu actions; on Windows, `menu.rs` also listens to main-window menu events because Tauri attaches the native menu to the `WebviewWindow`
|
||||
- `appCommandDispatcher.ts` suppresses the paired native-menu/renderer echo from a single shortcut so the command runs once
|
||||
- Deterministic QA uses two explicit proof paths from the shared manifest:
|
||||
- renderer shortcut-event proof through `window.__laputaTest.triggerShortcutCommand()`
|
||||
|
||||
@@ -364,7 +364,7 @@ type SidebarSelection =
|
||||
|
||||
### Command Registry
|
||||
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
`useCommandRegistry` + `useAppCommands` build a centralized command registry. Commands are registered with labels, shortcuts, and handlers. The `CommandPalette` (Cmd+K) fuzzy-searches this registry. Settings commands can update installation-local preferences directly when they reuse an existing settings path, such as the light/dark theme-mode actions writing `settings.theme_mode`. Shortcut combos live in `appCommandCatalog.ts`; real keypresses always flow through `useAppKeyboard`, native menu clicks emit the same command IDs through `useMenuEvents`, and `appCommandDispatcher.ts` suppresses the duplicate native/renderer echo from a single shortcut. Plain-text paste follows this same path: the command owns `Cmd+Shift+V`, the menu and palette expose the same action, and `plainTextPaste.ts` resolves the active rich/raw editor target or focused text control before reading clipboard text. On macOS, any browser-reserved chord that WKWebView swallows before that path must also be added to the narrow `tauri-plugin-prevent-default` registration in `src-tauri/src/lib.rs`. On Windows, native menu clicks arrive from the main `WebviewWindow`, so `src-tauri/src/menu.rs` must keep its window-scoped menu event handler in addition to the app-level handler. On Linux, `LinuxTitlebar.tsx` and `LinuxMenuButton.tsx` reuse the same command IDs through `trigger_menu_command` because the native GTK menu bar is intentionally not mounted. The same shortcut manifest also declares the deterministic QA mode for each shortcut-capable command.
|
||||
|
||||
Commands whose availability depends on the current note or Git state must also flow through `update_menu_state` so the native menu stays in sync with the command palette. The deleted-note restore action in Changes view is the reference example: the row opens a deleted diff preview, the command palette exposes "Restore Deleted Note", and the Note menu enables the same action only while that preview is active.
|
||||
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use crate::window_state::MAIN_WINDOW_LABEL;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
use std::{
|
||||
collections::{BTreeMap, HashSet},
|
||||
error::Error,
|
||||
sync::OnceLock,
|
||||
};
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use tauri::{menu::MenuEvent, Manager};
|
||||
use tauri::{
|
||||
menu::{MenuBuilder, MenuItem, MenuItemBuilder, MenuItemKind, Submenu, SubmenuBuilder},
|
||||
App, AppHandle, Emitter,
|
||||
@@ -234,6 +238,10 @@ fn app_menu_includes_services(target_os: &str) -> bool {
|
||||
target_os == "macos"
|
||||
}
|
||||
|
||||
fn window_menu_event_handler_required(target_os: &str) -> bool {
|
||||
target_os != "macos"
|
||||
}
|
||||
|
||||
fn build_manifest_menu_item(
|
||||
app: &App,
|
||||
item: &ManifestMenuItem,
|
||||
@@ -383,6 +391,28 @@ pub fn setup_menu(app: &App) -> Result<(), Box<dyn Error>> {
|
||||
let _ = emit_custom_menu_event(app_handle, id);
|
||||
});
|
||||
|
||||
register_window_menu_event_handler(app)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn register_window_menu_event_handler(app: &App) -> Result<(), Box<dyn Error>> {
|
||||
debug_assert!(window_menu_event_handler_required(std::env::consts::OS));
|
||||
let window = app.get_webview_window(MAIN_WINDOW_LABEL).ok_or_else(|| {
|
||||
format!("setup_menu: window '{MAIN_WINDOW_LABEL}' not found; menu events will not fire")
|
||||
})?;
|
||||
let app_handle = app.handle().clone();
|
||||
window.on_menu_event(move |_window, event: MenuEvent| {
|
||||
let id = event.id().0.as_str();
|
||||
let _ = emit_custom_menu_event(&app_handle, id);
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn register_window_menu_event_handler(_app: &App) -> Result<(), Box<dyn Error>> {
|
||||
debug_assert!(!window_menu_event_handler_required(std::env::consts::OS));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -564,4 +594,11 @@ mod tests {
|
||||
assert!(!app_menu_includes_services("windows"));
|
||||
assert!(!app_menu_includes_services("linux"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_menu_event_handler_is_required_off_macos() {
|
||||
assert!(!window_menu_event_handler_required("macos"));
|
||||
assert!(window_menu_event_handler_required("windows"));
|
||||
assert!(window_menu_event_handler_required("linux"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use tauri::{
|
||||
WindowEvent,
|
||||
};
|
||||
|
||||
const MAIN_WINDOW_LABEL: &str = "main";
|
||||
pub(crate) const MAIN_WINDOW_LABEL: &str = "main";
|
||||
const WINDOW_STATE_FILE: &str = "window-state.json";
|
||||
const MIN_WINDOW_WIDTH: u32 = 480;
|
||||
const MIN_WINDOW_HEIGHT: u32 = 400;
|
||||
|
||||
@@ -654,6 +654,39 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows immediate feedback while a menu-driven update check is pending', async () => {
|
||||
let resolveUpdate: ((result: { kind: 'up-to-date' }) => void) | null = null
|
||||
const checkForUpdates = vi.fn(() => new Promise<{ kind: 'up-to-date' }>((resolve) => {
|
||||
resolveUpdate = resolve
|
||||
}))
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(checkForUpdates))
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(typeof window.__laputaTest?.dispatchBrowserMenuCommand).toBe('function')
|
||||
})
|
||||
|
||||
act(() => {
|
||||
window.__laputaTest?.dispatchBrowserMenuCommand?.('app-check-for-updates')
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Checking for updates...')).toBeInTheDocument()
|
||||
})
|
||||
expect(checkForUpdates).toHaveBeenCalledOnce()
|
||||
|
||||
await act(async () => {
|
||||
resolveUpdate?.({ kind: 'up-to-date' })
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No newer stable update is available right now')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows the external AI setup dialog from the menu when AI onboarding is active', async () => {
|
||||
localStorage.removeItem(AI_AGENTS_ONBOARDING_DISMISSED_KEY)
|
||||
localStorage.removeItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY)
|
||||
|
||||
@@ -101,6 +101,7 @@ import {
|
||||
getBrowserLanguagePreferences,
|
||||
resolveEffectiveLocale,
|
||||
serializeUiLanguagePreference,
|
||||
translate,
|
||||
type UiLanguagePreference,
|
||||
} from './lib/i18n'
|
||||
import { normalizeReleaseChannel } from './lib/releaseChannel'
|
||||
@@ -1280,6 +1281,7 @@ function App() {
|
||||
await restartApp()
|
||||
return
|
||||
}
|
||||
setToastMessage(translate(appLocale, 'update.checking'))
|
||||
const result = await updateActions.checkForUpdates()
|
||||
if (result.kind === 'up-to-date') {
|
||||
const checkedChannel = normalizeReleaseChannel(settings.release_channel)
|
||||
@@ -1289,7 +1291,7 @@ function App() {
|
||||
} else {
|
||||
setToastMessage(result.message)
|
||||
}
|
||||
}, [settings.release_channel, updateActions, updateStatus.state, setToastMessage])
|
||||
}, [appLocale, settings.release_channel, updateActions, updateStatus.state, setToastMessage])
|
||||
|
||||
const handleRepairVault = useCallback(async () => {
|
||||
if (!resolvedPath) return
|
||||
|
||||
@@ -567,6 +567,7 @@ describe('StatusBar', () => {
|
||||
/>
|
||||
)
|
||||
fireEvent.click(screen.getByTestId('status-sync'))
|
||||
expect(screen.getByTestId('status-bar')).toHaveStyle({ zIndex: '30' })
|
||||
expect(screen.getByTestId('git-status-popup')).toBeInTheDocument()
|
||||
expect(screen.getByText('main')).toBeInTheDocument()
|
||||
expect(screen.getByText(/2 ahead/)).toBeInTheDocument()
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { VaultOption } from './status-bar/types'
|
||||
export type { VaultOption } from './status-bar/types'
|
||||
|
||||
const COMPACT_STATUS_BAR_MAX_WIDTH = 1000
|
||||
const STATUS_BAR_STACKING_Z_INDEX = 30
|
||||
|
||||
function getWindowWidth() {
|
||||
return typeof window === 'undefined' ? Number.POSITIVE_INFINITY : window.innerWidth
|
||||
@@ -246,7 +247,7 @@ function StatusBarFooter(props: StatusBarFooterProps) {
|
||||
fontSize: 12,
|
||||
color: 'var(--muted-foreground)',
|
||||
position: 'relative',
|
||||
zIndex: 10,
|
||||
zIndex: STATUS_BAR_STACKING_Z_INDEX,
|
||||
}}
|
||||
>
|
||||
<StatusBarPrimaryFromFooter {...props} />
|
||||
|
||||
@@ -330,35 +330,102 @@ describe('tolariaEditorFormatting behavior', () => {
|
||||
})
|
||||
|
||||
it('hides the floating toolbar while the editor is composing IME text', () => {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const editorInput = editor.domElement.firstElementChild as HTMLElement
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const editorInput = editor.domElement.firstElementChild as HTMLElement
|
||||
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionStart(editorInput)
|
||||
})
|
||||
act(() => {
|
||||
fireEvent.compositionStart(editorInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionEnd(editorInput)
|
||||
})
|
||||
act(() => {
|
||||
fireEvent.compositionEnd(editorInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(250)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the floating toolbar hidden through rapid Zhuyin composition settle cycles', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const editor = createMockEditor('paragraph')
|
||||
const editorInput = editor.domElement.firstElementChild as HTMLElement
|
||||
|
||||
useBlockNoteEditorMock.mockReturnValue(editor)
|
||||
|
||||
render(<TolariaFormattingToolbarController />)
|
||||
|
||||
act(() => {
|
||||
fireEvent.compositionStart(editorInput)
|
||||
fireEvent.compositionEnd(editorInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(120)
|
||||
fireEvent.compositionStart(editorInput)
|
||||
fireEvent.compositionEnd(editorInput)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(249)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: undefined,
|
||||
useFloatingOptions: expect.objectContaining({ open: false }),
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(1)
|
||||
})
|
||||
|
||||
expect(positionPopoverState.lastProps).toEqual(expect.objectContaining({
|
||||
position: { from: 1, to: 5 },
|
||||
useFloatingOptions: expect.objectContaining({ open: true }),
|
||||
}))
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores composition events that start outside the editor', () => {
|
||||
|
||||
@@ -6,10 +6,31 @@ import type {
|
||||
} from '@blocknote/core'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
const COMPOSITION_SETTLE_MS = 250
|
||||
|
||||
function eventTargetsEditor(editorElement: Element, target: EventTarget | null) {
|
||||
return target instanceof Node && editorElement.contains(target)
|
||||
}
|
||||
|
||||
function focusTargetsEditor(editorElement: Element) {
|
||||
const activeElement = editorElement.ownerDocument.activeElement
|
||||
return activeElement instanceof Node && editorElement.contains(activeElement)
|
||||
}
|
||||
|
||||
function selectionTargetsEditor(editorElement: Element) {
|
||||
const anchorNode = editorElement.ownerDocument.getSelection()?.anchorNode
|
||||
return anchorNode instanceof Node && editorElement.contains(anchorNode)
|
||||
}
|
||||
|
||||
function compositionEventTargetsEditor(
|
||||
editorElement: Element,
|
||||
event: CompositionEvent,
|
||||
) {
|
||||
return eventTargetsEditor(editorElement, event.target)
|
||||
|| focusTargetsEditor(editorElement)
|
||||
|| selectionTargetsEditor(editorElement)
|
||||
}
|
||||
|
||||
export function useEditorComposing<
|
||||
BSchema extends BlockSchema,
|
||||
ISchema extends InlineContentSchema,
|
||||
@@ -17,41 +38,82 @@ export function useEditorComposing<
|
||||
>(editor: BlockNoteEditor<BSchema, ISchema, SSchema>) {
|
||||
const [isComposing, setIsComposing] = useState(false)
|
||||
const composingRef = useRef(false)
|
||||
const settleTimeoutRef = useRef<number | null>(null)
|
||||
const editorElement = editor.domElement ?? null
|
||||
|
||||
useEffect(() => {
|
||||
const clearSettleTimeout = () => {
|
||||
if (settleTimeoutRef.current === null) return
|
||||
window.clearTimeout(settleTimeoutRef.current)
|
||||
settleTimeoutRef.current = null
|
||||
}
|
||||
|
||||
const updateComposing = (nextIsComposing: boolean) => {
|
||||
if (composingRef.current === nextIsComposing) return
|
||||
composingRef.current = nextIsComposing
|
||||
setIsComposing(nextIsComposing)
|
||||
}
|
||||
|
||||
const startComposing = () => {
|
||||
clearSettleTimeout()
|
||||
updateComposing(true)
|
||||
}
|
||||
|
||||
const finishComposing = () => {
|
||||
clearSettleTimeout()
|
||||
settleTimeoutRef.current = window.setTimeout(() => {
|
||||
settleTimeoutRef.current = null
|
||||
updateComposing(false)
|
||||
}, COMPOSITION_SETTLE_MS)
|
||||
}
|
||||
|
||||
clearSettleTimeout()
|
||||
updateComposing(false)
|
||||
|
||||
if (!editorElement) return
|
||||
|
||||
const handleCompositionStart = (event: CompositionEvent) => {
|
||||
if (!eventTargetsEditor(editorElement, event.target)) return
|
||||
updateComposing(true)
|
||||
if (!compositionEventTargetsEditor(editorElement, event)) return
|
||||
startComposing()
|
||||
}
|
||||
|
||||
const handleCompositionUpdate = (event: CompositionEvent) => {
|
||||
if (!compositionEventTargetsEditor(editorElement, event)) return
|
||||
startComposing()
|
||||
}
|
||||
|
||||
const handleCompositionEnd = (event: CompositionEvent) => {
|
||||
if (
|
||||
!composingRef.current
|
||||
&& !eventTargetsEditor(editorElement, event.target)
|
||||
&& !compositionEventTargetsEditor(editorElement, event)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
updateComposing(false)
|
||||
finishComposing()
|
||||
}
|
||||
|
||||
const handleCompositionCancel: EventListener = (event) => {
|
||||
if (event instanceof CompositionEvent) {
|
||||
handleCompositionEnd(event)
|
||||
return
|
||||
}
|
||||
|
||||
if (!composingRef.current) return
|
||||
finishComposing()
|
||||
}
|
||||
|
||||
document.addEventListener('compositionstart', handleCompositionStart, true)
|
||||
document.addEventListener('compositionupdate', handleCompositionUpdate, true)
|
||||
document.addEventListener('compositionend', handleCompositionEnd, true)
|
||||
document.addEventListener('compositioncancel', handleCompositionCancel, true)
|
||||
|
||||
return () => {
|
||||
clearSettleTimeout()
|
||||
document.removeEventListener('compositionstart', handleCompositionStart, true)
|
||||
document.removeEventListener('compositionupdate', handleCompositionUpdate, true)
|
||||
document.removeEventListener('compositionend', handleCompositionEnd, true)
|
||||
document.removeEventListener('compositioncancel', handleCompositionCancel, true)
|
||||
}
|
||||
}, [editorElement])
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildNoteContent,
|
||||
resolveNewNote,
|
||||
resolveNewType,
|
||||
planNewTypeCreation,
|
||||
DEFAULT_TEMPLATES,
|
||||
resolveTemplate,
|
||||
} from './useNoteCreation'
|
||||
@@ -319,3 +320,28 @@ describe('resolveNewType', () => {
|
||||
expect(entry.path).not.toContain('/Users/luca/Laputa')
|
||||
})
|
||||
})
|
||||
|
||||
describe('planNewTypeCreation', () => {
|
||||
it('blocks creating a type when a same-slug non-Type note already exists', () => {
|
||||
const plan = planNewTypeCreation({
|
||||
entries: [makeEntry({ path: '/my/vault/tasks.md', filename: 'tasks.md', title: 'Tasks', isA: 'Note' })],
|
||||
typeName: 'Tasks',
|
||||
vaultPath: '/my/vault',
|
||||
})
|
||||
|
||||
expect(plan).toEqual({
|
||||
status: 'blocked',
|
||||
message: 'Cannot create type "Tasks" because tasks.md already exists',
|
||||
})
|
||||
})
|
||||
|
||||
it('blocks type collisions case-insensitively for cross-platform vaults', () => {
|
||||
const plan = planNewTypeCreation({
|
||||
entries: [makeEntry({ path: '/my/vault/TASKS.md', filename: 'TASKS.md', title: 'Tasks', isA: 'Note' })],
|
||||
typeName: 'tasks',
|
||||
vaultPath: '/my/vault',
|
||||
})
|
||||
|
||||
expect(plan.status).toBe('blocked')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -584,11 +584,8 @@ describe('useNoteCreation hook', () => {
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "Note" because note.md already exists')
|
||||
})
|
||||
|
||||
it('handleCreateType lets disk creation decide when a stale entry collides with the target type path', async () => {
|
||||
it('handleCreateType blocks when a loaded non-Type entry collides with the target type path', async () => {
|
||||
vi.mocked(isTauri).mockReturnValue(true)
|
||||
vi.mocked(invoke)
|
||||
.mockRejectedValueOnce(new Error('not found'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
const staleEntry = makeEntry({
|
||||
path: '/test/vault/pttep.md',
|
||||
filename: 'pttep.md',
|
||||
@@ -597,26 +594,16 @@ describe('useNoteCreation hook', () => {
|
||||
})
|
||||
const { result } = renderHook(() => useNoteCreation(makeConfig([staleEntry]), tabDeps))
|
||||
|
||||
let created = false
|
||||
let created = true
|
||||
await act(async () => {
|
||||
created = await result.current.handleCreateType('PTTEP')
|
||||
})
|
||||
|
||||
expect(created).toBe(true)
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('get_note_content', {
|
||||
path: '/test/vault/pttep.md',
|
||||
})
|
||||
expect(vi.mocked(invoke)).toHaveBeenCalledWith('create_note_content', {
|
||||
path: '/test/vault/pttep.md',
|
||||
content: expect.stringContaining('type: Type'),
|
||||
})
|
||||
expect(addEntry).toHaveBeenCalledWith(expect.objectContaining({
|
||||
path: '/test/vault/pttep.md',
|
||||
filename: 'pttep.md',
|
||||
title: 'PTTEP',
|
||||
isA: 'Type',
|
||||
}))
|
||||
expect(setToastMessage).not.toHaveBeenCalled()
|
||||
expect(created).toBe(false)
|
||||
expect(vi.mocked(invoke)).not.toHaveBeenCalled()
|
||||
expect(addEntry).not.toHaveBeenCalled()
|
||||
expect(openTabWithContent).not.toHaveBeenCalled()
|
||||
expect(setToastMessage).toHaveBeenCalledWith('Cannot create type "PTTEP" because pttep.md already exists')
|
||||
})
|
||||
|
||||
it('handleCreateType writes new type entries to the vault root even when older type entries live in a folder', async () => {
|
||||
|
||||
@@ -200,6 +200,14 @@ export function planNewTypeCreation({
|
||||
if (existingType) return { status: 'existing', entry: existingType }
|
||||
|
||||
const resolved = resolveNewType({ typeName, vaultPath })
|
||||
const collision = findPathCollision(entries, resolved.entry.path)
|
||||
if (collision) {
|
||||
return {
|
||||
status: 'blocked',
|
||||
message: buildCreationCollisionMessage({ noun: 'type', title: typeName, path: resolved.entry.path }),
|
||||
}
|
||||
}
|
||||
|
||||
return { status: 'create', resolved }
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user