Replace plain textarea with Tiptap (ProseMirror) WYSIWYG editor for notes and tasks. Headings, bold, lists, and code blocks now render inline while editing. Tags and wikilinks get visual highlighting via decoration plugins, and autocomplete uses @tiptap/suggestion with heading disambiguation. Layout: pin navbar with app-shell flex column (100dvh), sticky editor toolbar above scrolling content, AI Assist panel fixed at bottom 1/3 with full-height section selector and prompt. Increase max-width to 960px across all views. Hide assist controls during streaming/review. Other fixes: markdown paste handling, auto-syncing assist sections via body watcher, trailing newline on AI accept, ChatView height fix. Add README.md describing the app, usage guide, and technical overview. Update summary.md with Phase 5.2 roadmap and current status. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
184 lines
4.5 KiB
TypeScript
184 lines
4.5 KiB
TypeScript
import type { JSONContent } from "@tiptap/vue-3";
|
|
|
|
export function serializeToMarkdown(doc: JSONContent): string {
|
|
if (doc.type !== "doc") return "";
|
|
return serializeNodes(doc.content || []).replace(/\n{3,}/g, "\n\n").trimEnd();
|
|
}
|
|
|
|
function serializeNodes(nodes: JSONContent[], listIndent = 0): string {
|
|
let out = "";
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
out += serializeNode(nodes[i], listIndent);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serializeNode(node: JSONContent, listIndent = 0): string {
|
|
switch (node.type) {
|
|
case "paragraph":
|
|
return serializeInline(node.content || []) + "\n\n";
|
|
|
|
case "heading": {
|
|
const level = node.attrs?.level ?? 1;
|
|
const prefix = "#".repeat(level) + " ";
|
|
return prefix + serializeInline(node.content || []) + "\n\n";
|
|
}
|
|
|
|
case "bulletList":
|
|
return (
|
|
serializeBulletList(node.content || [], listIndent) +
|
|
(listIndent === 0 ? "\n" : "")
|
|
);
|
|
|
|
case "orderedList":
|
|
return (
|
|
serializeOrderedList(node.content || [], listIndent, node.attrs?.start ?? 1) +
|
|
(listIndent === 0 ? "\n" : "")
|
|
);
|
|
|
|
case "listItem":
|
|
// Handled by bulletList/orderedList
|
|
return "";
|
|
|
|
case "codeBlock": {
|
|
const lang = node.attrs?.language || "";
|
|
const code = getTextContent(node);
|
|
return "```" + lang + "\n" + code + "\n```\n\n";
|
|
}
|
|
|
|
case "blockquote": {
|
|
const inner = serializeNodes(node.content || []);
|
|
return (
|
|
inner
|
|
.trimEnd()
|
|
.split("\n")
|
|
.map((line) => "> " + line)
|
|
.join("\n") + "\n\n"
|
|
);
|
|
}
|
|
|
|
case "horizontalRule":
|
|
return "---\n\n";
|
|
|
|
case "hardBreak":
|
|
return "\n";
|
|
|
|
case "text":
|
|
return applyMarks(node.text || "", node.marks || []);
|
|
|
|
default:
|
|
// Unknown node — try to serialize children
|
|
if (node.content) {
|
|
return serializeNodes(node.content);
|
|
}
|
|
return node.text || "";
|
|
}
|
|
}
|
|
|
|
function serializeBulletList(
|
|
items: JSONContent[],
|
|
indent: number
|
|
): string {
|
|
let out = "";
|
|
for (const item of items) {
|
|
if (item.type !== "listItem") continue;
|
|
const children = item.content || [];
|
|
const prefix = " ".repeat(indent) + "- ";
|
|
out += serializeListItem(children, prefix, indent);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serializeOrderedList(
|
|
items: JSONContent[],
|
|
indent: number,
|
|
start: number
|
|
): string {
|
|
let out = "";
|
|
let num = start;
|
|
for (const item of items) {
|
|
if (item.type !== "listItem") continue;
|
|
const children = item.content || [];
|
|
const prefix = " ".repeat(indent) + num + ". ";
|
|
out += serializeListItem(children, prefix, indent);
|
|
num++;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serializeListItem(
|
|
children: JSONContent[],
|
|
prefix: string,
|
|
indent: number
|
|
): string {
|
|
let out = "";
|
|
for (let i = 0; i < children.length; i++) {
|
|
const child = children[i];
|
|
if (child.type === "paragraph") {
|
|
if (i === 0) {
|
|
out += prefix + serializeInline(child.content || []) + "\n";
|
|
} else {
|
|
out +=
|
|
" ".repeat(indent + 1) +
|
|
serializeInline(child.content || []) +
|
|
"\n";
|
|
}
|
|
} else if (child.type === "bulletList") {
|
|
out += serializeBulletList(child.content || [], indent + 1);
|
|
} else if (child.type === "orderedList") {
|
|
out += serializeOrderedList(
|
|
child.content || [],
|
|
indent + 1,
|
|
child.attrs?.start ?? 1
|
|
);
|
|
} else {
|
|
out += serializeNode(child, indent + 1);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function serializeInline(nodes: JSONContent[]): string {
|
|
let out = "";
|
|
for (const node of nodes) {
|
|
if (node.type === "text") {
|
|
out += applyMarks(node.text || "", node.marks || []);
|
|
} else if (node.type === "hardBreak") {
|
|
out += "\n";
|
|
} else if (node.content) {
|
|
out += serializeInline(node.content);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function applyMarks(text: string, marks: NonNullable<JSONContent["marks"]>): string {
|
|
let result = text;
|
|
for (const mark of marks) {
|
|
switch (mark.type) {
|
|
case "bold":
|
|
result = `**${result}**`;
|
|
break;
|
|
case "italic":
|
|
result = `_${result}_`;
|
|
break;
|
|
case "strike":
|
|
result = `~~${result}~~`;
|
|
break;
|
|
case "code":
|
|
result = "`" + result + "`";
|
|
break;
|
|
case "link":
|
|
result = `[${result}](${mark.attrs?.href || ""})`;
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function getTextContent(node: JSONContent): string {
|
|
if (node.text) return node.text;
|
|
if (!node.content) return "";
|
|
return node.content.map(getTextContent).join("");
|
|
}
|