diff --git a/manifest.json b/manifest.json index c02d43a..ea377f3 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "feed-bases", "name": "Feed Bases", - "version": "0.1.2", + "version": "0.2.0", "minAppVersion": "1.10.0", "description": "Adds a feed layout to bases so you can display notes with their content in an editable feed view.", "author": "Edrick Leong", diff --git a/package.json b/package.json index 1baa0e0..b86e70a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "feed-bases", - "version": "0.1.2", + "version": "0.2.0", "description": "Adds feed view to Obsidian Bases.", "main": "main.js", "scripts": { diff --git a/src/FeedReactView.tsx b/src/FeedReactView.tsx index 1d431a6..5b34ef8 100644 --- a/src/FeedReactView.tsx +++ b/src/FeedReactView.tsx @@ -2,6 +2,7 @@ import { App, BasesEntry, MarkdownView, WorkspaceLeaf } from "obsidian"; import React, { useCallback, useMemo } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useApp } from "./hooks"; +import { MasonryView } from "./MasonryView"; export const FeedReactView: React.FC = ({ entries, @@ -9,6 +10,45 @@ export const FeedReactView: React.FC = ({ onEntryContextMenu, scrollElement, showProperties, + multipleColumns = false, + maxCardWidth = 400, +}) => { + const app = useApp(); + + // Conditionally render masonry or single column view + if (multipleColumns) { + return ( + + ); + } + + // Single column centered view + return ( + + ); +}; + +const SingleColumnView: React.FC = ({ + entries, + onEntryClick, + onEntryContextMenu, + scrollElement, + showProperties, + maxCardWidth, }) => { const app = useApp(); const getScrollEl = useMemo(() => () => scrollElement, [scrollElement]); @@ -16,7 +56,7 @@ export const FeedReactView: React.FC = ({ const rowVirtualizer = useVirtualizer({ count: entries.length, getScrollElement: getScrollEl, - estimateSize: () => 280, // rough average height; real size will be measured + estimateSize: () => 280, overscan: 8, measureElement: (element, entry, instance) => { const direction = instance.scrollDirection; @@ -39,11 +79,13 @@ export const FeedReactView: React.FC = ({ const virtualItems = rowVirtualizer.getVirtualItems(); return ( -
+
{entries.length === 0 ? (
No notes to display
) : ( - // The container must be position:relative; items are absolutely positioned.
= ({ className="bases-feed-virtual-item" style={{ transform: `translateY(${vi.start}px)`, - transition: "transform 0.3s", }} > void; scrollElement: HTMLElement; showProperties: boolean; + multipleColumns?: boolean; + maxCardWidth?: number; +}; + +type SingleColumnViewProps = { + entries: BasesEntry[]; + onEntryClick: (entry: BasesEntry, isModEvent: boolean) => void; + onEntryContextMenu: (evt: React.MouseEvent, entry: BasesEntry) => void; + scrollElement: HTMLElement; + showProperties: boolean; + maxCardWidth: number; }; type FeedEntryProps = { diff --git a/src/MasonryView.tsx b/src/MasonryView.tsx new file mode 100644 index 0000000..0c8c4d6 --- /dev/null +++ b/src/MasonryView.tsx @@ -0,0 +1,283 @@ +import { App, BasesEntry, MarkdownView, WorkspaceLeaf } from "obsidian"; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useApp } from "./hooks"; + +export const MasonryView: React.FC = ({ + entries, + onEntryClick, + onEntryContextMenu, + scrollElement, + showProperties, + maxCardWidth, +}) => { + const app = useApp(); + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(0); + const [columnCount, setColumnCount] = useState(1); + + // Track container width for responsive column calculation + useEffect(() => { + if (!containerRef.current) return; + + const updateWidth = () => { + if (containerRef.current) { + const width = containerRef.current.offsetWidth; + setContainerWidth(width); + + // Calculate column count based on container width and max card width + // Account for gaps between columns (16px per gap) + const gapSize = 16; + const availableWidth = width - gapSize * 2; // padding on sides + const cols = Math.max( + 1, + Math.floor((availableWidth + gapSize) / (maxCardWidth + gapSize)), + ); + setColumnCount(cols); + } + }; + + updateWidth(); + + const resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + }; + }, [maxCardWidth]); + + // Distribute entries across columns + const columns = useMemo(() => { + const cols: BasesEntry[][] = Array.from({ length: columnCount }, () => []); + + // Distribute entries evenly across columns (round-robin) + entries.forEach((entry, index) => { + cols[index % columnCount].push(entry); + }); + + return cols; + }, [entries, columnCount]); + + return ( +
+ {entries.length === 0 ? ( +
No notes to display
+ ) : ( +
+ {columns.map((columnEntries, columnIndex) => ( + + ))} +
+ )} +
+ ); +}; + +const MasonryColumn: React.FC = ({ + entries, + scrollElement, + app, + showProperties, + onEntryClick, + onEntryContextMenu, +}) => { + const getScrollEl = useMemo(() => () => scrollElement, [scrollElement]); + + const rowVirtualizer = useVirtualizer({ + count: entries.length, + getScrollElement: getScrollEl, + estimateSize: () => 280, + overscan: 5, + measureElement: (element, entry, instance) => { + const direction = instance.scrollDirection; + if (direction === "forward" || direction === null) { + return ( + (element as HTMLElement | null)?.getBoundingClientRect().height ?? 0 + ); + } else { + // Don't remeasure if we are scrolling up to prevent stuttering + const indexKey = Number( + (element as HTMLElement).getAttribute("data-index"), + ); + // @ts-ignore - accessing private property for performance fix + let cacheMeasurement = instance.itemSizeCache.get(indexKey); + return cacheMeasurement ?? 0; + } + }, + }); + + const virtualItems = rowVirtualizer.getVirtualItems(); + + return ( +
+
+ {virtualItems.map( + (vi: ReturnType[number]) => { + const entry = entries[vi.index]; + return ( +
+ +
+ ); + }, + )} +
+
+ ); +}; + +const FeedEntry: React.FC = ({ + entry, + app, + showProperties, + onEntryClick, + onEntryContextMenu, +}) => { + const handleTitleClick = (evt: React.MouseEvent) => { + evt.preventDefault(); + const isModEvent = evt.ctrlKey || evt.metaKey; + onEntryClick(entry, isModEvent); + }; + + const handleContextMenu = (evt: React.MouseEvent) => { + onEntryContextMenu(evt, entry); + }; + + const handleHover = (evt: React.MouseEvent) => { + if (app) { + app.workspace.trigger("hover-link", { + event: evt.nativeEvent, + source: "bases", + hoverParent: app.renderContext, + targetEl: evt.currentTarget, + linktext: entry.file.path, + }); + } + }; + + const setEditorHost = useCallback( + (node: HTMLDivElement) => { + let alive = true; + // @ts-ignore using internal API + const leaf = new WorkspaceLeaf(app); + (async () => { + try { + await leaf.openFile(entry.file, { + state: { mode: "source", source: false }, + }); + if (!alive) return; + + const view = leaf.view; + if (!(view instanceof MarkdownView)) { + node.replaceChildren(); + const err = node.createDiv("bases-feed-error"); + err.setText("Failed to load markdown editor"); + return; + } + + node.replaceChildren(view.containerEl); + } catch (e) { + if (alive) console.error("Error setting up editor:", e); + } + })(); + + return () => { + alive = false; + try { + node.replaceChildren(); + } catch {} + }; + }, + [app, entry.file], + ); + + return ( +
+ + +
+
+
+
+ ); +}; + +// Props + +type MasonryViewProps = { + entries: BasesEntry[]; + onEntryClick: (entry: BasesEntry, isModEvent: boolean) => void; + onEntryContextMenu: (evt: React.MouseEvent, entry: BasesEntry) => void; + scrollElement: HTMLElement; + showProperties: boolean; + maxCardWidth: number; +}; + +type MasonryColumnProps = { + entries: BasesEntry[]; + scrollElement: HTMLElement; + app: App; + showProperties: boolean; + onEntryClick: (entry: BasesEntry, isModEvent: boolean) => void; + onEntryContextMenu: (evt: React.MouseEvent, entry: BasesEntry) => void; +}; + +type FeedEntryProps = { + entry: BasesEntry; + app: App; + showProperties: boolean; + onEntryClick: (entry: BasesEntry, isModEvent: boolean) => void; + onEntryContextMenu: (evt: React.MouseEvent, entry: BasesEntry) => void; +}; diff --git a/src/feed-view.tsx b/src/feed-view.tsx index bcead8c..8a812f4 100644 --- a/src/feed-view.tsx +++ b/src/feed-view.tsx @@ -148,6 +148,10 @@ export class FeedView extends BasesView { const showProperties = (this.config.get("showProperties") as boolean | undefined) ?? false; + const multipleColumns = + (this.config.get("multipleColumns") as boolean | undefined) ?? false; + const maxCardWidth = + (this.config.get("maxCardWidth") as number | undefined) ?? 400; this.root.render( @@ -156,6 +160,8 @@ export class FeedView extends BasesView { entries={this.entries} scrollElement={this.scrollEl} showProperties={showProperties} + multipleColumns={multipleColumns} + maxCardWidth={maxCardWidth} onEntryClick={(entry: BasesEntry, isModEvent: boolean) => { void this.app.workspace.openLinkText( entry.file.path, diff --git a/src/main.ts b/src/main.ts index d42156d..2b7a0f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,21 @@ export default class ObsidianFeedPlugin extends Plugin { displayName: "Show note properties (Experimental)", default: false, }, + { + key: "multipleColumns", + type: "toggle", + displayName: "Show notes in multiple columns (Experimental)", + default: false, + }, + { + key: "maxCardWidth", + type: "slider", + displayName: "Maximum card width (Experimental)", + default: 400, + min: 200, + max: 800, + step: 10, + }, ], }); } diff --git a/styles.css b/styles.css index 840d2a9..3d64623 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,8 @@ /* Feed View Styles */ .bases-feed-container { padding: 20px; - max-width: 700px; - margin: 0 auto; + width: 100%; + box-sizing: border-box; } .bases-feed-container.is-loading { @@ -12,6 +12,29 @@ .bases-feed { display: flex; flex-direction: column; + width: 100%; +} + +/* Single Column Centered Layout */ +.bases-feed-single-column { + margin: 0 auto; +} + +/* Masonry Grid Layout */ +.bases-feed-masonry { + width: 100%; +} + +.bases-feed-masonry-grid { + display: grid; + gap: 16px; + width: 100%; +} + +.bases-feed-masonry-column { + position: relative; + width: 100%; + min-height: 100px; } /* Virtualized list container holds absolute-positioned rows */ diff --git a/versions.json b/versions.json index 0623f0d..49512ad 100644 --- a/versions.json +++ b/versions.json @@ -1,5 +1,6 @@ { "0.1.0": "1.10.0", "0.1.1": "1.10.0", - "0.1.2": "1.10.0" + "0.1.2": "1.10.0", + "0.2.0": "1.10.0" } \ No newline at end of file