Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { TldrawPreviewComponent } from "./TldrawViewComponent";
import { TLStore } from "tldraw";
import React from "react";
import DiscourseGraphPlugin from "~/index";
import { processInitialData, TLData } from "~/utils/tldraw";
import { ObsidianTLAssetStore } from "~/utils/assetStore";
import { processInitialData, TLData } from "~/components/canvas/tldraw";
import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore";

export class TldrawView extends TextFileView {
plugin: DiscourseGraphPlugin;
Expand Down Expand Up @@ -106,7 +106,16 @@ export class TldrawView extends TextFileView {
return;
}

const { store } = processInitialData(data, assetStore);
if (!this.file) {
console.warn("TldrawView not initialized: missing file");
return;
}

const { store } = processInitialData(data, assetStore, {
app: this.app,
canvasFile: this.file,
plugin: this.plugin,
});

return store;
} catch (e) {
Expand All @@ -131,6 +140,11 @@ export class TldrawView extends TextFileView {
if (!this.store)
throw new Error("TldrawView not initialized: missing store");

if (!this.assetStore) {
console.warn("Asset store is not set");
return;
}

root.render(
<React.StrictMode>
<TldrawPreviewComponent
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Editor, ErrorBoundary, Tldraw, TLStore } from "tldraw";
import { defaultShapeUtils, ErrorBoundary, Tldraw, TLStore } from "tldraw";
import "tldraw/tldraw.css";
import {
getTLDataTemplate,
createRawTldrawFile,
getUpdatedMdContent,
TLData,
processInitialData,
} from "~/utils/tldraw";
} from "~/components/canvas/tldraw";
import DiscourseGraphPlugin from "~/index";
import {
DEFAULT_SAVE_DELAY,
TLDATA_DELIMITER_END,
TLDATA_DELIMITER_START,
} from "~/constants";
import { TFile } from "obsidian";
import { ObsidianTLAssetStore } from "~/utils/assetStore";
import { ObsidianTLAssetStore } from "~/components/canvas/stores/assetStore";
import { DiscourseNodeUtil } from "~/components/canvas/shapes/DiscourseNodeShape";


interface TldrawPreviewProps {
store: TLStore;
Expand All @@ -36,6 +38,15 @@ export const TldrawPreviewComponent = ({
const saveTimeoutRef = useRef<NodeJS.Timeout>();
const lastSavedDataRef = useRef<string>("");

const customShapeUtils = [
...defaultShapeUtils,
DiscourseNodeUtil.configure({
app: plugin.app,
canvasFile: file,
plugin,
}),
];

useEffect(() => {
const timer = setTimeout(() => {
setIsReady(true);
Expand Down Expand Up @@ -92,7 +103,11 @@ export const TldrawPreviewComponent = ({
);
if (match?.[1]) {
const data = JSON.parse(match[1]) as TLData;
const { store: newStore } = processInitialData(data, assetStore);
const { store: newStore } = processInitialData(data, assetStore, {
app: plugin.app,
canvasFile: file,
plugin,
});
setCurrentStore(newStore);
}
}
Expand Down Expand Up @@ -128,7 +143,12 @@ export const TldrawPreviewComponent = ({
<div>Error in Tldraw component: {JSON.stringify(error)}</div>
)}
>
<Tldraw store={currentStore} autoFocus={true} initialState="select" />
<Tldraw
store={currentStore}
autoFocus={true}
initialState="select"
shapeUtils={customShapeUtils}
/>
</ErrorBoundary>
) : (
<div>Loading Tldraw...</div>
Expand Down
199 changes: 199 additions & 0 deletions apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import {
BaseBoxShapeUtil,
HTMLContainer,
T,
TLBaseShape,
useEditor,
} from "tldraw";
import type { App, TFile } from "obsidian";
import { memo, createElement, useEffect } from "react";
import DiscourseGraphPlugin from "~/index";
import {
getFrontmatterForFile,
getNodeTypeIdFromFrontmatter,
getNodeTypeById,
FrontmatterRecord,
} from "./discourseNodeShapeUtils";
import { DiscourseNode } from "~/types";
import { resolveLinkedFileFromSrc } from "~/components/canvas/stores/assetStore";

export type DiscourseNodeShape = TLBaseShape<
"discourse-node",
{
w: number;
h: number;
// asset-style source: asset:obsidian.blockref.<id>
src: string | null;
// Cached display data
title: string;
nodeTypeId: string;
}
>;

export type DiscourseNodeUtilOptions = {
app: App;
plugin: DiscourseGraphPlugin;
canvasFile: TFile;
};

export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
static type = "discourse-node" as const;
declare options: DiscourseNodeUtilOptions;

static props = {
w: T.number,
h: T.number,
src: T.string.nullable(),
title: T.string.optional(),
nodeTypeId: T.string.nullable().optional(),
nodeTypeName: T.string.optional(),
};

getDefaultProps(): DiscourseNodeShape["props"] {
return {
w: 200,
h: 100,
src: null,
title: "",
nodeTypeId: "",
};
}

component(shape: DiscourseNodeShape) {
return (
<HTMLContainer>
{createElement(discourseNodeContent, {
shape,
app: this.options.app,
canvasFile: this.options.canvasFile,
plugin: this.options.plugin,
})}
</HTMLContainer>
);
}

indicator(shape: DiscourseNodeShape) {
return <rect width={shape.props.w} height={shape.props.h} />;
}

getFile = async (
shape: DiscourseNodeShape,
ctx: { app: App; canvasFile: TFile },
): Promise<TFile | null> => {
const app = ctx?.app ?? this.options.app;
const canvasFile = ctx?.canvasFile ?? this.options.canvasFile;
return resolveLinkedFileFromSrc({
app,
canvasFile,
src: shape.props.src ?? undefined,
});
};

getFrontmatter = async (
shape: DiscourseNodeShape,
ctx: { app: App; canvasFile: TFile },
): Promise<FrontmatterRecord | null> => {
const app = ctx?.app ?? this.options.app;
const file = await this.getFile(shape, ctx);
if (!file) return null;
return getFrontmatterForFile(app, file);
};

getRelations = async (
shape: DiscourseNodeShape,
ctx: { app: App; canvasFile: TFile },
): Promise<unknown[]> => {
const frontmatter = await this.getFrontmatter(shape, ctx);
if (!frontmatter) return [];
// TODO: derive relations from frontmatter
return [];
};
}

const discourseNodeContent = memo(
({
shape,
app,
canvasFile,
plugin,
}: {
shape: DiscourseNodeShape;
app: App;
canvasFile: TFile;
plugin: DiscourseGraphPlugin;
}) => {
const editor = useEditor();
const { src, title, nodeTypeId } = shape.props;
const nodeType = getNodeTypeById(plugin, nodeTypeId);

useEffect(() => {
const loadNodeData = async () => {
if (!src) {
editor.updateShape<DiscourseNodeShape>({
id: shape.id,
type: "discourse-node",
props: {
...shape.props,
title: "(no source)",
},
});
return;
}

try {
const linkedFile = await resolveLinkedFileFromSrc({
app,
canvasFile,
src,
});

if (!linkedFile) {
editor.updateShape<DiscourseNodeShape>({
id: shape.id,
type: "discourse-node",
props: {
...shape.props,
title: "(unlinked)",
},
});
return;
}

if (linkedFile.basename !== shape.props.title) {
editor.updateShape<DiscourseNodeShape>({
id: shape.id,
type: "discourse-node",
props: {
...shape.props,
title: linkedFile.basename,
},
});
}
} catch (error) {
console.error("Error loading node data", error);
return;
}
};

void loadNodeData();

return () => {
return;
};
}, [src, shape.id, shape.props, editor, app, canvasFile, plugin]);

return (
<div
style={{
backgroundColor: nodeType?.color ?? "",
}}
className="box-border flex h-full w-full flex-col items-start justify-center rounded-md border-2 p-2"
>
<h1 className="m-0 text-base">{title || "..."}</h1>
<p className="m-0 text-sm opacity-80">{nodeType?.name || ""}</p>
</div>
);
},
);

discourseNodeContent.displayName = "DiscourseNodeContent";
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { App, TFile } from "obsidian";
import type DiscourseGraphPlugin from "~/index";
import type { DiscourseNode } from "~/types";
import { resolveLinkedFileFromSrc } from "~/components/canvas/stores/assetStore";

export type FrontmatterRecord = Record<string, unknown>;

export const getFrontmatterForFile = (
app: App,
file: TFile,
): FrontmatterRecord | null => {
return (app.metadataCache.getFileCache(file)?.frontmatter ??
null) as FrontmatterRecord | null;
};

export const getNodeTypeIdFromFrontmatter = (
frontmatter: FrontmatterRecord | null,
): string | null => {
if (!frontmatter) return null;
return (frontmatter as { nodeTypeId?: string })?.nodeTypeId ?? null;
};

export const getNodeTypeById = (
plugin: DiscourseGraphPlugin,
nodeTypeId: string | null,
): DiscourseNode | null => {
if (!nodeTypeId) return null;
return (
plugin.settings.nodeTypes.find((nodeType) => nodeType.id === nodeTypeId) ??
null
);
};

export const getRelationsFromFrontmatter = (
_frontmatter: FrontmatterRecord | null,
): unknown[] => {
// TODO: derive relations from frontmatter when schema is defined
return [];
};
Loading