From e1271ff384f839fdf1e50904375d5552733b8598 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:25:03 +0000 Subject: [PATCH 1/4] Initial plan From 1e8a4e19807fa936e40fb621193b0e2625091974 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:32:09 +0000 Subject: [PATCH 2/4] feat: add markdown tabs support in rendered view Adds a `TabGroupExtension` TipTap extension that renders ` ```tabs ``` ` fenced code blocks as interactive tabbed panels. Syntax: ```tabs ## Tab 1 Content for the first tab. ## Tab 2 Content for the second tab. ``` - packages/editor/src/tabs-extension.tsx: new extension + parse/serialize helpers - packages/editor/src/tabs-extension.test.ts: unit tests (9 passing) - packages/editor/src/rendered-editor.tsx: wire TabGroupExtension into editor - packages/editor/src/index.ts: re-export new module - apps/web/src/styles.css: tab-group UI styles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/web/src/styles.css | 57 +++++ package-lock.json | 32 +-- packages/editor/src/index.ts | 1 + packages/editor/src/rendered-editor.tsx | 2 + packages/editor/src/tabs-extension.test.ts | 62 +++++ packages/editor/src/tabs-extension.tsx | 252 +++++++++++++++++++++ 6 files changed, 376 insertions(+), 30 deletions(-) create mode 100644 packages/editor/src/tabs-extension.test.ts create mode 100644 packages/editor/src/tabs-extension.tsx diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c62c557..3e8270a 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1610,6 +1610,63 @@ button { padding: 8px; } +/* Tab group (```tabs fenced block) */ +.tab-group { + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + margin: 12px 0; +} + +.tab-group__bar { + display: flex; + flex-wrap: wrap; + gap: 0; + background: var(--bg-subtle, var(--bg-2)); + border-bottom: 1px solid var(--border); +} + +.tab-group__tab { + padding: 6px 16px; + border: none; + background: transparent; + color: var(--fg-muted); + cursor: pointer; + font-size: 13px; + border-bottom: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; + user-select: none; +} + +.tab-group__tab:hover { + color: var(--fg); +} + +.tab-group__tab--active { + color: var(--accent, var(--fg)); + border-bottom-color: var(--accent, var(--fg)); +} + +.tab-group__panel { + padding: 12px 16px; + background: var(--bg); +} + +.tab-group__panel > *:first-child { + margin-top: 0; +} + +.tab-group__panel > *:last-child { + margin-bottom: 0; +} + +.tab-group__empty { + padding: 12px 16px; + color: var(--fg-muted); + font-style: italic; + font-size: 13px; +} + .image-note { display: flex; flex-direction: column; diff --git a/package-lock.json b/package-lock.json index 12cd6a4..6effa22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2472,9 +2472,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2492,9 +2489,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2512,9 +2506,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2532,9 +2523,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2552,9 +2540,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2572,9 +2557,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4713,6 +4695,7 @@ "version": "13.0.3", "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz", "integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==", + "hasInstallScript": true, "license": "MIT", "dependencies": { "node-addon-api": "^8.0.0" @@ -9383,9 +9366,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9407,9 +9387,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9431,9 +9408,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9455,9 +9429,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -12436,6 +12407,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts index 6aa08dd..c636680 100644 --- a/packages/editor/src/index.ts +++ b/packages/editor/src/index.ts @@ -9,6 +9,7 @@ export * from "./note-toolbar"; export * from "./suggestion-popup"; export * from "./wikilink-decorator"; export * from "./embed-extension"; +export * from "./tabs-extension"; export * from "./image-node"; export * from "./prompt-dialog"; export * from "./native-source-editor"; diff --git a/packages/editor/src/rendered-editor.tsx b/packages/editor/src/rendered-editor.tsx index a1cbb76..e756aff 100644 --- a/packages/editor/src/rendered-editor.tsx +++ b/packages/editor/src/rendered-editor.tsx @@ -21,6 +21,7 @@ import { } from "react"; import { Markdown } from "tiptap-markdown"; import { Embed } from "./embed-extension"; +import { TabGroupExtension } from "./tabs-extension"; import { ImageNode } from "./image-node"; import { StyledTextMark } from "./styled-text-mark"; import { SuggestionPopup } from "./suggestion-popup"; @@ -184,6 +185,7 @@ export function RenderedEditor({ StyledTextMark, Markdown.configure({ html: true, tightLists: true, transformPastedText: true }), WikilinkDecorator, + TabGroupExtension, ...(callbacks?.renderEmbed ? [ Embed.configure({ diff --git a/packages/editor/src/tabs-extension.test.ts b/packages/editor/src/tabs-extension.test.ts new file mode 100644 index 0000000..1a29700 --- /dev/null +++ b/packages/editor/src/tabs-extension.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { parseTabsContent, serializeTabsContent } from "./tabs-extension"; + +describe("parseTabsContent", () => { + it("parses two tabs separated by ## headings", () => { + const input = "## Tab 1\nHello from tab one.\n\n## Tab 2\nHello from tab two."; + const result = parseTabsContent(input); + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ label: "Tab 1", content: "Hello from tab one." }); + expect(result[1]).toEqual({ label: "Tab 2", content: "Hello from tab two." }); + }); + + it("trims surrounding whitespace from each tab's content", () => { + const input = "## My Tab\n\n content here \n\n"; + const result = parseTabsContent(input); + expect(result[0].content).toBe("content here"); + }); + + it("returns empty array when no ## headings are present", () => { + const result = parseTabsContent("just some text with no headings"); + expect(result).toHaveLength(0); + }); + + it("discards text before the first ## heading", () => { + const input = "preamble\n\n## First\ncontent"; + const result = parseTabsContent(input); + expect(result).toHaveLength(1); + expect(result[0].label).toBe("First"); + }); + + it("preserves multi-line content within a tab", () => { + const input = "## Tab A\nLine 1\nLine 2\n\n## Tab B\nOnly line"; + const result = parseTabsContent(input); + expect(result[0].content).toBe("Line 1\nLine 2"); + expect(result[1].content).toBe("Only line"); + }); + + it("handles a single tab", () => { + const result = parseTabsContent("## Solo\nSingle tab content."); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ label: "Solo", content: "Single tab content." }); + }); +}); + +describe("serializeTabsContent", () => { + it("round-trips through parse → serialize", () => { + const original = "## Tab 1\nContent one\n\n## Tab 2\nContent two"; + const tabs = parseTabsContent(original); + const serialized = serializeTabsContent(tabs); + const reparsed = parseTabsContent(serialized); + expect(reparsed).toEqual(tabs); + }); + + it("returns empty string for empty tab array", () => { + expect(serializeTabsContent([])).toBe(""); + }); + + it("produces the expected format for a single tab", () => { + const tabs = [{ label: "Foo", content: "bar" }]; + expect(serializeTabsContent(tabs)).toBe("## Foo\nbar"); + }); +}); diff --git a/packages/editor/src/tabs-extension.tsx b/packages/editor/src/tabs-extension.tsx new file mode 100644 index 0000000..662f9d5 --- /dev/null +++ b/packages/editor/src/tabs-extension.tsx @@ -0,0 +1,252 @@ +import { Node, mergeAttributes } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { NodeViewWrapper, ReactNodeViewRenderer, type NodeViewProps } from "@tiptap/react"; +import { useState } from "react"; + +export interface TabEntry { + label: string; + content: string; +} + +/** + * Parses tab-fenced content (`\`\`\`tabs ... \`\`\``) into an array of + * `{ label, content }` pairs. Tabs are delimited by lines starting with + * `## ` (level-2 heading); everything before the first heading is discarded. + * + * Example: + * ``` + * ## Tab 1 + * Body of tab 1. + * + * ## Tab 2 + * Body of tab 2. + * ``` + */ +export function parseTabsContent(raw: string): TabEntry[] { + const lines = raw.split("\n"); + const tabs: TabEntry[] = []; + let currentLabel = ""; + let currentLines: string[] = []; + + for (const line of lines) { + if (line.startsWith("## ") || line.startsWith("##\t")) { + const label = line.slice(2).trim(); + if (currentLabel) { + tabs.push({ label: currentLabel, content: currentLines.join("\n").trim() }); + } + currentLabel = label; + currentLines = []; + } else if (currentLabel) { + currentLines.push(line); + } + } + if (currentLabel) { + tabs.push({ label: currentLabel, content: currentLines.join("\n").trim() }); + } + return tabs; +} + +/** + * Serialises an array of tab entries back to the fenced-block body. + */ +export function serializeTabsContent(tabs: TabEntry[]): string { + return tabs.map((tab) => `## ${tab.label}\n${tab.content}`).join("\n\n"); +} + +function TabGroupNodeView(props: NodeViewProps) { + const raw = String(props.node.attrs.content ?? ""); + const tabs = parseTabsContent(raw); + const [activeIndex, setActiveIndex] = useState(0); + + if (tabs.length === 0) { + return ( + +
(empty tab group)
+
+ ); + } + + const safeIndex = Math.min(activeIndex, tabs.length - 1); + const activeTab = tabs[safeIndex]; + + return ( + +
+ {tabs.map((tab, i) => ( + + ))} +
+
+ + ); +} + +/** + * Very lightweight markdown-to-HTML converter for tab panel content. + * Handles the most common inline elements so tab bodies render nicely + * without pulling in a full parser. + */ +function markdownToHtml(md: string): string { + const escaped = md + .replace(/&/g, "&") + .replace(//g, ">"); + + const paragraphs = escaped + .split(/\n{2,}/) + .map((block) => { + const trimmed = block.trim(); + if (!trimmed) return ""; + // Fenced code block (```...```) + const fence = /^```(?:\w+)?\n([\s\S]*?)```$/.exec(trimmed); + if (fence) { + return `
${fence[1]}
`; + } + // Unordered list + if (/^[*\-] /m.test(trimmed)) { + const items = trimmed + .split("\n") + .filter((l) => /^[*\-] /.test(l)) + .map((l) => `
  • ${inlineMarkdown(l.replace(/^[*\-] /, ""))}
  • `) + .join(""); + return ``; + } + // Ordered list + if (/^\d+\. /m.test(trimmed)) { + const items = trimmed + .split("\n") + .filter((l) => /^\d+\. /.test(l)) + .map((l) => `
  • ${inlineMarkdown(l.replace(/^\d+\. /, ""))}
  • `) + .join(""); + return `
      ${items}
    `; + } + // Headings + const hashEnd = trimmed.search(/[^#]/); + if (hashEnd > 0 && hashEnd <= 6 && (trimmed[hashEnd] === " " || trimmed[hashEnd] === "\t")) { + const level = hashEnd; + const text = trimmed.slice(hashEnd + 1).trim(); + return `${inlineMarkdown(text)}`; + } + // Blockquote + if (trimmed.startsWith(">")) { + const inner = trimmed + .split("\n") + .map((l) => l.replace(/^>\s?/, "")) + .join("
    "); + return `
    ${inner}
    `; + } + // Paragraph (single line-breaks within become
    ) + return `

    ${inlineMarkdown(trimmed.replace(/\n/g, "
    "))}

    `; + }) + .filter(Boolean); + + return paragraphs.join("\n"); +} + +function inlineMarkdown(text: string): string { + return text + .replace(/`([^`]+)`/g, "$1") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); +} + +/** + * TipTap extension that renders ` ```tabs ... ``` ` fenced code blocks as an + * interactive tabbed-panel component in the WYSIWYG (rendered) editor. + * + * Markdown syntax: + * ``` + * \`\`\`tabs + * ## Tab 1 + * Content for the first tab. + * + * ## Tab 2 + * Content for the second tab. + * \`\`\` + * ``` + */ +export const TabGroupExtension = Node.create({ + name: "tabGroup", + group: "block", + atom: true, + selectable: true, + draggable: false, + + addAttributes() { + return { content: { default: "" } }; + }, + + parseHTML() { + return [ + { + tag: "div[data-tab-group]", + getAttrs: (element) => ({ + content: (element as HTMLElement).getAttribute("data-tab-group") ?? "", + }), + }, + ]; + }, + + renderHTML({ HTMLAttributes }) { + return ["div", mergeAttributes({ "data-tab-group": HTMLAttributes.content as string })]; + }, + + addStorage() { + return { + markdown: { + serialize( + state: { write: (text: string) => void; closeBlock: (node: unknown) => void }, + node: { attrs: { content: string } }, + ) { + state.write("```tabs\n" + node.attrs.content + "\n```"); + state.closeBlock(node); + }, + parse: {}, + }, + }; + }, + + addNodeView() { + return ReactNodeViewRenderer(TabGroupNodeView); + }, + + addProseMirrorPlugins() { + const type = this.type; + return [ + new Plugin({ + key: new PluginKey("tabGroupNormalize"), + appendTransaction(_transactions, _oldState, newState) { + const matches: { from: number; to: number; content: string }[] = []; + newState.doc.descendants((node, pos) => { + if (node.type.name === "codeBlock" && node.attrs.language === "tabs") { + matches.push({ from: pos, to: pos + node.nodeSize, content: node.textContent }); + return false; + } + return true; + }); + if (matches.length === 0) { + return null; + } + const tr = newState.tr; + for (const match of matches.reverse()) { + tr.replaceWith(match.from, match.to, type.create({ content: match.content })); + } + return tr; + }, + }), + ]; + }, +}); From 5b3a7980d748c48fa3c9ceef75ab350424cfae5a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:33:16 +0000 Subject: [PATCH 3/4] Apply remaining changes --- apps/web/src/styles.css | 4 +++- packages/editor/src/tabs-extension.tsx | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 3e8270a..8e2fd8f 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1634,7 +1634,9 @@ button { cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; - transition: color 0.15s, border-color 0.15s; + transition: + color 0.15s, + border-color 0.15s; user-select: none; } diff --git a/packages/editor/src/tabs-extension.tsx b/packages/editor/src/tabs-extension.tsx index 662f9d5..9f965b1 100644 --- a/packages/editor/src/tabs-extension.tsx +++ b/packages/editor/src/tabs-extension.tsx @@ -99,10 +99,7 @@ function TabGroupNodeView(props: NodeViewProps) { * without pulling in a full parser. */ function markdownToHtml(md: string): string { - const escaped = md - .replace(/&/g, "&") - .replace(//g, ">"); + const escaped = md.replace(/&/g, "&").replace(//g, ">"); const paragraphs = escaped .split(/\n{2,}/) From c273491c45233277fa03e8d96a1308daf8aa9c86 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:20:41 +0000 Subject: [PATCH 4/4] fix: remove unnecessary escape characters flagged by eslint Replace /^[*\-] / with /^[*-] / in the tabs-extension markdownToHtml helper to fix the no-useless-escape lint errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/editor/src/tabs-extension.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/editor/src/tabs-extension.tsx b/packages/editor/src/tabs-extension.tsx index 9f965b1..68d7322 100644 --- a/packages/editor/src/tabs-extension.tsx +++ b/packages/editor/src/tabs-extension.tsx @@ -112,11 +112,11 @@ function markdownToHtml(md: string): string { return `
    ${fence[1]}
    `; } // Unordered list - if (/^[*\-] /m.test(trimmed)) { + if (/^[*-] /m.test(trimmed)) { const items = trimmed .split("\n") - .filter((l) => /^[*\-] /.test(l)) - .map((l) => `
  • ${inlineMarkdown(l.replace(/^[*\-] /, ""))}
  • `) + .filter((l) => /^[*-] /.test(l)) + .map((l) => `
  • ${inlineMarkdown(l.replace(/^[*-] /, ""))}
  • `) .join(""); return ``; }