Files
MindSift/frontend/src/lib/pasteOutline.ts
T
sandrodachandClaude Opus 5.5 375dda982b
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m9s
feat(workspace): journal panel toggle, view kept across tabs, sections nest under headings
- the journal opens as a framed panel and closes on a second click,
  returning to the previous note; journal state and the last regular note
  survive tab switches, the fallback note is never a journal day
- pasted and imported markdown nests content under its heading (H2 below
  H1), Enter on a heading creates a child, '- ' / '* ' / '+ ' start a bullet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:11:09 +01:00

118 lines
3.7 KiB
TypeScript

import type { BlockType } from './types';
// Turns pasted (Markdown) text into outline items: headings, todos, bullets, numbered items, dividers,
// fenced code and paragraphs. A blank line ends a paragraph; indentation becomes the nesting level.
// Content below a heading is nested under it (H2 below H1 one level deeper), so sections fold and zoom.
export interface PastedItem {
type: BlockType;
checked?: boolean;
content: string;
indent: number;
}
function leadingWidth(line: string): { width: number; rest: string } {
let width = 0;
let i = 0;
while (i < line.length && (line[i] === ' ' || line[i] === '\t')) {
width += line[i] === '\t' ? 4 : 1;
i++;
}
return { width, rest: line.slice(i) };
}
export function parsePastedLines(rawLines: string[]): PastedItem[] {
// Indentation steps differ (2, 3 or 4 spaces, tabs): the distinct widths in use are the levels
const widths = Array.from(
new Set(rawLines.filter((l) => l.trim() !== '').map((l) => leadingWidth(l).width))
).sort((a, b) => a - b);
const levelOf = (w: number) => Math.max(0, widths.indexOf(w));
const items: PastedItem[] = [];
let current: PastedItem | null = null;
let inCode = false;
let afterHeading = false;
const headings: { rank: number; level: number }[] = []; // open headings, innermost last
const sectionLevel = () => (headings.length ? headings[headings.length - 1].level + 1 : 0);
const flush = () => {
if (current) {
current.content = current.content.trimEnd();
items.push(current);
current = null;
}
};
for (const rawLine of rawLines) {
if (inCode) {
if (current) (current as PastedItem).content += '\n' + rawLine;
if (rawLine.trim().startsWith('```')) {
inCode = false;
flush();
}
continue;
}
if (rawLine.trim().startsWith('```')) {
inCode = true;
flush();
current = { type: 'text', content: rawLine, indent: sectionLevel() };
afterHeading = false;
continue;
}
const { width, rest } = leadingWidth(rawLine);
const indent = sectionLevel() + levelOf(width);
const line = rest.trimEnd();
// A blank line ends the paragraph or list item
if (line === '') {
flush();
continue;
}
const heading = line.match(/^(#{1,3})\s+(.*)$/);
if (heading) {
flush();
afterHeading = true;
const rank = heading[1].length;
while (headings.length && headings[headings.length - 1].rank >= rank) headings.pop();
const level = sectionLevel();
headings.push({ rank, level });
items.push({ type: (['h1', 'h2', 'h3'] as const)[rank - 1], content: heading[2], indent: level });
continue;
}
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(line)) {
flush();
afterHeading = false;
headings.length = 0; // a divider closes the sections
items.push({ type: 'divider', content: '---', indent: 0 });
continue;
}
const todo = line.match(/^[-*+]\s*\[([ xX])\]\s*(.*)$/);
const bullet = line.match(/^[-*+•]\s+(.*)$/);
const numbered = line.match(/^\d+[.)]\s+(.*)$/);
if (todo || bullet || numbered) {
flush();
afterHeading = false;
current = todo
? { type: 'todo', checked: todo[1].toLowerCase() === 'x', content: todo[2], indent }
: numbered
? { type: 'numbered', content: numbered[1], indent }
: { type: 'text', content: bullet![1], indent };
continue;
}
// Plain text: continues the paragraph or list item above (a wrapped line), else starts a paragraph
if (current && !afterHeading) {
(current as PastedItem).content += '\n' + line;
} else {
flush();
current = { type: 'text', content: line, indent };
}
afterHeading = false;
}
flush();
return items;
}