Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 38 additions & 8 deletions apps/obsidian/src/components/NodeTypeSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type BaseFieldConfig = {
label: string;
description: string;
required?: boolean;
type: "text" | "select" | "color";
type: "text" | "select" | "color" | "boolean";
placeholder?: string;
validate?: (
value: string,
Expand Down Expand Up @@ -116,10 +116,32 @@ const FIELD_CONFIGS: Record<EditableFieldKey, BaseFieldConfig> = {
return { isValid: true };
},
},
keyImage: {
key: "keyImage",
label: "Key image (first image from file)",
description:
"When enabled, canvas nodes of this type will show the first image from the linked file",
type: "boolean",
required: false,
},
};

const FIELD_CONFIG_ARRAY = Object.values(FIELD_CONFIGS);

const BooleanField = ({
value,
onChange,
}: {
value: boolean;
onChange: (value: boolean) => void;
}) => (
<input
type="checkbox"
checked={!!value}
onChange={(e) => onChange((e.target as HTMLInputElement).checked)}
/>
);

const TextField = ({
fieldConfig,
value,
Expand Down Expand Up @@ -297,12 +319,14 @@ const NodeTypeSettings = () => {

const handleNodeTypeChange = (
field: EditableFieldKey,
value: string,
value: string | boolean,
): void => {
if (!editingNodeType) return;

const updatedNodeType = { ...editingNodeType, [field]: value };
validateField(field, value, updatedNodeType);
if (typeof value === "string") {
validateField(field, value, updatedNodeType);
}
setEditingNodeType(updatedNodeType);
setHasUnsavedChanges(true);
};
Expand Down Expand Up @@ -434,9 +458,9 @@ const NodeTypeSettings = () => {
const renderField = (fieldConfig: BaseFieldConfig) => {
if (!editingNodeType) return null;

const value = editingNodeType[fieldConfig.key] as string;
const value = editingNodeType[fieldConfig.key] as string | boolean;
const error = errors[fieldConfig.key];
const handleChange = (newValue: string) =>
const handleChange = (newValue: string | boolean) =>
handleNodeTypeChange(fieldConfig.key, newValue);

return (
Expand All @@ -447,18 +471,24 @@ const NodeTypeSettings = () => {
>
{fieldConfig.key === "template" ? (
<TemplateField
value={value}
value={value as string}
error={error}
onChange={handleChange}
templateConfig={templateConfig}
templateFiles={templateFiles}
/>
) : fieldConfig.type === "color" ? (
<ColorField value={value} error={error} onChange={handleChange} />
<ColorField
value={value as string}
error={error}
onChange={handleChange}
/>
) : fieldConfig.type === "boolean" ? (
<BooleanField value={value as boolean} onChange={handleChange} />
) : (
<TextField
fieldConfig={fieldConfig}
value={value}
value={value as string}
error={error}
onChange={handleChange}
nodeType={editingNodeType}
Expand Down
42 changes: 37 additions & 5 deletions apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ import DiscourseGraphPlugin from "~/index";
import { QueryEngine } from "~/services/QueryEngine";
import SearchBar from "~/components/SearchBar";
import { addWikilinkBlockrefForFile } from "./stores/assetStore";
import { getFrontmatterForFile } from "./shapes/discourseNodeShapeUtils";
import {
getFirstImageSrcForFile,
getFrontmatterForFile,
} from "./shapes/discourseNodeShapeUtils";
import { DiscourseNode } from "~/types";
import { calcDiscourseNodeSize } from "~/utils/calcDiscourseNodeSize";

export const ExistingNodeSearch = ({
plugin,
Expand Down Expand Up @@ -50,18 +55,45 @@ export const ExistingNodeSearch = ({
canvasFile,
linkedFile: file,
});
const fmNodeTypeId = getFrontmatterForFile(plugin.app, file)
?.nodeTypeId as string | undefined;
const nodeType: DiscourseNode | undefined = fmNodeTypeId
? plugin.settings.nodeTypes.find((n) => n.id === fmNodeTypeId)
: undefined;
let preloadedImageSrc: string | undefined = undefined;
if (nodeType?.keyImage) {
try {
const found = await getFirstImageSrcForFile(plugin.app, file);
if (found) preloadedImageSrc = found;
} catch (e) {
console.warn(
"ExistingNodeSearch: failed to preload key image",
e,
);
}
}

// Calculate optimal dimensions using dynamic measurement
const { w, h } = await calcDiscourseNodeSize({
title: file.basename,
nodeTypeId: fmNodeTypeId ?? "",
imageSrc: preloadedImageSrc,
plugin,
});

const id = createShapeId();
editor.createShape({
id,
type: "discourse-node",
x: pagePoint.x - Math.random() * 100,
y: pagePoint.y - Math.random() * 100,
props: {
w: 200,
h: 100,
w,
h,
src,
title: file.basename,
nodeTypeId: getFrontmatterForFile(plugin.app, file)?.nodeTypeId,
nodeTypeId: fmNodeTypeId ?? "",
imageSrc: preloadedImageSrc,
},
});
editor.markHistoryStoppingPoint("add existing discourse node");
Expand All @@ -71,7 +103,7 @@ export const ExistingNodeSearch = ({
}
})();
},
[canvasFile, getEditor, plugin.app],
[canvasFile, getEditor, plugin],
);

return (
Expand Down
104 changes: 101 additions & 3 deletions apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
BaseBoxShapeUtil,
HTMLContainer,
resizeBox,
T,
TLBaseShape,
TLResizeInfo,
useEditor,
} from "tldraw";
import type { App, TFile } from "obsidian";
Expand All @@ -11,9 +13,11 @@ import DiscourseGraphPlugin from "~/index";
import {
getFrontmatterForFile,
FrontmatterRecord,
getFirstImageSrcForFile,
} from "./discourseNodeShapeUtils";
import { resolveLinkedFileFromSrc } from "~/components/canvas/stores/assetStore";
import { getNodeTypeById } from "~/utils/typeUtils";
import { calcDiscourseNodeSize } from "~/utils/calcDiscourseNodeSize";

export type DiscourseNodeShape = TLBaseShape<
"discourse-node",
Expand All @@ -25,6 +29,7 @@ export type DiscourseNodeShape = TLBaseShape<
// Cached display data
title: string;
nodeTypeId: string;
imageSrc?: string;
}
>;

Expand All @@ -44,6 +49,7 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
src: T.string.nullable(),
title: T.string.optional(),
nodeTypeId: T.string.nullable().optional(),
imageSrc: T.string.optional(),
};

getDefaultProps(): DiscourseNodeShape["props"] {
Expand All @@ -53,9 +59,20 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
src: null,
title: "",
nodeTypeId: "",
imageSrc: undefined,
};
}

override isAspectRatioLocked = () => false;
override canResize = () => true;

override onResize(
shape: DiscourseNodeShape,
info: TLResizeInfo<DiscourseNodeShape>,
) {
return resizeBox(shape, info);
}

component(shape: DiscourseNodeShape) {
return (
<HTMLContainer>
Expand Down Expand Up @@ -158,6 +175,60 @@ const discourseNodeContent = memo(
},
});
}

let didImageChange = false;
let currentImageSrc = shape.props.imageSrc;
if (nodeType?.keyImage) {
const imageSrc = await getFirstImageSrcForFile(app, linkedFile);

if (imageSrc && imageSrc !== shape.props.imageSrc) {
didImageChange = true;
currentImageSrc = imageSrc;
editor.updateShape<DiscourseNodeShape>({
id: shape.id,
type: "discourse-node",
props: {
...shape.props,
imageSrc,
},
});
}
} else if (shape.props.imageSrc) {
didImageChange = true;
currentImageSrc = undefined;
editor.updateShape<DiscourseNodeShape>({
id: shape.id,
type: "discourse-node",
props: {
...shape.props,
imageSrc: undefined,
},
});
}

if (didImageChange) {
const { w, h } = await calcDiscourseNodeSize({
title: linkedFile.basename,
nodeTypeId: shape.props.nodeTypeId,
imageSrc: currentImageSrc,
plugin,
});
// Only update dimensions if they differ significantly (>1px)
if (
Math.abs((shape.props.w || 0) - w) > 1 ||
Math.abs((shape.props.h || 0) - h) > 1
) {
editor.updateShape<DiscourseNodeShape>({
id: shape.id,
type: "discourse-node",
props: {
...shape.props,
w,
h,
},
});
}
}
} catch (error) {
console.error("Error loading node data", error);
return;
Expand All @@ -169,17 +240,44 @@ const discourseNodeContent = memo(
return () => {
return;
};
}, [src, shape.id, shape.props, editor, app, canvasFile, plugin]);
// Only trigger when content changes, not when dimensions change (to avoid fighting manual resizing)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
src,
shape.id,
shape.props.title,
shape.props.nodeTypeId,
shape.props.imageSrc,
editor,
app,
canvasFile,
plugin,
nodeType?.keyImage,
]);

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"
// NOTE: These Tailwind classes (p-2, border-2, rounded-md, m-1, text-base, m-0, text-sm)
// correspond to constants in nodeConstants.ts. If you change these classes, update the
// constants and the measureNodeText function to keep measurements accurate.
className="box-border flex h-full w-full flex-col items-start justify-start rounded-md border-2 p-2"
>
<h1 className="m-0 text-base">{title || "..."}</h1>
<h1 className="m-1 text-base">{title || "..."}</h1>
<p className="m-0 text-sm opacity-80">{nodeType?.name || ""}</p>
{shape.props.imageSrc ? (
<div className="mt-2 flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden">
<img
src={shape.props.imageSrc}
loading="lazy"
decoding="async"
draggable="false"
className="max-h-full max-w-full object-contain"
/>
</div>
) : null}
</div>
);
},
Expand Down
Loading