Compare commits
11 Commits
stable-v20
...
alpha-v202
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6105c7527c | ||
|
|
92eee0d470 | ||
|
|
f14fda09d5 | ||
|
|
31d12d8cc8 | ||
|
|
6a002f0dc0 | ||
|
|
9d30d1165f | ||
|
|
b709acd58a | ||
|
|
080e3498a7 | ||
|
|
51010ca578 | ||
|
|
15bc3a4701 | ||
|
|
3ebfa642fa |
@@ -186,6 +186,7 @@ Panels are separated by `ResizeHandle` components that support drag-to-resize.
|
||||
The main Tauri window derives its minimum width from the visible panes instead of a single fixed floor. `useMainWindowSizeConstraints` treats the editor-only shell as the 480px baseline, adds sidebar / note-list / expanded-inspector allowances on top, and calls the native `update_current_window_min_size` command whenever view mode or inspector visibility changes. That same native command also grows the current window back out when a wider pane combination is restored, while note windows skip this path and keep their dedicated 800×700 initial sizing.
|
||||
|
||||
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 macOS uses for native menu clicks.
|
||||
When Tolaria is launched from a Linux AppImage, `run()` also injects `WEBKIT_DISABLE_DMABUF_RENDERER=1` unless the user already set that variable. This keeps the workaround scoped to bundled WebKitGTK launches that are prone to Fedora/Wayland DMA-BUF crashes without changing native package installs.
|
||||
|
||||
## Multi-Window (Note Windows)
|
||||
|
||||
@@ -496,6 +497,7 @@ sequenceDiagram
|
||||
participant MCP as MCP Server
|
||||
participant U as User
|
||||
|
||||
T->>T: apply Linux AppImage WebKit env override<br/>(AppImage only)
|
||||
T->>T: run_startup_tasks()<br/>(migrate + seed only)
|
||||
T->>MCP: spawn_ws_bridge() — ports 9710 + 9711
|
||||
T->>A: App mounts
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:smoke": "playwright test --config playwright.smoke.config.ts tests/smoke/delete-note-nonblocking.spec.ts tests/smoke/example.spec.ts tests/smoke/fix-ai-chat-empty-body-v3.spec.ts tests/smoke/fix-crash-create-note.spec.ts tests/smoke/fresh-start-telemetry-onboarding.spec.ts tests/smoke/getting-started-template.spec.ts tests/smoke/h1-title-decoupled.spec.ts tests/smoke/h1-untitled-auto-rename.spec.ts tests/smoke/keyboard-command-routing.spec.ts tests/smoke/linkify-init-warnings.spec.ts tests/smoke/multi-selection-shortcuts.spec.ts tests/smoke/wikilink-path-fix.spec.ts",
|
||||
"playwright:regression": "playwright test tests/smoke/",
|
||||
"playwright:integration": "playwright test --config playwright.integration.config.ts",
|
||||
"test:coverage": "node scripts/run-vitest-coverage.mjs",
|
||||
|
||||
@@ -30,7 +30,7 @@ export default defineConfig({
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { browserName: 'chromium' } }],
|
||||
webServer: {
|
||||
command: `pnpm dev --host 127.0.0.1 --port ${port} --strictPort`,
|
||||
command: `node scripts/playwright-smoke-server.mjs ${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer,
|
||||
timeout: 30_000,
|
||||
|
||||
32
scripts/playwright-smoke-server.mjs
Normal file
32
scripts/playwright-smoke-server.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const port = process.argv[2] ?? process.env.PORT ?? '41741'
|
||||
|
||||
const child = spawn(
|
||||
'pnpm',
|
||||
['dev', '--host', '127.0.0.1', '--port', port, '--strictPort'],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
},
|
||||
)
|
||||
|
||||
function forwardSignal(signal) {
|
||||
if (child.killed) return
|
||||
child.kill(signal)
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => forwardSignal('SIGINT'))
|
||||
process.on('SIGTERM', () => forwardSignal('SIGTERM'))
|
||||
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
process.exit(code ?? 1)
|
||||
})
|
||||
@@ -47,6 +47,45 @@ struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
#[cfg(desktop)]
|
||||
struct ActiveAssetScopeRoots(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")))]
|
||||
fn linux_appimage_startup_env_overrides_with<F>(mut get_var: F) -> Vec<StartupEnvOverride>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
let is_appimage = ["APPIMAGE", "APPDIR"]
|
||||
.into_iter()
|
||||
.any(|key| get_var(key).is_some_and(|value| !value.trim().is_empty()));
|
||||
if !is_appimage {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if get_var("WEBKIT_DISABLE_DMABUF_RENDERER").is_some_and(|value| !value.trim().is_empty()) {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
vec![StartupEnvOverride {
|
||||
key: "WEBKIT_DISABLE_DMABUF_RENDERER",
|
||||
value: "1",
|
||||
}]
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, target_os = "linux"))]
|
||||
fn apply_linux_appimage_startup_env_overrides() {
|
||||
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(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 {
|
||||
@@ -338,6 +377,7 @@ 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();
|
||||
let builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(desktop)]
|
||||
@@ -357,6 +397,8 @@ pub fn run() {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::linux_appimage_startup_env_overrides_with;
|
||||
use super::StartupEnvOverride;
|
||||
use super::MACOS_WEBVIEW_RESERVED_COMMAND_SHIFT_KEYS;
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
@@ -367,6 +409,40 @@ 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_dmabuf_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",
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_appimage_startup_env_overrides_preserve_explicit_user_setting() {
|
||||
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!(overrides.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(all(desktop, unix))]
|
||||
#[test]
|
||||
fn vault_asset_scope_roots_include_requested_symlink_path() {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"script-src": "'self' https://us.i.posthog.com https://eu.i.posthog.com https://us-assets.i.posthog.com https://eu-assets.i.posthog.com",
|
||||
"connect-src": "'self' ipc: http://ipc.localhost ws://localhost:9710 ws://127.0.0.1:9710 ws://localhost:9711 ws://127.0.0.1:9711 https:",
|
||||
"img-src": "'self' asset: http://asset.localhost data: blob: https:",
|
||||
"style-src": "'self' 'unsafe-inline' 'nonce-tolaria-codemirror-style' https://fonts.googleapis.com",
|
||||
"style-src": "'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src": "'self' data: https://fonts.gstatic.com",
|
||||
"media-src": "'self' data: blob: https:",
|
||||
"object-src": "'none'"
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { DEFAULT_VAULTS } from './hooks/useVaultSwitcher'
|
||||
import { formatShortcutDisplay } from './hooks/appCommandCatalog'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
// Provide a localStorage mock that supports all methods (jsdom's may be incomplete)
|
||||
const localStorageMock = (() => {
|
||||
@@ -303,7 +304,7 @@ function resolveMockCommandResult(cmd: string, args?: unknown) {
|
||||
}
|
||||
|
||||
vi.mock('./mock-tauri', () => ({
|
||||
isTauri: () => false,
|
||||
isTauri: vi.fn(() => false),
|
||||
mockInvoke: vi.fn(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args)),
|
||||
addMockEntry: vi.fn(),
|
||||
updateMockContent: vi.fn(),
|
||||
@@ -317,6 +318,24 @@ vi.mock('./utils/ai-chat', () => ({
|
||||
streamClaudeChat: vi.fn(async () => 'mock-session'),
|
||||
}))
|
||||
|
||||
vi.mock('./hooks/useUpdater', async () => {
|
||||
const actual = await vi.importActual<typeof import('./hooks/useUpdater')>('./hooks/useUpdater')
|
||||
|
||||
return {
|
||||
...actual,
|
||||
useUpdater: vi.fn(() => ({
|
||||
status: { state: 'idle' },
|
||||
actions: {
|
||||
checkForUpdates: vi.fn(async () => ({ kind: 'up-to-date' })),
|
||||
startDownload: vi.fn(),
|
||||
openReleaseNotes: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
})),
|
||||
restartApp: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
// Mock BlockNote components (they need DOM APIs not available in jsdom)
|
||||
vi.mock('@blocknote/core', () => ({
|
||||
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
|
||||
@@ -396,14 +415,33 @@ vi.mock('./components/tolariaEditorFormatting', () => ({
|
||||
}))
|
||||
|
||||
import App from './App'
|
||||
import { useUpdater } from './hooks/useUpdater'
|
||||
import { isTauri } from './mock-tauri'
|
||||
|
||||
const AI_AGENTS_ONBOARDING_DISMISSED_KEY = 'tolaria:ai-agents-onboarding-dismissed'
|
||||
const CLAUDE_CODE_ONBOARDING_DISMISSED_KEY = 'tolaria:claude-code-onboarding-dismissed'
|
||||
|
||||
function createMockUpdaterResult(
|
||||
checkForUpdates: () => Promise<{ kind: 'up-to-date' } | { kind: 'available'; version: string; displayVersion: string } | { kind: 'error'; message: string }> = async () => ({ kind: 'up-to-date' }),
|
||||
) {
|
||||
return {
|
||||
status: { state: 'idle' as const },
|
||||
actions: {
|
||||
checkForUpdates,
|
||||
startDownload: vi.fn(),
|
||||
openReleaseNotes: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetMockCommandResults()
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string, args?: unknown) => resolveMockCommandResult(cmd, args))
|
||||
vi.mocked(isTauri).mockReturnValue(false)
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult())
|
||||
localStorage.clear()
|
||||
window.history.replaceState({}, '', '/')
|
||||
localStorage.setItem(CLAUDE_CODE_ONBOARDING_DISMISSED_KEY, '1')
|
||||
@@ -480,6 +518,45 @@ describe('App', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows visible feedback when a manual update check finds an update', async () => {
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(async () => ({
|
||||
kind: 'available',
|
||||
version: '2026.4.25',
|
||||
displayVersion: '2026.4.25',
|
||||
})))
|
||||
|
||||
render(<App />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('status-build-number'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Tolaria 2026.4.25 is available')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows visible feedback when a menu-driven update check finds no eligible update', async () => {
|
||||
vi.mocked(useUpdater).mockReturnValue(createMockUpdaterResult(async () => ({ kind: 'up-to-date' })))
|
||||
|
||||
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('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)
|
||||
|
||||
14
src/App.tsx
14
src/App.tsx
@@ -89,6 +89,7 @@ import { openNoteListPropertiesPicker } from './components/note-list/noteListPro
|
||||
import type { NoteListMultiSelectionCommands } from './components/note-list/multiSelectionCommands'
|
||||
import { focusNoteIconPropertyEditor } from './components/noteIconPropertyEvents'
|
||||
import { trackEvent } from './lib/telemetry'
|
||||
import { normalizeReleaseChannel } from './lib/releaseChannel'
|
||||
import {
|
||||
buildVaultAiGuidanceRefreshKey,
|
||||
} from './lib/vaultAiGuidance'
|
||||
@@ -1097,12 +1098,15 @@ function App() {
|
||||
return
|
||||
}
|
||||
const result = await updateActions.checkForUpdates()
|
||||
if (result === 'up-to-date') {
|
||||
setToastMessage("You're on the latest version")
|
||||
} else if (result === 'error') {
|
||||
setToastMessage('Could not check for updates')
|
||||
if (result.kind === 'up-to-date') {
|
||||
const checkedChannel = normalizeReleaseChannel(settings.release_channel)
|
||||
setToastMessage(`No newer ${checkedChannel} update is available right now`)
|
||||
} else if (result.kind === 'available') {
|
||||
setToastMessage(`Tolaria ${result.displayVersion} is available`)
|
||||
} else {
|
||||
setToastMessage(result.message)
|
||||
}
|
||||
}, [updateActions, updateStatus.state, setToastMessage])
|
||||
}, [settings.release_channel, updateActions, updateStatus.state, setToastMessage])
|
||||
|
||||
const handleRepairVault = useCallback(async () => {
|
||||
if (!resolvedPath) return
|
||||
|
||||
@@ -105,6 +105,16 @@ describe('CommandPalette', () => {
|
||||
expect(screen.getByPlaceholderText('Type a command...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('opts the command input out of spellcheck and text correction', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
const input = screen.getByPlaceholderText('Type a command...')
|
||||
|
||||
expect(input).toHaveAttribute('spellcheck', 'false')
|
||||
expect(input).toHaveAttribute('autocorrect', 'off')
|
||||
expect(input).toHaveAttribute('autocapitalize', 'off')
|
||||
expect(input).toHaveAttribute('autocomplete', 'off')
|
||||
})
|
||||
|
||||
it('shows all enabled commands grouped by category', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
expect(screen.getByText('Search Notes')).toBeInTheDocument()
|
||||
@@ -181,6 +191,35 @@ describe('CommandPalette', () => {
|
||||
expect(onClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a short query keyboard-selectable after ArrowDown and Enter', () => {
|
||||
const changeNoteType = makeCommand({
|
||||
id: 'change-note-type',
|
||||
label: 'Change Note Type…',
|
||||
group: 'Note',
|
||||
})
|
||||
|
||||
render(
|
||||
<CommandPalette
|
||||
open={true}
|
||||
commands={[
|
||||
changeNoteType,
|
||||
makeCommand({ id: 'open-settings', label: 'Open Settings', group: 'Settings' }),
|
||||
]}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('Type a command...'), { target: { value: 'ch' } })
|
||||
fireEvent.keyDown(window, { key: 'ArrowDown' })
|
||||
|
||||
const selectedRow = screen.getByText('Change Note Type…').closest('[data-selected]')
|
||||
expect(selectedRow).toHaveAttribute('data-selected', 'true')
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Enter' })
|
||||
expect(changeNoteType.execute).toHaveBeenCalledOnce()
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not go below the last item', () => {
|
||||
render(<CommandPalette open={true} commands={commands} onClose={onClose} />)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CommandAction, CommandGroup } from '../hooks/useCommandRegistry'
|
||||
import { groupSortKey } from '../hooks/useCommandRegistry'
|
||||
import { rememberFeedbackDialogOpener } from '../lib/feedbackDialogOpener'
|
||||
import { CommandPaletteAiMode } from './CommandPaletteAiMode'
|
||||
import { Input } from './ui/input'
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean
|
||||
@@ -124,12 +125,16 @@ function CommandPaletteInput({
|
||||
onChange: (value: string) => void
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
<Input
|
||||
ref={inputRef}
|
||||
className="border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground outline-none placeholder:text-muted-foreground"
|
||||
className="h-auto rounded-none border-x-0 border-t-0 border-b border-border bg-transparent px-4 py-3 text-[15px] text-foreground shadow-none transition-none outline-none placeholder:text-muted-foreground focus-visible:border-border focus-visible:ring-0 md:text-[15px]"
|
||||
type="text"
|
||||
placeholder="Type a command..."
|
||||
value={query}
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
autoComplete="off"
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,81 +1,94 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { ConfirmDeleteDialog } from './ConfirmDeleteDialog'
|
||||
|
||||
describe('ConfirmDeleteDialog', () => {
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
const defaultProps = {
|
||||
open: true,
|
||||
title: 'Delete permanently?',
|
||||
message: 'This cannot be undone.',
|
||||
}
|
||||
|
||||
const onConfirm = vi.fn()
|
||||
const onCancel = vi.fn()
|
||||
|
||||
function renderDialog(overrides: Partial<React.ComponentProps<typeof ConfirmDeleteDialog>> = {}) {
|
||||
return render(
|
||||
<ConfirmDeleteDialog
|
||||
{...defaultProps}
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
{...overrides}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('ConfirmDeleteDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders with title and message', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
renderDialog()
|
||||
expect(screen.getByText('Delete permanently?')).toBeInTheDocument()
|
||||
expect(screen.getByText('This cannot be undone.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onConfirm when delete button clicked', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
renderDialog()
|
||||
fireEvent.click(screen.getByTestId('confirm-delete-btn'))
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('focuses the destructive action when the dialog opens', async () => {
|
||||
renderDialog()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('confirm-delete-btn')).toHaveFocus()
|
||||
})
|
||||
})
|
||||
|
||||
it('submits the destructive action when Enter is pressed', () => {
|
||||
renderDialog()
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('confirm-delete-dialog'), { key: 'Enter' })
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not submit twice when Enter repeats', () => {
|
||||
renderDialog()
|
||||
|
||||
fireEvent.keyDown(screen.getByTestId('confirm-delete-dialog'), { key: 'Enter' })
|
||||
fireEvent.keyDown(screen.getByTestId('confirm-delete-dialog'), { key: 'Enter', repeat: true })
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('treats Enter as the primary confirm shortcut even from the cancel button', () => {
|
||||
renderDialog()
|
||||
|
||||
fireEvent.keyDown(screen.getByText('Cancel'), { key: 'Enter' })
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onCancel when cancel button clicked', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
renderDialog()
|
||||
fireEvent.click(screen.getByText('Cancel'))
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not render when open is false', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={false}
|
||||
title="Delete permanently?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
renderDialog({ open: false })
|
||||
expect(screen.queryByText('Delete permanently?')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses custom confirm label when provided', () => {
|
||||
render(
|
||||
<ConfirmDeleteDialog
|
||||
open={true}
|
||||
title="Empty Trash?"
|
||||
message="Delete all notes?"
|
||||
confirmLabel="Empty Trash"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
)
|
||||
renderDialog({
|
||||
title: 'Empty Trash?',
|
||||
message: 'Delete all notes?',
|
||||
confirmLabel: 'Empty Trash',
|
||||
})
|
||||
expect(screen.getByText('Empty Trash')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { memo } from 'react'
|
||||
import { memo, useCallback, useEffect, useRef } from 'react'
|
||||
import type { FormEvent, KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { Trash } from '@phosphor-icons/react'
|
||||
import {
|
||||
Dialog,
|
||||
@@ -19,6 +20,48 @@ interface ConfirmDeleteDialogProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function focusConfirmButton(confirmButton: HTMLButtonElement | null) {
|
||||
confirmButton?.focus()
|
||||
}
|
||||
|
||||
function focusPrimaryDeleteButton(root: ParentNode | null) {
|
||||
focusConfirmButton(root?.querySelector<HTMLButtonElement>('[data-confirm-delete-primary="true"]') ?? null)
|
||||
}
|
||||
|
||||
function scheduleConfirmButtonFocus(root: ParentNode | null) {
|
||||
const focusDelays = [0, 50, 150]
|
||||
return focusDelays.map((delay) => (
|
||||
window.setTimeout(() => {
|
||||
focusPrimaryDeleteButton(root)
|
||||
}, delay)
|
||||
))
|
||||
}
|
||||
|
||||
type ConfirmShortcutEvent = Pick<
|
||||
KeyboardEvent,
|
||||
'key' | 'defaultPrevented' | 'repeat' | 'metaKey' | 'ctrlKey' | 'altKey' | 'shiftKey'
|
||||
> & {
|
||||
isComposing?: boolean
|
||||
nativeEvent?: Pick<KeyboardEvent, 'isComposing'>
|
||||
}
|
||||
|
||||
function isComposingEvent(event: ConfirmShortcutEvent) {
|
||||
return event.isComposing || event.nativeEvent?.isComposing === true
|
||||
}
|
||||
|
||||
function isConfirmShortcut(event: ConfirmShortcutEvent) {
|
||||
return (
|
||||
event.key === 'Enter'
|
||||
&& !event.defaultPrevented
|
||||
&& !event.repeat
|
||||
&& !event.metaKey
|
||||
&& !event.ctrlKey
|
||||
&& !event.altKey
|
||||
&& !event.shiftKey
|
||||
&& !isComposingEvent(event)
|
||||
)
|
||||
}
|
||||
|
||||
export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
|
||||
open,
|
||||
title,
|
||||
@@ -27,9 +70,88 @@ export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDeleteDialogProps) {
|
||||
const confirmingRef = useRef(false)
|
||||
const cancelFocusIsIntentionalRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
confirmingRef.current = false
|
||||
cancelFocusIsIntentionalRef.current = false
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
focusPrimaryDeleteButton(document)
|
||||
const timeoutIds = scheduleConfirmButtonFocus(document)
|
||||
|
||||
return () => {
|
||||
timeoutIds.forEach((timeoutId) => {
|
||||
window.clearTimeout(timeoutId)
|
||||
})
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (confirmingRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
confirmingRef.current = true
|
||||
onConfirm()
|
||||
}, [onConfirm])
|
||||
|
||||
const handleSubmit = useCallback((event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
handleConfirm()
|
||||
}, [handleConfirm])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
const handleWindowKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isConfirmShortcut(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
handleConfirm()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleWindowKeyDown, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleWindowKeyDown, true)
|
||||
}
|
||||
}, [handleConfirm, open])
|
||||
|
||||
const handleKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isConfirmShortcut(event)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
handleConfirm()
|
||||
}, [handleConfirm])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => { if (!isOpen) onCancel() }}>
|
||||
<DialogContent showCloseButton={false} data-testid="confirm-delete-dialog">
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
data-testid="confirm-delete-dialog"
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (event.key === 'Tab') {
|
||||
cancelFocusIsIntentionalRef.current = true
|
||||
}
|
||||
}}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault()
|
||||
const dialogRoot = event.currentTarget as ParentNode | null
|
||||
focusPrimaryDeleteButton(dialogRoot)
|
||||
scheduleConfirmButtonFocus(dialogRoot)
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Trash size={18} className="text-destructive" />
|
||||
@@ -37,12 +159,38 @@ export const ConfirmDeleteDialog = memo(function ConfirmDeleteDialog({
|
||||
</DialogTitle>
|
||||
<DialogDescription>{message}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={onConfirm} data-testid="confirm-delete-btn">
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="destructive"
|
||||
data-testid="confirm-delete-btn"
|
||||
data-confirm-delete-primary="true"
|
||||
autoFocus
|
||||
>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
onFocus={() => {
|
||||
if (cancelFocusIsIntentionalRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
focusPrimaryDeleteButton(document)
|
||||
}}
|
||||
onPointerDown={() => {
|
||||
cancelFocusIsIntentionalRef.current = true
|
||||
}}
|
||||
data-confirm-delete-cancel="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -16,7 +16,11 @@ import {
|
||||
extractInlineWikilinkReferences,
|
||||
findActiveWikilinkQuery,
|
||||
} from './inlineWikilinkText'
|
||||
import { serializeInlineNode } from './inlineWikilinkDom'
|
||||
import { extractDroppedPathText } from './inlineWikilinkDropText'
|
||||
import {
|
||||
readSelectionRange,
|
||||
serializeInlineNode,
|
||||
} from './inlineWikilinkDom'
|
||||
import {
|
||||
buildPendingPasteState,
|
||||
type PendingPasteState,
|
||||
@@ -31,6 +35,7 @@ import { handleInlineWikilinkKeyDown } from './inlineWikilinkKeydown'
|
||||
import { useInlineWikilinkSelection } from './useInlineWikilinkSelection'
|
||||
import { useInlineWikilinkSuggestionsState } from './useInlineWikilinkSuggestionsState'
|
||||
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
|
||||
import { isInsertBeforeInput } from './inlineWikilinkBeforeInput'
|
||||
|
||||
interface InlineWikilinkInputProps {
|
||||
entries: VaultEntry[]
|
||||
@@ -162,6 +167,7 @@ export function InlineWikilinkInput({
|
||||
const pendingPasteRef = useRef<PendingPasteState | null>(null)
|
||||
const isComposingRef = useRef(false)
|
||||
const pendingCompositionInputRef = useRef(false)
|
||||
const handledFileDropRef = useRef(false)
|
||||
const activeQuery = useMemo(
|
||||
() => selectionRange.start === selectionRange.end
|
||||
? findActiveWikilinkQuery(value, selectionIndex)
|
||||
@@ -190,6 +196,14 @@ export function InlineWikilinkInput({
|
||||
onChange(nextState.value)
|
||||
setSelectionRange(nextState.selection)
|
||||
}
|
||||
const insertTransferText = (text: string) => {
|
||||
const currentSelectionRange = editorRef.current
|
||||
? readSelectionRange(editorRef.current)
|
||||
: selectionRange
|
||||
const nextState = replaceInlineSelection(value, currentSelectionRange, text)
|
||||
onChange(nextState.value)
|
||||
setSelectionRange(nextState.selection)
|
||||
}
|
||||
const notifyUnsupportedPaste = () => onUnsupportedPaste?.(UNSUPPORTED_INLINE_PASTE_MESSAGE)
|
||||
const recoverUnsupportedMutation = () => {
|
||||
pendingCompositionInputRef.current = false
|
||||
@@ -208,14 +222,44 @@ export function InlineWikilinkInput({
|
||||
if (disabled) return
|
||||
|
||||
const nativeEvent = event.nativeEvent as InputEvent
|
||||
if (!nativeEvent.inputType.startsWith('insert')) return
|
||||
if (!isInsertBeforeInput(nativeEvent)) return
|
||||
|
||||
const dataTransfer = nativeEvent.dataTransfer
|
||||
if (!dataTransfer || !hasUnsupportedClipboardPayload(dataTransfer)) return
|
||||
|
||||
if (nativeEvent.inputType === 'insertFromDrop' && handledFileDropRef.current) {
|
||||
handledFileDropRef.current = false
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
|
||||
if (nativeEvent.inputType === 'insertFromDrop') {
|
||||
const droppedPathText = extractDroppedPathText(dataTransfer)
|
||||
if (droppedPathText) {
|
||||
event.preventDefault()
|
||||
insertTransferText(droppedPathText)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
notifyUnsupportedPaste()
|
||||
}
|
||||
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
|
||||
if (disabled) return
|
||||
if (!hasUnsupportedClipboardPayload(event.dataTransfer)) return
|
||||
|
||||
handledFileDropRef.current = true
|
||||
const droppedPathText = extractDroppedPathText(event.dataTransfer)
|
||||
event.preventDefault()
|
||||
|
||||
if (!droppedPathText) {
|
||||
notifyUnsupportedPaste()
|
||||
return
|
||||
}
|
||||
|
||||
insertTransferText(droppedPathText)
|
||||
}
|
||||
const handlePaste = (event: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
if (disabled) return
|
||||
|
||||
@@ -307,6 +351,7 @@ export function InlineWikilinkInput({
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onInput={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDrop={handleDrop}
|
||||
onPaste={handlePaste}
|
||||
onSelectionChange={syncSelectionRange}
|
||||
segments={segments}
|
||||
|
||||
@@ -163,6 +163,7 @@ export function InlineWikilinkEditorField({
|
||||
onCompositionStart,
|
||||
onInput,
|
||||
onKeyDown,
|
||||
onDrop,
|
||||
onPaste,
|
||||
onSelectionChange,
|
||||
segments,
|
||||
@@ -179,6 +180,7 @@ export function InlineWikilinkEditorField({
|
||||
onCompositionStart: () => void
|
||||
onInput: () => void
|
||||
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
|
||||
onDrop: (event: React.DragEvent<HTMLDivElement>) => void
|
||||
onPaste: (event: React.ClipboardEvent<HTMLDivElement>) => void
|
||||
onSelectionChange: () => void
|
||||
segments: InlineWikilinkSegment[]
|
||||
@@ -215,6 +217,7 @@ export function InlineWikilinkEditorField({
|
||||
onCompositionStart={onCompositionStart}
|
||||
onInput={onInput}
|
||||
onKeyDown={onKeyDown}
|
||||
onDrop={onDrop}
|
||||
onPaste={onPaste}
|
||||
onClick={onSelectionChange}
|
||||
onKeyUp={onSelectionChange}
|
||||
|
||||
@@ -492,6 +492,34 @@ describe('SingleEditorView', () => {
|
||||
expect(editor.focus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the nearest editable block when the trailing block has no inline content', () => {
|
||||
const editor = createEditor()
|
||||
editor.document = [
|
||||
{ id: 'paragraph-block', type: 'paragraph', content: [], children: [] },
|
||||
{ id: 'image-block', type: 'image', children: [] },
|
||||
]
|
||||
editor.setTextCursorPosition = vi.fn((blockId: string) => {
|
||||
if (blockId === 'image-block') {
|
||||
throw new Error('Attempting to set selection anchor in block without content (id image-block)')
|
||||
}
|
||||
})
|
||||
|
||||
render(
|
||||
<SingleEditorView
|
||||
editor={editor as never}
|
||||
entries={[makeEntry()]}
|
||||
onNavigateWikilink={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
|
||||
expect(container).toBeTruthy()
|
||||
|
||||
expect(() => fireEvent.click(container!)).not.toThrow()
|
||||
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('paragraph-block', 'end')
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes the custom link-toolbar open action through openExternalUrl', () => {
|
||||
render(
|
||||
<SingleEditorView
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
} from './tolariaEditorFormatting'
|
||||
import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
|
||||
import { useEditorLinkActivation } from './useEditorLinkActivation'
|
||||
import { findNearestTextCursorBlock } from './blockNoteCursorTarget'
|
||||
|
||||
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
|
||||
| --- | --- | --- |
|
||||
@@ -241,7 +242,11 @@ function queueTitleHeadingCursorRepair(
|
||||
const firstBlock = editor.document[0]
|
||||
if (firstBlock?.type !== 'heading') return
|
||||
|
||||
editor.setTextCursorPosition(firstBlock.id, 'end')
|
||||
try {
|
||||
editor.setTextCursorPosition(firstBlock.id, 'end')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
editor.focus()
|
||||
})
|
||||
|
||||
@@ -261,7 +266,14 @@ function useEditorContainerClickHandler(options: {
|
||||
if (shouldIgnoreContainerClick(target)) return
|
||||
const blocks = editor.document
|
||||
if (blocks.length > 0) {
|
||||
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
|
||||
const targetBlock = findNearestTextCursorBlock(blocks, blocks.length - 1)
|
||||
if (targetBlock) {
|
||||
try {
|
||||
editor.setTextCursorPosition(targetBlock.id, 'end')
|
||||
} catch {
|
||||
// Ignore transient BlockNote selection errors and at least restore focus.
|
||||
}
|
||||
}
|
||||
}
|
||||
editor.focus()
|
||||
}, [editor, editable])
|
||||
|
||||
@@ -8,7 +8,11 @@ import {
|
||||
waitFor,
|
||||
} from '@testing-library/react'
|
||||
import { WikilinkChatInput } from './WikilinkChatInput'
|
||||
import { UNSUPPORTED_INLINE_PASTE_MESSAGE } from './InlineWikilinkInput'
|
||||
import { extractDroppedPathText } from './inlineWikilinkDropText'
|
||||
import {
|
||||
UNSUPPORTED_INLINE_PASTE_MESSAGE,
|
||||
} from './InlineWikilinkInput'
|
||||
import { isInsertBeforeInput } from './inlineWikilinkBeforeInput'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const makeEntry = (overrides: Partial<VaultEntry> = {}): VaultEntry => ({
|
||||
@@ -119,6 +123,24 @@ function fireComposingKeyDown(editor: HTMLElement, key: string) {
|
||||
fireEvent(editor, event)
|
||||
}
|
||||
|
||||
function createFileLikeDataTransfer({
|
||||
plainText = '',
|
||||
uriList = '',
|
||||
}: {
|
||||
plainText?: string
|
||||
uriList?: string
|
||||
}) {
|
||||
return {
|
||||
getData: vi.fn((type: string) => {
|
||||
if (type === 'text/plain') return plainText
|
||||
if (type === 'text/uri-list') return uriList
|
||||
return ''
|
||||
}),
|
||||
files: [new File(['folder'], 'Projects')],
|
||||
items: [{ kind: 'file', type: '' }],
|
||||
}
|
||||
}
|
||||
|
||||
describe('WikilinkChatInput', () => {
|
||||
it('renders the placeholder overlay for an empty draft', () => {
|
||||
render(<Controlled placeholder="Ask something..." />)
|
||||
@@ -271,6 +293,43 @@ describe('WikilinkChatInput', () => {
|
||||
expect(editor.textContent).toContain('still works')
|
||||
})
|
||||
|
||||
it('extracts dropped folder paths from text/plain payloads', () => {
|
||||
expect(extractDroppedPathText(
|
||||
createFileLikeDataTransfer({
|
||||
plainText: '/Users/test/Projects',
|
||||
}) as DataTransfer,
|
||||
)).toBe('/Users/test/Projects')
|
||||
})
|
||||
|
||||
it('falls back to file URLs exposed through uri lists', () => {
|
||||
expect(extractDroppedPathText(
|
||||
createFileLikeDataTransfer({
|
||||
uriList: 'file:///Users/test/My%20Folder',
|
||||
}) as DataTransfer,
|
||||
)).toBe('"/Users/test/My Folder"')
|
||||
})
|
||||
|
||||
it('treats missing inputType as a non-insert beforeinput event', () => {
|
||||
expect(() => isInsertBeforeInput({} as InputEvent)).not.toThrow()
|
||||
expect(isInsertBeforeInput({} as InputEvent)).toBe(false)
|
||||
expect(isInsertBeforeInput({ inputType: 'insertFromPaste' } as InputEvent)).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores beforeinput events without inputType instead of crashing', () => {
|
||||
render(<Controlled />)
|
||||
|
||||
const editor = screen.getByTestId('agent-input')
|
||||
const beforeInputEvent = new Event('beforeinput', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
|
||||
expect(() => fireEvent(editor, beforeInputEvent)).not.toThrow()
|
||||
|
||||
updateEditorText('still works')
|
||||
expect(editor.textContent).toContain('still works')
|
||||
})
|
||||
|
||||
it('recovers if unsupported media lands in the editor DOM', async () => {
|
||||
const onUnsupportedPaste = vi.fn()
|
||||
render(<Controlled onUnsupportedPaste={onUnsupportedPaste} />)
|
||||
|
||||
45
src/components/blockNoteCursorTarget.ts
Normal file
45
src/components/blockNoteCursorTarget.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
interface CursorTargetBlockLike {
|
||||
id: string
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
function blockSupportsTextCursor(block: CursorTargetBlockLike | undefined): block is CursorTargetBlockLike {
|
||||
return Array.isArray(block?.content)
|
||||
}
|
||||
|
||||
export function findNearestTextCursorBlock(
|
||||
blocks: CursorTargetBlockLike[],
|
||||
targetIndex: number,
|
||||
): CursorTargetBlockLike | null {
|
||||
if (blocks.length === 0) return null
|
||||
|
||||
const clampedTargetIndex = Math.min(Math.max(targetIndex, 0), blocks.length - 1)
|
||||
const targetBlock = blocks[clampedTargetIndex]
|
||||
if (blockSupportsTextCursor(targetBlock)) {
|
||||
return targetBlock
|
||||
}
|
||||
|
||||
for (let distance = 1; distance < blocks.length; distance += 1) {
|
||||
const forwardBlock = blocks[clampedTargetIndex + distance]
|
||||
if (blockSupportsTextCursor(forwardBlock)) {
|
||||
return forwardBlock
|
||||
}
|
||||
|
||||
const backwardBlock = blocks[clampedTargetIndex - distance]
|
||||
if (blockSupportsTextCursor(backwardBlock)) {
|
||||
return backwardBlock
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function findNearestTextCursorBlockById(
|
||||
blocks: CursorTargetBlockLike[],
|
||||
targetBlockId: string,
|
||||
): CursorTargetBlockLike | null {
|
||||
const targetIndex = blocks.findIndex((block) => block.id === targetBlockId)
|
||||
if (targetIndex === -1) return null
|
||||
|
||||
return findNearestTextCursorBlock(blocks, targetIndex)
|
||||
}
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
interface MockBlock {
|
||||
id: string
|
||||
markdown: string
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
const content = '---\ntitle: Demo\n---\n# Title\n\nParagraph one\n\n## Tail'
|
||||
const blocks: MockBlock[] = [
|
||||
{ id: 'title', markdown: '# Title' },
|
||||
{ id: 'details', markdown: 'Paragraph one' },
|
||||
{ id: 'tail', markdown: '## Tail' },
|
||||
{ id: 'title', markdown: '# Title', content: [] },
|
||||
{ id: 'details', markdown: 'Paragraph one', content: [] },
|
||||
{ id: 'tail', markdown: '## Tail', content: [] },
|
||||
]
|
||||
|
||||
function makeEditor(blocks: MockBlock[]): BlockNotePositionEditor {
|
||||
@@ -153,4 +154,36 @@ describe('editorModePosition', () => {
|
||||
expect(editor.setSelection).toHaveBeenCalledWith('title', 'tail')
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to the nearest content block when raw restore lands on media', () => {
|
||||
const contentWithMedia = '---\ntitle: Demo\n---\n# Title\n\n\n\nParagraph tail'
|
||||
const mediaBlocks: MockBlock[] = [
|
||||
{ id: 'title', markdown: '# Title', content: [] },
|
||||
{ id: 'image', markdown: '' },
|
||||
{ id: 'tail', markdown: 'Paragraph tail', content: [] },
|
||||
]
|
||||
const editor = makeEditor(mediaBlocks)
|
||||
const view: CodeMirrorViewLike = {
|
||||
state: {
|
||||
doc: { toString: () => contentWithMedia },
|
||||
selection: {
|
||||
main: {
|
||||
anchor: contentWithMedia.indexOf('![media]') + 3,
|
||||
head: contentWithMedia.indexOf('![media]') + 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
scrollDOM: { scrollTop: 48 },
|
||||
dispatch: vi.fn(),
|
||||
focus: vi.fn(),
|
||||
}
|
||||
|
||||
installRawView(view)
|
||||
const snapshot = captureRawEditorPositionSnapshot(document)
|
||||
const restored = restoreBlockNoteView(editor, snapshot!, document)
|
||||
|
||||
expect(restored).toBe(true)
|
||||
expect(editor.setTextCursorPosition).toHaveBeenCalledWith('tail', 'end')
|
||||
expect(editor.focus).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { compactMarkdown } from '../utils/compact-markdown'
|
||||
import { restoreWikilinksInBlocks, splitFrontmatter } from '../utils/wikilinks'
|
||||
import { findNearestTextCursorBlockById } from './blockNoteCursorTarget'
|
||||
|
||||
interface BlockLike {
|
||||
id: string
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
interface BlockSelectionLike {
|
||||
@@ -234,9 +236,19 @@ function buildBlockNoteRestoreState(
|
||||
const headIndex = findNearestBlockIndex({ ranges, targetLine: headLine })
|
||||
const startIndex = Math.min(anchorIndex, headIndex)
|
||||
const endIndex = Math.max(anchorIndex, headIndex)
|
||||
const startBlockId = findNearestTextCursorBlockById(
|
||||
editor.document,
|
||||
editor.document[startIndex].id,
|
||||
)?.id
|
||||
const endBlockId = findNearestTextCursorBlockById(
|
||||
editor.document,
|
||||
editor.document[endIndex].id,
|
||||
)?.id
|
||||
if (!startBlockId || !endBlockId) return null
|
||||
|
||||
return {
|
||||
startBlockId: editor.document[startIndex].id,
|
||||
endBlockId: editor.document[endIndex].id,
|
||||
startBlockId,
|
||||
endBlockId,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,10 +349,14 @@ export function restoreBlockNoteView(
|
||||
const state = buildBlockNoteRestoreState(editor, snapshot)
|
||||
if (!state) return false
|
||||
|
||||
if (state.startBlockId === state.endBlockId) {
|
||||
editor.setTextCursorPosition(state.endBlockId, 'end')
|
||||
} else {
|
||||
editor.setSelection(state.startBlockId, state.endBlockId)
|
||||
try {
|
||||
if (state.startBlockId === state.endBlockId) {
|
||||
editor.setTextCursorPosition(state.endBlockId, 'end')
|
||||
} else {
|
||||
editor.setSelection(state.startBlockId, state.endBlockId)
|
||||
}
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
editor.focus()
|
||||
documentObject
|
||||
|
||||
4
src/components/inlineWikilinkBeforeInput.ts
Normal file
4
src/components/inlineWikilinkBeforeInput.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function isInsertBeforeInput(nativeEvent: Pick<InputEvent, 'inputType'>) {
|
||||
return typeof nativeEvent.inputType === 'string'
|
||||
&& nativeEvent.inputType.startsWith('insert')
|
||||
}
|
||||
64
src/components/inlineWikilinkDropText.ts
Normal file
64
src/components/inlineWikilinkDropText.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { normalizeInlineWikilinkValue } from './inlineWikilinkTokens'
|
||||
|
||||
function normalizeDroppedFileUrl(value: string): string {
|
||||
if (!value.startsWith('file://')) return value
|
||||
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
if (parsed.protocol !== 'file:') return value
|
||||
|
||||
const decodedPath = decodeURIComponent(parsed.pathname)
|
||||
if (parsed.hostname) return `//${parsed.hostname}${decodedPath}`
|
||||
if (/^\/[A-Za-z]:/.test(decodedPath)) return decodedPath.slice(1)
|
||||
return decodedPath
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function formatDroppedPath(path: string): string {
|
||||
return /\s/.test(path) ? JSON.stringify(path) : path
|
||||
}
|
||||
|
||||
function normalizeDroppedTransferText(rawText: string): string | null {
|
||||
const trimmedText = normalizeInlineWikilinkValue(rawText).trim()
|
||||
if (!trimmedText) return null
|
||||
|
||||
const lines = trimmedText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
if (lines.length > 0 && lines.every((line) => line.startsWith('file://'))) {
|
||||
return lines.map((line) => formatDroppedPath(normalizeDroppedFileUrl(line))).join(' ')
|
||||
}
|
||||
|
||||
return trimmedText
|
||||
}
|
||||
|
||||
export function extractDroppedPathText(dataTransfer: DataTransfer): string | null {
|
||||
const plainText = normalizeDroppedTransferText(dataTransfer.getData('text/plain'))
|
||||
if (plainText) return plainText
|
||||
|
||||
const uriPaths = dataTransfer.getData('text/uri-list')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith('#'))
|
||||
.map(normalizeDroppedFileUrl)
|
||||
|
||||
if (uriPaths.length > 0) {
|
||||
return uriPaths.map(formatDroppedPath).join(' ')
|
||||
}
|
||||
|
||||
const filePaths = Array.from(dataTransfer.files)
|
||||
.map((file) => (typeof (file as File & { path?: string }).path === 'string'
|
||||
? (file as File & { path?: string }).path!.trim()
|
||||
: ''))
|
||||
.filter(Boolean)
|
||||
|
||||
if (filePaths.length > 0) {
|
||||
return filePaths.map(formatDroppedPath).join(' ')
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -14,13 +14,14 @@ import { useEditorModePositionSync } from './useEditorModePositionSync'
|
||||
interface MockBlock {
|
||||
id: string
|
||||
markdown: string
|
||||
content?: unknown
|
||||
}
|
||||
|
||||
const content = '---\ntitle: Demo\n---\n# Title\n\nParagraph one\n\n## Tail'
|
||||
const blocks: MockBlock[] = [
|
||||
{ id: 'title', markdown: '# Title' },
|
||||
{ id: 'details', markdown: 'Paragraph one' },
|
||||
{ id: 'tail', markdown: '## Tail' },
|
||||
{ id: 'title', markdown: '# Title', content: [] },
|
||||
{ id: 'details', markdown: 'Paragraph one', content: [] },
|
||||
{ id: 'tail', markdown: '## Tail', content: [] },
|
||||
]
|
||||
|
||||
function makeEditor(): BlockNotePositionEditor {
|
||||
@@ -114,7 +115,7 @@ describe('useEditorModePositionSync', () => {
|
||||
expect(focus).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the BlockNote cursor after toggling back from raw mode', () => {
|
||||
it('restores the BlockNote cursor after toggling back from raw mode', async () => {
|
||||
const editor = makeEditor()
|
||||
const paragraphOffset = content.indexOf('Paragraph one') + 5
|
||||
const pendingRawRestoreRef = { current: null as CodeMirrorRestoreState | null }
|
||||
@@ -149,6 +150,9 @@ describe('useEditorModePositionSync', () => {
|
||||
result.current.pendingRichRestoreRef.current = captureRawEditorPositionSnapshot(document)
|
||||
})
|
||||
rerender({ rawMode: false })
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
act(() => {
|
||||
window.dispatchEvent(new CustomEvent('laputa:editor-tab-swapped', {
|
||||
detail: { path: 'note.md' },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { CODEMIRROR_CSP_NONCE, useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
|
||||
import { useCodeMirror, type CodeMirrorCallbacks } from './useCodeMirror'
|
||||
|
||||
const noop = () => {}
|
||||
const noopCallbacks: CodeMirrorCallbacks = {
|
||||
@@ -31,14 +31,6 @@ describe('useCodeMirror', () => {
|
||||
expect(container.querySelector('.cm-editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('mounts CodeMirror generated styles with the configured CSP nonce', () => {
|
||||
const ref = { current: container }
|
||||
renderHook(() => useCodeMirror(ref, 'hello', noopCallbacks))
|
||||
|
||||
const style = document.head.querySelector(`style[nonce="${CODEMIRROR_CSP_NONCE}"]`)
|
||||
expect(style?.textContent).toContain('.cm-scroller')
|
||||
})
|
||||
|
||||
it('calls requestMeasure when laputa-zoom-change event fires', () => {
|
||||
const ref = { current: container }
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -16,8 +16,6 @@ const RAW_EDITOR_COLORS = {
|
||||
gutterBorder: 'var(--border-subtle)',
|
||||
gutterText: 'var(--text-muted)',
|
||||
}
|
||||
export const CODEMIRROR_CSP_NONCE = 'tolaria-codemirror-style'
|
||||
|
||||
export interface CodeMirrorCallbacks {
|
||||
onDocChange: (doc: string) => void
|
||||
onCursorActivity: (view: EditorView) => void
|
||||
@@ -145,7 +143,6 @@ export function useCodeMirror(
|
||||
buildArrowLigaturesExtension(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap]),
|
||||
buildSaveKeymap(callbacksRef),
|
||||
EditorView.cspNonce.of(CODEMIRROR_CSP_NONCE),
|
||||
buildBaseTheme(),
|
||||
markdownLanguage(),
|
||||
frontmatterHighlightTheme(),
|
||||
|
||||
@@ -550,28 +550,21 @@ export function useNoteCreation(config: NoteCreationConfig, tabDeps: CreationTab
|
||||
}
|
||||
}, [openTabWithContent, addEntry, addPendingSave, removePendingSave, config.onNewNotePersisted, removeEntry])
|
||||
|
||||
const creationDeps = {
|
||||
entries,
|
||||
vaultPath,
|
||||
setToastMessage,
|
||||
persistResolvedEntry,
|
||||
}
|
||||
|
||||
const handleCreateNote = useCallback((title: string, type: string): Promise<boolean> =>
|
||||
createNamedNote({ ...creationDeps, title, type, creationPath: 'plus_button' }),
|
||||
[creationDeps])
|
||||
createNamedNote({ entries, vaultPath, setToastMessage, persistResolvedEntry, title, type, creationPath: 'plus_button' }),
|
||||
[entries, vaultPath, setToastMessage, persistResolvedEntry])
|
||||
|
||||
const handleCreateType = useCallback((typeName: string): Promise<boolean> =>
|
||||
createTypeFromName({ ...creationDeps, typeName }),
|
||||
[creationDeps])
|
||||
createTypeFromName({ entries, vaultPath, setToastMessage, persistResolvedEntry, typeName }),
|
||||
[entries, vaultPath, setToastMessage, persistResolvedEntry])
|
||||
|
||||
const createTypeEntrySilent = useCallback((typeName: string): Promise<VaultEntry> =>
|
||||
createTypeSilently({ ...creationDeps, typeName }),
|
||||
[creationDeps])
|
||||
createTypeSilently({ entries, vaultPath, setToastMessage, persistResolvedEntry, typeName }),
|
||||
[entries, vaultPath, setToastMessage, persistResolvedEntry])
|
||||
|
||||
const handleCreateNoteForRelationship = useCallback((title: string): Promise<boolean> =>
|
||||
createNamedNote({ ...creationDeps, title, type: 'Note' }),
|
||||
[creationDeps])
|
||||
createNamedNote({ entries, vaultPath, setToastMessage, persistResolvedEntry, title, type: 'Note' }),
|
||||
[entries, vaultPath, setToastMessage, persistResolvedEntry])
|
||||
|
||||
const handleCreateNoteImmediate = useImmediateCreateQueue({
|
||||
entries,
|
||||
|
||||
@@ -171,7 +171,7 @@ describe('useUpdater', () => {
|
||||
it('returns up-to-date when no update is available', async () => {
|
||||
const { result, outcome } = await performManualCheck('stable', null)
|
||||
|
||||
expect(outcome).toBe('up-to-date')
|
||||
expect(outcome).toEqual({ kind: 'up-to-date' })
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
@@ -181,7 +181,11 @@ describe('useUpdater', () => {
|
||||
makeUpdate({ body: undefined }),
|
||||
)
|
||||
|
||||
expect(outcome).toBe('available')
|
||||
expect(outcome).toEqual({
|
||||
kind: 'available',
|
||||
version: '2026.4.16',
|
||||
displayVersion: '2026.4.16',
|
||||
})
|
||||
expect(result.current.status).toEqual({
|
||||
state: 'available',
|
||||
version: '2026.4.16',
|
||||
@@ -210,7 +214,10 @@ describe('useUpdater', () => {
|
||||
new Error('network error'),
|
||||
)
|
||||
|
||||
expect(outcome).toBe('error')
|
||||
expect(outcome).toEqual({
|
||||
kind: 'error',
|
||||
message: 'Could not check for updates: network error',
|
||||
})
|
||||
expect(console.warn).toHaveBeenCalledWith('[updater] Failed to check for updates')
|
||||
expect(result.current.status).toEqual({ state: 'idle' })
|
||||
})
|
||||
|
||||
@@ -23,7 +23,10 @@ export type UpdateStatus =
|
||||
| ({ state: 'ready' } & UpdateVersionInfo)
|
||||
| { state: 'error' }
|
||||
|
||||
export type UpdateCheckResult = 'up-to-date' | 'available' | 'error'
|
||||
export type UpdateCheckResult =
|
||||
| { kind: 'up-to-date' }
|
||||
| ({ kind: 'available' } & UpdateVersionInfo)
|
||||
| { kind: 'error'; message: string }
|
||||
|
||||
export interface UpdateActions {
|
||||
checkForUpdates: () => Promise<UpdateCheckResult>
|
||||
@@ -57,6 +60,16 @@ function createVersionInfo(version: string): UpdateVersionInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function buildUpdateCheckErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim()) {
|
||||
return `Could not check for updates: ${error.message}`
|
||||
}
|
||||
if (typeof error === 'string' && error.trim()) {
|
||||
return `Could not check for updates: ${error}`
|
||||
}
|
||||
return 'Could not check for updates'
|
||||
}
|
||||
|
||||
function toAvailableStatus(update: AppUpdateMetadata): UpdateStatus {
|
||||
return {
|
||||
state: 'available',
|
||||
@@ -96,22 +109,23 @@ export function useUpdater(
|
||||
const updateRef = useRef<AppUpdateMetadata | null>(null)
|
||||
|
||||
const checkForUpdates = useCallback(async (): Promise<UpdateCheckResult> => {
|
||||
if (!isTauri()) return 'up-to-date'
|
||||
if (!isTauri()) return { kind: 'up-to-date' }
|
||||
|
||||
try {
|
||||
const update = await checkForAppUpdate(releaseChannel)
|
||||
if (!update) {
|
||||
updateRef.current = null
|
||||
setStatus({ state: 'idle' })
|
||||
return 'up-to-date'
|
||||
return { kind: 'up-to-date' }
|
||||
}
|
||||
|
||||
const versionInfo = createVersionInfo(update.version)
|
||||
updateRef.current = update
|
||||
setStatus(toAvailableStatus(update))
|
||||
return 'available'
|
||||
} catch {
|
||||
return { kind: 'available', ...versionInfo }
|
||||
} catch (error) {
|
||||
console.warn('[updater] Failed to check for updates')
|
||||
return 'error'
|
||||
return { kind: 'error', message: buildUpdateCheckErrorMessage(error) }
|
||||
}
|
||||
}, [releaseChannel])
|
||||
|
||||
|
||||
@@ -408,6 +408,7 @@ function useLoadPersistedVaultState(
|
||||
onSwitchRef: MutableRefObject<() => void>,
|
||||
) {
|
||||
const {
|
||||
lastPersistedSnapshotRef,
|
||||
setDefaultAvailable,
|
||||
setDefaultPath,
|
||||
setExtraVaults,
|
||||
@@ -424,7 +425,7 @@ function useLoadPersistedVaultState(
|
||||
.then(({ activeVault, defaultAvailable, hiddenDefaults: hidden, persistedSnapshot, resolvedDefaultPath, vaults }) => {
|
||||
if (cancelled) return
|
||||
|
||||
store.lastPersistedSnapshotRef.current = persistedSnapshot
|
||||
lastPersistedSnapshotRef.current = persistedSnapshot
|
||||
setExtraVaults(vaults)
|
||||
setHiddenDefaults(hidden)
|
||||
applyResolvedDefaultPath({
|
||||
@@ -448,7 +449,7 @@ function useLoadPersistedVaultState(
|
||||
})
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [onSwitchRef, setDefaultAvailable, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
|
||||
}, [lastPersistedSnapshotRef, onSwitchRef, setDefaultAvailable, setDefaultPath, setExtraVaults, setHiddenDefaults, setLoaded, setSelectedVaultPath, setVaultPath])
|
||||
}
|
||||
|
||||
function usePersistedVaultStorage(store: PersistedVaultStore) {
|
||||
|
||||
11
src/utils/tauriCsp.test.ts
Normal file
11
src/utils/tauriCsp.test.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
describe('Tauri Content Security Policy', () => {
|
||||
it('keeps broad inline styles available when runtime libraries inject style tags', () => {
|
||||
const config = JSON.parse(readFileSync(`${process.cwd()}/src-tauri/tauri.conf.json`, 'utf8'))
|
||||
const styleSrc = config.app.security.csp['style-src'] as string
|
||||
|
||||
expect(styleSrc).toContain("'unsafe-inline'")
|
||||
expect(styleSrc).not.toMatch(/'nonce-|sha(256|384|512)-/)
|
||||
})
|
||||
})
|
||||
@@ -35,7 +35,7 @@ test.describe('responsive note deletion', () => {
|
||||
await triggerShortcutCommand(page, APP_COMMAND_IDS.noteDelete)
|
||||
await expect(page.getByTestId('confirm-delete-dialog')).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
await page.getByTestId('confirm-delete-btn').focus()
|
||||
await expect(page.getByTestId('confirm-delete-btn')).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
const progressNotice = page.getByTestId('delete-progress-notice')
|
||||
|
||||
@@ -65,7 +65,6 @@ test.describe('multi-selection shortcuts', () => {
|
||||
await expect(dialog).toBeVisible({ timeout: 5_000 })
|
||||
await expect(dialog).toContainText(/Delete \d+ notes permanently\?/)
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(confirmButton).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user