Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions manual/ja/materials-and-citations.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

サイドバーには **素材** セクションがあり、種類ごとに項目が並びます。**メモ**、**画像**、**ドキュメント**(PDF と Word ファイル)、**データ**(装置が吐く `.csv` / `.txt` / `.dat`。[表として取り込めます](/ja/notes-and-editor#importing-measurement-data))、**動画**、**音声**、**URL**。どれかをクリックするとギャラリーが開きます。

素材もフォルダに入れられます。ノートと同じフォルダです。リスト表示で選んで **フォルダに入れる** を押し、一覧の上の **フォルダ** ボタンで絞り込みます。どのノートにも貼っていない素材でもフォルダに入れられるので、取り込んだその日に片付けられます。あわせて、その素材を使っているノートのフォルダにも入って見えます。**材料X** のノートに写真を貼れば、その写真も **材料X** で見つかります(薄いチップで表示されます)。これはノートに追随するので、ノートをそのフォルダから出せば写真も出ますが、自分で付けたフォルダは常に残ります。

ギャラリーの中では次のことができます。

| 操作 | 何ができるか |
Expand Down
2 changes: 2 additions & 0 deletions manual/materials-and-citations.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ Research rarely starts from a blank page — it starts from a paper, a web page,

The sidebar has a **Materials** section with one entry per type: **Memos**, **Images**, **Documents** (PDFs and Word files), **Data** (the `.csv` / `.txt` / `.dat` files instruments write, [imported as tables](/notes-and-editor#importing-measurement-data)), **Videos**, **Audio**, and **URLs**. Click any of them to open the gallery.

Materials can go in folders too — the same folders your notes use. Select some in the list view and choose **Add to a folder**, then use the **Folders** button above the list to show only what is in one. A material keeps its folder whether or not any note uses it yet, so you can file a PDF the day you download it. It also picks up the folders of the notes it is used in — put a photo in a note that lives in **Material X** and the photo shows up under **Material X** as well, shown in a lighter chip. Those follow the note: move the note out and the photo stops appearing there, while folders you set by hand always stay.

Inside the gallery you can:

| Control | What it does |
Expand Down
204 changes: 202 additions & 2 deletions src/features/asset-browser/AssetGalleryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@
// メディアタイプ別にサムネイル一覧を表示、ノート紐付き・削除に対応

import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Image, Video, Volume2, FileText, Table, Paperclip, Play, Link, ExternalLink, Plus, LayoutGrid, List as ListIcon, Bot, MoreHorizontal, Download, Images, Loader2, ScanText } from "lucide-react";
import { Image, Video, Volume2, FileText, Table, Paperclip, Play, Link, ExternalLink, Plus, LayoutGrid, List as ListIcon, Bot, MoreHorizontal, Download, Images, Loader2, ScanText, Folder } from "lucide-react";
import { UNFILED_PATH } from "../note-context/folder-tree-model";
import { aggregateNoteContexts, noteContextHue, addNoteContext, removeNoteContext } from "../note-context/context-tags";
import { ContextTagPicker } from "../note-context/ContextTagPicker";
import { ContextBadge } from "../note-context/ContextBadge";
import {
assetFolderValues,
resolveAssetFolders,
type NoteFolderLookup,
} from "./asset-folders";
import { FilterPopup, type FilterOption } from "@/ui/filter-popup";
import { useT } from "../../i18n";
import { getActiveProvider } from "../../lib/storage/registry";
import { useRangeSelect } from "../../hooks/use-range-select";
Expand Down Expand Up @@ -429,6 +439,18 @@ export type AssetGalleryViewProps = {
/** 素材を参照している版スナップショット数のオンデマンド集計(削除ダイアログ用) */
countSnapshotRefs?: (entry: MediaIndexEntry) => Promise<number>;
onRenameMedia: (entry: MediaIndexEntry, newName: string) => Promise<void>;
/**
* 素材のフォルダ(noteContexts)を保存する。渡されたときだけ付与 UI を出す。
* ノートと同じフォルダ体系を共有する。
*/
onSetMediaContexts?: (fileId: string, contexts: string[]) => Promise<void> | void;
/** ノート側で使われているフォルダ名(付与ピッカーの候補に混ぜる。体系を共有するため) */
noteFolders?: readonly string[];
/**
* ノート id → そのノートのフォルダ。素材が使われているノートのフォルダを
* 「その素材のフォルダ」として導出するために使う(保存はしない)。
*/
noteFolderLookup?: NoteFolderLookup;
/** URL ブックマーク登録コールバック(type === "url" のときのみ使用) */
onAddUrlBookmark?: (entry: MediaIndexEntry) => void;
/** ファイル直接アップロード(image/video/audio/pdf/document、ノート非経由) */
Expand Down Expand Up @@ -561,6 +583,9 @@ export function AssetGalleryView({
onArchiveMedia,
countSnapshotRefs,
onRenameMedia,
onSetMediaContexts,
noteFolders,
noteFolderLookup,
onAddUrlBookmark,
onUploadMedia,
onIngestMedia,
Expand Down Expand Up @@ -590,6 +615,23 @@ export function AssetGalleryView({
const [sortAsc, setSortAsc] = useState(false);
// Documents タブのサブフィルタ(PDF / Word / All)
const [docFilter, setDocFilter] = useState<"all" | "pdf" | "word">("all");
// フォルダでの絞り込み(ノートと同じ体系。UNFILED_PATH は「フォルダに入っていない素材」)
const [folderFilter, setFolderFilter] = useState<string[]>([]);
// 素材が属するフォルダ(自分で付けたもの + 使われているノートのフォルダ)を求める。
// 参照表が渡らない文脈(Storybook など)では自分で付けた分だけになる。
const emptyLookup = useMemo(() => new Map<string, readonly string[]>(), []);
const foldersOf = useCallback(
(entry: MediaIndexEntry) => resolveAssetFolders(entry, noteFolderLookup ?? emptyLookup),
[noteFolderLookup, emptyLookup],
);
const [folderFilterOpen, setFolderFilterOpen] = useState(false);
// 選択した素材へのフォルダ付与(ノート一覧の一括付与と同じ ContextTagPicker)
const [assignOpen, setAssignOpen] = useState(false);
const [assignPos, setAssignPos] = useState({ top: 0, left: 0 });
const [assignApplied, setAssignApplied] = useState<string[]>([]);
const assignBtnRef = useRef<HTMLButtonElement>(null);
const [folderFilterPos, setFolderFilterPos] = useState({ top: 0, left: 0 });
const folderFilterBtnRef = useRef<HTMLButtonElement>(null);
const [deleteTarget, setDeleteTarget] = useState<MediaIndexEntry | null>(null);
const [deleting, setDeleting] = useState(false);
const [detailEntry, setDetailEntry] = useState<MediaIndexEntry | null>(null);
Expand Down Expand Up @@ -802,6 +844,19 @@ export function AssetGalleryView({
if (docFilter === "word") return m.type === "document";
return m.type === "document" || m.type === "pdf";
});
// フォルダ絞り込み(OR・小文字比較)。ノート一覧の文脈フィルタと同じ規則にそろえる
if (folderFilter.length > 0) {
const wantsUnfiled = folderFilter.includes(UNFILED_PATH);
const set = new Set(
folderFilter.filter((c) => c !== UNFILED_PATH).map((c) => c.toLowerCase()),
);
result = result.filter((m) => {
// 導出込み。ノートに貼っただけの素材も、そのノートのフォルダで拾える
const all = assetFolderValues(m, noteFolderLookup ?? emptyLookup);
if (wantsUnfiled && all.length === 0) return true;
return all.some((c) => set.has(c.trim().toLowerCase()));
});
}
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
result = result.filter(
Expand All @@ -824,7 +879,52 @@ export function AssetGalleryView({
}
return sortAsc ? cmp : -cmp;
});
}, [mediaIndex, mediaType, searchQuery, sortKey, sortAsc, docFilter]);
}, [mediaIndex, mediaType, searchQuery, sortKey, sortAsc, docFilter, folderFilter, noteFolderLookup, emptyLookup]);

// フォルダ絞り込みの選択肢。いま見ている種類の素材に実際に付いているものだけ出す
// (空振りする候補を並べない)。未分類は件数が 1 件以上あるときだけ足す。
const folderFilterOptions = useMemo<FilterOption[]>(() => {
if (!mediaIndex) return [];
const inScope = mediaIndex.media.filter((m) => {
if (m.archivedAt) return false;
if (mediaType !== "document") return m.type === mediaType;
return m.type === "document" || m.type === "pdf";
});
const counts = aggregateNoteContexts(
inScope.map((m) => ({ noteContexts: assetFolderValues(m, noteFolderLookup ?? emptyLookup) })),
);
const options: FilterOption[] = counts.map(({ value, count }) => ({
value,
label: value,
count,
icon: (
<span
className="block w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: `hsl(${noteContextHue(value)} 45% 45%)` }}
/>
),
}));
const unfiled = inScope.filter(
(m) => assetFolderValues(m, noteFolderLookup ?? emptyLookup).length === 0,
).length;
if (unfiled > 0) {
options.push({ value: UNFILED_PATH, label: t("nav.unfiled"), count: unfiled });
}
return options;
}, [mediaIndex, mediaType, t, noteFolderLookup, emptyLookup]);

// その素材が属するフォルダ(自分で付けたもの + 使われているノートのフォルダ)。
// 参照表が渡らない文脈(Storybook など)では自分で付けた分だけになる。
// 付与ピッカーの候補。素材に付いているものと、ノート側のフォルダの両方から集める
// (体系を共有しているので、ノートで使っているフォルダに素材も入れられるべき)。
const assignSuggestions = useMemo(
() =>
aggregateNoteContexts([
...(mediaIndex?.media ?? []),
...(noteFolders ?? []).map((value) => ({ noteContexts: [value] })),
]),
[mediaIndex, noteFolders],
);

// Documents タブのサブフィルタ用件数
const docCounts = useMemo(() => {
Expand Down Expand Up @@ -1066,6 +1166,7 @@ export function AssetGalleryView({
return (
<AiAssistantProvider key={detailEntry.fileId} aiAvailable={aiAvailable}>
<MaterialFullView
noteFolderLookup={noteFolderLookup}
entry={detailEntry}
onClose={() => {
setDetailEntry(null);
Expand Down Expand Up @@ -1263,6 +1364,29 @@ export function AssetGalleryView({
))}
</div>
)}
{/* フォルダ絞り込み。ノートと同じ体系なので、一覧の「フォルダ」列と同じ見え方にする */}
{folderFilterOptions.length > 0 && (
<button
ref={folderFilterBtnRef}
onClick={() => {
const rect = folderFilterBtnRef.current?.getBoundingClientRect();
if (rect) setFolderFilterPos({ top: rect.bottom + 4, left: rect.left });
setFolderFilterOpen((v) => !v);
}}
title={t("nav.filterContexts")}
className={`inline-flex items-center gap-1 px-2.5 py-1 text-[11px] rounded-full transition-colors ${
folderFilter.length > 0
? "bg-primary/10 text-primary font-semibold"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
<Folder size={12} />
<span>{t("nav.folders")}</span>
{folderFilter.length > 0 && (
<span className="text-[10px] opacity-70">({folderFilter.length})</span>
)}
</button>
)}
<div className="flex items-center gap-1 ml-auto">
{/* ソートボタンは gallery モード専用(list モードは列ヘッダのクリックで揃える) */}
{viewMode === "gallery" && (
Expand Down Expand Up @@ -1332,6 +1456,22 @@ export function AssetGalleryView({
{t("asset.deselectAll")}
</button>
<div className="ml-auto flex items-center gap-2">
{onSetMediaContexts && (
<button
ref={assignBtnRef}
onClick={() => {
const rect = assignBtnRef.current?.getBoundingClientRect();
if (rect) setAssignPos({ top: rect.bottom + 4, left: rect.left - 120 });
setAssignApplied([]);
setAssignOpen(true);
}}
className="px-3 py-1 text-xs font-medium rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors inline-flex items-center gap-1.5"
title={t("nav.applyContextsTooltip")}
>
<Folder size={12} />
{t("nav.applyContexts", { count: String(selectedIds.size) })}
</button>
)}
{bulkActionable && onIngestMedia && (
<button
onClick={handleBulkIngest}
Expand Down Expand Up @@ -1514,6 +1654,20 @@ export function AssetGalleryView({
</a>
)}
</div>
{/* この素材が入っているフォルダ。ノートと同じ体系なので、
ノート一覧のフォルダ列と同じ ContextBadge で見せる */}
{foldersOf(entry).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{foldersOf(entry).map((f) => (
<ContextBadge
key={f.value}
value={f.value}
// ノート由来は薄く出す。外すならノート側、という違いを見せる
className={f.derived ? "opacity-60" : undefined}
/>
))}
</div>
)}
{entry.type === "url" && entry.urlMeta?.domain && (
<p className="text-[10px] text-muted-foreground truncate mt-0.5">
{entry.urlMeta.domain}
Expand Down Expand Up @@ -1592,6 +1746,7 @@ export function AssetGalleryView({
Full mode は早期 return で別ルートに渡す */}
{detailEntry && (
<MaterialSidePeek
noteFolderLookup={noteFolderLookup}
inline={isDesktop}
entry={detailEntry}
onClose={() => {
Expand Down Expand Up @@ -1626,6 +1781,51 @@ export function AssetGalleryView({
onSaveImageAsAsset={onSaveImageAsAsset}
/>
)}
{assignOpen && onSetMediaContexts && (
<ContextTagPicker
position={assignPos}
onClose={() => setAssignOpen(false)}
suggestions={assignSuggestions}
selected={assignApplied}
onAdd={(value) => {
// 選択中の素材すべてに足す(既に入っているものはそのまま)
void (async () => {
for (const fileId of selectedIds) {
const entry = mediaIndex?.media.find((m) => m.fileId === fileId);
if (!entry) continue;
const next = addNoteContext(entry.noteContexts, value);
await onSetMediaContexts(fileId, next ?? []);
}
setAssignApplied((prev) => (prev.includes(value) ? prev : [...prev, value]));
})();
}}
onRemove={(value) => {
void (async () => {
for (const fileId of selectedIds) {
const entry = mediaIndex?.media.find((m) => m.fileId === fileId);
if (!entry) continue;
const next = removeNoteContext(entry.noteContexts, value);
await onSetMediaContexts(fileId, next ?? []);
}
setAssignApplied((prev) => prev.filter((v) => v !== value));
})();
}}
/>
)}
{folderFilterOpen && (
<FilterPopup
position={folderFilterPos}
onClose={() => setFolderFilterOpen(false)}
title={t("nav.folders")}
options={folderFilterOptions}
selected={folderFilter}
onChange={setFolderFilter}
searchPlaceholder={t("common.search")}
clearLabel={t("nav.clearFilter")}
noMatchText={t("nav.contextEmpty")}
minWidth={220}
/>
)}
</div>
);
}
5 changes: 5 additions & 0 deletions src/features/asset-browser/MaterialFullView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Network, Info, StickyNote, Bot } from "lucide-react";
import { cn } from "../../lib/utils";
import { useT } from "../../i18n";
import type { MediaIndex, MediaIndexEntry, MediaSharedRef } from "./media-index";
import type { NoteFolderLookup } from "./asset-folders";
import { MediaPreview } from "./media-preview";
import type { CitationSource } from "./SelectionPill";
import { AssetGraphPanel, shouldShowAssetGraph, type KnowledgeKindLookup } from "./asset-graph-panel";
Expand All @@ -41,6 +42,8 @@ type RightTab = "graph" | "metadata" | "memos" | "chat" | null;

export type MaterialFullViewProps = {
entry: MediaIndexEntry;
/** ノート id → フォルダ。素材のフォルダ導出に使う(そのまま詳細ヘッダへ渡す) */
noteFolderLookup?: NoteFolderLookup;
onClose: () => void;
onToggleFull?: () => void;
onDelete?: (entry: MediaIndexEntry) => void;
Expand Down Expand Up @@ -84,6 +87,7 @@ export type MaterialFullViewProps = {

export function MaterialFullView({
entry,
noteFolderLookup,
onClose,
onToggleFull,
onDelete,
Expand Down Expand Up @@ -289,6 +293,7 @@ export function MaterialFullView({
>
<MaterialDetailHeader
entry={entry}
noteFolderLookup={noteFolderLookup}
onClose={onClose}
onRename={onRename}
onIngest={onIngest}
Expand Down
5 changes: 5 additions & 0 deletions src/features/asset-browser/MaterialSidePeek.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useSidePeekWidth } from "../../hooks/use-resizable-width";
import { ResizeHandle } from "../../components/ResizeHandle";
import { useIsDesktop } from "../../hooks/use-media-query";
import type { MediaIndex, MediaIndexEntry, MediaSharedRef } from "./media-index";
import type { NoteFolderLookup } from "./asset-folders";
import { MediaPreview } from "./media-preview";
import type { CitationSource } from "./SelectionPill";
import {
Expand Down Expand Up @@ -159,10 +160,13 @@ export type MaterialSidePeekProps = {
* 未指定なら何も描画しない。
*/
footer?: React.ReactNode;
/** ノート id → フォルダ。素材のフォルダ導出に使う(詳細ヘッダへそのまま渡す) */
noteFolderLookup?: NoteFolderLookup;
};

export function MaterialSidePeek({
entry,
noteFolderLookup,
onClose,
onToggleFull,
onDelete,
Expand Down Expand Up @@ -246,6 +250,7 @@ export function MaterialSidePeek({
)}
<MaterialDetailHeader
entry={entry}
noteFolderLookup={noteFolderLookup}
onClose={onClose}
// メモピーク(transient エントリ)は素材ではないため、素材系の操作
// (リネーム・Knowledge 化・PROV・抽出・共有・削除・Full 昇格)を出さない。
Expand Down
Loading
Loading