diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index c62c557..8e2fd8f 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -1610,6 +1610,65 @@ 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..68d7322 --- /dev/null +++ b/packages/editor/src/tabs-extension.tsx @@ -0,0 +1,249 @@ +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; + }, + }), + ]; + }, +});