mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
update shortcuts
This commit is contained in:
parent
ab7b9a04c1
commit
6bb265d0ed
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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\\./",
|
||||
|
|
|
|||
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user