mirror of
https://github.com/edrickleong/obsidian-feed-bases.git
synced 2026-07-22 06:42:57 +00:00
feat: add experimental masonry support
This commit is contained in:
parent
e908a1855d
commit
4bf7cd73e1
8 changed files with 389 additions and 9 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
283
src/MasonryView.tsx
Normal 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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
15
src/main.ts
15
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,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
27
styles.css
27
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 */
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
Loading…
Reference in a new issue