update shortcuts

This commit is contained in:
swithek 2026-09-07 15:33:15 +02:00
parent ab7b9a04c1
commit 6bb265d0ed
22 changed files with 1188 additions and 423 deletions

View File

@ -1034,8 +1034,7 @@ describe("<AppSidebar>", { concurrent: false }, () => {
})
// the suite runs as a non-mac host, so the row prints the keys of the
// "other" variant, one <kbd> each and without the "then" connector the
// tooltips put between them
// "other" variant, one <kbd> each; a chord carries no connector word
it("spells the shortcut out on the shortcuts row", async ({ expect }) => {
stubQueries()
const wrapper = await mountSidebar()

View File

@ -578,6 +578,12 @@ async function installSlack() {
<ShadcnUiKbd v-if="!val.connector">
{{ val.key }}
</ShadcnUiKbd>
<span
v-else-if="val.connector === 'sequence'"
class="text-xs text-muted-foreground"
>
{{ $t("shortcuts.connector") }}
</span>
</template>
</ShadcnUiKbdGroup>
</ShadcnUiSidebarMenuButton>

View File

@ -1,5 +1,6 @@
import { mountSuspended } from "@nuxt/test-utils/runtime"
import { beforeEach, describe, it } from "vitest"
import { allItems } from "./editor/slash/items"
import ShortcutModal from "./ShortcutModal.vue"
import { clearTeleportedOverlays, t, teleportedButton } from "./test-helpers"
import { SHORTCUT_GROUPS } from "~/utils/shortcuts"
@ -12,6 +13,31 @@ function dialog() {
return document.body.querySelector("[data-slot='dialog-content']")
}
function row(id: string): Element {
const found = document.body.querySelector(`[data-shortcut-row="${id}"]`)
if (!found) {
throw new Error(`no shortcut row "${id}"`)
}
return found
}
// the keys a row prints, one <kbd> each; a sequence connector would be a
// plain span and is left out
function rowKeys(id: string): string[] {
return Array.from(
row(id).querySelectorAll("kbd[data-slot='kbd-group'] kbd"),
).map((key) => key.textContent.trim())
}
function rowDescription(id: string): string {
return (
row(id).querySelector(".text-muted-foreground")?.textContent.trim() ?? ""
)
}
const entryId = (key: string) => `shortcuts.modal.entries.${key}.label`
// the dialog body is teleported into the shared <body>
describe("<ShortcutModal>", { concurrent: false }, () => {
beforeEach(() => {
@ -30,63 +56,95 @@ describe("<ShortcutModal>", { concurrent: false }, () => {
expect(dialog()?.textContent).toContain(t("shortcuts.modal.title"))
})
it("heads a section for every group, in order", async ({ expect }) => {
it("heads a section for every group, then the slash commands", async ({
expect,
}) => {
await mountModal()
const headings = Array.from(
document.body.querySelectorAll("[data-slot='dialog-content'] h3"),
).map((heading) => heading.textContent)
expect(headings).toEqual(SHORTCUT_GROUPS.map((group) => t(group.i18nKey)))
expect(headings).toEqual([
...SHORTCUT_GROUPS.map((group) => t(group.i18nKey)),
t("shortcuts.groups.slash-commands"),
])
})
it("names every grouped shortcut and prints its keys", async ({ expect }) => {
it("names and describes every grouped shortcut", async ({ expect }) => {
await mountModal()
const text = dialog()?.textContent ?? ""
SHORTCUT_GROUPS.flatMap((group) => group.shortcuts).forEach((entry) => {
expect(text).toContain(t(entry.i18nKey))
expect(text).toContain(t(entry.descriptionI18nKey))
expect(row(entry.i18nKey).textContent).toContain(t(entry.i18nKey))
expect(rowDescription(entry.i18nKey)).toBe(t(entry.descriptionI18nKey))
})
// the suite runs as a non-mac host. Only the keys themselves are
// <kbd>; the connector between them is a plain span
const keys = Array.from(
document.body.querySelectorAll(
"[data-slot='dialog-content'] kbd[data-slot='kbd-group'] kbd",
),
).map((key) => key.textContent)
expect(keys).toEqual(
SHORTCUT_GROUPS.flatMap((group) => group.shortcuts).flatMap((entry) =>
entry.action.keyboardKey.other.split("+"),
),
)
})
it("separates the keys of a shortcut with the connector", async ({
// the suite runs as a non-mac host, and a chord's keys sit side by side
it("prints an app shortcut and an editor binding as chords", async ({
expect,
}) => {
await mountModal()
const rendered = Array.from(
document.body.querySelectorAll(
"[data-slot='dialog-content'] kbd[data-slot='kbd-group']",
),
).map((group) =>
Array.from(group.children).map((child) => child.textContent.trim()),
expect(rowKeys(entryId("toggle-inbox"))).toEqual(["Ctrl", "Shift", "\\"])
expect(rowKeys(entryId("bold"))).toEqual(["Ctrl", "B"])
expect(rowKeys(entryId("outdent-list-item"))).toEqual(["Shift", "Tab"])
})
it("prints typed markdown as written", async ({ expect }) => {
await mountModal()
expect(rowKeys(entryId("md-bold"))).toEqual(["**text**"])
expect(rowKeys(entryId("md-quote"))).toEqual([
">",
t("shortcuts.modal.space-key"),
])
})
it("lists the markdown of every block the slash menu inserts", async ({
expect,
}) => {
await mountModal()
const withMarkdown = allItems.filter((item) => item.shortcut)
expect(withMarkdown.length).toBeGreaterThan(0)
withMarkdown.forEach((item) => {
const id = `markdown-${item.titleI18nKey}`
const pattern = item.shortcut ?? ""
const keys = pattern.endsWith(" ")
? [pattern.trimEnd(), t("shortcuts.modal.space-key")]
: [pattern]
expect(row(id).textContent).toContain(t(item.titleI18nKey))
expect(rowKeys(id)).toEqual(keys)
expect(rowDescription(id)).toBe(
`${t(item.descriptionI18nKey)} ${t("shortcuts.modal.markdown-block-hint")}`,
)
})
})
it("lists every slash command by the text that opens it", async ({
expect,
}) => {
await mountModal()
const section = document.body.querySelector(
"[data-shortcut-section='slash-commands']",
)
expect(rendered).toEqual(
SHORTCUT_GROUPS.flatMap((group) => group.shortcuts).map((entry) =>
entry.action.keyboardKey.other
.split("+")
.flatMap((key, index) =>
index === 0 ? [key] : [t("shortcuts.connector"), key],
),
),
expect(section?.querySelectorAll("[data-shortcut-row]")).toHaveLength(
allItems.length,
)
allItems.forEach((item) => {
const id = `slash-${item.titleI18nKey}`
expect(row(id).textContent).toContain(t(item.titleI18nKey))
expect(rowKeys(id)).toEqual([`/${t(item.titleI18nKey).toLowerCase()}`])
expect(rowDescription(id)).toBe(
`${t(item.descriptionI18nKey)} ${t("shortcuts.modal.slash-hint")}`,
)
})
})
it("closes when the close button is pressed", async ({ expect }) => {

View File

@ -1,12 +1,105 @@
<script lang="ts" setup>
import type { ShortcutEntry } from "~/utils/shortcuts"
import { allItems } from "./editor/slash/items"
import { SLASH_COMMAND_TRIGGER_CHAR } from "./editor/slash/extension"
const open = defineModel<boolean>({ default: false })
const { osType } = useDetectHost()
const { t } = useI18n({ useScope: "global" })
interface Row {
id: string
label: string
description: string
tokens: ReturnType<typeof extractShortcutKeys>
// markdown and slash text is shown as typed, in a monospace face
mono: boolean
}
// markdown that inserts a block is typed as written; a trailing space in
// the pattern is the space the rule waits for, shown as its own key
function typedTokens(pattern: string): Row["tokens"] {
const keys = pattern.endsWith(" ")
? [pattern.trimEnd(), t("shortcuts.modal.space-key")]
: [pattern]
return keys.map((key) => ({ key }))
}
function entryRow(entry: ShortcutEntry): Row {
const base = {
id: entry.i18nKey,
label: t(entry.i18nKey),
description: t(entry.descriptionI18nKey),
}
switch (entry.kind) {
case "action":
return {
...base,
tokens: extractShortcutKeys(
shortcutByOS(entry.action.keyboardKey, osType.value),
),
mono: false,
}
case "keys":
return {
...base,
tokens: extractShortcutKeys(
shortcutByOS(entry.keyboardKey, osType.value),
),
mono: false,
}
case "typed":
return { ...base, tokens: typedTokens(entry.pattern), mono: true }
}
}
// the slash menu's items are the one place a block's name, description and
// markdown live, so both sections below read them from there
const markdownBlockRows = allItems
.filter((item) => item.shortcut)
.map((item): Row => ({
id: `markdown-${item.titleI18nKey}`,
label: t(item.titleI18nKey),
description: `${t(item.descriptionI18nKey)} ${t("shortcuts.modal.markdown-block-hint")}`,
tokens: typedTokens(item.shortcut ?? ""),
mono: true,
}))
const slashRows = allItems.map((item): Row => ({
id: `slash-${item.titleI18nKey}`,
label: t(item.titleI18nKey),
description: `${t(item.descriptionI18nKey)} ${t("shortcuts.modal.slash-hint")}`,
tokens: [
{
key: `${SLASH_COMMAND_TRIGGER_CHAR}${t(item.titleI18nKey).toLowerCase()}`,
},
],
mono: true,
}))
const sections = computed(() => [
...SHORTCUT_GROUPS.map((group) => ({
id: group.id,
title: t(group.i18nKey),
rows: [
...group.shortcuts.map(entryRow),
...(group.id === "markdown" ? markdownBlockRows : []),
],
})),
{
id: "slash-commands",
title: t("shortcuts.groups.slash-commands"),
rows: slashRows,
},
])
</script>
<template>
<ShadcnUiDialog :open="open" @update:open="(val) => (open = val)">
<ShadcnUiDialogContent
class="max-h-[85dvh] w-[90dvw] overflow-y-auto p-0 text-foreground outline-none md:max-h-[70dvh] lg:w-176"
class="max-h-[85dvh] w-[90dvw] overflow-y-auto p-0 text-foreground outline-none md:max-h-[70dvh] lg:w-200"
@open-auto-focus.prevent
@close-auto-focus.prevent
>
@ -30,34 +123,39 @@ const { osType } = useDetectHost()
</ShadcnUiButton>
</ShadcnUiDialogHeader>
<div
v-for="group in SHORTCUT_GROUPS"
:key="group.i18nKey"
v-for="section in sections"
:key="section.id"
:data-shortcut-section="section.id"
class="flex flex-col gap-4"
>
<h3 class="text-2base">{{ $t(group.i18nKey) }}</h3>
<h3 class="text-2base">{{ section.title }}</h3>
<div
class="flex flex-col divide-y divide-border/70 rounded-md border bg-muted/50 px-4 py-1 dark:divide-border/50"
>
<div
v-for="entry in group.shortcuts"
:key="entry.i18nKey"
v-for="row in section.rows"
:key="row.id"
:data-shortcut-row="row.id"
class="flex items-center justify-between gap-4 py-2.5"
>
<div class="flex min-w-0 flex-col gap-0.5">
<div class="text-2base">{{ $t(entry.i18nKey) }}</div>
<div class="text-2base">{{ row.label }}</div>
<div class="text-xs text-muted-foreground">
{{ $t(entry.descriptionI18nKey) }}
{{ row.description }}
</div>
</div>
<ShadcnUiKbdGroup class="shrink-0">
<template
v-for="(val, index) in extractShortcutKeys(
shortcutByOS(entry.action.keyboardKey, osType),
)"
:key="index"
>
<ShadcnUiKbd v-if="!val.connector">{{ val.key }}</ShadcnUiKbd>
<span v-else class="text-xs text-muted-foreground">
<template v-for="(val, index) in row.tokens" :key="index">
<ShadcnUiKbd
v-if="!val.connector"
:class="row.mono ? 'font-mono' : undefined"
>
{{ val.key }}
</ShadcnUiKbd>
<span
v-else-if="val.connector === 'sequence'"
class="text-xs text-muted-foreground"
>
{{ $t("shortcuts.connector") }}
</span>
</template>

View File

@ -91,7 +91,24 @@ describe("<ShortcutTooltip>", { concurrent: false }, () => {
await wrapper.get("button").trigger("pointerenter")
await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS)
expect(openTooltipText(wrapper)).toBe("CtrlthenK")
expect(openTooltipText(wrapper)).toBe("CtrlK")
})
it("writes the connector only between the keys of a sequence", async ({
expect,
}) => {
vi.useFakeTimers()
const wrapper = await mountTooltip({
shortcut: {
keyboardKey: { macOS: "G Shift+I", other: "G Shift+I" },
i18nKey: null,
},
})
await wrapper.get("button").trigger("pointerenter")
await vi.advanceTimersByTimeAsync(HOVER_DELAY_MS)
expect(openTooltipText(wrapper)).toBe(`G${t("shortcuts.connector")}ShiftI`)
})
it("cancels a pending open when the pointer leaves early", async ({

View File

@ -56,7 +56,10 @@ function handlePointerLeave() {
:key="index"
>
<ShadcnUiKbd v-if="!val.connector">{{ val.key }}</ShadcnUiKbd>
<span v-else class="text-xs text-muted-foreground">
<span
v-else-if="val.connector === 'sequence'"
class="text-xs text-muted-foreground"
>
{{ $t("shortcuts.connector") }}
</span>
</template>

View File

@ -195,6 +195,7 @@ const contentEditor = useEditor({
RemoteDeleteSelectionGuard,
TrailingNode.configure({ notAfter: [Paragraph.name] }),
SlashCommands.configure({
t,
decorationClass: cn(
"font-normal relative z-1",
"before:absolute before:content-[''] before:-top-[0.075em] before:-left-[0.25em] before:-right-[0.25em] before:-bottom-[0.075em] before:bg-[var(--tw-prose-pre-bg)] before:-z-1 before:rounded-sm before:border-1 before:border-border",

View File

@ -1412,118 +1412,83 @@ describe("SplitDocumentation", () => {
})
})
describe("Mod-e", () => {
it("appends a parameter list when the cursor is on the left side", ({
expect,
}) => {
const node = defaultDoc()
const { editor, appendParameterListOnLeftSide, appendBlockOnRightSide } =
makeShortcutEditor(node, startOfText(node, "body"))
const modE = nodeKeyboardShortcut(
SplitDocumentation,
editor,
"Mod-e",
schema,
)
// each block shortcut acts on one fixed side and reaches it from
// anywhere in the split documentation, so the cursor's side never
// changes what the key does
describe.for([
{
key: "Mod-Alt-p",
command: "appendParameterListOnLeftSide" as const,
side: SPLIT_DOCUMENTATION_LEFT_SIDE_NAME,
blockType: null,
},
{
key: "Mod-Alt-e",
command: "appendBlockOnRightSide" as const,
side: SPLIT_DOCUMENTATION_RIGHT_SIDE_NAME,
blockType: "code",
},
{
key: "Mod-Alt-g",
command: "appendBlockOnRightSide" as const,
side: SPLIT_DOCUMENTATION_RIGHT_SIDE_NAME,
blockType: "metrics",
},
])("$key", ({ key, command, side, blockType }) => {
it.for([
{ name: "left", text: "body" },
{ name: "right", text: "run me" },
])(
"acts on its own side with the cursor on the $name side",
({ text }, { expect }) => {
const node = defaultDoc()
const commands = makeShortcutEditor(node, startOfText(node, text))
const shortcut = nodeKeyboardShortcut(
SplitDocumentation,
commands.editor,
key,
schema,
)
expect(modE({ editor })).toBe(true)
expect(appendParameterListOnLeftSide).toHaveBeenCalledExactlyOnceWith(
posOf(node, SPLIT_DOCUMENTATION_LEFT_SIDE_NAME),
)
expect(appendBlockOnRightSide).toHaveBeenCalledTimes(0)
})
it("appends a code block when the cursor is on the right side", ({
expect,
}) => {
const node = defaultDoc()
const { editor, appendParameterListOnLeftSide, appendBlockOnRightSide } =
makeShortcutEditor(node, startOfText(node, "run me"))
const modE = nodeKeyboardShortcut(
SplitDocumentation,
editor,
"Mod-e",
schema,
)
expect(modE({ editor })).toBe(true)
expect(appendBlockOnRightSide).toHaveBeenCalledExactlyOnceWith(
posOf(node, SPLIT_DOCUMENTATION_RIGHT_SIDE_NAME),
"code",
)
expect(appendParameterListOnLeftSide).toHaveBeenCalledTimes(0)
})
expect(shortcut({ editor: commands.editor })).toBe(true)
expect(commands[command]).toHaveBeenCalledExactlyOnceWith(
...(blockType === null
? [posOf(node, side)]
: [posOf(node, side), blockType]),
)
},
)
it("does nothing outside a split documentation", ({ expect }) => {
const node = doc(paragraph("plain"))
const { editor, appendParameterListOnLeftSide, appendBlockOnRightSide } =
makeShortcutEditor(node, startOfText(node, "plain"))
const modE = nodeKeyboardShortcut(
const shortcut = nodeKeyboardShortcut(
SplitDocumentation,
editor,
"Mod-e",
key,
schema,
)
expect(modE({ editor })).toBe(false)
expect(shortcut({ editor })).toBe(false)
expect(appendParameterListOnLeftSide).toHaveBeenCalledTimes(0)
expect(appendBlockOnRightSide).toHaveBeenCalledTimes(0)
})
})
describe("Mod-m", () => {
it("appends a metric block when the cursor is on the right side", ({
expect,
}) => {
const node = defaultDoc()
const { editor, appendParameterListOnLeftSide, appendBlockOnRightSide } =
makeShortcutEditor(node, startOfText(node, "run me"))
const modM = nodeKeyboardShortcut(
SplitDocumentation,
editor,
"Mod-m",
schema,
)
// the bare Mod-e used to shadow the code mark inside the block
describe("replaced bindings", () => {
it.for([{ key: "Mod-e" }, { key: "Mod-m" }])(
"no longer binds $key",
({ key }, { expect }) => {
const node = defaultDoc()
const { editor } = makeShortcutEditor(node, startOfText(node, "body"))
expect(modM({ editor })).toBe(true)
expect(appendBlockOnRightSide).toHaveBeenCalledExactlyOnceWith(
posOf(node, SPLIT_DOCUMENTATION_RIGHT_SIDE_NAME),
"metrics",
)
expect(appendParameterListOnLeftSide).toHaveBeenCalledTimes(0)
})
it("does nothing when the cursor is on the left side", ({ expect }) => {
const node = defaultDoc()
const { editor, appendParameterListOnLeftSide, appendBlockOnRightSide } =
makeShortcutEditor(node, startOfText(node, "body"))
const modM = nodeKeyboardShortcut(
SplitDocumentation,
editor,
"Mod-m",
schema,
)
expect(modM({ editor })).toBe(false)
expect(appendBlockOnRightSide).toHaveBeenCalledTimes(0)
expect(appendParameterListOnLeftSide).toHaveBeenCalledTimes(0)
})
it("does nothing outside a split documentation", ({ expect }) => {
const node = doc(paragraph("plain"))
const { editor, appendParameterListOnLeftSide, appendBlockOnRightSide } =
makeShortcutEditor(node, startOfText(node, "plain"))
const modM = nodeKeyboardShortcut(
SplitDocumentation,
editor,
"Mod-m",
schema,
)
expect(modM({ editor })).toBe(false)
expect(appendBlockOnRightSide).toHaveBeenCalledTimes(0)
expect(appendParameterListOnLeftSide).toHaveBeenCalledTimes(0)
})
expect(() =>
nodeKeyboardShortcut(SplitDocumentation, editor, key, schema),
).toThrow(`defines no ${key} shortcut`)
},
)
})
describe("Enter", () => {

View File

@ -568,88 +568,38 @@ export const SplitDocumentation = Node.create({
},
addKeyboardShortcuts() {
return {
"Mod-e": ({ editor }) => {
const { state } = editor
const { selection } = state
const $from = selection.$from
// Find enclosing splitDocumentation node
let splitDocDepth = -1
for (let d = $from.depth; d >= 0; d--) {
if ($from.node(d).type.name === this.name) {
splitDocDepth = d
break
}
}
if (splitDocDepth === -1) {
"Mod-Alt-p": () => {
const pos = sidePos(
this.editor.state,
SPLIT_DOCUMENTATION_LEFT_SIDE_NAME,
)
if (pos === null) {
return false
}
const splitDocNode = $from.node(splitDocDepth)
const splitDocPos = $from.before(splitDocDepth)
// Figure out which child contains the cursor
const index = $from.index(splitDocDepth) // child index inside splitDocumentation
const child = splitDocNode.child(index)
// Compute absolute pos of that child
let offset = 0
for (let i = 0; i < index; i++) {
offset += splitDocNode.child(i).nodeSize
}
const childPos = splitDocPos + 1 + offset
if (child.type.name === "splitDocumentationLeftSide") {
return this.editor.commands.appendParameterListOnLeftSide(childPos)
}
if (child.type.name === "splitDocumentationRightSide") {
return this.editor.commands.appendBlockOnRightSide(childPos, "code")
}
return false
return this.editor.commands.appendParameterListOnLeftSide(pos)
},
"Mod-m": ({ editor }) => {
const { state } = editor
const { selection } = state
const $from = selection.$from
// Find enclosing splitDocumentation node
let splitDocDepth = -1
for (let d = $from.depth; d >= 0; d--) {
if ($from.node(d).type.name === this.name) {
splitDocDepth = d
break
}
}
if (splitDocDepth === -1) {
"Mod-Alt-e": () => {
const pos = sidePos(
this.editor.state,
SPLIT_DOCUMENTATION_RIGHT_SIDE_NAME,
)
if (pos === null) {
return false
}
const splitDocNode = $from.node(splitDocDepth)
const splitDocPos = $from.before(splitDocDepth)
// Figure out which child contains the cursor
const index = $from.index(splitDocDepth) // child index inside splitDocumentation
const child = splitDocNode.child(index)
// Compute absolute pos of that child
let offset = 0
for (let i = 0; i < index; i++) {
offset += splitDocNode.child(i).nodeSize
return this.editor.commands.appendBlockOnRightSide(pos, "code")
},
"Mod-Alt-g": () => {
const pos = sidePos(
this.editor.state,
SPLIT_DOCUMENTATION_RIGHT_SIDE_NAME,
)
if (pos === null) {
return false
}
const childPos = splitDocPos + 1 + offset
if (child.type.name === "splitDocumentationRightSide") {
return this.editor.commands.appendBlockOnRightSide(
childPos,
"metrics",
)
}
return false
return this.editor.commands.appendBlockOnRightSide(pos, "metrics")
},
// when the cursor is inside the heading and the Enter key is
// pressed, we don't want the cursor to create extra paragraphs,
@ -799,3 +749,33 @@ function findAncestorDepth(pos: ResolvedPos, names: string[]): number {
return -1
}
// resolves the position of one side of the split documentation block
// holding the cursor, whichever part of the block the cursor is actually
// in. Returns null when the cursor is outside a split documentation.
function sidePos(state: EditorState, sideName: string): number | null {
const { $from } = state.selection
for (let depth = $from.depth; depth > 0; depth--) {
if ($from.node(depth).type.name !== SPLIT_DOCUMENTATION_NAME) {
continue
}
const block = $from.node(depth)
const blockPos = $from.before(depth)
let offset = 0
for (let index = 0; index < block.childCount; index++) {
const child = block.child(index)
if (child.type.name === sideName) {
return blockPos + 1 + offset
}
offset += child.nodeSize
}
return null
}
return null
}

View File

@ -4,16 +4,21 @@ import { describe, it, vi } from "vitest"
import CommandList from "./CommandList.vue"
import CommandButton from "./CommandButton.vue"
import { CommandGroup, type CommandData, type CommandItem } from "./items"
import { at } from "~/components/test-helpers"
import { at, t } from "~/components/test-helpers"
// the list renders the translated title of each item, so fixtures name a
// real slash command and expectations resolve the same string
const title = (slug: string) => t(`editor.slash-commands.items.${slug}.title`)
function item(
title: string,
slug: string,
group: CommandGroup,
overrides: Partial<CommandItem> = {},
): CommandItem {
return {
title: title,
nodeType: title.toLowerCase(),
titleI18nKey: `editor.slash-commands.items.${slug}.title`,
descriptionI18nKey: `editor.slash-commands.items.${slug}.description`,
nodeType: slug,
icon: "lucide:type",
group: group,
command: () => undefined,
@ -26,7 +31,7 @@ function mountList(
options: {
query?: string
command?: (item: {
title: string
titleI18nKey: string
command: (data: CommandData) => void
}) => void
initClose?: () => void
@ -45,7 +50,7 @@ function mountList(
function titles(wrapper: VueWrapper): string[] {
return wrapper
.findAllComponents(CommandButton)
.map((button) => (button.props("item") as CommandItem).title)
.map((button) => (button.props("item") as { title: string }).title)
}
function selectedTitle(wrapper: VueWrapper): string | undefined {
@ -73,34 +78,37 @@ function pressKey(wrapper: VueWrapper, key: string): boolean {
describe("<CommandList>", () => {
it("lists the commands it was given", async ({ expect }) => {
const wrapper = await mountList([
item("Heading 1", CommandGroup.Text),
item("Bullet list", CommandGroup.List),
item("heading-1", CommandGroup.Text),
item("bulleted-list", CommandGroup.List),
])
expect(titles(wrapper)).toEqual(["Heading 1", "Bullet list"])
expect(titles(wrapper)).toEqual([
title("heading-1"),
title("bulleted-list"),
])
})
it("orders the commands by group", async ({ expect }) => {
const wrapper = await mountList([
item("Mermaid", CommandGroup.PowerBlock),
item("Bullet list", CommandGroup.List),
item("Heading 1", CommandGroup.Text),
item("Code", CommandGroup.BasicBlock),
item("mermaid-diagram", CommandGroup.PowerBlock),
item("bulleted-list", CommandGroup.List),
item("heading-1", CommandGroup.Text),
item("code-block", CommandGroup.BasicBlock),
])
expect(titles(wrapper)).toEqual([
"Heading 1",
"Bullet list",
"Code",
"Mermaid",
title("heading-1"),
title("bulleted-list"),
title("code-block"),
title("mermaid-diagram"),
])
})
it("separates the groups from one another", async ({ expect }) => {
const wrapper = await mountList([
item("Heading 1", CommandGroup.Text),
item("Heading 2", CommandGroup.Text),
item("Bullet list", CommandGroup.List),
item("heading-1", CommandGroup.Text),
item("heading-2", CommandGroup.Text),
item("bulleted-list", CommandGroup.List),
])
expect(wrapper.findAll(".bg-border")).toHaveLength(1)
@ -108,8 +116,8 @@ describe("<CommandList>", () => {
it("draws no separators inside a single group", async ({ expect }) => {
const wrapper = await mountList([
item("Heading 1", CommandGroup.Text),
item("Heading 2", CommandGroup.Text),
item("heading-1", CommandGroup.Text),
item("heading-2", CommandGroup.Text),
])
expect(wrapper.findAll(".bg-border")).toHaveLength(0)
@ -117,64 +125,64 @@ describe("<CommandList>", () => {
it("preselects the first command", async ({ expect }) => {
const wrapper = await mountList([
item("Heading 1", CommandGroup.Text),
item("Heading 2", CommandGroup.Text),
item("heading-1", CommandGroup.Text),
item("heading-2", CommandGroup.Text),
])
expect(selectedTitle(wrapper)).toBe("Heading 1")
expect(selectedTitle(wrapper)).toBe(title("heading-1"))
})
it("follows the pointer onto another command", async ({ expect }) => {
const wrapper = await mountList([
item("Heading 1", CommandGroup.Text),
item("Heading 2", CommandGroup.Text),
item("heading-1", CommandGroup.Text),
item("heading-2", CommandGroup.Text),
])
await at(wrapper.findAll("button"), 1).trigger("mouseover")
expect(selectedTitle(wrapper)).toBe("Heading 2")
expect(selectedTitle(wrapper)).toBe(title("heading-2"))
})
it("moves the selection down with the arrow keys", async ({ expect }) => {
const wrapper = await mountList([
item("Heading 1", CommandGroup.Text),
item("Heading 2", CommandGroup.Text),
item("heading-1", CommandGroup.Text),
item("heading-2", CommandGroup.Text),
])
expect(pressKey(wrapper, "ArrowDown")).toBe(true)
await nextTick()
expect(selectedTitle(wrapper)).toBe("Heading 2")
expect(selectedTitle(wrapper)).toBe(title("heading-2"))
})
it("wraps around at the bottom of the list", async ({ expect }) => {
const wrapper = await mountList([
item("Heading 1", CommandGroup.Text),
item("Heading 2", CommandGroup.Text),
item("heading-1", CommandGroup.Text),
item("heading-2", CommandGroup.Text),
])
pressKey(wrapper, "ArrowDown")
pressKey(wrapper, "ArrowDown")
await nextTick()
expect(selectedTitle(wrapper)).toBe("Heading 1")
expect(selectedTitle(wrapper)).toBe(title("heading-1"))
})
it("wraps around at the top of the list", async ({ expect }) => {
const wrapper = await mountList([
item("Heading 1", CommandGroup.Text),
item("Heading 2", CommandGroup.Text),
item("heading-1", CommandGroup.Text),
item("heading-2", CommandGroup.Text),
])
expect(pressKey(wrapper, "ArrowUp")).toBe(true)
await nextTick()
expect(selectedTitle(wrapper)).toBe("Heading 2")
expect(selectedTitle(wrapper)).toBe(title("heading-2"))
})
it("runs the selected command on enter", async ({ expect }) => {
const command = vi.fn()
const heading = item("Heading 1", CommandGroup.Text)
const heading = item("heading-1", CommandGroup.Text)
const wrapper = await mountList([heading], { command: command })
expect(pressKey(wrapper, "Enter")).toBe(true)
@ -185,9 +193,9 @@ describe("<CommandList>", () => {
it("runs the command the reader clicked", async ({ expect }) => {
const command = vi.fn()
const list = item("Bullet list", CommandGroup.List)
const list = item("bulleted-list", CommandGroup.List)
const wrapper = await mountList(
[item("Heading 1", CommandGroup.Text), list],
[item("heading-1", CommandGroup.Text), list],
{ command: command },
)
@ -198,7 +206,7 @@ describe("<CommandList>", () => {
})
it("ignores keys it has no use for", async ({ expect }) => {
const wrapper = await mountList([item("Heading 1", CommandGroup.Text)])
const wrapper = await mountList([item("heading-1", CommandGroup.Text)])
expect(pressKey(wrapper, "Escape")).toBe(false)
})
@ -227,7 +235,7 @@ describe("<CommandList>", () => {
}) => {
const initClose = vi.fn()
const command = vi.fn()
const wrapper = await mountList([item("Heading 1", CommandGroup.Text)], {
const wrapper = await mountList([item("heading-1", CommandGroup.Text)], {
query: "xyz",
initClose: initClose,
command: command,
@ -244,18 +252,20 @@ describe("<CommandList>", () => {
expect,
}) => {
const wrapper = await mountList([
item("Heading 1", CommandGroup.Text),
item("Heading 2", CommandGroup.Text),
item("heading-1", CommandGroup.Text),
item("heading-2", CommandGroup.Text),
])
pressKey(wrapper, "ArrowDown")
await wrapper.setProps({ items: [item("Code", CommandGroup.BasicBlock)] })
await wrapper.setProps({
items: [item("code-block", CommandGroup.BasicBlock)],
})
expect(selectedTitle(wrapper)).toBe("Code")
expect(selectedTitle(wrapper)).toBe(title("code-block"))
})
it("fades in once it is mounted", async ({ expect }) => {
const wrapper = await mountList([item("Heading 1", CommandGroup.Text)])
const wrapper = await mountList([item("heading-1", CommandGroup.Text)])
expect(wrapper.get("div").attributes("data-state")).toBe("open")
})
@ -263,7 +273,7 @@ describe("<CommandList>", () => {
it("fades out before handing control back", async ({ expect }) => {
vi.useFakeTimers()
const afterClose = vi.fn()
const wrapper = await mountList([item("Heading 1", CommandGroup.Text)])
const wrapper = await mountList([item("heading-1", CommandGroup.Text)])
api(wrapper).close(afterClose)
await nextTick()

View File

@ -13,7 +13,7 @@ const props = defineProps<{
query: string
items: CommandItem[]
command: (props: {
title: string
titleI18nKey: string
command: (data: CommandData) => void
}) => void
initClose: () => void
@ -141,7 +141,12 @@ function selectItem(index: number) {
<template v-if="processedItems.length">
<template v-for="(item, index) in processedItems" :key="index">
<CommandButton
:item="item"
:item="{
icon: item.icon,
title: $t(item.titleI18nKey),
shortcut: item.shortcut,
disabled: item.disabled,
}"
:item-index="index"
:selected-index="selectedIndex"
@click="selectItem(index)"

View File

@ -167,8 +167,8 @@ function freshStorage(): SlashCommandStorage {
function commandItem(title: string): CommandItem {
return {
title,
description: "",
titleI18nKey: title,
descriptionI18nKey: "",
icon: "",
group: "text" as never,
command: vi.fn(),
@ -246,6 +246,7 @@ describe("SlashCommands", { concurrent: false }, () => {
expect(SlashCommands.config.addOptions?.call({} as never)).toEqual({
decorationClass: "slash-command-filter",
decorationEmptyClass: "slash-command-filter-empty",
t: expect.any(Function) as unknown,
})
})
@ -263,7 +264,19 @@ describe("SlashCommands", { concurrent: false }, () => {
expect(plugins).toEqual([{ suggestion: true }])
expect(suggestion.char).toBe(SLASH_COMMAND_TRIGGER_CHAR)
expect(suggestion.editor).toBe(editor)
expect(suggestion.items).toBe(filterSlashItems)
// the items callback forwards the options' t: under the default
// identity t a title only matches its own key, so a hit on the key
// proves the wrapper handed it through
const items = suggestion.items?.({
query: "heading-1",
editor,
signal: new AbortController().signal,
})
expect(items).not.toHaveLength(0)
expect(items).toEqual(
filterSlashItems({ query: "heading-1", editor, t: options.t }),
)
expect(suggestion.decorationClass).toBe(options.decorationClass)
expect(suggestion.decorationEmptyClass).toBe(options.decorationEmptyClass)
})

View File

@ -20,6 +20,8 @@ const MAX_UNKNOWN_CHARS = 5
export interface SlashCommandOptions {
decorationClass: string
decorationEmptyClass: string
// resolves an item's title for matching the typed query against it
t: (key: string) => string
}
export interface SlashCommandStorage {
@ -48,6 +50,7 @@ export const SlashCommands = Extension.create<
return {
decorationClass: "slash-command-filter",
decorationEmptyClass: "slash-command-filter-empty",
t: (key: string) => key,
}
},
addStorage() {
@ -71,7 +74,7 @@ export const SlashCommands = Extension.create<
allowSlashItemsByContext(state)
)
},
items: filterSlashItems,
items: (props) => filterSlashItems({ ...props, t: this.options.t }),
render: () => {
const storage = this.storage
const editor = this.editor

View File

@ -1,11 +1,21 @@
import type { JSONContent } from "@tiptap/core"
import { Editor } from "@tiptap/core"
import type { AnyExtension, InputRule, JSONContent } from "@tiptap/core"
import { Editor, getExtensionField } from "@tiptap/core"
import Document from "@tiptap/extension-document"
import Paragraph from "@tiptap/extension-paragraph"
import Text from "@tiptap/extension-text"
import { Schema } from "@tiptap/pm/model"
import { EditorState, TextSelection } from "@tiptap/pm/state"
import { describe, it, vi } from "vitest"
import Heading from "@tiptap/extension-heading"
import HorizontalRule from "@tiptap/extension-horizontal-rule"
import {
BulletList,
OrderedList,
TaskItem,
TaskList,
} from "@tiptap/extension-list"
import { InputRules } from "../input-utils"
import { extensionContext } from "../test-helpers/editor"
import {
CALLOUT_BLOCK_NAME,
CODE_BLOCK_NAME,
@ -18,12 +28,33 @@ import {
import { FileBlock } from "../blocks/file"
import { ImageBlock } from "../blocks/image"
import {
allItems,
allowSlashItemsByContext,
CommandGroup,
commandGroupSortIndex,
filterSlashItems,
} from "./items"
import { paragraph } from "~/components/editor/test-helpers"
import messages from "~/../i18n/locales/en/editor.json"
// node tests have no i18n runtime; titles resolve straight from the
// locale file the app ships
function t(key: string): string {
const message = key
.split(".")
.reduce<unknown>(
(node, part) =>
node && typeof node === "object"
? (node as Record<string, unknown>)[part]
: undefined,
messages,
)
if (typeof message !== "string") {
throw new Error(`no translation for "${key}"`)
}
return message
}
// minimal stand-ins for the real editor nodes — the context whitelist
// only inspects ancestor node names, so the content rules can stay
@ -110,7 +141,9 @@ function stateAt(text: string): EditorState {
function titles(query: string, cursorText: string): string[] {
const editor = { state: stateAt(cursorText) } as unknown as Editor
return filterSlashItems({ query, editor }).map((item) => item.title)
return filterSlashItems({ query, editor, t }).map((item) =>
t(item.titleI18nKey),
)
}
// a live editor holding the two upload atoms, for the commands that
@ -192,7 +225,7 @@ describe("filterSlashItems", () => {
expect,
}) => {
const editor = { state: stateAt("top") } as unknown as Editor
const items = filterSlashItems({ query: "", editor })
const items = filterSlashItems({ query: "", editor, t })
expect(new Set(items.map((item) => item.group))).toEqual(
new Set([
@ -280,8 +313,8 @@ describe("filterSlashItems", () => {
chain: () => chain,
} as unknown as Editor
const item = filterSlashItems({ query: title, editor }).find(
(v) => v.title === title,
const item = filterSlashItems({ query: title, editor, t }).find(
(v) => t(v.titleI18nKey) === title,
)
if (!item) {
throw new Error(`the ${title} item is missing from the slash menu`)
@ -308,8 +341,8 @@ describe("filterSlashItems", () => {
editor.commands.setTextSelection(10)
const from = editor.state.selection.from
const item = filterSlashItems({ query: title, editor }).find(
(v) => v.title === title,
const item = filterSlashItems({ query: title, editor, t }).find(
(v) => t(v.titleI18nKey) === title,
)
if (!item) {
throw new Error(`the ${title} item is missing from the slash menu`)
@ -336,8 +369,8 @@ describe("filterSlashItems", () => {
})
editor.commands.setTextSelection(1)
const item = filterSlashItems({ query: "File", editor }).find(
(v) => v.title === "File",
const item = filterSlashItems({ query: "File", editor, t }).find(
(v) => t(v.titleI18nKey) === "File",
)
if (!item) {
throw new Error("the File item is missing from the slash menu")
@ -348,3 +381,56 @@ describe("filterSlashItems", () => {
expect(topLevelTypes(editor)).toEqual(["paragraph"])
})
})
// every input rule that can turn typed markdown into a block, built the
// way the editor builds them: the heading levels the editor allows, the
// list rules (the checklist one sits on the item, not the list), the
// divider, and the custom block rules
function blockInputRules(): InputRule[] {
const sources: [AnyExtension, string][] = [
[Heading.configure({ levels: [1, 2, 3] }), Heading.name],
[BulletList, BulletList.name],
[OrderedList, OrderedList.name],
[TaskList, TaskList.name],
[TaskItem.configure({ nested: true }), TaskItem.name],
[HorizontalRule, HorizontalRule.name],
[InputRules, InputRules.name],
]
return sources.flatMap(([extension, name]) => {
const build = getExtensionField<(() => InputRule[]) | undefined>(
extension,
"addInputRules",
extensionContext(extension, name),
)
return build?.() ?? []
})
}
function matchedBySomeRule(rules: InputRule[], text: string): boolean {
return rules.some((rule) =>
typeof rule.find === "function"
? rule.find(text) !== null
: rule.find.test(text),
)
}
describe("allItems", () => {
it("describes every command", ({ expect }) => {
allItems.forEach((item) => {
expect(t(item.titleI18nKey).length).toBeGreaterThan(0)
expect(t(item.descriptionI18nKey).length).toBeGreaterThan(0)
})
})
// the shortcut field is shown as "type this to get the same block", so
// it has to be text an input rule actually reacts to, trailing space
// included where the rule waits for one
it.for(allItems.filter((item) => item.shortcut))(
"types markdown the editor recognises for $titleI18nKey",
({ shortcut }, { expect }) => {
expect(matchedBySomeRule(blockInputRules(), shortcut ?? "")).toBe(true)
},
)
})

View File

@ -55,10 +55,13 @@ export interface CommandData {
}
export interface CommandItem {
title: string
titleI18nKey: string
descriptionI18nKey: string
nodeType: string
icon: string
group: CommandGroup
// the markdown that inserts the same block when typed; a trailing space
// means the rule only fires on the space that follows it
shortcut?: string
disabled?: boolean
specialContext?: boolean
@ -114,14 +117,15 @@ function whitelistSlashItemsByContext(state: EditorState): string[] {
return allNormalItems.map((v) => v.nodeType)
}
const allItems: CommandItem[] = [
export const allItems: CommandItem[] = [
// text group
{
title: "Heading 1",
titleI18nKey: "editor.slash-commands.items.heading-1.title",
descriptionI18nKey: "editor.slash-commands.items.heading-1.description",
nodeType: Heading.name,
icon: "lucide:heading-1",
group: CommandGroup.Text,
shortcut: "#",
shortcut: "# ",
command: ({ editor, range }: CommandData) => {
const $pos = editor.state.doc.resolve(range.from)
const provider = extractHocuspocusProviderFromEditor(editor)
@ -145,11 +149,12 @@ const allItems: CommandItem[] = [
},
},
{
title: "Heading 2",
titleI18nKey: "editor.slash-commands.items.heading-2.title",
descriptionI18nKey: "editor.slash-commands.items.heading-2.description",
nodeType: Heading.name,
icon: "lucide:heading-2",
group: CommandGroup.Text,
shortcut: "##",
shortcut: "## ",
command: ({ editor, range }: CommandData) => {
const $pos = editor.state.doc.resolve(range.from)
const provider = extractHocuspocusProviderFromEditor(editor)
@ -173,11 +178,12 @@ const allItems: CommandItem[] = [
},
},
{
title: "Heading 3",
titleI18nKey: "editor.slash-commands.items.heading-3.title",
descriptionI18nKey: "editor.slash-commands.items.heading-3.description",
nodeType: Heading.name,
icon: "lucide:heading-3",
group: CommandGroup.Text,
shortcut: "###",
shortcut: "### ",
command: ({ editor, range }: CommandData) => {
const $pos = editor.state.doc.resolve(range.from)
const provider = extractHocuspocusProviderFromEditor(editor)
@ -202,11 +208,12 @@ const allItems: CommandItem[] = [
},
// list group
{
title: "Bulleted list",
titleI18nKey: "editor.slash-commands.items.bulleted-list.title",
descriptionI18nKey: "editor.slash-commands.items.bulleted-list.description",
nodeType: BulletList.name,
icon: "lucide:list",
group: CommandGroup.List,
shortcut: "-",
shortcut: "- ",
command: ({ editor, range }: CommandData) => {
const $pos = editor.state.doc.resolve(range.from)
const provider = extractHocuspocusProviderFromEditor(editor)
@ -225,11 +232,12 @@ const allItems: CommandItem[] = [
},
},
{
title: "Numbered list",
titleI18nKey: "editor.slash-commands.items.numbered-list.title",
descriptionI18nKey: "editor.slash-commands.items.numbered-list.description",
nodeType: OrderedList.name,
icon: "lucide:list-ordered",
group: CommandGroup.List,
shortcut: "1.",
shortcut: "1. ",
command: ({ editor, range }: CommandData) => {
const $pos = editor.state.doc.resolve(range.from)
const provider = extractHocuspocusProviderFromEditor(editor)
@ -248,11 +256,12 @@ const allItems: CommandItem[] = [
},
},
{
title: "Checklist",
titleI18nKey: "editor.slash-commands.items.checklist.title",
descriptionI18nKey: "editor.slash-commands.items.checklist.description",
nodeType: TaskList.name,
icon: "lucide:list-checks",
group: CommandGroup.List,
shortcut: "[]",
shortcut: "[] ",
command: ({ editor, range }: CommandData) => {
const $pos = editor.state.doc.resolve(range.from)
const provider = extractHocuspocusProviderFromEditor(editor)
@ -272,7 +281,8 @@ const allItems: CommandItem[] = [
},
// basic block group
{
title: "Code Block",
titleI18nKey: "editor.slash-commands.items.code-block.title",
descriptionI18nKey: "editor.slash-commands.items.code-block.description",
nodeType: CodeBlock.name,
icon: "lucide:square-code",
group: CommandGroup.BasicBlock,
@ -317,7 +327,8 @@ const allItems: CommandItem[] = [
},
},
{
title: "Callout",
titleI18nKey: "editor.slash-commands.items.callout.title",
descriptionI18nKey: "editor.slash-commands.items.callout.description",
nodeType: CalloutBlock.name,
icon: "lucide:square-m",
group: CommandGroup.BasicBlock,
@ -362,7 +373,8 @@ const allItems: CommandItem[] = [
},
},
{
title: "Image",
titleI18nKey: "editor.slash-commands.items.image.title",
descriptionI18nKey: "editor.slash-commands.items.image.description",
nodeType: ImageBlock.name,
icon: "lucide:image",
group: CommandGroup.BasicBlock,
@ -371,7 +383,8 @@ const allItems: CommandItem[] = [
},
},
{
title: "File",
titleI18nKey: "editor.slash-commands.items.file.title",
descriptionI18nKey: "editor.slash-commands.items.file.description",
nodeType: FileBlock.name,
icon: "lucide:paperclip",
group: CommandGroup.BasicBlock,
@ -380,7 +393,8 @@ const allItems: CommandItem[] = [
},
},
{
title: "Figma Embed",
titleI18nKey: "editor.slash-commands.items.figma-embed.title",
descriptionI18nKey: "editor.slash-commands.items.figma-embed.description",
nodeType: FigmaBlock.name,
icon: "simple-icons:figma",
group: CommandGroup.BasicBlock,
@ -419,7 +433,8 @@ const allItems: CommandItem[] = [
},
},
{
title: "Divider",
titleI18nKey: "editor.slash-commands.items.divider.title",
descriptionI18nKey: "editor.slash-commands.items.divider.description",
nodeType: HorizontalRule.name,
icon: "lucide:minus",
group: CommandGroup.BasicBlock,
@ -461,7 +476,9 @@ const allItems: CommandItem[] = [
},
// power block group
{
title: "Mermaid Diagram",
titleI18nKey: "editor.slash-commands.items.mermaid-diagram.title",
descriptionI18nKey:
"editor.slash-commands.items.mermaid-diagram.description",
nodeType: MermaidBlock.name,
icon: "lucide:network",
group: CommandGroup.PowerBlock,
@ -503,7 +520,9 @@ const allItems: CommandItem[] = [
},
},
{
title: "Split Documentation",
titleI18nKey: "editor.slash-commands.items.split-documentation.title",
descriptionI18nKey:
"editor.slash-commands.items.split-documentation.description",
nodeType: SplitDocumentation.name,
icon: "lucide:square-split-horizontal",
group: CommandGroup.PowerBlock,
@ -527,7 +546,8 @@ const allItems: CommandItem[] = [
},
},
{
title: "Live Metrics",
titleI18nKey: "editor.slash-commands.items.live-metrics.title",
descriptionI18nKey: "editor.slash-commands.items.live-metrics.description",
nodeType: METRIC_BLOCK_NAME,
icon: "lucide:chart-line",
group: CommandGroup.PowerBlock,
@ -553,12 +573,16 @@ const allItems: CommandItem[] = [
]
const allNormalItems = allItems.filter((v) => !v.specialContext)
// the query matches the title the reader sees, so the caller supplies the
// translation
export function filterSlashItems({
query,
editor,
t,
}: {
query: string
editor: Editor
t: (key: string) => string
}): CommandItem[] {
// NOTE: the group order is determined by the group enum, but the item order
// within the group is established here.
@ -566,7 +590,7 @@ export function filterSlashItems({
const res = whitelistSlashItemsByContext(editor.state)
return (
res.includes(item.nodeType) &&
item.title.toLowerCase().includes(query.toLowerCase())
t(item.titleI18nKey).toLowerCase().includes(query.toLowerCase())
)
})
}

View File

@ -20,17 +20,60 @@ function mountShortcut(
return mount(Host)
}
function pressKey(key: string) {
window.dispatchEvent(new KeyboardEvent("keydown", { key }))
function pressKey(key: string, code?: string) {
window.dispatchEvent(new KeyboardEvent("keydown", { key, code }))
}
function releaseKey(key: string) {
window.dispatchEvent(new KeyboardEvent("keyup", { key }))
function releaseKey(key: string, code?: string) {
window.dispatchEvent(new KeyboardEvent("keyup", { key, code }))
}
// the tests drive shared window key state — the tests cannot interleave,
// and each releases the keys it pressed
describe("useShortcut", { concurrent: false }, () => {
// with shift held the browser reports "|" as the key for the backslash;
// the shortcut still matches because punctuation is bound by its code
it("matches shifted punctuation by its key code", async ({ expect }) => {
const handler = vi.fn()
const wrapper = mountShortcut(
{ macOS: "⌘+⇧+\\", other: "Ctrl+Shift+\\" },
handler,
)
pressKey("Control")
pressKey("Shift")
pressKey("|", "Backslash")
await nextTick()
expect(handler).toHaveBeenCalledTimes(1)
releaseKey("|", "Backslash")
releaseKey("Shift")
releaseKey("Control")
wrapper.unmount()
})
// ⌘⇧\ holds every key of ⌘\, so without this the sidebar would toggle
// on the inbox shortcut
it("ignores the combo while a modifier outside it is held", async ({
expect,
}) => {
const handler = vi.fn()
const wrapper = mountShortcut({ macOS: "⌘+K", other: "Ctrl+K" }, handler)
pressKey("Control")
pressKey("Shift")
pressKey("K", "KeyK")
await nextTick()
expect(handler).toHaveBeenCalledTimes(0)
releaseKey("K", "KeyK")
releaseKey("Shift")
releaseKey("Control")
wrapper.unmount()
})
it("fires the handler when the shortcut combo is pressed", async ({
expect,
}) => {

View File

@ -1,6 +1,7 @@
import type { UseMagicKeysReturn } from "@vueuse/core"
const SHORTCUT_KEY = Symbol("icon-picker")
const MODIFIERS = ["meta", "control", "alt", "shift"]
// the shortcut/handler are optional; if not provided, the composable is used
// for side effects only (i.e., to provide the magicKeys instance)
@ -18,15 +19,28 @@ export default function (
if (shortcut && handler) {
const combo = normalizeShortcut(shortcut, osType.value)
const parts = combo.split("_")
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- useMagicKeys returns a proxy that materializes a ref for any key
const comboKey = magicKeys[combo]!
const current = magicKeys.current
// a combo's ref is true whenever its keys are down, so holding an
// extra modifier satisfies it too and ⌘⇧\ would fire ⌘\ as well.
// The shortcut only counts while no modifier outside it is held.
function exact() {
return MODIFIERS.every(
(modifier) => !current.has(modifier) || parts.includes(modifier),
)
}
const stop = whenever(comboKey, () => {
handler()
if (exact()) {
handler()
}
})
const stopPrevent = useEventListener(window, "keydown", (ev) => {
if (comboKey.value) {
if (comboKey.value && exact()) {
ev.preventDefault()
}
})

View File

@ -1,4 +1,22 @@
import type { AnyExtension, InputRule } from "@tiptap/core"
import { getExtensionField } from "@tiptap/core"
import Blockquote from "@tiptap/extension-blockquote"
import Bold from "@tiptap/extension-bold"
import Code from "@tiptap/extension-code"
import Collaboration from "@tiptap/extension-collaboration"
import Heading from "@tiptap/extension-heading"
import Italic from "@tiptap/extension-italic"
import {
BulletList,
ListItem,
OrderedList,
TaskList,
} from "@tiptap/extension-list"
import Strike from "@tiptap/extension-strike"
import Underline from "@tiptap/extension-underline"
import { describe, it } from "vitest"
import { CodeBlock } from "~/components/editor/blocks/code-block"
import { extensionContext } from "~/components/editor/test-helpers/editor"
import { HostOsType } from "~/composables/useDetectHost"
import {
extractShortcutKeys,
@ -65,39 +83,132 @@ describe("normalizeShortcut", () => {
format: undefined,
expected: "esc",
},
{
name: "binds punctuation by its key code for the default format",
shortcut: { macOS: "⌘+⇧+\\", other: "Ctrl+Shift+\\" },
os: HostOsType.MacOS,
format: undefined,
expected: "meta_shift_backslash",
},
{
name: "binds the comma, period and slash by their key codes",
shortcut: { macOS: "⌘+,", other: "Ctrl+," },
os: HostOsType.Other,
format: undefined,
expected: "control_comma",
},
])("$name", ({ shortcut, os, format, expected }, { expect }) => {
expect(normalizeShortcut(shortcut, os, format)).toBe(expected)
})
})
describe("extractShortcutKeys", () => {
it("splits a two-key shortcut with a connector between", ({ expect }) => {
it("joins keys held together with a chord connector", ({ expect }) => {
expect(extractShortcutKeys("Ctrl+K")).toEqual([
{ key: "Ctrl" },
{ connector: true },
{ connector: "chord" },
{ key: "K" },
])
})
it("joins keys pressed one after another with a sequence connector", ({
expect,
}) => {
expect(extractShortcutKeys("G I")).toEqual([
{ key: "G" },
{ connector: "sequence" },
{ key: "I" },
])
})
it("keeps a chord inside a sequence together", ({ expect }) => {
expect(extractShortcutKeys("G Shift+I")).toEqual([
{ key: "G" },
{ connector: "sequence" },
{ key: "Shift" },
{ connector: "chord" },
{ key: "I" },
])
})
it("returns a single key without connectors", ({ expect }) => {
expect(extractShortcutKeys("esc")).toEqual([{ key: "esc" }])
})
it("trims whitespace around keys", ({ expect }) => {
it("reads whitespace around a plus as part of the chord", ({ expect }) => {
expect(extractShortcutKeys("Ctrl + K")).toEqual([
{ key: "Ctrl" },
{ connector: true },
{ connector: "chord" },
{ key: "K" },
])
})
})
// the editor's own bindings, keyed by the extension name a "keys" entry
// cites. Heading is configured as the editor configures it, since its
// keymap is built from the levels it allows.
const EDITOR_EXTENSIONS: Record<string, AnyExtension> = {
bold: Bold,
italic: Italic,
underline: Underline,
strike: Strike,
code: Code,
heading: Heading.configure({ levels: [1, 2, 3] }),
blockquote: Blockquote,
bulletList: BulletList,
orderedList: OrderedList,
taskList: TaskList,
listItem: ListItem,
codeBlock: CodeBlock,
collaboration: Collaboration,
}
function editorKeymap(name: string): Record<string, unknown> {
const extension = EDITOR_EXTENSIONS[name]
if (!extension) {
throw new Error(`no editor extension registered as "${name}"`)
}
const build = getExtensionField<() => Record<string, unknown>>(
extension,
"addKeyboardShortcuts",
extensionContext(extension, name),
)
return build()
}
function editorInputRules(name: string): InputRule[] {
const extension = EDITOR_EXTENSIONS[name]
if (!extension) {
throw new Error(`no editor extension registered as "${name}"`)
}
const build = getExtensionField<(() => InputRule[]) | undefined>(
extension,
"addInputRules",
extensionContext(extension, name),
)
return build?.() ?? []
}
function matchedBySomeRule(rules: InputRule[], text: string): boolean {
return rules.some((rule) =>
typeof rule.find === "function"
? rule.find(text) !== null
: rule.find.test(text),
)
}
const entries = SHORTCUT_GROUPS.flatMap((group) => group.shortcuts)
describe("SHORTCUT_GROUPS", () => {
// the modal renders nothing but these groups, so an action that never
// lands in one is a shortcut the user can never look up
it("files every documented action into exactly one group", ({ expect }) => {
const grouped = SHORTCUT_GROUPS.flatMap((group) =>
group.shortcuts.map((entry) => entry.action),
const grouped = entries.flatMap((entry) =>
entry.kind === "action" ? [entry.action] : [],
)
const documented = Object.values(SHORTCUT_ACTIONS).filter(
(action) => action.i18nKey !== null,
@ -111,8 +222,8 @@ describe("SHORTCUT_GROUPS", () => {
})
it("leaves the self-explanatory actions out", ({ expect }) => {
const grouped = SHORTCUT_GROUPS.flatMap((group) =>
group.shortcuts.map((entry) => entry.action),
const grouped = entries.flatMap((entry) =>
entry.kind === "action" ? [entry.action] : [],
)
expect(grouped).not.toContain(
@ -120,4 +231,50 @@ describe("SHORTCUT_GROUPS", () => {
)
expect(grouped).not.toContain(SHORTCUT_ACTIONS.openEditorCompletionMenu)
})
// a "keys" entry documents a binding the editor owns; if the extension
// drops or renames it the modal would keep advertising a dead key
it.for(entries.flatMap((entry) => (entry.kind === "keys" ? [entry] : [])))(
"cites a live editor binding for $i18nKey",
({ editorKey, extension }, { expect }) => {
expect(Object.keys(editorKeymap(extension))).toContain(editorKey)
},
)
// a "typed" entry documents markdown an input rule reacts to; the
// pattern must trip the rule the way a user would type it
it.for([
{ i18nKey: "shortcuts.modal.entries.md-bold.label", extension: "bold" },
{ i18nKey: "shortcuts.modal.entries.md-italic.label", extension: "italic" },
{
i18nKey: "shortcuts.modal.entries.md-inline-code.label",
extension: "code",
},
{
i18nKey: "shortcuts.modal.entries.md-strikethrough.label",
extension: "strike",
},
{
i18nKey: "shortcuts.modal.entries.md-quote.label",
extension: "blockquote",
},
])(
"types a pattern the editor recognises for $i18nKey",
({ i18nKey, extension }, { expect }) => {
const entry = entries.find((candidate) => candidate.i18nKey === i18nKey)
if (entry?.kind !== "typed") {
throw new Error(`${i18nKey} is not a typed entry`)
}
expect(
matchedBySomeRule(editorInputRules(extension), entry.pattern),
).toBe(true)
},
)
it("lists every typed entry in the pattern check above", ({ expect }) => {
const typed = entries.filter((entry) => entry.kind === "typed")
expect(typed).toHaveLength(5)
})
})

View File

@ -25,8 +25,8 @@ export const SHORTCUT_ACTIONS = {
},
toggleInbox: {
keyboardKey: {
macOS: "⌘+I",
other: "Ctrl+I",
macOS: "⌘+⇧+\\",
other: "Ctrl+Shift+\\",
},
i18nKey: "shortcuts.keys.toggle-inbox",
},
@ -39,8 +39,8 @@ export const SHORTCUT_ACTIONS = {
},
toggleSettings: {
keyboardKey: {
macOS: "⌘+,",
other: "Ctrl+,",
macOS: "⌘+⇧+,",
other: "Ctrl+Shift+,",
},
i18nKey: "shortcuts.keys.toggle-settings",
},
@ -54,24 +54,24 @@ export const SHORTCUT_ACTIONS = {
addParamsToSplitDocLeftSide: {
// context-dependent (handle by the node)
keyboardKey: {
macOS: "⌘+E",
other: "Ctrl+E",
macOS: "⌘+⌥+P",
other: "Ctrl+Alt+P",
},
i18nKey: "shortcuts.keys.add-params-to-split-doc-left-side",
},
addCodeBlockToSplitDocRightSide: {
// context-dependent (handle by the node)
keyboardKey: {
macOS: "⌘+E",
other: "Ctrl+E",
macOS: "⌘+⌥+E",
other: "Ctrl+Alt+E",
},
i18nKey: "shortcuts.keys.add-code-block-to-split-doc-right-side",
},
addMetricsToSplitDocRightSide: {
// context-dependent (handle by the node)
keyboardKey: {
macOS: "⌘+M",
other: "Ctrl+M",
macOS: "⌘+⌥+G",
other: "Ctrl+Alt+G",
},
i18nKey: "shortcuts.keys.add-metrics-to-split-doc-right-side",
},
@ -84,83 +84,178 @@ export const SHORTCUT_ACTIONS = {
},
}
export interface ShortcutAction {
keyboardKey: { macOS: string; other: string }
i18nKey: string | null
}
// one row of the shortcut modal. An "action" is bound app-wide through
// SHORTCUT_ACTIONS; "keys" is a binding the editor's tiptap extensions
// own, named here by its tiptap notation and extension so a test can check
// it still exists; "typed" is markdown the editor turns into formatting
// as it is typed, where a trailing space means the rule fires on that
// space.
type ShortcutTrigger =
| { kind: "action"; action: ShortcutAction }
| {
kind: "keys"
keyboardKey: { macOS: string; other: string }
editorKey: string
extension: string
}
| { kind: "typed"; pattern: string }
export type ShortcutEntry = {
i18nKey: string
descriptionI18nKey: string
} & ShortcutTrigger
function entry(key: string, trigger: ShortcutTrigger): ShortcutEntry {
return {
i18nKey: `shortcuts.modal.entries.${key}.label`,
descriptionI18nKey: `shortcuts.modal.entries.${key}.description`,
...trigger,
}
}
function keys(
key: string,
macOS: string,
other: string,
editorKey: string,
extension: string,
): ShortcutEntry {
return entry(key, {
kind: "keys",
keyboardKey: { macOS, other },
editorKey,
extension,
})
}
// the groups the shortcut modal renders, in order. Each entry carries its
// own label and description: an action's own i18nKey is the terse text the
// tooltips show next to a button that already names the action, which reads
// as filler in a standalone list. An action whose i18nKey is null explains
// itself where it is used and stays out of the modal.
export const SHORTCUT_GROUPS = [
// itself where it is used and stays out of the modal. Slash commands and
// the markdown that inserts blocks are not listed here: the modal reads
// them from the slash menu's own items so the two never drift.
export const SHORTCUT_GROUPS: {
id: string
i18nKey: string
shortcuts: ShortcutEntry[]
}[] = [
{
id: "general",
i18nKey: "shortcuts.groups.general",
shortcuts: [
{
entry("toggle-settings", {
kind: "action",
action: SHORTCUT_ACTIONS.toggleSettings,
i18nKey: "shortcuts.modal.entries.toggle-settings.label",
descriptionI18nKey:
"shortcuts.modal.entries.toggle-settings.description",
},
{
}),
entry("toggle-shortcuts", {
kind: "action",
action: SHORTCUT_ACTIONS.toggleShortcuts,
i18nKey: "shortcuts.modal.entries.toggle-shortcuts.label",
descriptionI18nKey:
"shortcuts.modal.entries.toggle-shortcuts.description",
},
}),
keys("undo", "⌘+Z", "Ctrl+Z", "Mod-z", "collaboration"),
keys("redo", "⌘+⇧+Z", "Ctrl+Shift+Z", "Shift-Mod-z", "collaboration"),
],
},
{
id: "navigation",
i18nKey: "shortcuts.groups.navigation",
shortcuts: [
{
entry("toggle-sidebar", {
kind: "action",
action: SHORTCUT_ACTIONS.toggleSidebar,
i18nKey: "shortcuts.modal.entries.toggle-sidebar.label",
descriptionI18nKey:
"shortcuts.modal.entries.toggle-sidebar.description",
},
{
}),
entry("toggle-inbox", {
kind: "action",
action: SHORTCUT_ACTIONS.toggleInbox,
i18nKey: "shortcuts.modal.entries.toggle-inbox.label",
descriptionI18nKey: "shortcuts.modal.entries.toggle-inbox.description",
},
{
}),
entry("search-for-documents", {
kind: "action",
action: SHORTCUT_ACTIONS.searchForDocuments,
i18nKey: "shortcuts.modal.entries.search-for-documents.label",
descriptionI18nKey:
"shortcuts.modal.entries.search-for-documents.description",
},
}),
],
},
{
id: "pages",
i18nKey: "shortcuts.groups.pages",
shortcuts: [
{
entry("create-new-document", {
kind: "action",
action: SHORTCUT_ACTIONS.createNewDocument,
i18nKey: "shortcuts.modal.entries.create-new-document.label",
descriptionI18nKey:
"shortcuts.modal.entries.create-new-document.description",
},
}),
],
},
{
i18nKey: "shortcuts.groups.editor",
id: "formatting",
i18nKey: "shortcuts.groups.formatting",
shortcuts: [
{
keys("bold", "⌘+B", "Ctrl+B", "Mod-b", "bold"),
keys("italic", "⌘+I", "Ctrl+I", "Mod-i", "italic"),
keys("underline", "⌘+U", "Ctrl+U", "Mod-u", "underline"),
keys("strikethrough", "⌘+⇧+S", "Ctrl+Shift+S", "Mod-Shift-s", "strike"),
keys("inline-code", "⌘+E", "Ctrl+E", "Mod-e", "code"),
keys("heading-1", "⌘+⌥+1", "Ctrl+Alt+1", "Mod-Alt-1", "heading"),
keys("heading-2", "⌘+⌥+2", "Ctrl+Alt+2", "Mod-Alt-2", "heading"),
keys("heading-3", "⌘+⌥+3", "Ctrl+Alt+3", "Mod-Alt-3", "heading"),
keys(
"bulleted-list",
"⌘+⇧+8",
"Ctrl+Shift+8",
"Mod-Shift-8",
"bulletList",
),
keys(
"numbered-list",
"⌘+⇧+7",
"Ctrl+Shift+7",
"Mod-Shift-7",
"orderedList",
),
keys("checklist", "⌘+⇧+9", "Ctrl+Shift+9", "Mod-Shift-9", "taskList"),
keys("quote", "⌘+⇧+B", "Ctrl+Shift+B", "Mod-Shift-b", "blockquote"),
keys("code-block", "⌘+⌥+C", "Ctrl+Alt+C", "Mod-Alt-c", "codeBlock"),
keys("indent-list-item", "Tab", "Tab", "Tab", "listItem"),
keys("outdent-list-item", "⇧+Tab", "Shift+Tab", "Shift-Tab", "listItem"),
keys(
"exit-code-block",
"⇧+Enter",
"Shift+Enter",
"Shift-Enter",
"codeBlock",
),
],
},
{
id: "split-documentation",
i18nKey: "shortcuts.groups.split-documentation",
shortcuts: [
entry("add-params-to-split-doc", {
kind: "action",
action: SHORTCUT_ACTIONS.addParamsToSplitDocLeftSide,
i18nKey: "shortcuts.modal.entries.add-params-to-split-doc.label",
descriptionI18nKey:
"shortcuts.modal.entries.add-params-to-split-doc.description",
},
{
}),
entry("add-code-block-to-split-doc", {
kind: "action",
action: SHORTCUT_ACTIONS.addCodeBlockToSplitDocRightSide,
i18nKey: "shortcuts.modal.entries.add-code-block-to-split-doc.label",
descriptionI18nKey:
"shortcuts.modal.entries.add-code-block-to-split-doc.description",
},
{
}),
entry("add-metrics-to-split-doc", {
kind: "action",
action: SHORTCUT_ACTIONS.addMetricsToSplitDocRightSide,
i18nKey: "shortcuts.modal.entries.add-metrics-to-split-doc.label",
descriptionI18nKey:
"shortcuts.modal.entries.add-metrics-to-split-doc.description",
},
}),
],
},
{
id: "markdown",
i18nKey: "shortcuts.groups.markdown",
shortcuts: [
entry("md-bold", { kind: "typed", pattern: "**text**" }),
entry("md-italic", { kind: "typed", pattern: "*text*" }),
entry("md-inline-code", { kind: "typed", pattern: "`text`" }),
entry("md-strikethrough", { kind: "typed", pattern: "~~text~~" }),
entry("md-quote", { kind: "typed", pattern: "> " }),
],
},
]
@ -176,6 +271,13 @@ export function shortcutByOS(
return action.other
}
const PUNCTUATION_CODE_NAMES: Record<string, string> = {
"\\": "backslash",
",": "comma",
".": "period",
"/": "slash",
}
export function normalizeShortcut(
shortcut: { macOS: string; other: string },
osType: HostOsType,
@ -186,15 +288,26 @@ export function normalizeShortcut(
switch (format) {
case "default":
return res
.toLowerCase()
.replace("⌘", "meta")
.replace("cmd", "meta")
.replace("command", "meta")
.replace("ctrl", "control")
.replace("⌥", "alt")
.replace("option", "alt")
.replace("⇧", "shift")
.replace(/\+/g, "_")
.split("+")
.map((part) => {
const key = part.trim().toLowerCase()
// useMagicKeys records event.key, which shift turns into "|"
// or "<" for punctuation, and event.code, which stays
// "backslash" or "comma" whatever else is held
return (
PUNCTUATION_CODE_NAMES[key] ??
key
.replace("⌘", "meta")
.replace("cmd", "meta")
.replace("command", "meta")
.replace("ctrl", "control")
.replace("⌥", "alt")
.replace("option", "alt")
.replace("⇧", "shift")
)
})
.join("_")
case "codemirror":
return res
.toLowerCase()
@ -209,23 +322,32 @@ export function normalizeShortcut(
}
}
// extractShortcutKeys takes a shortcut string like "Ctrl+K" and
// returns an array of objects representing each key and whether it's a
// connector (+). Only "+" is treated as a connector for now.
// extractShortcutKeys splits a shortcut string into its keys and the
// connectors between them. "+" joins keys held together (a chord, shown
// side by side); bare whitespace joins keys pressed one after another (a
// sequence, shown with "then" between).
export function extractShortcutKeys(full: string): {
key?: string
connector?: boolean
connector?: "chord" | "sequence"
}[] {
const parts = full.split("+")
const res: { key?: string; connector?: boolean }[] = []
const res: { key?: string; connector?: "chord" | "sequence" }[] = []
parts.forEach((part, index) => {
res.push({ key: part.trim() })
full
.trim()
.split(/(\s*\+\s*|\s+)/)
.forEach((part) => {
if (part === "") {
return
}
if (index < parts.length - 1) {
res.push({ connector: true })
}
})
if (part.includes("+")) {
res.push({ connector: "chord" })
} else if (/^\s+$/.test(part)) {
res.push({ connector: "sequence" })
} else {
res.push({ key: part })
}
})
return res
}

View File

@ -158,6 +158,7 @@ export default withNuxt([
// t() later (the rule only tracks direct call sites)
"/^editor\\.ai-chat\\.tool-status\\./",
"/^editor\\.hooks\\.[a-z-]+\\.existing-item-(block|full-document)-explanation$/",
"/^editor\\.slash-commands\\.items\\./",
"/^shortcuts\\.groups\\./",
"/^shortcuts\\.modal\\.entries\\./",
"/^shortcuts\\.keys\\./",

View File

@ -167,7 +167,69 @@
}
},
"slash-commands": {
"no-results": "Add '{query}' to the page"
"no-results": "Add '{query}' to the page",
"items": {
"heading-1": {
"title": "Heading 1",
"description": "Turns the line into a large heading."
},
"heading-2": {
"title": "Heading 2",
"description": "Turns the line into a medium heading."
},
"heading-3": {
"title": "Heading 3",
"description": "Turns the line into a small heading."
},
"bulleted-list": {
"title": "Bulleted list",
"description": "Starts a list with bullet points."
},
"numbered-list": {
"title": "Numbered list",
"description": "Starts a list with numbers."
},
"checklist": {
"title": "Checklist",
"description": "Starts a list with checkboxes."
},
"code-block": {
"title": "Code Block",
"description": "Adds a block for code with syntax highlighting."
},
"callout": {
"title": "Callout",
"description": "Adds a highlighted box for notes and warnings."
},
"image": {
"title": "Image",
"description": "Adds an image."
},
"file": {
"title": "File",
"description": "Attaches a file."
},
"figma-embed": {
"title": "Figma Embed",
"description": "Embeds a Figma file."
},
"divider": {
"title": "Divider",
"description": "Adds a horizontal line."
},
"mermaid-diagram": {
"title": "Mermaid Diagram",
"description": "Adds a Mermaid diagram."
},
"split-documentation": {
"title": "Split Documentation",
"description": "Adds a block with documentation on the left and code or metrics on the right."
},
"live-metrics": {
"title": "Live Metrics",
"description": "Adds a live chart from one of your data sources."
}
}
},
"icon-select-menu": {
"input-placeholder": "Filter...",

View File

@ -6,48 +6,146 @@
"description": "Every keyboard shortcut available in the app",
"entries": {
"toggle-settings": {
"label": "Settings",
"description": "Opens and closes settings. Works anywhere in the workspace."
"label": "Open settings",
"description": "Opens settings. Press again to close. Works anywhere in the workspace."
},
"toggle-shortcuts": {
"label": "Keyboard shortcuts",
"description": "Opens and closes this list. Works anywhere in the workspace."
"label": "View keyboard shortcuts",
"description": "Opens this list. Press again to close. Works anywhere in the workspace."
},
"toggle-sidebar": {
"label": "Sidebar",
"label": "Toggle sidebar",
"description": "Shows and hides the page sidebar. Works while you type too."
},
"toggle-inbox": {
"label": "Inbox",
"description": "Opens and closes the notification panel on the right."
"label": "Toggle inbox",
"description": "Opens and closes the inbox on the right. Works anywhere in the workspace."
},
"search-for-documents": {
"label": "Search",
"description": "Opens and closes page search."
"label": "Open search",
"description": "Opens page search. Press again to close."
},
"create-new-document": {
"label": "New page",
"label": "Create new page",
"description": "Adds a new top level page. You stay on the page you are on."
},
"add-params-to-split-doc": {
"label": "Add a parameter list",
"description": "Use it on the left side of a split documentation block. Adds a parameter list at the bottom and puts your cursor in it."
"label": "Add parameter list to split documentation",
"description": "Adds a parameter list to the left side of a split documentation block and puts your cursor in it. Works from anywhere in the block."
},
"add-code-block-to-split-doc": {
"label": "Add a code block",
"description": "The same key on the right side of a split documentation block. Adds a code block at the bottom and puts your cursor in its title."
"label": "Add code block to split documentation",
"description": "Adds a code block to the right side of a split documentation block and puts your cursor in its title. Works from anywhere in the block."
},
"add-metrics-to-split-doc": {
"label": "Add a metrics block",
"description": "Use it on the right side of a split documentation block. Adds a metrics block at the bottom. Your cursor does not move."
"label": "Add metrics block to split documentation",
"description": "Adds a metrics block to the right side of a split documentation block. Your cursor does not move. Works from anywhere in the block."
},
"undo": {
"label": "Undo",
"description": "Takes back your last change in the page."
},
"redo": {
"label": "Redo",
"description": "Puts back the change you just undid."
},
"bold": {
"label": "Bold",
"description": "Makes the selected text bold."
},
"italic": {
"label": "Italic",
"description": "Makes the selected text italic."
},
"underline": {
"label": "Underline",
"description": "Underlines the selected text."
},
"strikethrough": {
"label": "Strikethrough",
"description": "Strikes through the selected text."
},
"inline-code": {
"label": "Inline code",
"description": "Shows the selected text as code."
},
"heading-1": {
"label": "Heading 1",
"description": "Turns the line into a large heading. Press again to turn it back."
},
"heading-2": {
"label": "Heading 2",
"description": "Turns the line into a medium heading. Press again to turn it back."
},
"heading-3": {
"label": "Heading 3",
"description": "Turns the line into a small heading. Press again to turn it back."
},
"bulleted-list": {
"label": "Bulleted list",
"description": "Turns the line into a bullet point. Press again to turn it back."
},
"numbered-list": {
"label": "Numbered list",
"description": "Turns the line into a numbered item. Press again to turn it back."
},
"checklist": {
"label": "Checklist",
"description": "Turns the line into a checkbox item. Press again to turn it back."
},
"quote": {
"label": "Quote",
"description": "Turns the line into a quote. Press again to turn it back."
},
"code-block": {
"label": "Code block",
"description": "Turns the line into a code block. Press again to turn it back."
},
"indent-list-item": {
"label": "Indent list item",
"description": "Moves the list item one level deeper. Works with the cursor in a list."
},
"outdent-list-item": {
"label": "Outdent list item",
"description": "Moves the list item one level up. Works with the cursor in a list."
},
"exit-code-block": {
"label": "Leave code block",
"description": "Adds a new line below the code block and moves your cursor there. Works with the cursor in a code block or a Mermaid diagram."
},
"md-bold": {
"label": "Bold",
"description": "Type it around a word to make it bold."
},
"md-italic": {
"label": "Italic",
"description": "Type it around a word to make it italic. Underscores work too."
},
"md-inline-code": {
"label": "Inline code",
"description": "Type it around a word to show it as code."
},
"md-strikethrough": {
"label": "Strikethrough",
"description": "Type it around a word to strike it through."
},
"md-quote": {
"label": "Quote",
"description": "Type it at the start of a line to turn the line into a quote."
}
}
},
"space-key": "Space",
"markdown-block-hint": "Type it at the start of a line.",
"slash-hint": "Type it at the start of a line, then pick the block from the menu."
},
"groups": {
"general": "General",
"navigation": "Navigation",
"pages": "Pages",
"editor": "Editor"
"formatting": "Formatting",
"split-documentation": "Split Documentation",
"markdown": "Markdown",
"slash-commands": "Slash Commands"
},
"keys": {
"toggle-sidebar": "Open/close sidebar",