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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion packages/app/src/components/CodeMirrorPaperSurface.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -520,10 +520,44 @@ describe("CodeMirrorPaperSurface", () => {
expect(
getMarkraSlashMenuState(view).actions.map((action) => action.command),
).toEqual(
expect.arrayContaining(["block.callout", "block.table"]),
expect.arrayContaining([
"block.callout",
"block.table",
"block.task-list",
"insert.today",
]),
);
});

it("inserts today's local date from the slash command", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(2031, 4, 6, 10, 30));
const onEditorReady = vi.fn();

try {
render(
<CodeMirrorPaperSurface
autoFocus={false}
initialContent="Published: "
onEditorReady={onEditorReady}
onMarkdownChange={() => {}}
/>,
);
const view = onEditorReady.mock.calls[0]?.[0] as EditorView;
act(() => {
view.dispatch({
selection: EditorSelection.cursor(view.state.doc.length),
});
});

expect(runMarkraCommand(view, "insert.today")).toBe(true);
expect(view.state.doc.toString()).toBe("Published: 2031-05-06");
expect(view.state.selection.main.head).toBe(view.state.doc.length);
} finally {
vi.useRealTimers();
}
});

it("accepts host plugins and renders their toolbar actions", () => {
const onEditorReady = vi.fn();
const plugin = defineMarkraPlugin({
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/components/CodeMirrorPaperSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
getActiveCodeMirrorSpellcheckMatch,
horizontalRulePlugin,
imagePreviewPlugin,
insertionsPlugin,
liveMarkdown,
linksPlugin,
markdownEditingPlugin,
Expand Down Expand Up @@ -204,6 +205,7 @@ function markdownExtension({
"block.paragraph": t(language, "menu.paragraph"),
"block.quote": t(language, "menu.quote"),
"block.table": t(language, "menu.table"),
"block.task-list": t(language, "menu.taskList"),
},
}),
codeMirrorBlockDragPlugin({
Expand Down Expand Up @@ -253,6 +255,11 @@ function markdownExtension({
frontmatterPreviewPlugin(),
horizontalRulePlugin(),
imagePreviewPlugin(imageOptions),
insertionsPlugin({
labels: {
"insert.today": t(language, "menu.today"),
},
}),
...(linkOptions ? [linksPlugin(linkOptions)] : []),
mathPreviewPlugin(),
markdownEditingPlugin(),
Expand Down
22 changes: 22 additions & 0 deletions packages/editor/src/codemirror/blocks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ describe("blocksPlugin", () => {
icon: "list",
label: "Bullet list",
},
{
command: "block.task-list",
icon: "list-checks",
label: "Task list",
},
{
command: "block.ordered-list",
icon: "list-ordered",
Expand Down Expand Up @@ -181,13 +186,30 @@ describe("blocksPlugin", () => {
expect(runMarkraCommand(bullet, "block.bullet-list")).toBe(true);
expect(bullet.state.doc.toString()).toBe("Alpha\nBeta");

const task = createView();
expect(runMarkraCommand(task, "block.task-list")).toBe(true);
expect(task.state.doc.toString()).toBe("- [ ] Alpha\n- [ ] Beta");
expect(runMarkraCommand(task, "block.task-list")).toBe(true);
expect(task.state.doc.toString()).toBe("Alpha\nBeta");

const ordered = createView();
expect(runMarkraCommand(ordered, "block.ordered-list")).toBe(true);
expect(ordered.state.doc.toString()).toBe("1. Alpha\n2. Beta");
expect(runMarkraCommand(ordered, "block.ordered-list")).toBe(true);
expect(ordered.state.doc.toString()).toBe("Alpha\nBeta");
});

it("converts existing list markers without leaving task syntax behind", () => {
const view = createView({
doc: "- [x] Alpha\n* Beta",
from: 0,
to: "- [x] Alpha\n* Beta".length,
});

expect(runMarkraCommand(view, "block.ordered-list")).toBe(true);
expect(view.state.doc.toString()).toBe("1. Alpha\n2. Beta");
});

it("wraps and unwraps a selected block in a fenced code block", () => {
const view = createView();

Expand Down
53 changes: 46 additions & 7 deletions packages/editor/src/codemirror/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type BlockCommandId =
| "block.heading.5"
| "block.heading.6"
| "block.bullet-list"
| "block.task-list"
| "block.ordered-list"
| "block.quote"
| "block.callout"
Expand Down Expand Up @@ -62,6 +63,7 @@ const defaultBlockLabels: BlockLabels = {
"block.heading.5": "Heading 5",
"block.heading.6": "Heading 6",
"block.bullet-list": "Bullet list",
"block.task-list": "Task list",
"block.ordered-list": "Numbered list",
"block.quote": "Quote",
"block.callout": "Callout",
Expand Down Expand Up @@ -126,19 +128,33 @@ const blockSpecs: readonly BlockSpec[] = [
keywords: ["bullet", "unordered", "list", "项目符号", "列表"],
order: 80,
},
{
id: "block.task-list",
icon: "list-checks",
keywords: [
"task",
"todo",
"checkbox",
"checklist",
"任务",
"待办",
"复选框",
],
order: 90,
},
{
id: "block.ordered-list",
icon: "list-ordered",
key: "Mod-Shift-7",
keywords: ["numbered", "ordered", "list", "编号", "有序列表"],
order: 90,
order: 100,
},
{
id: "block.quote",
icon: "text-quote",
key: "Mod-Shift-b",
keywords: ["quote", "blockquote", "引用"],
order: 100,
order: 110,
},
{
id: "block.callout",
Expand All @@ -155,27 +171,30 @@ const blockSpecs: readonly BlockSpec[] = [
"提示",
"警告",
],
order: 105,
order: 115,
},
{
id: "block.code",
icon: "square-code",
key: "Mod-Alt-c",
keywords: ["code", "fence", "代码", "代码块"],
order: 110,
order: 120,
},
{
id: "block.table",
icon: "table-2",
keywords: ["table", "grid", "表格"],
order: 120,
order: 130,
},
];

const headingPattern = /^([\t ]{0,3})#{1,6}[\t ]+/;
const bulletPattern = /^([\t ]*)[-+*][\t ]+/;
const taskPattern = /^([\t ]*)[-+*][\t ]+\[[ xX]\](?:[\t ]+|$)/;
const bulletPattern =
/^([\t ]*)[-+*][\t ]+(?!\[[ xX]\](?:[\t ]+|$))/;
const orderedPattern = /^([\t ]*)\d+[.)][\t ]+/;
const listPattern = /^([\t ]*)(?:[-+*][\t ]+|\d+[.)][\t ]+)/;
const listPattern =
/^([\t ]*)(?:[-+*][\t ]+\[[ xX]\](?:[\t ]+|$)|[-+*][\t ]+|\d+[.)][\t ]+)/;
const quotePattern = /^([\t ]*)>[\t ]?/;
const openingFencePattern = /^[\t ]*```[^`]*$/;
const closingFencePattern = /^[\t ]*```[\t ]*$/;
Expand Down Expand Up @@ -312,6 +331,20 @@ function toggleList(view: EditorView, ordered: boolean) {
return dispatchLineChanges(view, changes);
}

function toggleTaskList(view: EditorView) {
if (!isEditable(view)) return false;
const lines = selectedLines(view.state);
const remove = lines.every((line) => taskPattern.test(line.text));
const changes = lines.map((line) =>
markerChange(
line,
remove ? taskPattern : listPattern,
remove ? "" : "- [ ] ",
),
);
return dispatchLineChanges(view, changes);
}

function codeBlockBounds(view: EditorView) {
if (view.state.selection.ranges.length !== 1) return undefined;
const range = view.state.selection.main;
Expand Down Expand Up @@ -471,6 +504,12 @@ function blockCommand(
isActive: (view) => everyLineMatches(view, bulletPattern),
run: (view) => toggleList(view, false),
};
case "block.task-list":
return {
...shared,
isActive: (view) => everyLineMatches(view, taskPattern),
run: toggleTaskList,
};
case "block.ordered-list":
return {
...shared,
Expand Down
6 changes: 6 additions & 0 deletions packages/editor/src/codemirror/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,12 @@ export {
markraHighlight,
} from "./highlight.ts";
export { horizontalRulePlugin } from "./horizontal-rule.ts";
export type {
InsertionCommandId,
InsertionLabels,
InsertionsPluginOptions,
} from "./insertions.ts";
export { insertionsPlugin } from "./insertions.ts";
export { markdownEditingPlugin } from "./markdown-editing.ts";
export type { MarkdownShortcutsPluginOptions } from "./markdown-shortcuts.ts";
export { markdownShortcutsPlugin } from "./markdown-shortcuts.ts";
Expand Down
79 changes: 79 additions & 0 deletions packages/editor/src/codemirror/insertions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { EditorSelection, EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { afterEach, describe, expect, it } from "vitest";
import {
insertionsPlugin,
listMarkraUi,
liveMarkdown,
runMarkraCommand,
} from "./index.ts";

import "./dom.test-support.ts";

const views: EditorView[] = [];

function createView(readOnly = false) {
const doc = "Published: placeholder";
const parent = document.createElement("div");
document.body.append(parent);
const view = new EditorView({
parent,
state: EditorState.create({
doc,
extensions: [
EditorState.readOnly.of(readOnly),
liveMarkdown({
plugins: [
insertionsPlugin({
labels: { "insert.today": "Current date" },
now: () => new Date(2031, 4, 6, 10, 30),
}),
],
}),
],
selection: EditorSelection.range(
doc.indexOf("placeholder"),
doc.length,
),
}),
});
views.push(view);
return view;
}

afterEach(() => {
for (const view of views.splice(0)) view.destroy();
document.body.replaceChildren();
});

describe("insertionsPlugin", () => {
it("publishes a Today slash command that replaces the selection", () => {
const view = createView();

expect(
listMarkraUi(view, "slash-menu").map((action) => ({
command: action.command,
icon: action.icon,
label: action.label,
})),
).toEqual([
{
command: "insert.today",
icon: "calendar-days",
label: "Current date",
},
]);

expect(runMarkraCommand(view, "insert.today")).toBe(true);
expect(view.state.doc.toString()).toBe("Published: 2031-05-06");
expect(view.state.selection.main.head).toBe(view.state.doc.length);
});

it("disables date insertion in a read-only editor", () => {
const view = createView(true);

expect(listMarkraUi(view, "slash-menu")[0]?.enabled).toBe(false);
expect(runMarkraCommand(view, "insert.today")).toBe(false);
expect(view.state.doc.toString()).toBe("Published: placeholder");
});
});
Loading
Loading