Changes
11 changed files (+1158/-38)
-
-
@@ -0,0 +1,56 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // // SPDX-License-Identifier: Apache-2.0 import type { ContentParser, DocumentContent, ParseParameters, } from "./interface.ts"; import { isJSONCanvas, type JSONCanvas } from "./json_canvas/types.ts"; export class JSONCanvasParseError extends Error {} export class InvalidJSONCanvasError extends JSONCanvasParseError { constructor() { super("The data is not valid JSONCanvas data"); } } export class InvalidJSONError extends JSONCanvasParseError { constructor(cause: unknown) { super(); const subMessage = cause instanceof Error ? cause.message : String(cause); this.message = `JSONCanvas data MUST be valid JSON string: ${subMessage}`; this.cause = cause; } } export type JSONCanvasDocument = DocumentContent< "json_canvas", JSONCanvas >; export class JSONCanvasParser implements ContentParser { async parse({ fileReader }: ParseParameters): Promise<JSONCanvasDocument> { const text = new TextDecoder().decode(await fileReader.read()); let json: unknown; try { json = JSON.parse(text); } catch (err) { throw new InvalidJSONError(err); } if (!isJSONCanvas(json)) { throw new InvalidJSONCanvasError(); } return { kind: "json_canvas", content: json, }; } }
-
-
-
@@ -0,0 +1,420 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // // SPDX-License-Identifier: Apache-2.0 // // This file is a TypeScript type definition and validation function for the JSONCanvas 1.0. // <https://jsoncanvas.org/spec/1.0/> export type Red = "1"; export type Orange = "2"; export type Yellow = "3"; export type Green = "4"; export type Cyan = "5"; export type Purple = "6"; // TypeScript can't handle strict representation due to union limit, but // that does not benefit so much. Also, the JSONCanvas "spec" does not define // what hex format is (alpha? shorthand? uppercase?), so this is okay. type HexColorFormat = `#${string}`; const HEX_COLOR_FORMAT_REGEXP_PATTERN = /^#[a-z0-9]{3}([a-z0-9]{3})?$/i; export function isHexColorFormat(x: unknown): x is HexColorFormat { if (typeof x !== "string") { return false; } return HEX_COLOR_FORMAT_REGEXP_PATTERN.test(x); } export type CanvasColor = | Red | Orange | Yellow | Green | Cyan | Purple | HexColorFormat; export function isCanvasColor(x: unknown): x is CanvasColor { switch (x) { case "1": case "2": case "3": case "4": case "5": case "6": return true; default: return isHexColorFormat(x); } } export interface GenericNode { /** * A unique ID for the node. * NOTE: Length range and available characters are not defined in the spec. */ id: string; /** * The node type. */ type: string; /** * The x position of the node in pixels. * NOTE: The spec does not define value range. Safe to use largest signed interger. * NOTE: This type currently does not care bigint ranges. */ x: number; /** * The y position of the node in pixels. * NOTE: The spec does not define value range. Safe to use largest signed interger. * NOTE: This type currently does not care bigint ranges. */ y: number; /** * The width of the node in pixels * NOTE: The spec does not defined value range. * Safe to use largest unsigned integer, with minimum value guard on parsing. */ width: number; /** * The height of the node in pixels * NOTE: The spec does not defined value range. * Safe to use largest unsigned integer, with minimum value guard on parsing. */ height: number; /** * The color of the node. */ color?: CanvasColor; } export function isGenericNode(x: unknown): x is GenericNode { if (typeof x !== "object" || !x) { return false; } if (!("id" in x) || typeof x.id !== "string") { return false; } if (!("type" in x) || typeof x.type !== "string") { return false; } if (!("x" in x) || typeof x.x !== "number" || !Number.isFinite(x.x)) { return false; } if (!("y" in x) || typeof x.y !== "number" || !Number.isFinite(x.y)) { return false; } if ( !("width" in x) || typeof x.width !== "number" || !Number.isFinite(x.width) ) { return false; } if ( !("height" in x) || typeof x.height !== "number" || !Number.isFinite(x.height) ) { return false; } if ("color" in x && !isCanvasColor(x.color)) { return false; } return true; } export interface TextNode extends GenericNode { type: "text"; /** * Plain text with Markdown syntax. * NOTE: Although the spec does not define what "Markdown" is, Obsidian seems to * using/parsing their Obsidian Flavored Markdown. Parsing and displaying * of this property is UB. */ text: string; } export function isTextNode(x: GenericNode): x is TextNode { if (x.type !== "text") { return false; } if (!("text" in x) || typeof x.text !== "string") { return false; } return true; } export interface FileNode extends GenericNode { type: "file"; /** * The path to the file within the system. * NOTE: Syntax (e.g. what separator to use?) and resolution is UB. */ file: string; /** * A subpath that may link to a heading or a block. Always starts with a #. * NOTE: Both "a heading" and "a block" are not defined in the spec, UB. */ subpath?: `#${string}`; } export function isFileNode(x: GenericNode): x is FileNode { if (x.type !== "file") { return false; } if (!("file" in x) || typeof x.file !== "string") { return false; } if ( "subpath" in x && !(typeof x.subpath === "string" && x.subpath.startsWith("#")) ) { return false; } return true; } export interface LinkNode extends GenericNode { type: "link"; /** * NOTE: Format is not defined in the spec. */ url: string; } export function isLinkNode(x: GenericNode): x is LinkNode { if (x.type !== "link") { return false; } if (!("url" in x && typeof x.url === "string")) { return false; } return true; } export interface GroupNode extends GenericNode { type: "group"; /** * A text label for the group. * NOTE: Length range and available characters are not defined in the spec. */ label?: string; /** * The path to the background image. * NOTE: Syntax (e.g. what separator to use?) and resolution is UB. */ background?: string; /** * the rendering style of the background image. Valid values: * - `cover` ... fills the entire width and height of the node. * - `ratio` ... maintains the aspect ratio of the background image. * - `repeat` ... repeats the image as a pattern in both x/y directions. * NOTE: UB when the field is empty. */ backgroundStyle?: "cover" | "ratio" | "repeat"; } export function isGroupNode(x: GenericNode): x is GroupNode { if (x.type !== "group") { return false; } if ("label" in x && typeof x.label !== "string") { return false; } if ("background" in x && typeof x.background !== "string") { return false; } if ("backgroundStyle" in x) { switch (x.backgroundStyle) { case "cover": case "ratio": case "repeat": break; default: return false; } } return true; } export type Node = TextNode | FileNode | LinkNode | GroupNode; export function isNode(x: unknown): x is Node { if (!isGenericNode(x)) { return false; } return isTextNode(x) || isFileNode(x) || isLinkNode(x) || isGroupNode(x); } export type NodeSide = "top" | "right" | "bottom" | "left"; export function isNodeSide(x: unknown): x is NodeSide { switch (x) { case "top": case "right": case "bottom": case "left": return true; default: return false; } } export type EdgeEnd = "none" | "arrow"; export function isEdgeEnd(x: unknown): x is EdgeEnd { return x === "none" || x === "arrow"; } export interface Edge { /** * A unique ID for the edge. * NOTE: Length range and available characters are not defined in the spec. */ id: string; /** * The node `id` where the connection starts. * NOTE: Pointing non-existent `id` is UB. */ fromNode: string; /** * The side where this edge starts. * NOTE: UB when the field is empty. */ fromSide?: NodeSide; /** * The shape of the endpoint at the edge start. * NOTE: UB when the field is empty. */ fromEnd?: EdgeEnd; /** * The node `id` where the connection ends. * NOTE: Pointing non-existent `id` is UB. */ toNode: string; /** * The side where this edge ends. * NOTE: UB when the field is empty. */ toSide?: NodeSide; /** * The shape of the endpoint at the edge end. * NOTE: UB when the field is empty. */ toEnd?: EdgeEnd; /** * The color of the line. */ color?: CanvasColor; /** * A text label for the edge. * NOTE: Length range and available characters are not defined in the spec. */ label?: string; } export function isEdge(x: unknown): x is Edge { if (typeof x !== "object" || !x) { return false; } if (!("id" in x) || typeof x.id !== "string") { return false; } if (!("fromNode" in x) || typeof x.fromNode !== "string") { return false; } if ("fromSide" in x && !isNodeSide(x.fromSide)) { return false; } if ("fromEnd" in x && !isEdgeEnd(x.fromEnd)) { return false; } if (!("toNode" in x) || typeof x.toNode !== "string") { return false; } if ("toSide" in x && !isNodeSide(x.toSide)) { return false; } if ("toEnd" in x && !isEdgeEnd(x.toEnd)) { return false; } if ("color" in x && !isCanvasColor(x.color)) { return false; } if ("label" in x && typeof x.label !== "string") { return false; } return true; } export interface JSONCanvas { nodes?: Node[]; edges?: Edge[]; } export function isJSONCanvas(x: unknown): x is JSONCanvas { if (typeof x !== "object" || !x) { return false; } if ("nodes" in x && !(Array.isArray(x.nodes) && x.nodes.every(isNode))) { return false; } if ("edges" in x && !(Array.isArray(x.edges) && x.edges.every(isEdge))) { return false; } return true; }
-
-
-
@@ -0,0 +1,84 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // // SPDX-License-Identifier: Apache-2.0 import { assertObjectMatch, unreachable, } from "../deps/deno.land/std/assert/mod.ts"; import { VaultParser } from "../metadata_parser/vault_parser.ts"; import { MemoryFsReader } from "../filesystem_reader/memory_fs.ts"; import type { ContentParser } from "./interface.ts"; import { oneof } from "./oneof.ts"; function literal(x: string): ContentParser { return { async parse({ fileReader }) { const content = new TextDecoder().decode(await fileReader.read()); if (content !== x) { throw new Error(`This is not ${x}, but ${content}`); } return { kind: x, content: x, }; }, }; } Deno.test("Should combine parsers", async () => { const fs = new MemoryFsReader([ { path: "foo.md", content: "foo" }, { path: "bar.md", content: "bar" }, ]); const metadataParser = new VaultParser(); const parser = oneof( literal("foo"), literal("bar"), ); const root = await fs.getRootDirectory(); let count: number = 0; for (const item of await root.read()) { if (item.type !== "file") { unreachable("MemoryFS gave a directory where expecting a file"); } const metadata = await metadataParser.parse(item); if ("skip" in metadata) { unreachable( "Metadata Parser skipped where it expected to return metadata", ); } const content = await parser.parse({ documentMetadata: metadata, fileReader: item, }); switch (count++) { case 0: assertObjectMatch(content, { kind: "foo", content: "foo", }); break; case 1: assertObjectMatch(content, { kind: "bar", content: "bar", }); break; default: unreachable("Unexpected iteration"); } } });
-
-
content_parser/oneof.ts (new)
-
@@ -0,0 +1,23 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // // SPDX-License-Identifier: Apache-2.0 import type { ContentParser } from "./interface.ts"; export function oneof(...parsers: readonly ContentParser[]): ContentParser { return { async parse(...args) { let lastError: unknown; for (const parser of parsers) { try { return await parser.parse(...args); } catch (err) { lastError = err; } } throw lastError || new Error("All of parsers failed."); }, }; }
-
-
-
@@ -6,6 +6,8 @@ import { DenoFsReader } from "../filesystem_reader/deno_fs.ts";import { DenoFsWriter } from "../filesystem_writer/deno_fs.ts"; import { DefaultTreeBuilder } from "../tree_builder/default_tree_builder.ts"; import { ObsidianMarkdownParser } from "../content_parser/obsidian_markdown.ts"; import { JSONCanvasParser } from "../content_parser/json_canvas.ts"; import { oneof } from "../content_parser/oneof.ts"; import { VaultParser } from "../metadata_parser/vault_parser.ts"; import { DefaultThemeBuilder } from "../page_builder/default_theme/builder.tsx";
-
@@ -34,7 +36,10 @@ return node.name.startsWith(".") ||(node.path.length === 1 && node.name.endsWith(".ts")); }, }); const contentParser = new ObsidianMarkdownParser(); const contentParser = oneof( new JSONCanvasParser(), new ObsidianMarkdownParser(), ); const metadataParser = new VaultParser({ override(node) { if (
-
-
-
@@ -37,6 +37,19 @@ - [ ] `<style>` <style>* { display: none; }</style>- [ ] Keep Raw HTML (Unified libraries tends to ignore spec by default, needs to opt-out) - [ ] <span style="color: red; background-color: yellow;">Colored text</span> - [ ] Strip attributes not exist in whitelist - [ ] JSONCanvas - [ ] Node rendering - [x] Basic shapes - [x] Colors - [ ] Text node - [ ] Group node - [ ] Link node - [ ] File node - [ ] Edge rendering - [x] Basic shape - [x] Arrow - [x] Colors - [ ] Label - [ ] Document sorting - [ ] Search functionality - [ ] Creation / Update date
-
-
-
@@ -12,8 +12,8 @@ DocumentDirectory,DocumentTree, } from "../../tree_builder/interface.ts"; import type { BuildParameters, PageBuilder } from "../interface.ts"; import type { DocumentContent } from "../../content_parser/interface.ts"; import type { ObsidianMarkdownDocument } from "../../content_parser/obsidian_markdown.ts"; import type { JSONCanvasDocument } from "../../content_parser/json_canvas.ts"; import * as css from "./css.ts";
-
@@ -21,8 +21,14 @@ import * as Html from "./components/html.tsx";import { PathResolverProvider } from "./contexts/path_resolver.tsx"; function isObsidianMarkdown(x: DocumentContent): x is ObsidianMarkdownDocument { return x.kind === "obsidian_markdown"; function isObsidianMarkdown( x: Document, ): x is Document<ObsidianMarkdownDocument> { return x.content.kind === "obsidian_markdown"; } function isJSONCanvas(x: Document): x is Document<JSONCanvasDocument> { return x.content.kind === "json_canvas"; } interface InnerBuildParameters {
-
@@ -74,15 +80,13 @@ ): Promise<void> {const { fileSystemWriter } = buildParameters; if ("file" in item) { if (isObsidianMarkdown(item.content)) { const content = item.content.content; if (isObsidianMarkdown(item) || isJSONCanvas(item)) { const html = "<!DOCTYPE html>" + renderSSR( () => ( // Adds 1 to depth due to `<name>/index.html` conversion. <PathResolverProvider depth={pathPrefix.length + 1}> <Html.View tree={tree} content={content} document={item} language={item.metadata.language || parentLanguage} copyright={this.#copyright}
-
-
-
@@ -21,6 +21,18 @@ --color-fg-sub: #534c37;--color-fg-light: #c5c5c5; --color-border: #b4af9d; color: var(--color-fg); --canvas-color-red: #e03131; --canvas-color-orange: #f76707; --canvas-color-yellow: #fcc419; --canvas-color-green: #2f9e44; --canvas-color-cyan: #22b8cf; --canvas-color-purple: #ae3ec9; --canvas-color-fallback: #adb5bd; --canvas-node-bg-opacity: 0.05; --canvas-node-stroke-width: 2px; --canvas-edge-stroke-width: 6px; } @media (prefers-color-scheme: dark) {
-
@@ -31,6 +43,8 @@ --color-fg: #fafafa;--color-fg-sub: #f3edd9; --color-fg-light: #c5c5c5; --color-border: #b4af9d; --canvas-node-bg-opacity: 0.1; } }
-
-
-
@@ -10,7 +10,6 @@ Fragment,jsx, jsxs, } from "../../../deps/deno.land/x/nano_jsx/jsx-runtime/index.ts"; import type * as Mdast from "../../../deps/esm.sh/mdast/types.ts"; import { toHast } from "../../../deps/esm.sh/mdast-util-to-hast/mod.ts"; import { toJsxRuntime } from "../../../deps/esm.sh/hast-util-to-jsx-runtime/mod.ts";
-
@@ -18,6 +17,8 @@ import type {Document, DocumentTree, } from "../../../tree_builder/interface.ts"; import type { ObsidianMarkdownDocument } from "../../../content_parser/obsidian_markdown.ts"; import type { JSONCanvasDocument } from "../../../content_parser/json_canvas.ts"; import { usePathResolver } from "../contexts/path_resolver.tsx"; import * as css from "../css.ts";
-
@@ -29,6 +30,7 @@ import * as DocumentTreeUI from "./organisms/document_tree.tsx";import * as Footer from "./organisms/footer.tsx"; import * as Toc from "./organisms/toc.tsx"; import * as SiteLayout from "./templates/site_layout.tsx"; import * as JSONCanvasRenderer from "./json_canvas_renderer.tsx"; function toNode(hast: ReturnType<typeof toHast>) { return toJsxRuntime(hast, {
-
@@ -46,39 +48,84 @@ DocumentTreeUI.styles,Footer.styles, SiteLayout.styles, Toc.styles, JSONCanvasRenderer.styles, ); interface ObsidianMarkdownBodyProps extends ViewProps { content: ObsidianMarkdownDocument; } function ObsidianMarkdownBody( { content, document, tree, copyright }: ObsidianMarkdownBodyProps, ) { const hast = toHast(content.content); const toc = tocMut(hast).map((item) => mapTocItem(item, (item) => toNode({ type: "root", children: item })) ); const contentNodes = toNode(hast); return ( <SiteLayout.View aside={toc.length > 0 && <Toc.View toc={toc} />} nav={ <DocumentTreeUI.View tree={tree} currentPath={document.path} /> } footer={<Footer.View copyright={copyright} />} > <h1>{document.metadata.title}</h1> {contentNodes} </SiteLayout.View> ); } interface JSONCanvasBodyProps extends ViewProps { content: JSONCanvasDocument; } function JSONCanvasBody( { content, document, copyright, tree }: JSONCanvasBodyProps, ) { return ( <SiteLayout.View nav={ <DocumentTreeUI.View tree={tree} currentPath={document.path} /> } footer={<Footer.View copyright={copyright} />} > <h1>{document.metadata.title}</h1> <JSONCanvasRenderer.View data={content.content} /> </SiteLayout.View> ); } export interface ViewProps { document: Document; document: Document<ObsidianMarkdownDocument | JSONCanvasDocument>; /** * Root document tree, for navigations and links. */ tree: DocumentTree; /** * The document's content HTML. */ content: Mdast.Nodes; language: string; copyright: string; } export function View( { document, language, content, tree, copyright }: ViewProps, props: ViewProps, ) { const { document, language } = props; const path = usePathResolver(); const hast = toHast(content); const toc = tocMut(hast).map((item) => mapTocItem(item, (item) => toNode({ type: "root", children: item })) ); const contentNodes = toNode(hast); return ( <html lang={language}> <head>
-
@@ -91,19 +138,9 @@ href={path.resolve(["assets", "global.css"])}/> </head> <body> <SiteLayout.View aside={toc.length > 0 && <Toc.View toc={toc} />} nav={ <DocumentTreeUI.View tree={tree} currentPath={document.path} /> } footer={<Footer.View copyright={copyright} />} > <h1>{document.metadata.title}</h1> {contentNodes} </SiteLayout.View> {document.content.kind === "json_canvas" ? <JSONCanvasBody content={document.content} {...props} /> : <ObsidianMarkdownBody content={document.content} {...props} />} </body> </html> );
-
-
-
@@ -0,0 +1,464 @@// SPDX-FileCopyrightText: 2024 Shota FUJI <pockawoooh@gmail.com> // // SPDX-License-Identifier: Apache-2.0 /** @jsx h */ import { h } from "../../../deps/deno.land/x/nano_jsx/mod.ts"; import { css } from "../css.ts"; import type { CanvasColor, JSONCanvas, Node, NodeSide, } from "../../../content_parser/json_canvas/types.ts"; const enum C { Wrapper = "jcr--wr", } export const styles = css` .${C.Wrapper} { overflow: auto; max-width: 100%; max-height: 80dvh; margin-top: calc(var(--baseline) * 1rem); border: 1px solid var(--color-border); border-radius: calc(1rem / 4); padding: 4px; } `; interface Rect { x: number; y: number; width: number; height: number; } // TODO: Automatically calculate padding based on stroke-width, edges and shadows. function getBoundingBox(canvas: JSONCanvas, padding: number = 20): Rect { let minX: number = 0; let minY: number = 0; let maxX: number = 0; let maxY: number = 0; for (const node of canvas.nodes ?? []) { minX = Math.min(node.x, minX); minY = Math.min(node.y, minY); maxX = Math.max(node.x + node.width, maxX); maxY = Math.max(node.y + node.height, maxY); } return { x: minX - padding * 0.5, y: minY - padding * 0.5, width: maxX - minX + padding, height: maxY - minY + padding, }; } type Point2D = readonly [number, number]; function getConnectionPoint(node: Node, side: NodeSide): Point2D { switch (side) { case "top": return [node.x + node.width * 0.5, node.y]; case "right": return [node.x + node.width, node.y + node.height * 0.5]; case "bottom": return [node.x + node.width * 0.5, node.y + node.height]; case "left": return [node.x, node.y + node.height * 0.5]; } } type Vec2D = Point2D; function dot(a: Vec2D, b: Vec2D): number { return a[0] * b[0] + a[1] * b[1]; } function magnitude(v: Vec2D): number { return Math.sqrt(v[0] ** 2 + v[1] ** 2); } /** * Which node side faces the point? * We need this function because `fromSide` and `toSide` property is optional... * but Obsidian does not render Canvas if either of one is missing. wtf */ function getNearestSideToPoint(node: Node, p: Point2D): NodeSide { const center: Point2D = [ node.x + node.width * 0.5, node.y + node.height * 0.5, ]; // First, we get a vector from node's center to the target point. // The line segment intersecting with this vector is the face we want. const vp: Vec2D = [p[0] - center[0], p[1] - center[1]]; // Determine the angle between a vector (0, 1) and `vp` (center to point). // We test with angle instead of line segment intersection: the latter requires // many calculation and range checkings, which I can't handle. const vd: Vec2D = [0, 1]; const angleToPointRad: number = Math.acos( dot(vp, vd) / (magnitude(vp) * magnitude(vd)), ); // As the node is rectangle not square, we can't simply switch at 45deg. // However, for this usecase, we only need single diagonal (because it's rect). const diag: number = magnitude([node.width, node.height]); const diagYAngleRad: number = Math.PI - 2 * (Math.acos( (node.width ** 2 + diag ** 2 - node.height ** 2) / (2 * node.width * diag), )); // Because the angle above is diag-to-diag but we need to test against // angle of [0, 1] to `vp`, halve the angle so it can be an angle of // [0, 1] to diag. const bottomRight: number = diagYAngleRad * 0.5; if (angleToPointRad <= bottomRight) { return "bottom"; } const topRight = Math.PI - bottomRight; if (angleToPointRad <= topRight) { // The angle does not have direction, hence we need to check if the vector // is directing right or left. return vp[0] > 0 ? "right" : "left"; } // Angle can't be more than 180deg, so no need for more tests. return "top"; } function getClosestSides( a: Node, aSide: NodeSide | undefined, b: Node, bSide: NodeSide | undefined, ): readonly [NodeSide, NodeSide] { if (aSide && bSide) { return [aSide, bSide]; } if (aSide) { return [aSide, getNearestSideToPoint(b, getConnectionPoint(a, aSide))]; } if (bSide) { return [getNearestSideToPoint(a, getConnectionPoint(b, bSide)), bSide]; } const ca: Point2D = [a.x + a.width * 0.5, a.y + a.height * 0.5]; const cb: Point2D = [b.x + b.width * 0.5, b.y + b.height * 0.5]; return [getNearestSideToPoint(a, cb), getNearestSideToPoint(b, ca)]; } function getDirV(side: NodeSide): Vec2D { switch (side) { case "top": return [0, -1]; case "right": return [1, 0]; case "bottom": return [0, 1]; case "left": return [-1, 0]; } } function vecMul(a: Vec2D, b: Vec2D): Vec2D { return [a[0] * b[0], a[1] * b[1]]; } function move(p: Point2D, v: Vec2D): Point2D { return [p[0] + v[0], p[1] + v[1]]; } function canvasColorToCssColor(color: CanvasColor): string { switch (color) { case "1": return "var(--canvas-color-red)"; case "2": return "var(--canvas-color-orange)"; case "3": return "var(--canvas-color-yellow)"; case "4": return "var(--canvas-color-green)"; case "5": return "var(--canvas-color-cyan)"; case "6": return "var(--canvas-color-purple)"; default: return color; } } interface EdgeArrowProps { target: Point2D; pointTo: NodeSide; size?: number; fill?: string; stroke?: string; } function EdgeArrow({ target, pointTo, size = 15, ...rest }: EdgeArrowProps) { const start = `M${target[0]},${target[1]}`; const width = size * 1.2; let v1: Vec2D; let v2: Vec2D; switch (pointTo) { case "top": v1 = [-width * 0.5, -size]; v2 = [width, 0]; break; case "right": v1 = [size, -width * 0.5]; v2 = [0, width]; break; case "bottom": v1 = [width * 0.5, size]; v2 = [-width, 0]; break; case "left": v1 = [-size, -width * 0.5]; v2 = [0, width]; break; } return ( <path {...rest} d={[start, `l${v1[0]},${v1[1]}`, `l${v2[0]},${v2[1]}`, start, "Z"].join( " ", )} /> ); } export interface JSONCanvasRendererProps { data: JSONCanvas; radius?: number; arrowSize?: number; } export function JSONCanvasRenderer( { data, radius = 6, arrowSize = 20 }: JSONCanvasRendererProps, ) { const boundingBox = getBoundingBox(data); const viewBox = [ boundingBox.x, boundingBox.y, boundingBox.width, boundingBox.height, ].map((n) => n.toFixed(0)).join(" "); /** * Edges refer nodes by ID. This map helps and optimizes its retrieving operation. * Without using `Map`, lookup takes `O(N)`. */ const nodes = new Map<string, Node>( data.nodes?.map((node) => [node.id, node]), ); return ( <svg xmlns="http://www.w3.org/2000/svg" viewbox={viewBox} data-original-width={boundingBox.width} data-original-height={boundingBox.height} > <filter id="shadow"> <feDropShadow dx="0.0" dy="0.5" stdDeviation="3" /> </filter> {data.nodes?.map((node) => { const color = node.color ? canvasColorToCssColor(node.color) : "var(--canvas-color-fallback)"; return ( <g> <rect x={node.x} y={node.y} width={node.width} height={node.height} fill="var(--color-bg)" rx={radius} ry={radius} stroke-width="var(--canvas-node-stroke-width)" style="filter: url(#shadow);" /> <rect x={node.x} y={node.y} width={node.width} height={node.height} fill={color} fill-opacity="var(--canvas-node-bg-opacity)" stroke={color} stroke-width="var(--canvas-node-stroke-width)" rx={radius} ry={radius} /> {node.type === "text" && ( <foreignObject x={node.x} y={node.y} width={node.width} height={node.height} > <div xmlns="http://www.w3.org/1999/xhtml" style="padding: 1em;white-space: pre-wrap;" > {node.text} </div> </foreignObject> )} </g> ); })} {data.edges?.map((edge) => { const color = edge.color ? canvasColorToCssColor(edge.color) : "var(--canvas-color-fallback)"; const fromNode = nodes.get(edge.fromNode); if (!fromNode) { // TODO: Proper logging console.warn( `JSONCanvas Renderer: Edge(id=${edge.id}) points to non-existing fromNode(id=${edge.fromNode})`, ); return; } const toNode = nodes.get(edge.toNode); if (!toNode) { // TODO: Proper logging console.warn( `JSONCanvas Renderer: Edge(id=${edge.id}) points to non-existing toNode(id=${edge.toNode})`, ); return; } const [fromSide, toSide] = getClosestSides( fromNode, edge.fromSide, toNode, edge.toSide, ); const fromOffsetUnitVec = getDirV(fromSide); const toOffsetUnitVec = getDirV(toSide); // Not defined in spec or documents, but Obsidian Canvas uses // different defaults. wtf const fromEnd = edge.fromEnd ?? "none"; const toEnd = edge.toEnd ?? "arrow"; const fromPoint = getConnectionPoint(fromNode, fromSide); const toPoint = getConnectionPoint(toNode, toSide); // Subtract by 1 otherwise tiny gap appears. const fromStart = fromEnd === "arrow" ? move( fromPoint, vecMul(fromOffsetUnitVec, [arrowSize - 1, arrowSize - 1]), ) : fromPoint; const toStart = toEnd === "arrow" ? move( toPoint, vecMul(toOffsetUnitVec, [arrowSize - 1, arrowSize - 1]), ) : toPoint; const center: Point2D = [ (toStart[0] + fromStart[0]) / 2, (toStart[1] + fromStart[1]) / 2, ]; // TODO: Improve Bezier control points: Most of curves looks nearly perfect, // but Obsidian seems to employ special handling when a connector // overlaps with a node. const p1: Point2D = move( fromStart, vecMul(fromOffsetUnitVec, [ Math.abs(center[0] - fromStart[0]), Math.abs(center[1] - fromStart[1]), ]), ); const p2: Point2D = move( toStart, vecMul(toOffsetUnitVec, [ Math.abs(toStart[0] - center[0]), Math.abs(toStart[1] - center[1]), ]), ); const d = [ `M ${fromStart[0]},${fromStart[1]}`, `C ${p1[0]},${p1[1]} ${p2[0]},${p2[1]} ${toStart[0]},${toStart[1]}`, ].join(" "); return ( <g> <path d={d} stroke={color} stroke-width="var(--canvas-edge-stroke-width)" fill="none" /> {edge.fromEnd === "arrow" && ( <EdgeArrow target={fromPoint} pointTo={fromSide} fill={color} size={arrowSize} /> )} {edge.toEnd !== "none" && ( <EdgeArrow target={toPoint} pointTo={toSide} fill={color} size={arrowSize} /> )} </g> ); })} </svg> ); } export type ViewProps = JSONCanvasRendererProps; export function View({ data }: ViewProps) { return ( <div className={C.Wrapper}> <JSONCanvasRenderer data={data} /> </div> ); }
-
-
-
@@ -16,12 +16,12 @@ ContentParser,DocumentContent, } from "../content_parser/interface.ts"; export interface Document { export interface Document<Content extends DocumentContent = DocumentContent> { readonly type: "document"; readonly metadata: DocumentMetadata; readonly file: FileReader; readonly content: DocumentContent; readonly content: Content; /** * Document path: list of names, not file paths.
-