Skip to content

Commit 84813e8

Browse files
authored
[ENG-869] Key figure (#522)
* [ENG-869] Key figure * key figure * final touches * cleanup * address PR comments
1 parent 52f5a2b commit 84813e8

File tree

9 files changed

+489
-17
lines changed

9 files changed

+489
-17
lines changed

apps/obsidian/src/components/NodeTypeSettings.tsx

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ type BaseFieldConfig = {
3232
label: string;
3333
description: string;
3434
required?: boolean;
35-
type: "text" | "select" | "color";
35+
type: "text" | "select" | "color" | "boolean";
3636
placeholder?: string;
3737
validate?: (
3838
value: string,
@@ -116,10 +116,32 @@ const FIELD_CONFIGS: Record<EditableFieldKey, BaseFieldConfig> = {
116116
return { isValid: true };
117117
},
118118
},
119+
keyImage: {
120+
key: "keyImage",
121+
label: "Key image (first image from file)",
122+
description:
123+
"When enabled, canvas nodes of this type will show the first image from the linked file",
124+
type: "boolean",
125+
required: false,
126+
},
119127
};
120128

121129
const FIELD_CONFIG_ARRAY = Object.values(FIELD_CONFIGS);
122130

131+
const BooleanField = ({
132+
value,
133+
onChange,
134+
}: {
135+
value: boolean;
136+
onChange: (value: boolean) => void;
137+
}) => (
138+
<input
139+
type="checkbox"
140+
checked={!!value}
141+
onChange={(e) => onChange((e.target as HTMLInputElement).checked)}
142+
/>
143+
);
144+
123145
const TextField = ({
124146
fieldConfig,
125147
value,
@@ -297,12 +319,14 @@ const NodeTypeSettings = () => {
297319

298320
const handleNodeTypeChange = (
299321
field: EditableFieldKey,
300-
value: string,
322+
value: string | boolean,
301323
): void => {
302324
if (!editingNodeType) return;
303325

304326
const updatedNodeType = { ...editingNodeType, [field]: value };
305-
validateField(field, value, updatedNodeType);
327+
if (typeof value === "string") {
328+
validateField(field, value, updatedNodeType);
329+
}
306330
setEditingNodeType(updatedNodeType);
307331
setHasUnsavedChanges(true);
308332
};
@@ -434,9 +458,9 @@ const NodeTypeSettings = () => {
434458
const renderField = (fieldConfig: BaseFieldConfig) => {
435459
if (!editingNodeType) return null;
436460

437-
const value = editingNodeType[fieldConfig.key] as string;
461+
const value = editingNodeType[fieldConfig.key] as string | boolean;
438462
const error = errors[fieldConfig.key];
439-
const handleChange = (newValue: string) =>
463+
const handleChange = (newValue: string | boolean) =>
440464
handleNodeTypeChange(fieldConfig.key, newValue);
441465

442466
return (
@@ -447,18 +471,24 @@ const NodeTypeSettings = () => {
447471
>
448472
{fieldConfig.key === "template" ? (
449473
<TemplateField
450-
value={value}
474+
value={value as string}
451475
error={error}
452476
onChange={handleChange}
453477
templateConfig={templateConfig}
454478
templateFiles={templateFiles}
455479
/>
456480
) : fieldConfig.type === "color" ? (
457-
<ColorField value={value} error={error} onChange={handleChange} />
481+
<ColorField
482+
value={value as string}
483+
error={error}
484+
onChange={handleChange}
485+
/>
486+
) : fieldConfig.type === "boolean" ? (
487+
<BooleanField value={value as boolean} onChange={handleChange} />
458488
) : (
459489
<TextField
460490
fieldConfig={fieldConfig}
461-
value={value}
491+
value={value as string}
462492
error={error}
463493
onChange={handleChange}
464494
nodeType={editingNodeType}

apps/obsidian/src/components/canvas/ExistingNodeSearch.tsx

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import DiscourseGraphPlugin from "~/index";
55
import { QueryEngine } from "~/services/QueryEngine";
66
import SearchBar from "~/components/SearchBar";
77
import { addWikilinkBlockrefForFile } from "./stores/assetStore";
8-
import { getFrontmatterForFile } from "./shapes/discourseNodeShapeUtils";
8+
import {
9+
getFirstImageSrcForFile,
10+
getFrontmatterForFile,
11+
} from "./shapes/discourseNodeShapeUtils";
12+
import { DiscourseNode } from "~/types";
13+
import { calcDiscourseNodeSize } from "~/utils/calcDiscourseNodeSize";
914

1015
export const ExistingNodeSearch = ({
1116
plugin,
@@ -50,18 +55,45 @@ export const ExistingNodeSearch = ({
5055
canvasFile,
5156
linkedFile: file,
5257
});
58+
const fmNodeTypeId = getFrontmatterForFile(plugin.app, file)
59+
?.nodeTypeId as string | undefined;
60+
const nodeType: DiscourseNode | undefined = fmNodeTypeId
61+
? plugin.settings.nodeTypes.find((n) => n.id === fmNodeTypeId)
62+
: undefined;
63+
let preloadedImageSrc: string | undefined = undefined;
64+
if (nodeType?.keyImage) {
65+
try {
66+
const found = await getFirstImageSrcForFile(plugin.app, file);
67+
if (found) preloadedImageSrc = found;
68+
} catch (e) {
69+
console.warn(
70+
"ExistingNodeSearch: failed to preload key image",
71+
e,
72+
);
73+
}
74+
}
75+
76+
// Calculate optimal dimensions using dynamic measurement
77+
const { w, h } = await calcDiscourseNodeSize({
78+
title: file.basename,
79+
nodeTypeId: fmNodeTypeId ?? "",
80+
imageSrc: preloadedImageSrc,
81+
plugin,
82+
});
83+
5384
const id = createShapeId();
5485
editor.createShape({
5586
id,
5687
type: "discourse-node",
5788
x: pagePoint.x - Math.random() * 100,
5889
y: pagePoint.y - Math.random() * 100,
5990
props: {
60-
w: 200,
61-
h: 100,
91+
w,
92+
h,
6293
src,
6394
title: file.basename,
64-
nodeTypeId: getFrontmatterForFile(plugin.app, file)?.nodeTypeId,
95+
nodeTypeId: fmNodeTypeId ?? "",
96+
imageSrc: preloadedImageSrc,
6597
},
6698
});
6799
editor.markHistoryStoppingPoint("add existing discourse node");
@@ -72,7 +104,7 @@ export const ExistingNodeSearch = ({
72104
}
73105
})();
74106
},
75-
[canvasFile, getEditor, plugin.app],
107+
[canvasFile, getEditor, plugin],
76108
);
77109

78110
return (

apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {
22
BaseBoxShapeUtil,
33
HTMLContainer,
4+
resizeBox,
45
T,
56
TLBaseShape,
7+
TLResizeInfo,
68
useEditor,
79
} from "tldraw";
810
import type { App, TFile } from "obsidian";
@@ -11,9 +13,11 @@ import DiscourseGraphPlugin from "~/index";
1113
import {
1214
getFrontmatterForFile,
1315
FrontmatterRecord,
16+
getFirstImageSrcForFile,
1417
} from "./discourseNodeShapeUtils";
1518
import { resolveLinkedFileFromSrc } from "~/components/canvas/stores/assetStore";
1619
import { getNodeTypeById } from "~/utils/typeUtils";
20+
import { calcDiscourseNodeSize } from "~/utils/calcDiscourseNodeSize";
1721

1822
export type DiscourseNodeShape = TLBaseShape<
1923
"discourse-node",
@@ -25,6 +29,7 @@ export type DiscourseNodeShape = TLBaseShape<
2529
// Cached display data
2630
title: string;
2731
nodeTypeId: string;
32+
imageSrc?: string;
2833
}
2934
>;
3035

@@ -44,6 +49,7 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
4449
src: T.string.nullable(),
4550
title: T.string.optional(),
4651
nodeTypeId: T.string.nullable().optional(),
52+
imageSrc: T.string.optional(),
4753
};
4854

4955
getDefaultProps(): DiscourseNodeShape["props"] {
@@ -53,9 +59,20 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil<DiscourseNodeShape> {
5359
src: null,
5460
title: "",
5561
nodeTypeId: "",
62+
imageSrc: undefined,
5663
};
5764
}
5865

66+
override isAspectRatioLocked = () => false;
67+
override canResize = () => true;
68+
69+
override onResize(
70+
shape: DiscourseNodeShape,
71+
info: TLResizeInfo<DiscourseNodeShape>,
72+
) {
73+
return resizeBox(shape, info);
74+
}
75+
5976
component(shape: DiscourseNodeShape) {
6077
return (
6178
<HTMLContainer>
@@ -158,6 +175,60 @@ const discourseNodeContent = memo(
158175
},
159176
});
160177
}
178+
179+
let didImageChange = false;
180+
let currentImageSrc = shape.props.imageSrc;
181+
if (nodeType?.keyImage) {
182+
const imageSrc = await getFirstImageSrcForFile(app, linkedFile);
183+
184+
if (imageSrc && imageSrc !== shape.props.imageSrc) {
185+
didImageChange = true;
186+
currentImageSrc = imageSrc;
187+
editor.updateShape<DiscourseNodeShape>({
188+
id: shape.id,
189+
type: "discourse-node",
190+
props: {
191+
...shape.props,
192+
imageSrc,
193+
},
194+
});
195+
}
196+
} else if (shape.props.imageSrc) {
197+
didImageChange = true;
198+
currentImageSrc = undefined;
199+
editor.updateShape<DiscourseNodeShape>({
200+
id: shape.id,
201+
type: "discourse-node",
202+
props: {
203+
...shape.props,
204+
imageSrc: undefined,
205+
},
206+
});
207+
}
208+
209+
if (didImageChange) {
210+
const { w, h } = await calcDiscourseNodeSize({
211+
title: linkedFile.basename,
212+
nodeTypeId: shape.props.nodeTypeId,
213+
imageSrc: currentImageSrc,
214+
plugin,
215+
});
216+
// Only update dimensions if they differ significantly (>1px)
217+
if (
218+
Math.abs((shape.props.w || 0) - w) > 1 ||
219+
Math.abs((shape.props.h || 0) - h) > 1
220+
) {
221+
editor.updateShape<DiscourseNodeShape>({
222+
id: shape.id,
223+
type: "discourse-node",
224+
props: {
225+
...shape.props,
226+
w,
227+
h,
228+
},
229+
});
230+
}
231+
}
161232
} catch (error) {
162233
console.error("Error loading node data", error);
163234
return;
@@ -169,17 +240,44 @@ const discourseNodeContent = memo(
169240
return () => {
170241
return;
171242
};
172-
}, [src, shape.id, shape.props, editor, app, canvasFile, plugin]);
243+
// Only trigger when content changes, not when dimensions change (to avoid fighting manual resizing)
244+
// eslint-disable-next-line react-hooks/exhaustive-deps
245+
}, [
246+
src,
247+
shape.id,
248+
shape.props.title,
249+
shape.props.nodeTypeId,
250+
shape.props.imageSrc,
251+
editor,
252+
app,
253+
canvasFile,
254+
plugin,
255+
nodeType?.keyImage,
256+
]);
173257

174258
return (
175259
<div
176260
style={{
177261
backgroundColor: nodeType?.color ?? "",
178262
}}
179-
className="box-border flex h-full w-full flex-col items-start justify-center rounded-md border-2 p-2"
263+
// NOTE: These Tailwind classes (p-2, border-2, rounded-md, m-1, text-base, m-0, text-sm)
264+
// correspond to constants in nodeConstants.ts. If you change these classes, update the
265+
// constants and the measureNodeText function to keep measurements accurate.
266+
className="box-border flex h-full w-full flex-col items-start justify-start rounded-md border-2 p-2"
180267
>
181-
<h1 className="m-0 text-base">{title || "..."}</h1>
268+
<h1 className="m-1 text-base">{title || "..."}</h1>
182269
<p className="m-0 text-sm opacity-80">{nodeType?.name || ""}</p>
270+
{shape.props.imageSrc ? (
271+
<div className="mt-2 flex min-h-0 w-full flex-1 items-center justify-center overflow-hidden">
272+
<img
273+
src={shape.props.imageSrc}
274+
loading="lazy"
275+
decoding="async"
276+
draggable="false"
277+
className="max-h-full max-w-full object-contain"
278+
/>
279+
</div>
280+
) : null}
183281
</div>
184282
);
185283
},

0 commit comments

Comments
 (0)