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
56 changes: 56 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,47 @@
word-break: break-word;
}

.app__action--small {
padding: 0.4rem 0.9rem;
font-size: 0.9rem;
}

.app__action:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}

.app__panel .app__action--ghost {
color: #93c5fd;
background: rgba(59, 130, 246, 0.18);
}

.app__md-input {
box-sizing: border-box;
width: 100%;
min-height: 200px;
resize: vertical;
padding: 0.75rem;
border: 1px solid rgba(148, 163, 184, 0.3);
border-radius: 0.5rem;
background: rgba(15, 23, 42, 0.6);
color: inherit;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.9rem;
line-height: 1.45;
}

.app__md-input::placeholder {
color: rgba(148, 163, 184, 0.7);
}

.app__md-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35);
}

.app__error {
margin: 0;
color: #fca5a5;
Expand Down Expand Up @@ -260,6 +301,21 @@ html.dark .app__panel {
box-shadow: inset 0 0 0 1px rgba(64, 64, 64, 0.5);
}

html.dark .app__md-input {
background: #0a0a0a;
border-color: #404040;
}

html.dark .app__md-input:focus {
border-color: #6366f1;
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.35);
}

html.dark .app__panel .app__action--ghost {
color: #818cf8;
background: rgba(99, 102, 241, 0.15);
}

