Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions e2e/pages/EditPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,31 @@ export default class EditPage {
await this.page.keyboard.type(content);
}

async openReplacePanel() {
const editor = this.page.locator('.cm-editor');
await editor.click();
await this.page.keyboard.press('Control+h');
await this.page.locator('.cm-search input[main-field="true"]').waitFor({ state: 'visible' });
}

async replaceAll(search: string, replace: string) {
const searchInput = this.page.locator('.cm-search input[main-field="true"]');
const replaceInput = this.page.locator('.cm-search input[name="replace"]');

await searchInput.fill(search);
await replaceInput.fill(replace);
await this.page.locator('.cm-search button[name="replaceAll"]').click();
}

async closeSearchPanelWithEscape() {
await this.page.keyboard.press('Escape');
await this.page.locator('.cm-search').waitFor({ state: 'hidden' });
}

async expectEditorStillOpen() {
await this.page.locator('.cm-editor').waitFor({ state: 'visible' });
}

async savePage() {
const saveButton = this.page.locator('button[data-testid="save-page-button"]');
await saveButton.waitFor({ state: 'visible' });
Expand Down
34 changes: 29 additions & 5 deletions e2e/pages/ViewPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,32 @@ import { expect } from '@playwright/test';
export default class ViewPage {
constructor(private page: Page) {}

private async activateControl(locator: ReturnType<Page['locator']>) {
await locator.waitFor({ state: 'visible' });

try {
await locator.click({ timeout: 2000 });
return;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const pointerIntercepted =
message.includes('intercepts pointer events') ||
message.includes('element is not visible') ||
message.includes('detached from the DOM');

if (!pointerIntercepted) {
throw error;
}
}

await locator.evaluate((element) => {
if (!(element instanceof HTMLElement)) {
throw new Error('Expected HTMLElement');
}
element.click();
});
}

async goto(pagePath: string = '/') {
await this.page.goto(toAppPath(pagePath));
await this.page.locator('article').waitFor({ state: 'visible' });
Expand Down Expand Up @@ -138,8 +164,7 @@ export default class ViewPage {

async openToolbarOverflow() {
const overflowButton = this.page.getByTestId('toolbar-overflow-button');
await overflowButton.waitFor({ state: 'visible' });
await overflowButton.click();
await this.activateControl(overflowButton);
}

async clickDeletePageMenuItem() {
Expand Down Expand Up @@ -169,14 +194,13 @@ export default class ViewPage {
.toBe(true);

if (await historyButton.isVisible().catch(() => false)) {
await historyButton.click();
await this.activateControl(historyButton);
return;
}

await this.openToolbarOverflow();
const historyMenuItem = this.page.getByTestId('page-history-menu-item');
await historyMenuItem.waitFor({ state: 'visible' });
await historyMenuItem.click();
await this.activateControl(historyMenuItem);
}

async openCurrentPageHistory() {
Expand Down
59 changes: 59 additions & 0 deletions e2e/tests/page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,57 @@ async function expectMarkdownLinkAutocompleteWorks(page: import('@playwright/tes
await welcomeLink.getByText('Welcome').waitFor({ state: 'visible' });
}

async function expectSearchAndReplaceWorks(page: import('@playwright/test').Page) {
const timestamp = Date.now();
const slug = `search-replace-${timestamp}`;
const title = `Search Replace ${timestamp}`;
const originalContent = 'Alpha paragraph\n\nAlpha list item\n\nAlpha closing line';

await createPageWithContent(page, {
title,
slug,
content: originalContent,
});

const viewPage = new ViewPage(page);
await viewPage.goto(`/${slug}`);
await viewPage.clickEditPageButton();

const editPage = new EditPage(page);
await editPage.openReplacePanel();
await editPage.replaceAll('Alpha', 'Beta');
await editPage.savePage();
await editPage.closeEditor();

const content = await viewPage.getContent();
test.expect(content).toContain('Beta paragraph');
test.expect(content).toContain('Beta list item');
test.expect(content).toContain('Beta closing line');
test.expect(content).not.toContain('Alpha');
}

async function expectEscapeClosesSearchPanelButNotEditor(page: import('@playwright/test').Page) {
const timestamp = Date.now();
const slug = `search-escape-${timestamp}`;
const title = `Search Escape ${timestamp}`;

await createPageWithContent(page, {
title,
slug,
content: 'Escape should close only the search panel.',
});

const viewPage = new ViewPage(page);
await viewPage.goto(`/${slug}`);
await viewPage.clickEditPageButton();

const editPage = new EditPage(page);
await editPage.openReplacePanel();
await editPage.closeSearchPanelWithEscape();
await editPage.expectEditorStillOpen();
await viewPage.goto(`/${slug}`);
}

async function expectOpenedPageMarkedInNavigationDuringEditMode(
page: import('@playwright/test').Page,
) {
Expand Down Expand Up @@ -678,6 +729,14 @@ for the page edited at ${new Date().toISOString()}
await expectMarkdownLinkAutocompleteWorks(page);
});

test('search and replace works in markdown editor', async ({ page }) => {
await expectSearchAndReplaceWorks(page);
});

test('escape closes search panel but keeps editor open', async ({ page }) => {
await expectEscapeClosesSearchPanelButNotEditor(page);
});

test('headline anchor keeps classic hash navigation for plain headings', async ({ page }) => {
const timestamp = Date.now();
const slug = `headline-anchor-${timestamp}`;
Expand Down
12 changes: 12 additions & 0 deletions ui/leafwiki-ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ui/leafwiki-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.5.4",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.41.0",
Expand Down
19 changes: 16 additions & 3 deletions ui/leafwiki-ui/src/components/HotKeyHandler.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,26 @@ export function HotKeyHandler() {

const onKeyDown = useCallback(
(e: KeyboardEvent) => {
// Skip events already handled by CodeMirror (e.g. search panel closing),
// but not in dialog mode: BaseDialog deliberately calls e.preventDefault()
// in onEscapeKeyDown to prevent Radix from self-closing the dialog, while
// still relying on HotKeyHandler to dispatch the registered cancelHotkey.
if (e.defaultPrevented && currentMode !== 'dialog') {
return
}

const target = e.target instanceof HTMLElement ? e.target : null
if (target?.closest('.cm-search')) {
return
}

const comboString = getHotkeyComboFromEvent(e)

// Always allow Escape
// Escape bypasses the button/textarea guard below so it always reaches
// the registered hotkey (e.g. closing a dialog or the editor).
if (comboString !== 'Escape') {
// if the focus in on an button or texarea, we don't trigger hotkeys
// if the focus is on a button or textarea, we don't trigger hotkeys;
// this allows normal typing and button interactions
// On input fields, we allow hotkeys to function normally
const activeElement = document.activeElement
if (
activeElement &&
Expand Down
119 changes: 119 additions & 0 deletions ui/leafwiki-ui/src/features/editor/MarkdownCodeEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ import {
indentWithTab,
} from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { openSearchPanel, search, searchKeymap } from '@codemirror/search'
import { Compartment, EditorState } from '@codemirror/state'
import { oneDark } from '@codemirror/theme-one-dark'
import { EditorView, keymap } from '@codemirror/view'
import { githubLight } from '@fsegurai/codemirror-theme-github-light'
import { useEffect, useRef, useState } from 'react'
import { useDesignModeStore } from '../designtoggle/designmode'
import { insertHeadingAtStart, insertWrappedText } from './editorCommands'
import type { InternalLinkCompletion } from './internalLinkCompletion'
import { internalLinkCompletionSource } from './internalLinkCompletion'

Expand All @@ -29,6 +31,22 @@ type MarkdownCodeEditorProps = {
// CodeMirror uses 80 for the built-in detail slot, so render the path just before it.
const COMPLETION_PATH_POSITION_BEFORE_DETAIL = 79

function openReplacePanel(view: EditorView) {
openSearchPanel(view)

requestAnimationFrame(() => {
if (!view.dom.isConnected) return
const replaceField = view.dom.querySelector(
'.cm-search input[name="replace"]',
) as HTMLInputElement | null

replaceField?.focus()
replaceField?.select()
})

return true
}

export default function MarkdownCodeEditor({
initialValue,
editorViewRef,
Expand Down Expand Up @@ -67,6 +85,51 @@ export default function MarkdownCodeEditor({
})

const customShortcuts = [
{
key: 'Mod-h',
run: openReplacePanel,
preventDefault: true,
},
{
key: 'Mod-b',
run: (view: EditorView) => {
insertWrappedText(view, '**', '**')
return true
},
preventDefault: true,
},
{
key: 'Mod-i',
run: (view: EditorView) => {
insertWrappedText(view, '_', '_')
return true
},
preventDefault: true,
},
{
key: 'Mod-Alt-1',
run: (view: EditorView) => {
insertHeadingAtStart(view, 1)
return true
},
preventDefault: true,
},
{
key: 'Mod-Alt-2',
run: (view: EditorView) => {
insertHeadingAtStart(view, 2)
return true
},
preventDefault: true,
},
{
key: 'Mod-Alt-3',
run: (view: EditorView) => {
insertHeadingAtStart(view, 3)
return true
},
preventDefault: true,
},
{
key: 'Escape',
run: (view: EditorView) => {
Expand All @@ -85,6 +148,9 @@ export default function MarkdownCodeEditor({
extensions: [
themeCompartment.of(designMode === 'light' ? githubLight : oneDark),
markdown(),
search({
top: true,
}),
autocompletion({
override: [internalLinkCompletionSource],
icons: false,
Expand All @@ -105,6 +171,7 @@ export default function MarkdownCodeEditor({
history(),
keymap.of([
...customShortcuts,
...searchKeymap,
indentWithTab,
...historyKeymap,
...defaultKeymap,
Expand Down Expand Up @@ -133,6 +200,58 @@ export default function MarkdownCodeEditor({
'.cm-gutters': {
lineHeight: '1.5',
},
'.cm-panels': {
backgroundColor: 'hsl(var(--surface))',
color: 'hsl(var(--interface-text))',
},
'.cm-panel.cm-search': {
borderBottom: '1px solid hsl(var(--surface-border))',
padding: '10px 12px 8px',
gap: '4px',
},
'.cm-panel.cm-search [name="close"]': {
color: 'hsl(var(--muted-foreground))',
cursor: 'pointer',
},
'.cm-panel.cm-search label': {
display: 'inline-flex',
alignItems: 'center',
gap: '6px',
fontSize: '12px',
},
'.cm-panel.cm-search input.cm-textfield': {
border: '1px solid hsl(var(--surface-border))',
borderRadius: '6px',
backgroundColor: 'hsl(var(--surface-alt))',
color: 'hsl(var(--interface-text))',
padding: '6px 8px',
minWidth: '140px',
},
'.cm-panel.cm-search input.cm-textfield:focus': {
outline: '2px solid hsl(var(--ring))',
outlineOffset: '1px',
},
'.cm-panel.cm-search button.cm-button': {
border: '1px solid hsl(var(--surface-border))',
borderRadius: '6px',
backgroundColor: 'hsl(var(--surface-alt))',
color: 'hsl(var(--interface-text))',
padding: '6px 10px',
cursor: 'pointer',
},
'.cm-panel.cm-search button.cm-button:hover': {
backgroundColor: 'hsl(var(--accent))',
},
'.cm-panel.cm-search button.cm-button:disabled': {
cursor: 'not-allowed',
opacity: '0.6',
},
'.cm-searchMatch': {
backgroundColor: 'hsl(var(--warning) / 0.22)',
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: 'hsl(var(--primary) / 0.28)',
},
'&.cm-focused': {
outline: 'none',
},
Expand Down
Loading
Loading