Compare commits
3 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d16aa29d18 | ||
|
|
6188395141 | ||
|
|
1bf5a7d6e4 |
15
AGENTS.md
15
AGENTS.md
@@ -41,8 +41,8 @@ BASE_URL="http://localhost:5201" npx playwright test tests/smoke/<slug>.spec.ts
|
||||
```bash
|
||||
pnpm tauri dev &
|
||||
sleep 10
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/qa-native.png
|
||||
```
|
||||
|
||||
Use `osascript` for keyboard interactions. Write result as Todoist comment (✅ or ❌). **⚠️ WKWebView:** `osascript keystroke` blocked inside editor — rely on Playwright for text input features.
|
||||
@@ -107,7 +107,10 @@ After any Tauri command, new component/hook, data model change, or new integrati
|
||||
|
||||
### User vault (`~/Laputa/`)
|
||||
|
||||
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing: **never commit changes** — always run `cd ~/Laputa && git checkout -- . && git clean -fd` when done.
|
||||
Default to `demo-vault-v2/`. If you must use `~/Laputa/` for testing:
|
||||
- **Never commit or push** any test notes to the remote vault
|
||||
- **Delete all test notes from disk** when done — do not leave untitled or temporary notes on the filesystem. Run `cd ~/Laputa && git checkout -- . && git clean -fd` to restore the vault to its last committed state.
|
||||
- **Rationale:** test notes pollute the local vault over time, making it a collection of nonsensical untitled files. The vault must stay clean on disk, not just on the remote.
|
||||
|
||||
### UI design
|
||||
|
||||
@@ -145,9 +148,9 @@ Open `ui-design.pen` first (light mode). Create `design/<slug>.pen` for the task
|
||||
### QA scripts
|
||||
|
||||
```bash
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/screenshot.sh /tmp/out.png
|
||||
bash ~/.openclaw/skills/laputa-qa/scripts/shortcut.sh "command" "s"
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/focus-app.sh laputa
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/screenshot.sh /tmp/out.png
|
||||
bash ~/.openclaw/skills/tolaria-qa/scripts/shortcut.sh "command" "s"
|
||||
```
|
||||
|
||||
### Diagrams
|
||||
|
||||
@@ -52,6 +52,7 @@ vi.mock('@blocknote/mantine', () => ({
|
||||
vi.mock('@blocknote/mantine/style.css', () => ({}))
|
||||
|
||||
import { Editor } from './Editor'
|
||||
import { applyPendingRawExitContent } from './editorRawModeSync'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const mockEntry: VaultEntry = {
|
||||
@@ -98,6 +99,13 @@ This is a test note with some words to count.
|
||||
`
|
||||
|
||||
const mockTab = { entry: mockEntry, content: mockContent }
|
||||
const otherEntry: VaultEntry = {
|
||||
...mockEntry,
|
||||
path: '/vault/other.md',
|
||||
filename: 'other.md',
|
||||
title: 'Other Note',
|
||||
}
|
||||
const otherTab = { entry: otherEntry, content: '# Other\n' }
|
||||
|
||||
const defaultProps = {
|
||||
tabs: [] as { entry: VaultEntry; content: string }[],
|
||||
@@ -270,6 +278,27 @@ describe('Editor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyPendingRawExitContent', () => {
|
||||
it('overrides only the matching tab when raw content is newer than tab state', () => {
|
||||
const pending = {
|
||||
path: mockEntry.path,
|
||||
content: '---\ntype: Note\nstatus: Active\n---\n| Head 1 | Head 2 | Head 3 |\n| --- | --- | --- |\n| A | B | C |\n',
|
||||
}
|
||||
|
||||
const result = applyPendingRawExitContent([mockTab, otherTab], pending)
|
||||
|
||||
expect(result[0]).toEqual({ ...mockTab, content: pending.content })
|
||||
expect(result[1]).toBe(otherTab)
|
||||
})
|
||||
|
||||
it('returns the original tabs array when the pending raw content is already synced', () => {
|
||||
const tabs = [mockTab, otherTab]
|
||||
const pending = { path: mockEntry.path, content: mockContent }
|
||||
|
||||
expect(applyPendingRawExitContent(tabs, pending)).toBe(tabs)
|
||||
})
|
||||
})
|
||||
|
||||
describe('click empty editor space', () => {
|
||||
it('focuses editor at end of last block when clicking empty space below content', () => {
|
||||
mockEditor.focus.mockClear()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useEffect, useCallback, memo } from 'react'
|
||||
import { useRef, useEffect, useCallback, useState, memo } from 'react'
|
||||
import { useEditorTabSwap } from '../hooks/useEditorTabSwap'
|
||||
import { useCreateBlockNote } from '@blocknote/react'
|
||||
import '@blocknote/mantine/style.css'
|
||||
@@ -13,6 +13,16 @@ import { useEditorFocus } from '../hooks/useEditorFocus'
|
||||
import { EditorRightPanel } from './EditorRightPanel'
|
||||
import { EditorContent } from './EditorContent'
|
||||
import { schema } from './editorSchema'
|
||||
import { clearTableResizeState } from './tableResizeState'
|
||||
import {
|
||||
type PendingRawExitContent,
|
||||
applyPendingRawExitContent,
|
||||
buildPendingRawExitContent,
|
||||
rememberPendingRawExitContent,
|
||||
resolvePendingRawExitContent,
|
||||
resolveRawModeContent,
|
||||
syncActiveTabIntoRawBuffer,
|
||||
} from './editorRawModeSync'
|
||||
import './Editor.css'
|
||||
import './EditorTheme.css'
|
||||
|
||||
@@ -129,23 +139,45 @@ interface EditorSetupParams {
|
||||
}
|
||||
|
||||
function useRawModeWithFlush(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
activeTabPath: string | null,
|
||||
activeTabContent: string | null,
|
||||
onContentChange?: (path: string, content: string) => void,
|
||||
) {
|
||||
const rawLatestContentRef = useRef<string | null>(null)
|
||||
const [pendingRawExitContent, setPendingRawExitContent] = useState<PendingRawExitContent | null>(null)
|
||||
const [rawModeContentOverride, setRawModeContentOverride] = useState<PendingRawExitContent | null>(null)
|
||||
|
||||
const handleFlushPending = useCallback(async () => {
|
||||
const syncedContent = syncActiveTabIntoRawBuffer({
|
||||
editor,
|
||||
activeTabPath,
|
||||
activeTabContent,
|
||||
rawLatestContentRef,
|
||||
onContentChange,
|
||||
})
|
||||
setRawModeContentOverride(buildPendingRawExitContent(activeTabPath, syncedContent))
|
||||
clearTableResizeState(editor)
|
||||
return true
|
||||
}, [activeTabContent, activeTabPath, editor, onContentChange])
|
||||
|
||||
const handleBeforeRawEnd = useCallback(() => {
|
||||
if (rawLatestContentRef.current != null && activeTabPath) {
|
||||
onContentChange?.(activeTabPath, rawLatestContentRef.current)
|
||||
}
|
||||
setPendingRawExitContent(rememberPendingRawExitContent({
|
||||
activeTabPath,
|
||||
rawLatestContentRef,
|
||||
onContentChange,
|
||||
}))
|
||||
setRawModeContentOverride(null)
|
||||
rawLatestContentRef.current = null
|
||||
}, [activeTabPath, onContentChange])
|
||||
|
||||
const { rawMode, handleToggleRaw } = useRawMode({
|
||||
activeTabPath, onBeforeRawEnd: handleBeforeRawEnd,
|
||||
activeTabPath,
|
||||
onFlushPending: handleFlushPending,
|
||||
onBeforeRawEnd: handleBeforeRawEnd,
|
||||
})
|
||||
|
||||
return { rawMode, handleToggleRaw, rawLatestContentRef }
|
||||
return { rawMode, handleToggleRaw, rawLatestContentRef, pendingRawExitContent, setPendingRawExitContent, rawModeContentOverride }
|
||||
}
|
||||
|
||||
function useEditorSetup({
|
||||
@@ -160,15 +192,33 @@ function useEditorSetup({
|
||||
schema,
|
||||
uploadFile: (file: File) => uploadImageFile(file, vaultPathRef.current),
|
||||
})
|
||||
|
||||
const activeTab = tabs.find((t) => t.entry.path === activeTabPath) ?? null
|
||||
|
||||
const { rawMode, handleToggleRaw, rawLatestContentRef } = useRawModeWithFlush(
|
||||
activeTabPath, onContentChange,
|
||||
const {
|
||||
rawMode,
|
||||
handleToggleRaw,
|
||||
rawLatestContentRef,
|
||||
pendingRawExitContent,
|
||||
setPendingRawExitContent,
|
||||
rawModeContentOverride,
|
||||
} = useRawModeWithFlush(
|
||||
editor,
|
||||
activeTabPath,
|
||||
activeTab?.content ?? null,
|
||||
onContentChange,
|
||||
)
|
||||
const tabsForEditorSwap = applyPendingRawExitContent(tabs, pendingRawExitContent)
|
||||
const rawModeContent = resolveRawModeContent({ activeTab, rawModeContentOverride })
|
||||
|
||||
useEffect(() => {
|
||||
setPendingRawExitContent((current) => resolvePendingRawExitContent({
|
||||
activeTabPath,
|
||||
tabs,
|
||||
pendingRawExitContent: current,
|
||||
}))
|
||||
}, [activeTabPath, setPendingRawExitContent, tabs])
|
||||
|
||||
const { handleEditorChange, editorMountedRef } = useEditorTabSwap({
|
||||
tabs, activeTabPath, editor, onContentChange, rawMode,
|
||||
tabs: tabsForEditorSwap, activeTabPath, editor, onContentChange, rawMode,
|
||||
})
|
||||
useEditorFocus(editor, editorMountedRef)
|
||||
|
||||
@@ -185,7 +235,7 @@ function useEditorSetup({
|
||||
const showDiffToggle = !!(activeTab && (diffMode || activeStatus === 'modified'))
|
||||
|
||||
return {
|
||||
editor, activeTab, rawLatestContentRef,
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
@@ -209,7 +259,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
} = props
|
||||
|
||||
const {
|
||||
editor, activeTab, rawLatestContentRef,
|
||||
editor, activeTab, rawLatestContentRef, rawModeContent,
|
||||
rawMode, diffMode, diffContent, diffLoading,
|
||||
handleToggleDiffExclusive, handleToggleRawExclusive,
|
||||
handleEditorChange, handleViewCommitDiff,
|
||||
@@ -253,6 +303,7 @@ export const Editor = memo(function Editor(props: EditorProps) {
|
||||
onArchiveNote={onArchiveNote}
|
||||
onUnarchiveNote={onUnarchiveNote}
|
||||
vaultPath={vaultPath}
|
||||
rawModeContent={rawModeContent}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
onRenameFilename={onRenameFilename}
|
||||
isConflicted={isConflicted}
|
||||
|
||||
@@ -13,6 +13,72 @@ import type { VaultEntry } from '../types'
|
||||
import { _wikilinkEntriesRef } from './editorSchema'
|
||||
import { useEditorLinkActivation } from './useEditorLinkActivation'
|
||||
|
||||
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
|
||||
| --- | --- | --- |
|
||||
| A | B | C |
|
||||
| D | E | F |
|
||||
`
|
||||
|
||||
type TestTableBlock = {
|
||||
type?: string
|
||||
content?: { type?: string; columnWidths?: Array<number | null> }
|
||||
}
|
||||
|
||||
function applySeededColumnWidths(
|
||||
parsedBlocks: Array<TestTableBlock>,
|
||||
columnWidths?: Array<number | null>,
|
||||
) {
|
||||
const tableBlock = parsedBlocks[0]
|
||||
const tableContent = tableBlock?.content
|
||||
|
||||
if (
|
||||
!columnWidths ||
|
||||
tableBlock?.type !== 'table' ||
|
||||
tableContent?.type !== 'tableContent'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
tableContent.columnWidths = [...columnWidths]
|
||||
}
|
||||
|
||||
async function seedEditorWithTestTable(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
columnWidths?: Array<number | null>,
|
||||
) {
|
||||
const parsedBlocks = await Promise.resolve(
|
||||
editor.tryParseMarkdownToBlocks(TEST_TABLE_MARKDOWN),
|
||||
) as Array<TestTableBlock>
|
||||
|
||||
applySeededColumnWidths(parsedBlocks, columnWidths)
|
||||
|
||||
const tableHtml = editor.blocksToHTMLLossy([
|
||||
...parsedBlocks,
|
||||
{ type: 'paragraph', content: [], children: [] },
|
||||
] as typeof editor.document)
|
||||
editor._tiptapEditor.commands.setContent(tableHtml)
|
||||
editor.focus()
|
||||
}
|
||||
|
||||
function useSeedBlockNoteTableBridge(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
useEffect(() => {
|
||||
const seedBlockNoteTable = (columnWidths?: Array<number | null>) => (
|
||||
seedEditorWithTestTable(editor, columnWidths)
|
||||
)
|
||||
|
||||
window.__laputaTest = {
|
||||
...window.__laputaTest,
|
||||
seedBlockNoteTable,
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (window.__laputaTest?.seedBlockNoteTable === seedBlockNoteTable) {
|
||||
delete window.__laputaTest.seedBlockNoteTable
|
||||
}
|
||||
}
|
||||
}, [editor])
|
||||
}
|
||||
|
||||
/** Insert an image block after the current cursor position. */
|
||||
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
const editorRef = useRef(editor)
|
||||
@@ -54,6 +120,8 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
|
||||
_wikilinkEntriesRef.current = entries
|
||||
}, [entries])
|
||||
|
||||
useSeedBlockNoteTableBridge(editor)
|
||||
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
|
||||
const baseItems = useMemo(
|
||||
|
||||
@@ -62,13 +62,14 @@ function RawModeEditorSection({
|
||||
activeTab,
|
||||
entries,
|
||||
rawMode,
|
||||
rawModeContent,
|
||||
onRawContentChange,
|
||||
onSave,
|
||||
rawLatestContentRef,
|
||||
vaultPath,
|
||||
}: Pick<
|
||||
EditorContentModel,
|
||||
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'vaultPath'
|
||||
'activeTab' | 'entries' | 'onRawContentChange' | 'onSave' | 'rawLatestContentRef' | 'rawModeContent' | 'vaultPath'
|
||||
> & {
|
||||
rawMode: boolean
|
||||
}) {
|
||||
@@ -77,7 +78,7 @@ function RawModeEditorSection({
|
||||
return (
|
||||
<RawEditorView
|
||||
key={activeTab.entry.path}
|
||||
content={activeTab.content}
|
||||
content={rawModeContent ?? activeTab.content}
|
||||
path={activeTab.entry.path}
|
||||
entries={entries}
|
||||
onContentChange={onRawContentChange ?? (() => {})}
|
||||
@@ -226,6 +227,7 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
onEditorChange,
|
||||
isDeletedPreview,
|
||||
rawLatestContentRef,
|
||||
rawModeContent,
|
||||
} = model
|
||||
|
||||
if (!activeTab) {
|
||||
@@ -278,6 +280,7 @@ export function EditorContentLayout(model: EditorContentModel) {
|
||||
activeTab={activeTab}
|
||||
entries={entries}
|
||||
rawMode={effectiveRawMode}
|
||||
rawModeContent={rawModeContent}
|
||||
onRawContentChange={onRawContentChange}
|
||||
onSave={onSave}
|
||||
rawLatestContentRef={rawLatestContentRef}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface EditorContentProps {
|
||||
onArchiveNote?: (path: string) => void
|
||||
onUnarchiveNote?: (path: string) => void
|
||||
vaultPath?: string
|
||||
rawModeContent?: string | null
|
||||
rawLatestContentRef?: React.MutableRefObject<string | null>
|
||||
onRenameFilename?: (path: string, newFilenameStem: string) => void
|
||||
isConflicted?: boolean
|
||||
|
||||
116
src/components/editorRawModeSync.ts
Normal file
116
src/components/editorRawModeSync.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import { splitFrontmatter, restoreWikilinksInBlocks } from '../utils/wikilinks'
|
||||
import { compactMarkdown } from '../utils/compact-markdown'
|
||||
|
||||
interface Tab {
|
||||
entry: VaultEntry
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface PendingRawExitContent {
|
||||
path: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export function buildPendingRawExitContent(
|
||||
path: string | null,
|
||||
content: string | null,
|
||||
): PendingRawExitContent | null {
|
||||
if (!path || content === null) return null
|
||||
return { path, content }
|
||||
}
|
||||
|
||||
export function serializeEditorDocumentToMarkdown(
|
||||
editor: ReturnType<typeof useCreateBlockNote>,
|
||||
tabContent: string,
|
||||
): string {
|
||||
const blocks = editor.document
|
||||
const restored = restoreWikilinksInBlocks(blocks)
|
||||
const bodyMarkdown = compactMarkdown(editor.blocksToMarkdownLossy(restored as typeof blocks))
|
||||
const [frontmatter] = splitFrontmatter(tabContent)
|
||||
return `${frontmatter}${bodyMarkdown}`
|
||||
}
|
||||
|
||||
export function applyPendingRawExitContent(
|
||||
tabs: Tab[],
|
||||
pendingRawExitContent: PendingRawExitContent | null,
|
||||
): Tab[] {
|
||||
if (!pendingRawExitContent) return tabs
|
||||
|
||||
let changed = false
|
||||
const nextTabs = tabs.map((tab) => {
|
||||
if (tab.entry.path !== pendingRawExitContent.path || tab.content === pendingRawExitContent.content) {
|
||||
return tab
|
||||
}
|
||||
|
||||
changed = true
|
||||
return {
|
||||
...tab,
|
||||
content: pendingRawExitContent.content,
|
||||
}
|
||||
})
|
||||
|
||||
return changed ? nextTabs : tabs
|
||||
}
|
||||
|
||||
export function syncActiveTabIntoRawBuffer(options: {
|
||||
editor: ReturnType<typeof useCreateBlockNote>
|
||||
activeTabPath: string | null
|
||||
activeTabContent: string | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
}) {
|
||||
const { editor, activeTabPath, activeTabContent, rawLatestContentRef, onContentChange } = options
|
||||
if (!activeTabPath || activeTabContent === null) return null
|
||||
|
||||
const syncedContent = serializeEditorDocumentToMarkdown(editor, activeTabContent)
|
||||
rawLatestContentRef.current = syncedContent
|
||||
onContentChange?.(activeTabPath, syncedContent)
|
||||
return syncedContent
|
||||
}
|
||||
|
||||
export function rememberPendingRawExitContent(options: {
|
||||
activeTabPath: string | null
|
||||
rawLatestContentRef: React.MutableRefObject<string | null>
|
||||
onContentChange?: (path: string, content: string) => void
|
||||
}) {
|
||||
const { activeTabPath, rawLatestContentRef, onContentChange } = options
|
||||
const pendingRawExitContent = buildPendingRawExitContent(activeTabPath, rawLatestContentRef.current)
|
||||
if (!pendingRawExitContent) return null
|
||||
|
||||
onContentChange?.(pendingRawExitContent.path, pendingRawExitContent.content)
|
||||
return pendingRawExitContent
|
||||
}
|
||||
|
||||
export function resolvePendingRawExitContent(options: {
|
||||
activeTabPath: string | null
|
||||
tabs: Tab[]
|
||||
pendingRawExitContent: PendingRawExitContent | null
|
||||
}) {
|
||||
const { activeTabPath, tabs, pendingRawExitContent } = options
|
||||
if (!pendingRawExitContent) return null
|
||||
|
||||
if (activeTabPath !== pendingRawExitContent.path) {
|
||||
return null
|
||||
}
|
||||
|
||||
const syncedTab = tabs.find((tab) => tab.entry.path === pendingRawExitContent.path)
|
||||
if (syncedTab?.content === pendingRawExitContent.content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return pendingRawExitContent
|
||||
}
|
||||
|
||||
export function resolveRawModeContent(options: {
|
||||
activeTab: Tab | null
|
||||
rawModeContentOverride: PendingRawExitContent | null
|
||||
}) {
|
||||
const { activeTab, rawModeContentOverride } = options
|
||||
if (!activeTab) return null
|
||||
if (rawModeContentOverride?.path === activeTab.entry.path) {
|
||||
return rawModeContentOverride.content
|
||||
}
|
||||
return activeTab.content
|
||||
}
|
||||
22
src/components/tableResizeState.ts
Normal file
22
src/components/tableResizeState.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { useCreateBlockNote } from '@blocknote/react'
|
||||
|
||||
export function clearTableResizeState(editor: ReturnType<typeof useCreateBlockNote>) {
|
||||
const view = editor._tiptapEditor?.view
|
||||
if (!view || view.isDestroyed) return
|
||||
|
||||
const resizePluginKey = view.state.plugins.find((plugin: { key?: unknown }) => (
|
||||
typeof plugin.key === 'string' && plugin.key.startsWith('tableColumnResizing')
|
||||
))?.key
|
||||
if (!resizePluginKey) return
|
||||
|
||||
try {
|
||||
view.dispatch(
|
||||
view.state.tr.setMeta(resizePluginKey, {
|
||||
setHandle: -1,
|
||||
setDragging: null,
|
||||
}),
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('Failed to clear table resize state before raw mode toggle:', error)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { isTauri } from '../mock-tauri'
|
||||
import type { AppCommandShortcutEventInit, AppCommandShortcutEventOptions } from './appCommandCatalog'
|
||||
import {
|
||||
APP_COMMAND_EVENT_NAME,
|
||||
executeAppCommand,
|
||||
@@ -8,18 +7,6 @@ import {
|
||||
type AppCommandHandlers,
|
||||
} from './appCommandDispatcher'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__laputaTest?: {
|
||||
dispatchAppCommand?: (id: string) => void
|
||||
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
|
||||
dispatchBrowserMenuCommand?: (id: string) => void
|
||||
triggerMenuCommand?: (id: string) => Promise<unknown>
|
||||
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface MenuEventHandlers extends AppCommandHandlers {
|
||||
activeTabPath: string | null
|
||||
modifiedCount?: number
|
||||
|
||||
12
src/main.tsx
12
src/main.tsx
@@ -14,18 +14,6 @@ import {
|
||||
type AppCommandShortcutEventOptions,
|
||||
} from './hooks/appCommandCatalog'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__laputaTest?: {
|
||||
dispatchAppCommand?: (id: string) => void
|
||||
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
|
||||
dispatchBrowserMenuCommand?: (id: string) => void
|
||||
triggerMenuCommand?: (id: string) => Promise<unknown>
|
||||
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disable native WebKit context menu in Tauri (WKWebView intercepts right-click
|
||||
// at native level before React's synthetic events can call preventDefault).
|
||||
// Capture phase fires first → prevents native menu; React bubble phase still fires
|
||||
|
||||
21
src/types/laputaTestBridge.ts
Normal file
21
src/types/laputaTestBridge.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type {
|
||||
AppCommandShortcutEventInit,
|
||||
AppCommandShortcutEventOptions,
|
||||
} from '../hooks/appCommandCatalog'
|
||||
|
||||
export interface LaputaTestBridge {
|
||||
dispatchAppCommand?: (id: string) => void
|
||||
dispatchShortcutEvent?: (init: AppCommandShortcutEventInit) => void
|
||||
dispatchBrowserMenuCommand?: (id: string) => void
|
||||
triggerMenuCommand?: (id: string) => Promise<unknown>
|
||||
triggerShortcutCommand?: (id: string, options?: AppCommandShortcutEventOptions) => void
|
||||
seedBlockNoteTable?: (columnWidths?: Array<number | null>) => Promise<void> | void
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__laputaTest?: LaputaTestBridge
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { createFixtureVaultCopy, openFixtureVaultTauri, removeFixtureVaultCopy } from '../helpers/fixtureVault'
|
||||
import { triggerMenuCommand } from './testBridge'
|
||||
import { seedBlockNoteTable, triggerMenuCommand } from './testBridge'
|
||||
|
||||
let tempVaultDir: string
|
||||
|
||||
@@ -24,46 +24,15 @@ function trackUnexpectedErrors(page: Page): string[] {
|
||||
async function createUntitledNote(page: Page): Promise<void> {
|
||||
await triggerMenuCommand(page, 'file-new-note')
|
||||
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 5_000 })
|
||||
await expect.poll(async () => page.evaluate(() => {
|
||||
const active = document.activeElement as HTMLElement | null
|
||||
return Boolean(active?.isContentEditable || active?.closest('[contenteditable="true"]'))
|
||||
}), {
|
||||
timeout: 5_000,
|
||||
}).toBe(true)
|
||||
}
|
||||
|
||||
async function insertTable(page: Page): Promise<void> {
|
||||
const editorSurface = page.locator('.bn-editor')
|
||||
await expect(editorSurface).toBeVisible({ timeout: 5_000 })
|
||||
await editorSurface.click()
|
||||
await page.keyboard.type('/table')
|
||||
|
||||
const tableCommand = page.getByText('Table with editable cells', { exact: true })
|
||||
await expect(tableCommand).toBeVisible({ timeout: 5_000 })
|
||||
await tableCommand.click()
|
||||
|
||||
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
|
||||
}
|
||||
|
||||
async function dragFirstColumn(page: Page, deltaX: number): Promise<void> {
|
||||
const firstCell = page.locator('table td, table th').first()
|
||||
await expect(firstCell).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
const box = await firstCell.boundingBox()
|
||||
if (!box) throw new Error('Could not measure first table cell')
|
||||
|
||||
const handleX = box.x + box.width - 1
|
||||
const handleY = box.y + (box.height / 2)
|
||||
|
||||
await page.mouse.move(handleX, handleY)
|
||||
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(handleX + deltaX, handleY, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
async function seedResizedTable(page: Page): Promise<void> {
|
||||
await seedBlockNoteTable(page, [180, 120, 120])
|
||||
}
|
||||
|
||||
test.describe('table resize crash fix', () => {
|
||||
test.beforeEach((_, testInfo) => {
|
||||
test.beforeEach(({ page }, testInfo) => {
|
||||
void page
|
||||
testInfo.setTimeout(60_000)
|
||||
tempVaultDir = createFixtureVaultCopy()
|
||||
})
|
||||
@@ -72,50 +41,20 @@ test.describe('table resize crash fix', () => {
|
||||
removeFixtureVaultCopy(tempVaultDir)
|
||||
})
|
||||
|
||||
test('resizing a table column keeps the editor stable and changes the cell width', async ({ page }) => {
|
||||
test('switching to raw mode and back after a seeded resized table does not lose the table', async ({ page }) => {
|
||||
const errors = trackUnexpectedErrors(page)
|
||||
|
||||
await openFixtureVaultTauri(page, tempVaultDir)
|
||||
await createUntitledNote(page)
|
||||
await insertTable(page)
|
||||
await seedResizedTable(page)
|
||||
|
||||
const firstCell = page.locator('table td, table th').first()
|
||||
const before = await firstCell.boundingBox()
|
||||
if (!before) throw new Error('Could not measure first table cell before resize')
|
||||
|
||||
await dragFirstColumn(page, 80)
|
||||
|
||||
const after = await firstCell.boundingBox()
|
||||
if (!after) throw new Error('Could not measure first table cell after resize')
|
||||
|
||||
expect(after.width).toBeGreaterThan(before.width)
|
||||
await expect(page.locator('table')).toHaveCount(1)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('unmounting the editor mid-resize does not surface stale ProseMirror view errors', async ({ page }) => {
|
||||
const errors = trackUnexpectedErrors(page)
|
||||
|
||||
await openFixtureVaultTauri(page, tempVaultDir)
|
||||
await createUntitledNote(page)
|
||||
await insertTable(page)
|
||||
|
||||
const firstCell = page.locator('table td, table th').first()
|
||||
await expect(firstCell).toBeVisible({ timeout: 5_000 })
|
||||
|
||||
const box = await firstCell.boundingBox()
|
||||
if (!box) throw new Error('Could not measure first table cell before resize')
|
||||
|
||||
const handleX = box.x + box.width - 1
|
||||
const handleY = box.y + (box.height / 2)
|
||||
|
||||
await page.mouse.move(handleX, handleY)
|
||||
await expect(page.locator('.column-resize-handle')).toHaveCount(2, { timeout: 5_000 })
|
||||
await page.mouse.down()
|
||||
await page.keyboard.press('Meta+Backslash')
|
||||
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).toBeVisible({ timeout: 5_000 })
|
||||
await page.mouse.move(handleX + 80, handleY, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
await expect(page.locator('.cm-content')).toContainText('| Head 1 | Head 2 | Head 3 |')
|
||||
|
||||
await triggerMenuCommand(page, 'edit-toggle-raw-editor')
|
||||
await expect(page.getByTestId('raw-editor-codemirror')).not.toBeVisible({ timeout: 5_000 })
|
||||
await expect(page.locator('table')).toHaveCount(1, { timeout: 5_000 })
|
||||
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
@@ -38,6 +38,19 @@ export async function triggerMenuCommand(page: Page, id: string): Promise<void>
|
||||
}, id)
|
||||
}
|
||||
|
||||
export async function seedBlockNoteTable(
|
||||
page: Page,
|
||||
columnWidths?: Array<number | null>,
|
||||
): Promise<void> {
|
||||
await page.evaluate((widths) => {
|
||||
const bridge = window.__laputaTest?.seedBlockNoteTable
|
||||
if (typeof bridge !== 'function') {
|
||||
throw new Error('Tolaria test bridge is missing seedBlockNoteTable')
|
||||
}
|
||||
return bridge(widths ?? undefined)
|
||||
}, columnWidths)
|
||||
}
|
||||
|
||||
export async function dispatchShortcutEvent(
|
||||
page: Page,
|
||||
init: AppCommandShortcutEventInit,
|
||||
|
||||
Reference in New Issue
Block a user