html.dark .app__panel--light {
background: #262626;
color: #fafafa;
Expand Down
60 changes: 60 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,8 @@ function App() {
"idle",
);
const [darkMode, setDarkMode] = useState(false);
const [markdownInput, setMarkdownInput] = useState("");
const [inputError, setInputError] = useState<string | null>(null);

useEffect(() => {
document.documentElement.classList.toggle("dark", darkMode);
Expand Down Expand Up @@ -657,6 +659,21 @@ function App() {
if (id) focusStepField(editor, id);
};

// Load the pasted Markdown as the whole document, the way a host app does on
// initial load (unlike pasting into the editor, which goes through the paste handler).
const handleApplyMarkdown = () => {
try {
const blocks = markdownToBlocks(markdownInput);
editor.replaceBlocks(
editor.document,
blocks.length > 0 ? blocks : [{ type: "paragraph" }],
);
setInputError(null);
} catch (error) {
setInputError(error instanceof Error ? error.message : String(error));
}
};

const handleCopyMarkdown = async () => {
if (conversionError) {
return;
Expand Down Expand Up @@ -782,6 +799,49 @@ function App() {
</BlockNoteView>
</div>
<aside className="app__preview">
<div className="app__panel">
<div className="app__panel-header">
<h2>Markdown Input</h2>
<div className="app__copy">
<button
type="button"
className="app__action app__action--ghost app__action--small"
onClick={() => {
setMarkdownInput("");
setInputError(null);
}}
disabled={markdownInput.length === 0}
>
Clear
</button>
<button
type="button"
className="app__action app__action--small"
onClick={handleApplyMarkdown}
disabled={markdownInput.trim().length === 0}
title="Replace editor content (Ctrl/Cmd+Enter)"
>
Apply
</button>
</div>
</div>
<textarea
className="app__md-input"
value={markdownInput}
onChange={(event) => setMarkdownInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
if (markdownInput.trim().length > 0) {
handleApplyMarkdown();
}
}
}}
placeholder="Paste Markdown here, then Apply (Ctrl/Cmd+Enter) to load it into the editor"
spellCheck={false}
/>
{inputError && <p className="app__error">{inputError}</p>}
</div>
<div className="app__panel">
<div className="app__panel-header">
<h2>Markdown Preview</h2>
Expand Down
64 changes: 39 additions & 25 deletions src/editor/blocks/stepField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useStepAutocomplete, type StepSuggestion } from "../stepAutocomplete";
import { type SnippetSuggestion } from "../snippetAutocomplete";
import { useStepImageUpload } from "../stepImageUpload";
import { escapeMarkdownText, normalizePlainText } from "./markdown";
import { backtickRunLength, codeBlockFence, findBacktickRun, scanCodeSpan, wrapCodeSpan } from "../codeSpan";
import { useAutoResize } from "./useAutoResize";

type Suggestion = StepSuggestion | SnippetSuggestion;
Expand Down Expand Up @@ -167,7 +168,7 @@ function getActiveFormats(
}


function stripInlineMarkdown(markdown: string): {
export function stripInlineMarkdown(markdown: string): {
plainText: string;
links: LinkMeta[];
formatting: FormattingMeta[];
Expand Down Expand Up @@ -291,32 +292,36 @@ function stripInlineMarkdown(markdown: string): {
}
}

// Code block: ```\n...\n``` (triple backticks with newlines)
if (markdown[i] === "`" && markdown[i + 1] === "`" && markdown[i + 2] === "`") {
const contentStart = markdown[i + 3] === "\n" ? i + 4 : i + 3;
const closeIdx = markdown.indexOf("```", contentStart);
if (closeIdx !== -1) {
const contentEnd = markdown[closeIdx - 1] === "\n" ? closeIdx - 1 : closeIdx;
const inner = markdown.slice(contentStart, contentEnd);
const start = plainText.length;
plainText += inner;
formatting.push({ start, end: plainText.length, type: "code" });
i = closeIdx + 3;
continue;
if (markdown[i] === "`") {
const run = backtickRunLength(markdown, i);
// Code block: ```\n...\n``` — a fence of 3+ backticks closed by a run of
// the same length with a line break in between. Kept multi-line, unlike
// a code span whose line breaks collapse to spaces.
if (run >= 3) {
const contentStart = markdown[i + run] === "\n" ? i + run + 1 : i + run;
const closeIdx = findBacktickRun(markdown, contentStart, run);
if (closeIdx !== -1 && markdown.slice(i + run, closeIdx).includes("\n")) {
const contentEnd = markdown[closeIdx - 1] === "\n" ? closeIdx - 1 : closeIdx;
const start = plainText.length;
plainText += markdown.slice(contentStart, Math.max(contentStart, contentEnd));
formatting.push({ start, end: plainText.length, type: "code" });
i = closeIdx + run;
continue;
}
}
}

// Inline code: `text`
if (markdown[i] === "`") {
const closeIdx = markdown.indexOf("`", i + 1);
if (closeIdx !== -1) {
const inner = markdown.slice(i + 1, closeIdx);
// Inline code: `text`, ``te`xt``, ```text``` — CommonMark code span.
// An unmatched run stays literal text.
const scan = scanCodeSpan(markdown, i)!;
if (scan.kind === "span") {
const start = plainText.length;
plainText += inner;
plainText += scan.content;
formatting.push({ start, end: plainText.length, type: "code" });
i = closeIdx + 1;
continue;
} else {
plainText += markdown.slice(i, scan.end);
}
i = scan.end;
continue;
}

plainText += markdown[i];
Expand All @@ -340,9 +345,18 @@ export function buildFullMarkdown(plainText: string, links: LinkMeta[], formatti
let closeMarker: string;
if (fmt.type === "code") {
const content = plainText.slice(fmt.start, fmt.end);
const isMultiline = content.includes("\n");
openMarker = isMultiline ? "```\n" : "`";
closeMarker = isMultiline ? "\n```" : "`";
if (content.includes("\n")) {
const fence = codeBlockFence(content);
openMarker = `${fence}\n`;
closeMarker = `\n${fence}`;
} else {
// A delimiter longer than any backtick run inside, padded when the
// content touches a backtick — split back into open/close halves.
const wrapped = wrapCodeSpan(content);
const delimiterLength = (wrapped.length - content.length) / 2;
openMarker = wrapped.slice(0, delimiterLength);
closeMarker = wrapped.slice(wrapped.length - delimiterLength);
}
} else {
openMarker = fmt.type === "bold" ? "**" : "_";
closeMarker = openMarker;
Expand Down
42 changes: 42 additions & 0 deletions src/editor/blocks/stepFieldFormatting.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
applyInlineExclusion,
buildFullMarkdown,
stripInlineMarkdown,
type FormattingMeta,
type LinkMeta,
} from "./stepField";
Expand Down Expand Up @@ -103,3 +104,44 @@ describe("applyInlineExclusion mutual-exclusion rules", () => {
expect(result.formatting).toEqual([]);
});
});

describe("step field code spans", () => {
const URL = "{{baseURL}}/endpoint?query_param_one=value_one";

it.each([
["`" + URL + "`"],
["``" + URL + "``"],
["```" + URL + "```"],
])("reads %s as one code range without italics", (markdown) => {
expect(stripInlineMarkdown(markdown)).toEqual({
plainText: URL,
links: [],
formatting: [{ start: 0, end: URL.length, type: "code" }],
});
});

it("keeps an unmatched backtick run as text", () => {
expect(stripInlineMarkdown("a ``b` c")).toEqual({
plainText: "a ``b` c",
links: [],
formatting: [],
});
});

it("keeps multi-line code blocks multi-line", () => {
const result = stripInlineMarkdown(["```", "line 1", "line 2", "```"].join("\n"));
expect(result.plainText).toBe("line 1\nline 2");
expect(result.formatting).toEqual([{ start: 0, end: 13, type: "code" }]);
});

it("wraps code containing backticks in a longer delimiter", () => {
const formatting: FormattingMeta[] = [{ start: 4, end: 7, type: "code" }];
const markdown = buildFullMarkdown("run a`b now", [], formatting);
expect(markdown).toBe("run ``a`b`` now");
expect(stripInlineMarkdown(markdown)).toEqual({
plainText: "run a`b now",
links: [],
formatting,
});
});
});
54 changes: 54 additions & 0 deletions src/editor/codeSpan.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { codeBlockFence, indexOfOutsideCode, scanCodeSpan, wrapCodeSpan } from "./codeSpan";

describe("scanCodeSpan", () => {
it("returns null when the index is not a backtick", () => {
expect(scanCodeSpan("abc", 0)).toBeNull();
});

it.each([
["`foo`", "foo"],
["`` foo ` bar ``", "foo ` bar"],
["` `` `", "``"],
["` `` `", " `` "],
["` a`", " a"],
["` `", " "],
["`foo\\`", "foo\\"],
["``\nfoo\nbar \nbaz\n``", "foo bar baz"],
])("reads %j as the code %j (CommonMark examples)", (text, content) => {
expect(scanCodeSpan(text, 0)).toEqual({ kind: "span", content, end: text.length });
});

it("does not close on a run of a different length", () => {
expect(scanCodeSpan("```foo``", 0)).toEqual({ kind: "literal", end: 3 });
expect(scanCodeSpan("`foo``bar``", 0)).toEqual({ kind: "literal", end: 1 });
});
});

describe("wrapCodeSpan", () => {
it.each(["foo", "a`b", "``", "`code`", " a ", "a``b`c"])("round-trips %j", (content) => {
const wrapped = wrapCodeSpan(content);
expect(scanCodeSpan(wrapped, 0)).toEqual({ kind: "span", content, end: wrapped.length });
});

it("uses the shortest delimiter that works", () => {
expect(wrapCodeSpan("foo")).toBe("`foo`");
expect(wrapCodeSpan("a`b")).toBe("``a`b``");
expect(wrapCodeSpan("`code`")).toBe("`` `code` ``");
});
});

describe("codeBlockFence", () => {
it("is longer than any fence inside the body", () => {
expect(codeBlockFence("plain")).toBe("```");
expect(codeBlockFence("```js\nx\n```")).toBe("````");
expect(codeBlockFence("inline ```not a fence```")).toBe("```");
});
});

describe("indexOfOutsideCode", () => {
it("skips markers inside code spans and escapes", () => {
expect(indexOfOutsideCode("a `**` \\** **", "**", 0)).toBe(11);
expect(indexOfOutsideCode("a `unclosed **", "**", 0)).toBe(12);
});
});
Loading
Loading