feat: add experimental masonry support

This commit is contained in:
Edrick Leong 2025-10-27 21:11:45 +08:00
parent e908a1855d
commit 4bf7cd73e1
8 changed files with 389 additions and 9 deletions

View file

@ -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",

View file

@ -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": {

View file

@ -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<FeedReactViewProps> = ({
entries,
@ -9,6 +10,45 @@ export const FeedReactView: React.FC<FeedReactViewProps> = ({
onEntryContextMenu,
scrollElement,
showProperties,
multipleColumns = false,
maxCardWidth = 400,
}) => {
const app = useApp();
// Conditionally render masonry or single column view
if (multipleColumns) {
return (
<MasonryView
entries={entries}
onEntryClick={onEntryClick}
onEntryContextMenu={onEntryContextMenu}
scrollElement={scrollElement}
showProperties={showProperties}
maxCardWidth={maxCardWidth}
/>
);
}
// Single column centered view
return (
<SingleColumnView
entries={entries}
onEntryClick={onEntryClick}
onEntryContextMenu={onEntryContextMenu}
scrollElement={scrollElement}
showProperties={showProperties}
maxCardWidth={maxCardWidth}
/>
);
};
const SingleColumnView: React.FC<SingleColumnViewProps> = ({
entries,
onEntryClick,
onEntryContextMenu,
scrollElement,
showProperties,
maxCardWidth,
}) => {
const app = useApp();
const getScrollEl = useMemo(() => () => scrollElement, [scrollElement]);
@ -16,7 +56,7 @@ export const FeedReactView: React.FC<FeedReactViewProps> = ({
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<FeedReactViewProps> = ({
const virtualItems = rowVirtualizer.getVirtualItems();
return (
<div className="bases-feed">
<div
className="bases-feed bases-feed-single-column"
style={{ maxWidth: `${maxCardWidth}px` }}
>
{entries.length === 0 ? (
<div className="bases-feed-empty">No notes to display</div>
) : (
// The container must be position:relative; items are absolutely positioned.
<div
className="bases-feed-virtualizer"
style={{ height: rowVirtualizer.getTotalSize() }}
@ -59,7 +101,6 @@ export const FeedReactView: React.FC<FeedReactViewProps> = ({
className="bases-feed-virtual-item"
style={{
transform: `translateY(${vi.start}px)`,
transition: "transform 0.3s",
}}
>
<FeedEntry
@ -180,6 +221,17 @@ type FeedReactViewProps = {
onEntryContextMenu: (evt: React.MouseEvent, entry: BasesEntry) => 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 = {

283
src/MasonryView.tsx Normal file
View file

@ -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<MasonryViewProps> = ({
entries,
onEntryClick,
onEntryContextMenu,
scrollElement,
showProperties,
maxCardWidth,
}) => {
const app = useApp();
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="bases-feed bases-feed-masonry">
{entries.length === 0 ? (
<div className="bases-feed-empty">No notes to display</div>
) : (
<div
className="bases-feed-masonry-grid"
style={{ gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}
>
{columns.map((columnEntries, columnIndex) => (
<MasonryColumn
key={columnIndex}
entries={columnEntries}
scrollElement={scrollElement}
app={app}
showProperties={showProperties}
onEntryClick={onEntryClick}
onEntryContextMenu={onEntryContextMenu}
/>
))}
</div>
)}
</div>
);
};
const MasonryColumn: React.FC<MasonryColumnProps> = ({
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 (
<div className="bases-feed-masonry-column">
<div
className="bases-feed-virtualizer"
style={{ height: rowVirtualizer.getTotalSize() }}
>
{virtualItems.map(
(vi: ReturnType<typeof rowVirtualizer.getVirtualItems>[number]) => {
const entry = entries[vi.index];
return (
<div
key={vi.key}
data-index={vi.index}
ref={rowVirtualizer.measureElement}
className="bases-feed-virtual-item"
style={{
transform: `translateY(${vi.start}px)`,
}}
>
<FeedEntry
entry={entry}
app={app}
showProperties={showProperties}
onEntryClick={onEntryClick}
onEntryContextMenu={onEntryContextMenu}
/>
</div>
);
},
)}
</div>
</div>
);
};
const FeedEntry: React.FC<FeedEntryProps> = ({
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 (
<div className="bases-feed-entry" onContextMenu={handleContextMenu}>
<div className="bases-feed-entry-header">
<a
className="bases-feed-entry-title"
onClick={handleTitleClick}
onMouseEnter={handleHover}
href="#"
>
{entry.file.basename}
</a>
</div>
<div className="bases-feed-entry-content">
<div
ref={setEditorHost}
className="bases-feed-entry-editor"
style={
{
"--metadata-display-editing": showProperties ? "block" : "none",
} as React.CSSProperties
}
/>
</div>
</div>
);
};
// 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;
};

View file

@ -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(
<StrictMode>
@ -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,

View file

@ -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,
},
],
});
}

View file

@ -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 */

View file

@ -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"
}