diff --git a/apps/obsidian/src/components/NodeTypeSettings.tsx b/apps/obsidian/src/components/NodeTypeSettings.tsx index 6f37d1383..502dcefee 100644 --- a/apps/obsidian/src/components/NodeTypeSettings.tsx +++ b/apps/obsidian/src/components/NodeTypeSettings.tsx @@ -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, @@ -116,10 +116,32 @@ const FIELD_CONFIGS: Record = { 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; +}) => ( + onChange((e.target as HTMLInputElement).checked)} + /> +); + const TextField = ({ fieldConfig, value, @@ -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); }; @@ -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 ( @@ -447,18 +471,24 @@ const NodeTypeSettings = () => { > {fieldConfig.key === "template" ? ( ) : fieldConfig.type === "color" ? ( - + + ) : fieldConfig.type === "boolean" ? ( + ) : ( 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, @@ -57,11 +88,12 @@ export const ExistingNodeSearch = ({ 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"); @@ -71,7 +103,7 @@ export const ExistingNodeSearch = ({ } })(); }, - [canvasFile, getEditor, plugin.app], + [canvasFile, getEditor, plugin], ); return ( diff --git a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx index 2b406bafb..bd9ebfb8d 100644 --- a/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx +++ b/apps/obsidian/src/components/canvas/shapes/DiscourseNodeShape.tsx @@ -1,8 +1,10 @@ import { BaseBoxShapeUtil, HTMLContainer, + resizeBox, T, TLBaseShape, + TLResizeInfo, useEditor, } from "tldraw"; import type { App, TFile } from "obsidian"; @@ -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", @@ -25,6 +29,7 @@ export type DiscourseNodeShape = TLBaseShape< // Cached display data title: string; nodeTypeId: string; + imageSrc?: string; } >; @@ -44,6 +49,7 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil { src: T.string.nullable(), title: T.string.optional(), nodeTypeId: T.string.nullable().optional(), + imageSrc: T.string.optional(), }; getDefaultProps(): DiscourseNodeShape["props"] { @@ -53,9 +59,20 @@ export class DiscourseNodeUtil extends BaseBoxShapeUtil { src: null, title: "", nodeTypeId: "", + imageSrc: undefined, }; } + override isAspectRatioLocked = () => false; + override canResize = () => true; + + override onResize( + shape: DiscourseNodeShape, + info: TLResizeInfo, + ) { + return resizeBox(shape, info); + } + component(shape: DiscourseNodeShape) { return ( @@ -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({ + id: shape.id, + type: "discourse-node", + props: { + ...shape.props, + imageSrc, + }, + }); + } + } else if (shape.props.imageSrc) { + didImageChange = true; + currentImageSrc = undefined; + editor.updateShape({ + 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({ + id: shape.id, + type: "discourse-node", + props: { + ...shape.props, + w, + h, + }, + }); + } + } } catch (error) { console.error("Error loading node data", error); return; @@ -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 (
-

{title || "..."}

+

{title || "..."}

{nodeType?.name || ""}

+ {shape.props.imageSrc ? ( +
+ +
+ ) : null}
); }, diff --git a/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts index 426e51e5e..6862adbe0 100644 --- a/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts +++ b/apps/obsidian/src/components/canvas/shapes/discourseNodeShapeUtils.ts @@ -15,4 +15,67 @@ export const getNodeTypeIdFromFrontmatter = ( ): string | null => { if (!frontmatter) return null; return (frontmatter as { nodeTypeId?: string })?.nodeTypeId ?? null; -}; \ No newline at end of file +}; + +// Extracts the first image reference from a file in document order. +// Supports both internal vault embeds/links and external URLs. +export const getFirstImageSrcForFile = async ( + app: App, + file: TFile, +): Promise => { + try { + const content = await app.vault.cachedRead(file); + + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)|!\[\[([^\]]+)\]\]/g; + let match; + const normalizeLinkTarget = (s: string) => { + // Strip optional markdown title: ![alt](url "title") + const withoutTitle = s.replace(/\s+"[^"]*"\s*$/, ""); + // Unwrap angle brackets: ![alt]() + const unwrapped = withoutTitle.replace(/^<(.+)>$/, "$1"); + // Drop Obsidian alias and header fragments + return unwrapped.split("|")?.[0]?.split("#")?.[0]?.trim(); + }; + + while ((match = imageRegex.exec(content)) !== null) { + if (match[2]) { + const target = match[2].trim(); + + // External URL - return directly + if (/^https?:\/\//i.test(target)) { + return target; + } + + // Internal path - resolve to vault file + const normalized = normalizeLinkTarget(target); + const tfile = app.metadataCache.getFirstLinkpathDest( + normalized ?? target, + file.path, + ); + if ( + tfile && + /^(png|jpe?g|gif|webp|svg|bmp|tiff?)$/i.test(tfile.extension) + ) { + return app.vault.getResourcePath(tfile); + } + } else if (match[3]) { + // Wiki-style embed: ![[path]] + const target = match[3].trim(); + const normalized = normalizeLinkTarget(target); + const tfile = app.metadataCache.getFirstLinkpathDest( + normalized ?? target, + file.path, + ); + if ( + tfile && + /^(png|jpe?g|gif|webp|svg|bmp|tiff?)$/i.test(tfile.extension) + ) { + return app.vault.getResourcePath(tfile); + } + } + } + } catch (e) { + console.warn("getFirstImageSrcForFile: failed to extract image", e); + } + return null; +}; diff --git a/apps/obsidian/src/components/canvas/shapes/nodeConstants.ts b/apps/obsidian/src/components/canvas/shapes/nodeConstants.ts new file mode 100644 index 000000000..72c8e8ea4 --- /dev/null +++ b/apps/obsidian/src/components/canvas/shapes/nodeConstants.ts @@ -0,0 +1,45 @@ +/** + * Constants for Discourse Node styling and sizing. + * These values match the Tailwind classes used in DiscourseNodeShape component. + * + * IMPORTANT: If you change these values, you must also update: + * - The Tailwind classes in DiscourseNodeShape.tsx (line ~263) + * - The measurement function in measureNodeText.ts + */ + +export const DEFAULT_NODE_WIDTH = 200; +export const MIN_NODE_WIDTH = 160; +export const MAX_NODE_WIDTH = 400; + +// Container styles (matches: p-2 border-2 rounded-md) +export const CONTAINER_PADDING = "0.5rem"; // p-2 = 0.5rem = 8px +export const CONTAINER_BORDER_WIDTH = "2px"; // border-2 +export const CONTAINER_BORDER_RADIUS = "0.375rem"; // rounded-md = 6px + +// Title styles (matches: m-1 text-base) +export const TITLE_MARGIN = "0.25rem"; // m-1 = 0.25rem = 4px +export const TITLE_FONT_SIZE = "1rem"; // text-base = 1rem = 16px +export const TITLE_LINE_HEIGHT = 1.5; +export const TITLE_FONT_WEIGHT = "600"; // font-semibold + +// Subtitle styles (matches: m-0 text-sm) +export const SUBTITLE_MARGIN = "0"; // m-0 +export const SUBTITLE_FONT_SIZE = "0.875rem"; // text-sm = 0.875rem = 14px +export const SUBTITLE_LINE_HEIGHT = 1.25; + +// Legacy exports for backward compatibility +export const BASE_PADDING = 16; + +// Font family - use system font stack similar to Tailwind +export const FONT_FAMILY = + 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif'; + +// Maximum height for key images +export const MAX_IMAGE_HEIGHT = 250; + +// Gap between image and text +export const IMAGE_GAP = 4; + +// Base height for nodes without images (estimated) +export const BASE_HEIGHT_NO_IMAGE = 100; + diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index 9861d92ac..fc0055592 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -9,6 +9,7 @@ export type DiscourseNode = { shortcut?: string; color?: string; tag?: string; + keyImage?: boolean; }; export type DiscourseRelationType = { diff --git a/apps/obsidian/src/utils/calcDiscourseNodeSize.ts b/apps/obsidian/src/utils/calcDiscourseNodeSize.ts new file mode 100644 index 000000000..f4a9bcbff --- /dev/null +++ b/apps/obsidian/src/utils/calcDiscourseNodeSize.ts @@ -0,0 +1,68 @@ +import type DiscourseGraphPlugin from "~/index"; +import { measureNodeText } from "./measureNodeText"; +import { loadImage } from "./loadImage"; +import { + BASE_PADDING, + MAX_IMAGE_HEIGHT, + IMAGE_GAP, +} from "~/components/canvas/shapes/nodeConstants"; +import { getNodeTypeById } from "./typeUtils"; + +type CalcNodeSizeParams = { + title: string; + nodeTypeId: string; + imageSrc?: string; + plugin: DiscourseGraphPlugin; +}; + +/** + * Calculate the optimal dimensions for a discourse node shape. + * Uses actual DOM text measurement and image dimensions for accuracy. Matching Roam's approach. + */ +export const calcDiscourseNodeSize = async ({ + title, + nodeTypeId, + imageSrc, + plugin, +}: CalcNodeSizeParams): Promise<{ w: number; h: number }> => { + const nodeType = getNodeTypeById(plugin, nodeTypeId); + const nodeTypeName = nodeType?.name || ""; + + const { w, h: textHeight } = measureNodeText({ + title, + subtitle: nodeTypeName, + }); + + if (!imageSrc || !nodeType?.keyImage) { + return { w, h: textHeight }; + } + + try { + const { width: imgWidth, height: imgHeight } = await loadImage(imageSrc); + const aspectRatio = imgWidth / imgHeight; + + const effectiveWidth = w + BASE_PADDING; + + const imageHeight = Math.min( + effectiveWidth / aspectRatio, + MAX_IMAGE_HEIGHT, + ); + + let finalWidth = w; + if (imageHeight === MAX_IMAGE_HEIGHT) { + const imageWidth = MAX_IMAGE_HEIGHT * aspectRatio; + const minWidthForImage = imageWidth + BASE_PADDING; + if (minWidthForImage > w) { + finalWidth = minWidthForImage; + } + } + + const totalHeight = BASE_PADDING + imageHeight + IMAGE_GAP + textHeight; + + return { w: finalWidth, h: totalHeight }; + } catch (error) { + console.warn("calcDiscourseNodeSize: failed to load image", error); + return { w, h: textHeight }; + } +}; + diff --git a/apps/obsidian/src/utils/loadImage.ts b/apps/obsidian/src/utils/loadImage.ts new file mode 100644 index 000000000..08bc182a0 --- /dev/null +++ b/apps/obsidian/src/utils/loadImage.ts @@ -0,0 +1,41 @@ +/** + * Load an image and return its natural dimensions. + * Supports both vault resource paths (app://...) and external URLs (https://...). + * + * Note: This works with Obsidian's resource paths returned by app.vault.getResourcePath() + * which are special app:// protocol URLs handled by Obsidian's Electron environment. + */ +export const loadImage = ( + url: string, +): Promise<{ width: number; height: number }> => { + return new Promise((resolve, reject) => { + const img = new Image(); + let resolved = false; + + const timeoutId = setTimeout(() => { + if (!resolved) { + resolved = true; + reject(new Error("Failed to load image: timeout")); + } + }, 10000); + + img.onload = () => { + if (!resolved) { + resolved = true; + clearTimeout(timeoutId); + resolve({ width: img.naturalWidth, height: img.naturalHeight }); + } + }; + + img.onerror = () => { + if (!resolved) { + resolved = true; + clearTimeout(timeoutId); + reject(new Error("Failed to load image")); + } + }; + + img.src = url; + }); +}; + diff --git a/apps/obsidian/src/utils/measureNodeText.ts b/apps/obsidian/src/utils/measureNodeText.ts new file mode 100644 index 000000000..54511c34d --- /dev/null +++ b/apps/obsidian/src/utils/measureNodeText.ts @@ -0,0 +1,94 @@ +import { + MIN_NODE_WIDTH, + MAX_NODE_WIDTH, + CONTAINER_PADDING, + CONTAINER_BORDER_WIDTH, + CONTAINER_BORDER_RADIUS, + TITLE_MARGIN, + TITLE_FONT_SIZE, + TITLE_LINE_HEIGHT, + TITLE_FONT_WEIGHT, + SUBTITLE_MARGIN, + SUBTITLE_FONT_SIZE, + SUBTITLE_LINE_HEIGHT, +} from "~/components/canvas/shapes/nodeConstants"; + +/** + * Measure the dimensions needed for a discourse node's text content. + * This renders the actual DOM structure that appears in the component, + * matching the Tailwind classes and layout exactly. + * + * Width is dynamic (fit-content) with a max constraint, matching Roam's behavior. + * + * IMPORTANT: The styles used here must match DiscourseNodeShape.tsx. + * If you change styles in nodeConstants.ts, both this function and the component + * will automatically stay in sync. + * + * Structure matches DiscourseNodeShape.tsx: + * - Container: p-2 border-2 rounded-md (box-border flex-col) + * - Title (h1): m-1 text-base + * - Subtitle (p): m-0 text-sm + */ +export const measureNodeText = ({ + title, + subtitle, +}: { + title: string; + subtitle: string; +}): { w: number; h: number } => { + // Create a container matching the actual component structure + const container = document.createElement("div"); + container.style.setProperty("position", "absolute"); + container.style.setProperty("visibility", "hidden"); + container.style.setProperty("pointer-events", "none"); + + // Match the actual component classes and styles + // className="box-border flex h-full w-full flex-col items-start justify-start rounded-md border-2 p-2" + container.style.setProperty("box-sizing", "border-box"); + container.style.setProperty("display", "flex"); + container.style.setProperty("flex-direction", "column"); + container.style.setProperty("align-items", "flex-start"); + container.style.setProperty("justify-content", "flex-start"); + // Dynamic width with constraints - matches Roam's approach + container.style.setProperty("width", "fit-content"); + container.style.setProperty("min-width", `${MIN_NODE_WIDTH}px`); + container.style.setProperty("max-width", `${MAX_NODE_WIDTH}px`); + container.style.setProperty("padding", CONTAINER_PADDING as string); + container.style.setProperty( + "border", + `${CONTAINER_BORDER_WIDTH} solid transparent`, + ); + container.style.setProperty( + "border-radius", + CONTAINER_BORDER_RADIUS as string, + ); + + // Create title element:

+ const titleEl = document.createElement("h1"); + titleEl.style.setProperty("margin", TITLE_MARGIN as string); + titleEl.style.setProperty("font-size", TITLE_FONT_SIZE as string); + titleEl.style.setProperty("line-height", String(TITLE_LINE_HEIGHT)); + titleEl.style.setProperty("font-weight", TITLE_FONT_WEIGHT as string); + titleEl.textContent = title || "..."; + + // Create subtitle element:

+ const subtitleEl = document.createElement("p"); + subtitleEl.style.setProperty("margin", SUBTITLE_MARGIN as string); + subtitleEl.style.setProperty("font-size", SUBTITLE_FONT_SIZE as string); + subtitleEl.style.setProperty("line-height", String(SUBTITLE_LINE_HEIGHT)); + subtitleEl.textContent = subtitle || ""; + + container.appendChild(titleEl); + container.appendChild(subtitleEl); + + // Append to body, measure, and remove + document.body.appendChild(container); + const rect = container.getBoundingClientRect(); + document.body.removeChild(container); + + return { + w: rect.width, + h: rect.height, + }; +}; +