diff --git a/client/codemirror/editor_state.ts b/client/codemirror/editor_state.ts index b7bd4e15..31878355 100644 --- a/client/codemirror/editor_state.ts +++ b/client/codemirror/editor_state.ts @@ -357,7 +357,6 @@ export function isValidEditor( export function createCommandKeyBindings( client: Client, - options: { miniEditor?: boolean } = {}, ): Extension { const commandKeyBindings: KeyBinding[] = []; const vimMode = client.ui.viewState.uiOptions.vimMode; @@ -373,12 +372,6 @@ export function createCommandKeyBindings( continue; } - // Mini editors (filter box / page picker) are not page editors, so - // commands that target the page document should not capture keys there. - if (options.miniEditor && requiredEditor === "page") { - continue; - } - if ((def.key || def.mac) && isValidEditor(currentEditor, requiredEditor)) { const run = (): boolean => { if (def.contexts) { diff --git a/client/components/anything_picker.tsx b/client/components/anything_picker.tsx index f7095e72..b74e9ee4 100644 --- a/client/components/anything_picker.tsx +++ b/client/components/anything_picker.tsx @@ -181,8 +181,8 @@ export function AnythingPicker({ phrase = phrase.replaceAll(tagRegex, "").trim(); return phrase; }} - onKeyPress={(view, event) => { - const text = view.state.sliceDoc(); + onKeyPress={(value, event) => { + const text = value; // Pages cannot start with ^, as documented in Page Name Rules if (event.key === "^" && text === "^") { switch (mode) { diff --git a/client/components/basic_modals.tsx b/client/components/basic_modals.tsx index 9e871f66..360b1240 100644 --- a/client/components/basic_modals.tsx +++ b/client/components/basic_modals.tsx @@ -1,12 +1,10 @@ import { useEffect, useRef, useState } from "preact/hooks"; -import { MiniEditor } from "./mini_editor.tsx"; import type { ComponentChildren } from "preact"; -import { Button } from "@silverbulletmd/silverbullet/ui"; +import { Button, Input } from "@silverbulletmd/silverbullet/ui"; export function Prompt({ message, defaultValue, - darkMode, callback, }: { message: string; @@ -15,6 +13,15 @@ export function Prompt({ callback: (value?: string) => void; }) { const [text, setText] = useState(defaultValue || ""); + const inputRef = useRef(null); + useEffect(() => { + const input = inputRef.current; + if (input) { + input.focus(); + const end = input.value.length; + input.setSelectionRange(end, end); // caret at end of default value + } + }, []); const returnEl = ( { @@ -23,21 +30,13 @@ export function Prompt({ >
- { - callback(text); - return true; - }} - onEscape={() => { - callback(); - }} - onChange={(text) => { - setText(text); - }} - editable={true} + setText(e.currentTarget.value)} + onConfirm={(value) => callback(value)} + onExit={() => callback()} />
void; - onEscape?: (newText: string) => void; - onBlur?: (newText: string) => void | Promise; - onChange?: (newText: string) => void; - onKeyUp?: (view: EditorView, event: KeyboardEvent) => boolean; - onKeyDown?: (view: EditorView, event: KeyboardEvent) => boolean; -}; - -export function MiniEditor({ - text, - placeholderText, - darkMode, - onBlur, - onEscape, - onKeyUp, - onKeyDown, - onEnter, - onChange, - focus, - editable, -}: { - text: string; - placeholderText?: string; - darkMode?: boolean; - focus?: boolean; - editable: boolean; -} & MiniEditorEvents) { - const editorDiv = useRef(null); - const editorViewRef = useRef(); - // TODO: This super duper ugly, but I don't know how to avoid it - // Due to how MiniCodeEditor is built, it captures the closures of all callback functions - // which results in them pointing to old state variables, to avoid this we do this... - const callbacksRef = useRef(); - - useEffect(() => { - const currentEditorDiv = editorDiv.current; - if (currentEditorDiv) { - // console.log("Creating editor view"); - const editorView = new EditorView({ - state: buildEditorState(), - parent: currentEditorDiv, - }); - editorViewRef.current = editorView; - - const focusEditorView = editorView.focus.bind(editorView); - currentEditorDiv.addEventListener("focusin", focusEditorView); - - if (focus) { - editorView.focus(); - // Put cursor at the very end - editorView.dispatch({ - selection: { anchor: text.length }, - }); - } - - return () => { - currentEditorDiv.removeEventListener("focusin", focusEditorView); - if (editorViewRef.current) { - editorViewRef.current.destroy(); - } - }; - } - }, [editorDiv, placeholderText]); - - useEffect(() => { - callbacksRef.current = { - onBlur, - onEnter, - onEscape, - onKeyUp, - onKeyDown, - onChange, - }; - }); - - useEffect(() => { - if (editorViewRef.current) { - const currentEditorText = editorViewRef.current.state.sliceDoc(); - if (currentEditorText !== text) { - editorViewRef.current.setState(buildEditorState()); - editorViewRef.current.dispatch({ - selection: { anchor: text.length }, - }); - } - } - }, [text]); - - let onBlurred = false, - onEntered = false; - - return ( -
{ - let stopPropagation = false; - if (callbacksRef.current!.onKeyDown) { - stopPropagation = callbacksRef.current!.onKeyDown( - editorViewRef.current!, - e, - ); - } - if (stopPropagation) { - e.preventDefault(); - e.stopPropagation(); - } - }} - ref={editorDiv} - /> - ); - - function buildEditorState() { - return EditorState.create({ - doc: text, - extensions: [ - EditorView.theme({}, { dark: darkMode }), - createCommandKeyBindings(globalThis.client, { miniEditor: true }), - [ - ...(editable - ? [] - : [EditorView.editable.of(false), EditorState.readOnly.of(true)]), - ], - history(), - [...(placeholderText ? [placeholder(placeholderText)] : [])], - keymap.of([ - { - key: "Enter", - run: (view) => { - onEnterHandler(view, false); - return true; - }, - }, - { - key: "Shift-Enter", - run: (view) => { - onEnterHandler(view, true); - return true; - }, - }, - { - key: "Escape", - run: (view) => { - callbacksRef.current!.onEscape?.(view.state.sliceDoc()); - return true; - }, - }, - ...standardKeymap, - ...historyKeymap, - ]), - EditorView.domEventHandlers({ - click: (e) => { - e.stopPropagation(); - }, - keyup: (event, view) => { - if (event.key === "Escape") { - // Esc should be handled by the keymap - return false; - } - if (event.key === "Enter") { - // Enter should be handled by the keymap - return false; - } - if (callbacksRef.current!.onKeyUp) { - return callbacksRef.current!.onKeyUp(view, event); - } - return false; - }, - blur: (_e, view) => { - onBlurHandler(view); - }, - }), - - ViewPlugin.fromClass( - class { - update(update: ViewUpdate): void { - if (update.docChanged) { - callbacksRef.current!.onChange?.(update.state.sliceDoc()); - } - } - }, - ), - ], - }); - - // Avoid double triggering these events (may happen due to onkeypress vs onkeyup delay) - function onEnterHandler(view: EditorView, shiftDown: boolean) { - if (onEntered) { - return; - } - onEntered = true; - callbacksRef.current!.onEnter(view.state.sliceDoc(), shiftDown); - // Event may occur again in 500ms - setTimeout(() => { - onEntered = false; - }, 500); - } - - function onBlurHandler(view: EditorView) { - if (onBlurred || onEntered) { - return; - } - onBlurred = true; - if (callbacksRef.current!.onBlur) { - Promise.resolve( - callbacksRef.current!.onBlur(view.state.sliceDoc()), - ).catch(() => { - // Reset the state - view.setState(buildEditorState()); - }); - } - // Event may occur again in 500ms - setTimeout(() => { - onBlurred = false; - }, 500); - } - } -} diff --git a/client/components/top_bar.tsx b/client/components/top_bar.tsx index c8766d2f..99703b59 100644 --- a/client/components/top_bar.tsx +++ b/client/components/top_bar.tsx @@ -1,7 +1,8 @@ import type { ComponentChildren, FunctionalComponent } from "preact"; import { createPortal } from "preact/compat"; +import { useEffect, useRef, useState } from "preact/hooks"; import type { Notification } from "@silverbulletmd/silverbullet/type/client"; -import { MiniEditor } from "./mini_editor.tsx"; +import { Input } from "@silverbulletmd/silverbullet/ui"; export type ActionButton = { icon: FunctionalComponent; @@ -146,6 +147,51 @@ function ActionButtons({ ); } +function PageNameEditor({ + pageName, + readOnly, + onRename, +}: { + pageName?: string; + readOnly: boolean; + onRename: (newName?: string) => Promise; +}) { + const [name, setName] = useState(pageName ?? ""); + // Guards against the blur that fires when a successful rename refocuses the + // editor, which would otherwise trigger a second (same-name) commit. + const committing = useRef(false); + // Re-sync when navigating to a different page. + useEffect(() => setName(pageName ?? ""), [pageName]); + + const commit = (newName: string) => { + if (committing.current) { + return; + } + if (newName !== pageName) { + committing.current = true; + // On failure, restore the previous name + Promise.resolve(onRename(newName)) + .catch(() => setName(pageName ?? "")) + .finally(() => { + committing.current = false; + }); + } else { + void onRename(); + } + }; + + return ( + setName(e.currentTarget.value)} + onConfirm={(value) => commit(value)} + onBlur={(e) => commit(e.currentTarget.value)} + /> + ); +} + export function TopBar({ pageName, unsavedChanges, @@ -155,7 +201,6 @@ export function TopBar({ onRename, onDismissNotification, actionButtons, - darkMode, progressPercentage, progressType, lhs, @@ -170,7 +215,6 @@ export function TopBar({ isOnline: boolean; isLoading: boolean; notifications: Notification[]; - darkMode?: boolean; progressPercentage?: number; progressType?: string; onRename: (newName?: string) => Promise; @@ -197,20 +241,10 @@ export function TopBar({ id="sb-current-page" className={pageNameClass(isLoading, unsavedChanges, cssClass)} > - { - if (newName !== pageName) { - return onRename(newName); - } else { - return onRename(); - } - }} - onEnter={(newName) => { - void onRename(newName); - }} - editable={!readOnly} + { diff --git a/client/styles/colors.scss b/client/styles/colors.scss index a1bb3837..82d6a5ab 100644 --- a/client/styles/colors.scss +++ b/client/styles/colors.scss @@ -104,10 +104,6 @@ border: var(--modal-border-color) 1px solid; box-shadow: rgba(0, 0, 0, 0.35) 0px 20px 20px; - .cm-scroller { - font-family: var(--ui-font); - } - .sb-header { border-bottom: 1px var(--modal-border-color) solid; @@ -115,7 +111,7 @@ color: var(--modal-header-label-color); } - .sb-mini-editor { + .sb-input { font-family: var(--ui-font); } } diff --git a/client/styles/modals.scss b/client/styles/modals.scss index 30ea1c0e..0a600e90 100644 --- a/client/styles/modals.scss +++ b/client/styles/modals.scss @@ -18,21 +18,6 @@ border-radius: 0; } - .cm-content { - padding: 0; - - .cm-line { - padding: 2px 0 0 3px; - } - } - - // Fix Safari layout bug: CodeMirror's .cm-scroller uses height: 100% which - // creates a circular height dependency inside flex , causing extra - // whitespace. Setting height: auto breaks the cycle. - .cm-scroller { - height: auto; - } - .sb-header { padding: 13px 10px 10px 10px; display: flex; @@ -41,9 +26,16 @@ margin: 3px; } - .sb-mini-editor { + .sb-input { flex-grow: 1; min-width: 0; + background-color: transparent; + padding: 0; + border-radius: 0; + + &:focus { + box-shadow: none; + } } } @@ -54,13 +46,14 @@ font-weight: bold; } - .sb-mini-editor { - background-color: var(--text-field-background-color); + .sb-input { + width: 100%; + box-sizing: border-box; margin: 10px 0; - padding: 0.2em 0.5em; - border: 0; - border-radius: 0.5em; - outline: none; + // Give the field roomier, balanced horizontal padding (the base + // `.sb-input` only sets 0.6em, which reads as cramped next to the + // vertical breathing room in the dialog). + padding: 0.4em 0.8em; } .sb-prompt-buttons { diff --git a/client/styles/top.scss b/client/styles/top.scss index 77f5090e..aaf015c6 100644 --- a/client/styles/top.scss +++ b/client/styles/top.scss @@ -40,17 +40,25 @@ -webkit-app-region: drag; - .cm-scroller { + .sb-input { font-family: var(--ui-font); - overflow-x: hidden; - } - - .cm-content { + font-size: inherit; + // Match the line box the old CodeMirror title used so the text stays + // vertically centred in the bar (a native input defaults to a shorter + // `line-height: normal`, which pulls the title upward). + line-height: 1.4; + color: inherit; + background-color: transparent; padding: 0; + width: 100%; + border: 0; + border-radius: 0; + outline: none; + overflow-x: hidden; + caret-color: var(--editor-caret-color); - .cm-line { - padding: 0; - caret-color: var(--editor-caret-color); + &:focus { + box-shadow: none; } } } diff --git a/e2e/command-palette.test.ts b/e2e/command-palette.test.ts index 3383897d..71d5ad6d 100644 --- a/e2e/command-palette.test.ts +++ b/e2e/command-palette.test.ts @@ -25,7 +25,7 @@ test.describe("Command palette", () => { await expect(modal).toBeVisible(); // Type to filter - const paletteInput = modal.locator(".cm-content"); + const paletteInput = modal.locator("input.sb-input"); await paletteInput.click(); await sbPage.keyboard.type("Stats", { delay: 30 }); @@ -42,7 +42,7 @@ test.describe("Command palette", () => { // Open command palette and run "Stats: Show" await sbPage.keyboard.press(`${mod}+/`); const modal = sbPage.locator(".sb-modal-box"); - const paletteInput = modal.locator(".cm-content"); + const paletteInput = modal.locator("input.sb-input"); await paletteInput.click(); await sbPage.keyboard.type("Stats: Show", { delay: 30 }); diff --git a/e2e/editor-formatting.test.ts b/e2e/editor-formatting.test.ts index a156f8cb..1e71c3a2 100644 --- a/e2e/editor-formatting.test.ts +++ b/e2e/editor-formatting.test.ts @@ -7,7 +7,7 @@ test.describe("Editor formatting", () => { // Navigate to a fresh page await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box .cm-content").click(); + await sbPage.locator(".sb-modal-box input.sb-input").click(); await sbPage.keyboard.type("Formatting Test", { delay: 30 }); await sbPage.keyboard.press("Shift+Enter"); await expect(editor).toHaveText(""); @@ -35,7 +35,7 @@ test.describe("Editor formatting", () => { await expect(editor).toContainText("Welcome"); await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box .cm-content").click(); + await sbPage.locator(".sb-modal-box input.sb-input").click(); await sbPage.keyboard.type("Italic Test", { delay: 30 }); await sbPage.keyboard.press("Shift+Enter"); await expect(editor).toHaveText(""); @@ -56,7 +56,7 @@ test.describe("Editor formatting", () => { await expect(editor).toContainText("Welcome"); await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box .cm-content").click(); + await sbPage.locator(".sb-modal-box input.sb-input").click(); await sbPage.keyboard.type("List Test", { delay: 30 }); await sbPage.keyboard.press("Shift+Enter"); await expect(editor).toHaveText(""); diff --git a/e2e/guide-journaling.test.ts b/e2e/guide-journaling.test.ts index 90f7485b..4d9a7cb0 100644 --- a/e2e/guide-journaling.test.ts +++ b/e2e/guide-journaling.test.ts @@ -27,7 +27,7 @@ async function runJournalToday(sbPage: Page): Promise { const modal = sbPage.locator(".sb-modal-box"); await expect(modal).toBeVisible(); - const paletteInput = modal.locator(".cm-content"); + const paletteInput = modal.locator("input.sb-input"); await paletteInput.click(); await sbPage.keyboard.type("Journal: Today", { delay: 30 }); @@ -40,7 +40,7 @@ async function runJournalToday(sbPage: Page): Promise { await expect(modal).not.toBeVisible(); const expectedPage = `Journal/${today()}`; - await expect(sbPage.locator("#sb-current-page")).toContainText(expectedPage); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue(expectedPage); } test.describe("Guide: Journaling", () => { @@ -100,7 +100,7 @@ test.describe("Guide: Journaling", () => { await expect(wikiLinkText).toBeVisible({ timeout: 10_000 }); await wikiLinkText.click(); - await expect(sbPage.locator("#sb-current-page")).toContainText("Alice"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Alice"); // Alice's page should show a Linked Mentions section with the journal // entry we just wrote. The widget is rendered by a built-in script. diff --git a/e2e/guide-knowledge-base.test.ts b/e2e/guide-knowledge-base.test.ts index 3ca039cf..14b05a0b 100644 --- a/e2e/guide-knowledge-base.test.ts +++ b/e2e/guide-knowledge-base.test.ts @@ -51,7 +51,7 @@ test.describe("Guide: Knowledge Base", () => { await expect(ownershipLink).toBeVisible({ timeout: 10_000 }); await ownershipLink.click(); - await expect(sbPage.locator("#sb-current-page")).toContainText("Ownership"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Ownership"); await expect(editor).toContainText("compile-time memory safety"); }); @@ -74,7 +74,7 @@ test.describe("Guide: Knowledge Base", () => { await sbPage.keyboard.press(`${mod}+k`); const modal = sbPage.locator(".sb-modal-box"); await expect(modal).toBeVisible(); - const pickerInput = modal.locator(".cm-content"); + const pickerInput = modal.locator("input.sb-input"); await pickerInput.click(); await sbPage.keyboard.type("Atomic Notes", { delay: 30 }); await sbPage.keyboard.press("Shift+Enter"); diff --git a/e2e/page-navigation.test.ts b/e2e/page-navigation.test.ts index 93b83f6c..a1315f8d 100644 --- a/e2e/page-navigation.test.ts +++ b/e2e/page-navigation.test.ts @@ -10,7 +10,7 @@ test.describe("Page navigation", () => { await expect(sbPage.locator(".sb-modal-box")).toBeVisible(); // Type the new page name (with delay to avoid dropped keystrokes) - const pickerInput = sbPage.locator(".sb-modal-box .cm-content"); + const pickerInput = sbPage.locator(".sb-modal-box input.sb-input"); await pickerInput.click(); await sbPage.keyboard.type("My New Page", { delay: 30 }); @@ -24,7 +24,7 @@ test.describe("Page navigation", () => { await expect(sbPage.locator(".sb-modal-box")).not.toBeVisible(); // Page name should update in top bar - await expect(sbPage.locator("#sb-current-page")).toContainText("My New Page"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("My New Page"); // Editor should be empty (new page) await expect(editor).toHaveText(""); @@ -44,22 +44,22 @@ test.describe("Page navigation", () => { // First, create and navigate to a new page await sbPage.keyboard.press(`${mod}+k`); - const pickerInput = sbPage.locator(".sb-modal-box .cm-content"); + const pickerInput = sbPage.locator(".sb-modal-box input.sb-input"); await pickerInput.click(); await sbPage.keyboard.type("Temporary Page", { delay: 30 }); await sbPage.keyboard.press("Shift+Enter"); - await expect(sbPage.locator("#sb-current-page")).toContainText("Temporary Page"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Temporary Page"); // Now navigate back to index await sbPage.keyboard.press(`${mod}+k`); await expect(sbPage.locator(".sb-modal-box")).toBeVisible(); - const pickerInput2 = sbPage.locator(".sb-modal-box .cm-content"); + const pickerInput2 = sbPage.locator(".sb-modal-box input.sb-input"); await pickerInput2.click(); await sbPage.keyboard.type("index", { delay: 30 }); await sbPage.keyboard.press("Enter"); // Should be back on the index/welcome page - await expect(sbPage.locator("#sb-current-page")).toContainText("index"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("index"); await expect(editor).toContainText("Welcome to the wondrous world of SilverBullet"); }); @@ -69,13 +69,13 @@ test.describe("Page navigation", () => { // Open page picker and create a page with folder path await sbPage.keyboard.press(`${mod}+k`); - const pickerInput = sbPage.locator(".sb-modal-box .cm-content"); + const pickerInput = sbPage.locator(".sb-modal-box input.sb-input"); await pickerInput.click(); await sbPage.keyboard.type("Notes/My Subfolder Page", { delay: 30 }); await sbPage.keyboard.press("Shift+Enter"); // Page name should show the full path - await expect(sbPage.locator("#sb-current-page")).toContainText("Notes/My Subfolder Page"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Notes/My Subfolder Page"); await expect(editor).toHaveText(""); // Type something and verify it saves to server diff --git a/e2e/page-picker.test.ts b/e2e/page-picker.test.ts new file mode 100644 index 00000000..0363b45f --- /dev/null +++ b/e2e/page-picker.test.ts @@ -0,0 +1,64 @@ +import { expect, mod, test } from "./fixtures.ts"; + +test.describe("Page picker keyboard control", () => { + test.use({ + spaceFiles: { + "index.md": "Welcome to the wondrous world of SilverBullet", + "Fruit Apple.md": "apple", + "Fruit Banana.md": "banana", + "Fruit Cherry.md": "cherry", + "Fruit Date.md": "date", + "Projects/Alpha/One.md": "one", + "Projects/Alpha/Two.md": "two", + }, + }); + + test("arrow + Ctrl-n/Ctrl-p move the selection", async ({ sbPage }) => { + await sbPage.keyboard.press(`${mod}+k`); + const modal = sbPage.locator(".sb-modal-box"); + await expect(modal).toBeVisible(); + + const input = modal.locator("input.sb-input"); + await input.click(); + await sbPage.keyboard.type("Fruit", { delay: 30 }); + + const selected = modal.locator(".sb-option.sb-selected-option .sb-name"); + await expect(selected).toBeVisible(); + const first = (await selected.innerText()).trim(); + + await sbPage.keyboard.press("ArrowDown"); + const second = (await selected.innerText()).trim(); + expect(second).not.toBe(first); + + await sbPage.keyboard.press("Control+p"); + expect((await selected.innerText()).trim()).toBe(first); + + await sbPage.keyboard.press("Control+n"); + expect((await selected.innerText()).trim()).toBe(second); + + await sbPage.keyboard.press("Escape"); + await expect(modal).not.toBeVisible(); + }); + + test("Enter opens the selected page", async ({ sbPage }) => { + await sbPage.keyboard.press(`${mod}+k`); + const modal = sbPage.locator(".sb-modal-box"); + const input = modal.locator("input.sb-input"); + await input.click(); + await sbPage.keyboard.type("Fruit Cherry", { delay: 30 }); + await sbPage.keyboard.press("Enter"); + await expect(modal).not.toBeVisible(); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Fruit Cherry"); + }); + + test("Alt-Space completes the next path segment", async ({ sbPage }) => { + await sbPage.keyboard.press(`${mod}+k`); + const modal = sbPage.locator(".sb-modal-box"); + const input = modal.locator("input.sb-input"); + await input.click(); + await sbPage.keyboard.type("Projects", { delay: 30 }); + await sbPage.keyboard.press("Alt+Space"); + await expect(input).toHaveValue("Projects/Alpha"); + await sbPage.keyboard.press("Escape"); + }); +}); diff --git a/e2e/page-rename.test.ts b/e2e/page-rename.test.ts new file mode 100644 index 00000000..9205d3a1 --- /dev/null +++ b/e2e/page-rename.test.ts @@ -0,0 +1,59 @@ +import { expect, gotoSilverBulletPage, mod, test } from "./fixtures.ts"; + +test.describe("Top-bar page rename", () => { + test.use({ + spaceFiles: { + "index.md": "Welcome to the wondrous world of SilverBullet", + "OldName.md": "Content to keep", + }, + }); + + test("rename via Enter moves the page on the server", async ({ sbPage, sbServer }) => { + await gotoSilverBulletPage(sbPage, sbServer, "OldName"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("OldName"); + + const nameInput = sbPage.locator("#sb-current-page input.sb-input"); + await nameInput.click(); + await sbPage.keyboard.press(`${mod}+a`); + await sbPage.keyboard.type("NewName"); + await sbPage.keyboard.press("Enter"); + + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("NewName"); + + // Wait for the rename round-trip to land: the client navigates to the new + // page once `index.renamePageCommand` resolves on the server. + await sbPage.waitForURL(/\/NewName$/); + + const newResp = await fetch(`${sbServer.url}/.fs/NewName.md`); + expect(newResp.ok).toBe(true); + expect(await newResp.text()).toContain("Content to keep"); + + const oldResp = await fetch(`${sbServer.url}/.fs/OldName.md`); + expect(oldResp.status).toBe(404); + }); + + test("rename via blur commits", async ({ sbPage, sbServer }) => { + await gotoSilverBulletPage(sbPage, sbServer, "OldName"); + const nameInput = sbPage.locator("#sb-current-page input.sb-input"); + await nameInput.click(); + await sbPage.keyboard.press(`${mod}+a`); + await sbPage.keyboard.type("BlurRenamed"); + await sbPage.locator("#sb-editor .cm-content").click(); // blur the field + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("BlurRenamed"); + // Wait for the rename round-trip to land (client navigates to the new page). + await sbPage.waitForURL(/\/BlurRenamed$/); + const resp = await fetch(`${sbServer.url}/.fs/BlurRenamed.md`); + expect(resp.ok).toBe(true); + }); + + test("editor shortcuts still fire while the page-name field is focused", async ({ sbPage, sbServer }) => { + await gotoSilverBulletPage(sbPage, sbServer, "OldName"); + const nameInput = sbPage.locator("#sb-current-page input.sb-input"); + await nameInput.click(); + // The global key forwarder must still route mod+k to the editor and open + // the page picker even though a native input is focused (parity with the + // old CodeMirror page-name editor). + await sbPage.keyboard.press(`${mod}+k`); + await expect(sbPage.locator(".sb-modal-box")).toBeVisible(); + }); +}); diff --git a/e2e/rich-text-paste.test.ts b/e2e/rich-text-paste.test.ts index 1f2aef44..dcff870a 100644 --- a/e2e/rich-text-paste.test.ts +++ b/e2e/rich-text-paste.test.ts @@ -7,7 +7,7 @@ test.describe("Rich text paste", () => { // Navigate to a fresh page await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box .cm-content").click(); + await sbPage.locator(".sb-modal-box input.sb-input").click(); await sbPage.keyboard.type("Paste Test", { delay: 30 }); await sbPage.keyboard.press("Shift+Enter"); await expect(editor).toHaveText(""); @@ -51,7 +51,7 @@ test.describe("Rich text paste", () => { await expect(editor).toContainText("Welcome"); await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box .cm-content").click(); + await sbPage.locator(".sb-modal-box input.sb-input").click(); await sbPage.keyboard.type("Paste Link Test", { delay: 30 }); await sbPage.keyboard.press("Shift+Enter"); await expect(editor).toHaveText(""); diff --git a/e2e/tasks.test.ts b/e2e/tasks.test.ts index 08ed0bc7..04ed372d 100644 --- a/e2e/tasks.test.ts +++ b/e2e/tasks.test.ts @@ -16,7 +16,7 @@ test.describe("Task management", () => { await gotoSilverBulletPage(page, sbServer, "Tasks"); const editor = page.locator("#sb-editor .cm-content"); - await expect(page.locator("#sb-current-page")).toContainText("Tasks"); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("Tasks"); await expect(editor).toContainText("Buy groceries"); // Task checkboxes render as diff --git a/e2e/wiki-links.test.ts b/e2e/wiki-links.test.ts index a1f1bf3a..fd4d0078 100644 --- a/e2e/wiki-links.test.ts +++ b/e2e/wiki-links.test.ts @@ -20,7 +20,7 @@ test.describe("Wiki links", () => { await gotoSilverBulletPage(page, sbServer, "PageA"); const editor = page.locator("#sb-editor .cm-content"); - await expect(page.locator("#sb-current-page")).toContainText("PageA"); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageA"); await expect(editor).toContainText("Page A"); // Click on the "PageB" text inside the wiki link @@ -29,7 +29,7 @@ test.describe("Wiki links", () => { await wikiLinkText.click(); // Should navigate to PageB - await expect(page.locator("#sb-current-page")).toContainText("PageB"); + await expect(page.locator("#sb-current-page input.sb-input")).toHaveValue("PageB"); await expect(editor).toContainText("Page B"); }); }); @@ -40,7 +40,7 @@ test.describe("Wiki links", () => { // Navigate to a fresh page await sbPage.keyboard.press(`${mod}+k`); - await sbPage.locator(".sb-modal-box .cm-content").click(); + await sbPage.locator(".sb-modal-box input.sb-input").click(); await sbPage.keyboard.type("Link Source", { delay: 30 }); await sbPage.keyboard.press("Shift+Enter"); await expect(editor).toHaveText(""); @@ -64,6 +64,6 @@ test.describe("Wiki links", () => { await expect(wikiLinkText).toBeVisible({ timeout: 10_000 }); await wikiLinkText.click(); - await expect(sbPage.locator("#sb-current-page")).toContainText("Brand New Page"); + await expect(sbPage.locator("#sb-current-page input.sb-input")).toHaveValue("Brand New Page"); }); }); diff --git a/e2e/xray.test.ts b/e2e/xray.test.ts index d02cf113..7f67d890 100644 --- a/e2e/xray.test.ts +++ b/e2e/xray.test.ts @@ -54,7 +54,7 @@ test.describe("X-Ray lens", () => { await page.keyboard.press(`${mod}+/`); const modal = page.locator(".sb-modal-box"); await expect(modal).toBeVisible(); - const paletteInput = modal.locator(".cm-content"); + const paletteInput = modal.locator("input.sb-input"); await paletteInput.click(); await page.keyboard.type("Toggle X-Ray", { delay: 30 }); const option = modal.locator(".sb-option .sb-name", { diff --git a/plug-api/ui/input.tsx b/plug-api/ui/input.tsx index 3092d355..dae86890 100644 --- a/plug-api/ui/input.tsx +++ b/plug-api/ui/input.tsx @@ -7,14 +7,39 @@ export type InputProps = class?: string; /** Ref to the underlying (Preact function components don't forward `ref`). */ inputRef?: Ref; + /** Called with the current value when Enter is pressed in the field. */ + onConfirm?: (value: string) => void; + /** Called with the current value when Escape is pressed in the field. */ + onExit?: (value: string) => void; }; -export function Input({ class: extra, type, inputRef, ...rest }: InputProps) { +export function Input( + { class: extra, type, inputRef, onConfirm, onExit, onKeyDown, ...rest }: + InputProps, +) { return ( { + // Run any caller-supplied handler first; it may call preventDefault(). + onKeyDown?.(e); + // Ignore Enter/Escape that are part of an IME composition (e.g. CJK + // candidate confirmation), so they don't submit a half-composed value. + if (e.defaultPrevented || e.isComposing) { + return; + } + if (onConfirm && e.key === "Enter") { + e.preventDefault(); + onConfirm(e.currentTarget.value); + } else if (onExit && e.key === "Escape") { + e.preventDefault(); + onExit(e.currentTarget.value); + } + } + : undefined} {...rest} /> ); diff --git a/plug-api/ui/ui.test.ts b/plug-api/ui/ui.test.ts index 1a490fdd..48a54e3d 100644 --- a/plug-api/ui/ui.test.ts +++ b/plug-api/ui/ui.test.ts @@ -30,6 +30,17 @@ test("Input renders sb-input with default type text", () => { expect(html).toContain('type="text"'); }); +test("Input renders cleanly when onConfirm/onExit are provided", () => { + const html = render( + h(Input, { value: "hi", onConfirm: () => {}, onExit: () => {} }), + ); + expect(html).toContain('class="sb-input"'); + expect(html).toContain('value="hi"'); + // Callback props must not leak as DOM attributes + expect(html).not.toContain("onConfirm"); + expect(html).not.toContain("onExit"); +}); + test("Select wraps options", () => { const html = render(h(Select, {}, h("option", {}, "A"))); expect(html).toContain('class="sb-select"'); diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 592d02e9..13867896 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -13,6 +13,11 @@ Whenever a commit is pushed to the `main` branch, within ~5 minutes, it will be * Potentially **breaking** CSS change for theme authors: per-nesting-level indent values previously carried by `.sb-line-ul.sb-line-li-N`, `.sb-line-ol.sb-line-li-N`, `.sb-line-task` and `.sb-line-blockquote.sb-line-li-N` selectors have been removed. * Task checkboxes are now drawn in CSS (`appearance: none` + bordered box + rotated-rectangle checkmark) instead of relying on the native checkboxes. Should improve rendering on webkit browsers. * Clicking a wiki link to a page now places the cursor just after the page's frontmatter on first visit (matching fresh-load behavior), instead of at position 0. Pages already visited in the session still restore their previously saved cursor position. +* Technical simplification: replaced the CodeMirror-based mini-editor used in the page/command picker, prompt dialogs, and the top-bar page-name field with native text inputs, improving accessibility, mobile keyboard behavior, and IME handling, and removing several Safari/layout hacks. + * Potentially **breaking** CSS change for theme authors: these three fields are no longer CodeMirror instances, so any styling that targeted them via `.sb-mini-editor`, the `.cm-content` / `.cm-line` / `.cm-scroller` rules inside `.sb-modal-box`, or the `.cm-scroller` / `.cm-content` rules under `#sb-current-page`, no longer applies. They are now native `` elements sharing the `.sb-input` base class, each with a context-specific class to retarget: + * Picker / command-palette filter: `.sb-input.sb-filter-input` (inside `.sb-modal-box .sb-header`) + * Prompt dialog input: `.sb-input.sb-prompt-input` (inside `.sb-prompt`) + * Top-bar page title: `.sb-input.sb-page-name-editor` (inside `#sb-current-page`) * APIs: * Space Lua: added `spacelua.prettyPrintBlock` / `spacelua.prettyPrintExpression` to pretty-print a parsed Lua AST back to formatted source. Supports `indentWidth`, `quote` and `trailingComma` options. In preparation of future functionality that will manipulate existing Lua code. * `index.contentPages` now accepts an optional `tag` argument to filter content pages by an additional tag, matching the other type-specific [[API/index]] helpers.