mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.107.1: sheet versions, list pins, note forking + task/filter polish
This commit is contained in:
parent
0389881adc
commit
81157657fa
13 changed files with 1243 additions and 229 deletions
165
main.js
165
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "stashpad",
|
||||
"name": "Stashpad",
|
||||
"version": "0.103.10",
|
||||
"version": "0.107.1",
|
||||
"minAppVersion": "1.13.0",
|
||||
"description": "A chat-style, nested-notes workspace: rapid capture, outliner navigation, fast search, tasks, and per-folder templates, with one-click Open Knowledge Format (OKF) export for LLMs and agents.",
|
||||
"author": "Human",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "stashpad-obsidian",
|
||||
"version": "0.103.10",
|
||||
"version": "0.107.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
|
|||
29
release-notes/0.107.1.md
Normal file
29
release-notes/0.107.1.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# 0.107.1 — Sheet versions, list pins, note forking
|
||||
|
||||
## Sheet versions
|
||||
Keep multiple tabbed versions of a single note (drafts/variants) and switch between
|
||||
them inline. The active version persists across reloads, is hardened against being
|
||||
hidden by accident, and is gated behind a settings toggle that ships **off** by
|
||||
default — turn it on under settings when you want it.
|
||||
|
||||
## Pin notes in the list
|
||||
Pin notes to the top of a Stashpad list so they stay put above the rest. Pinned rows
|
||||
are marked with a `pin` icon in the meta column, and the folder panel can reveal a
|
||||
folder's Home list-pinned notes.
|
||||
|
||||
## Fork a note
|
||||
Fork any note into a variant under a parent you choose — a quick way to branch an
|
||||
idea without losing the original.
|
||||
|
||||
## Tasks
|
||||
- Unified due-date / assignee modal with dedicated clear (×) buttons on the date and
|
||||
time fields.
|
||||
- Right-click task actions are grouped under a single **Task** submenu, with
|
||||
context-menu gating so options only appear when relevant.
|
||||
- `Remove task` renamed to `Remove from tasks`; the redundant `Set due date` entry is
|
||||
dropped from the Task submenu.
|
||||
|
||||
## List & filtering
|
||||
- Collapse or expand every note body at once, plus an **expand bodies by default**
|
||||
setting.
|
||||
- Reworked tags filter: a searchable, ranked, keyboard-navigable popover.
|
||||
|
|
@ -119,6 +119,10 @@ export class StashpadFolderPanelView extends ItemView {
|
|||
/** Pin subtree expansion state (key = `folder|id`), kept across re-renders. */
|
||||
private pinExpanded = new Set<string>();
|
||||
|
||||
/** 0.105.1: which folder rows are expanded to reveal their Home list-pinned
|
||||
* notes (key = folder path), kept across re-renders. */
|
||||
private folderPinExpanded = new Set<string>();
|
||||
|
||||
private openPinnedOptionsMenu(e: MouseEvent): void {
|
||||
const cur = this.plugin.settings.folderPanelPinnedGrouping ?? "pin-order";
|
||||
const menu = new Menu();
|
||||
|
|
@ -456,6 +460,25 @@ export class StashpadFolderPanelView extends ItemView {
|
|||
pin.setAttr("aria-label", "Pinned");
|
||||
}
|
||||
|
||||
// 0.105.1: reveal this folder's Home list-pinned notes inline. The chevron
|
||||
// shows only when the folder has any; clicking toggles a sub-list below the
|
||||
// row. stopPropagation so it doesn't trigger the row's folder navigation.
|
||||
const homePinned = this.folderHomePinnedNotes(folder);
|
||||
const pinKey = StashpadFolderPanelView.clean(folder);
|
||||
const pinsExpanded = this.folderPinExpanded.has(pinKey);
|
||||
if (homePinned.length > 0) {
|
||||
const chev = row.createSpan({ cls: "stashpad-folderpanel-pinreveal" });
|
||||
renderCountBadge(chev, homePinned.length, pinsExpanded);
|
||||
chev.setAttr("aria-label", pinsExpanded ? "Hide pinned notes" : `${homePinned.length} pinned note${homePinned.length === 1 ? "" : "s"}`);
|
||||
chev.addEventListener("mousedown", (e) => { e.stopPropagation(); e.preventDefault(); });
|
||||
chev.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.folderPinExpanded.has(pinKey)) this.folderPinExpanded.delete(pinKey);
|
||||
else this.folderPinExpanded.add(pinKey);
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
|
||||
// 0.95.1: per-folder icon. Tinted by the folder's Home-note color when set.
|
||||
// 0.98.37: archive folders show the ARCHIVE icon in place of the folder icon
|
||||
// (one icon instead of folder + a separate badge).
|
||||
|
|
@ -495,6 +518,28 @@ export class StashpadFolderPanelView extends ItemView {
|
|||
this.onNavigateAway(); this.jumpToFolder(folder);
|
||||
});
|
||||
row.oncontextmenu = (e) => { e.preventDefault(); this.openFolderMenu(e, folder); };
|
||||
|
||||
// 0.105.1: when expanded, list the folder's Home list-pinned notes as a
|
||||
// sub-list below the folder row. Clicking one reveals it in its Stashpad.
|
||||
if (homePinned.length > 0 && pinsExpanded) {
|
||||
const box = list.createDiv({ cls: "stashpad-folderpanel-pinlist" });
|
||||
for (const f of homePinned) {
|
||||
const prow = box.createDiv({ cls: "stashpad-folderpanel-pinrow" });
|
||||
const fmc = (this.app.metadataCache.getFileCache(f)?.frontmatter ?? {}) as any;
|
||||
const pic = prow.createSpan({ cls: "stashpad-folderpanel-pinrow-icon" });
|
||||
setIcon(pic, "arrow-up-to-line");
|
||||
if (typeof fmc.color === "string") pic.style.color = fmc.color;
|
||||
prow.createSpan({ cls: "stashpad-folderpanel-pinrow-label", text: this.titleFromFile(f) });
|
||||
prow.onclick = () => { this.onNavigateAway(); void this.plugin.revealNoteInStashpad(f); };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A folder's ROOT-level (Home) list-pinned notes — backs the folder-panel
|
||||
* reveal. Reuses childrenOf(folder, ROOT_ID) + the listPinned frontmatter. */
|
||||
private folderHomePinnedNotes(folder: string): TFile[] {
|
||||
return this.childrenOf(folder, ROOT_ID).filter((f) =>
|
||||
(this.app.metadataCache.getFileCache(f)?.frontmatter as any)?.listPinned === true);
|
||||
}
|
||||
|
||||
/** Collapsible "Hidden (N)" group at the bottom of the Folders list, so hidden
|
||||
|
|
|
|||
46
src/main.ts
46
src/main.ts
|
|
@ -1034,6 +1034,16 @@ export default class StashpadPlugin extends Plugin {
|
|||
name: "Toggle split-on-newlines",
|
||||
callback: () => call("toggleSplit"),
|
||||
});
|
||||
this.addCommand({
|
||||
id: "stashpad-fork-version",
|
||||
name: "Fork as version (alternate draft / sheet)",
|
||||
callback: () => call("cmdForkVersion"),
|
||||
});
|
||||
this.addCommand({
|
||||
id: "stashpad-mark-version-final",
|
||||
name: "Mark version as final (sheet)",
|
||||
callback: () => call("cmdMarkVersionFinal"),
|
||||
});
|
||||
this.addCommand({
|
||||
id: "stashpad-command-palette",
|
||||
name: "Command palette (Stashpad only)",
|
||||
|
|
@ -1190,8 +1200,11 @@ export default class StashpadPlugin extends Plugin {
|
|||
// "Clone / duplicate / copy" — three synonyms in the name so command-palette
|
||||
// fuzzy search hits regardless of which word the user reaches for.
|
||||
this.addCommand({ id: "stashpad-clone", name: "Clone selection (duplicate / copy notes)", callback: () => call("cmdClone") });
|
||||
this.addCommand({ id: "stashpad-fork-note", name: "Fork note (copy as a variant under a chosen parent)", callback: () => call("cmdForkNote") });
|
||||
this.addCommand({ id: "stashpad-insert-template", name: "Insert template (clone an existing note)", callback: () => call("cmdInsertTemplate") });
|
||||
this.addCommand({ id: "stashpad-toggle-expand", name: "Show more / show less (expand toggle)", callback: () => call("cmdToggleExpand") });
|
||||
this.addCommand({ id: "stashpad-expand-all", name: "Expand all (show every note's full body)", callback: () => call("cmdExpandAll") });
|
||||
this.addCommand({ id: "stashpad-collapse-all", name: "Collapse all (clamp every note's body)", callback: () => call("cmdCollapseAll") });
|
||||
// Three view-level keybinds that previously had no command-palette
|
||||
// entry. Names mirror their COMMAND_META labels for fuzzy lookup.
|
||||
this.addCommand({ id: "stashpad-pick-move", name: "Move (in-list, arrow + Enter)", callback: () => call("cmdInListPicker") });
|
||||
|
|
@ -1333,6 +1346,7 @@ export default class StashpadPlugin extends Plugin {
|
|||
}
|
||||
this.addCommand({ id: "stashpad-swap-with-parent", name: "Swap with parent (ouroboros)", callback: () => call("cmdSwapWithParent") });
|
||||
this.addCommand({ id: "stashpad-toggle-pin", name: "Pin / unpin selected note (sidebar)", callback: () => call("cmdTogglePin") });
|
||||
this.addCommand({ id: "stashpad-list-pin", name: "Pin / unpin to top of list", callback: () => call("cmdToggleListPin") });
|
||||
// 0.61.1: tiny mode — opens a popout window with the minimal shell
|
||||
// (folder/focus title + list + composer + sticky/expand controls).
|
||||
this.addCommand({
|
||||
|
|
@ -4875,6 +4889,38 @@ export default class StashpadPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
// Sheet versions: which version (note id) of each `sheet:` group is the one
|
||||
// shown as a row. Persisted to localStorage (like last-cursor) so the choice
|
||||
// survives reloads. Keyed by group id (globally unique) within a folder.
|
||||
// Storage key: "stashpad:active-versions" → { "<folder>": { "<groupId>": "<noteId>" } }
|
||||
private readonly ACTIVE_VERSIONS_LS_KEY = "stashpad:active-versions";
|
||||
private readActiveVersionsFile(): Record<string, Record<string, string>> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(this.ACTIVE_VERSIONS_LS_KEY);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed as Record<string, Record<string, string>> : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
/** Map of <groupId> → <active version note id> for the given folder. */
|
||||
loadActiveVersions(folder: string): Map<string, string> {
|
||||
const all = this.readActiveVersionsFile();
|
||||
return new Map(Object.entries(all[folder] ?? {}));
|
||||
}
|
||||
/** Synchronously persist the active version for one (folder, group). */
|
||||
saveActiveVersion(folder: string, groupId: string, noteId: string): void {
|
||||
try {
|
||||
const all = this.readActiveVersionsFile();
|
||||
if (!all[folder]) all[folder] = {};
|
||||
all[folder][groupId] = noteId;
|
||||
window.localStorage.setItem(this.ACTIVE_VERSIONS_LS_KEY, JSON.stringify(all));
|
||||
} catch (e) {
|
||||
console.warn("[Stashpad] failed to save active-version", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Serializes ALL settings writes so a fast draft-write can't race with
|
||||
* a settings-tab edit and clobber a freshly-changed shortcut. Both
|
||||
* saveSettings() and persistSettingsQuiet() funnel through here. */
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ export interface DuePickerOptions {
|
|||
knownAuthors?: AssigneeRef[];
|
||||
/** Assignees already on the note, to pre-fill the chips. */
|
||||
currentAssignees?: AssigneeRef[];
|
||||
/** Modal title. Defaults to "Set due date". The "Assign to" command
|
||||
* opens this same modal with a different title. */
|
||||
title?: string;
|
||||
}
|
||||
import type { NotificationCategory, NotificationRecord, NotificationService } from "./notifications";
|
||||
// Obsidian types `moment` as the namespace (not callable); a callable view.
|
||||
|
|
@ -1519,36 +1522,6 @@ export function buildAssigneePicker(
|
|||
|
||||
/** 0.78.3: standalone "Assign to" modal — assignment without touching the
|
||||
* due date. onPick gets the chosen assignee set; not called on dismiss. */
|
||||
export class AssignModal extends Modal {
|
||||
private didChoose = false;
|
||||
private assignees: AssigneeRef[] = [];
|
||||
constructor(
|
||||
app: App,
|
||||
private opts: { knownAuthors: AssigneeRef[]; currentAssignees: AssigneeRef[] },
|
||||
private onPick: (assignees: AssigneeRef[]) => void,
|
||||
) {
|
||||
super(app);
|
||||
this.assignees = [...opts.currentAssignees];
|
||||
}
|
||||
onOpen(): void {
|
||||
this.modalEl?.addClass("stashpad-compact-modal");
|
||||
this.contentEl.empty();
|
||||
this.titleEl.setText("Assign task");
|
||||
const wrap = this.contentEl.createDiv({ cls: "stashpad-due-picker" });
|
||||
buildAssigneePicker(wrap, {
|
||||
knownAuthors: this.opts.knownAuthors,
|
||||
initial: this.assignees,
|
||||
onChange: (list) => { this.assignees = list; },
|
||||
});
|
||||
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
|
||||
const cancel = row.createEl("button", { text: "Cancel" });
|
||||
cancel.onclick = () => { this.didChoose = true; this.close(); };
|
||||
const ok = row.createEl("button", { cls: "mod-cta", text: "Save" });
|
||||
ok.onclick = () => { this.didChoose = true; this.close(); this.onPick(this.assignees); };
|
||||
}
|
||||
onClose(): void { this.contentEl.empty(); void this.didChoose; }
|
||||
}
|
||||
|
||||
export class DueDatePickerModal extends Modal {
|
||||
private didChoose = false;
|
||||
/** Working set of assignees, mutated by the chips UI. */
|
||||
|
|
@ -1571,7 +1544,7 @@ export class DueDatePickerModal extends Modal {
|
|||
onOpen(): void {
|
||||
this.modalEl?.addClass("stashpad-compact-modal"); // 0.76.18
|
||||
this.contentEl.empty();
|
||||
this.titleEl.setText("Set due date");
|
||||
this.titleEl.setText(this.opts.title ?? "Set due date");
|
||||
|
||||
// Pre-fill from the current value when parseable.
|
||||
let initial: Date | null = null;
|
||||
|
|
@ -1589,10 +1562,17 @@ export class DueDatePickerModal extends Modal {
|
|||
const dateIcon = dateField.createSpan({ cls: "stashpad-due-field-icon" });
|
||||
setIcon(dateIcon, "calendar");
|
||||
const dateInput = dateField.createEl("input", { type: "date", cls: "stashpad-due-date" });
|
||||
// 0.104.x: dedicated × to clear this field independently.
|
||||
const dateClear = dateField.createSpan({ cls: "stashpad-due-clear", attr: { "aria-label": "Clear date" } });
|
||||
setIcon(dateClear, "x");
|
||||
dateClear.onclick = () => { dateInput.value = ""; dateInput.focus(); };
|
||||
const timeField = fields.createDiv({ cls: "stashpad-due-field" });
|
||||
const timeIcon = timeField.createSpan({ cls: "stashpad-due-field-icon" });
|
||||
setIcon(timeIcon, "clock");
|
||||
const timeInput = timeField.createEl("input", { type: "time", cls: "stashpad-due-time" });
|
||||
const timeClear = timeField.createSpan({ cls: "stashpad-due-clear", attr: { "aria-label": "Clear time" } });
|
||||
setIcon(timeClear, "x");
|
||||
timeClear.onclick = () => { timeInput.value = ""; timeInput.focus(); };
|
||||
// 0.76.8: the leading icon IS the picker button. The native
|
||||
// ::-webkit-calendar-picker-indicator (on the input's right) is
|
||||
// hidden via CSS; clicking our left icon opens the OS picker via
|
||||
|
|
|
|||
|
|
@ -55,12 +55,12 @@ export type CommandId =
|
|||
| "toggleSplit" | "pickDestination" | "search" | "searchInParent" | "delete" | "undo" | "redo"
|
||||
| "toggleComplete" | "moveUp" | "moveDown" | "moveToTop" | "moveToBottom"
|
||||
| "outdent" | "setColor"
|
||||
| "clone" | "insertTemplate"
|
||||
| "toggleExpand"
|
||||
| "clone" | "forkNote" | "insertTemplate"
|
||||
| "toggleExpand" | "expandAll" | "collapseAll"
|
||||
| "exportStash" | "importStash" | "pickFolder"
|
||||
| "cloneStashpadTab" | "selectAll" | "copyCodeBlock"
|
||||
| "swapWithParent"
|
||||
| "togglePin"
|
||||
| "togglePin" | "listPin"
|
||||
| "toggleTask" | "setDue"
|
||||
| "jumpToTop" | "jumpToBottom"
|
||||
| "lockSelection" | "unlockAll" | "moveToArchive" | "encryptDelete"
|
||||
|
|
@ -119,8 +119,11 @@ export const COMMAND_META: CommandMeta[] = [
|
|||
{ id: "outdent", label: "Outdent (move to grandparent)", desc: "Default: Mod+[ — re-parents the selection one level up.", defaultPrimary: "Mod+[" },
|
||||
{ id: "setColor", label: "Set note color", desc: "Default: Shift+: or ; — open the color picker for the selection (both chords active).", defaultPrimary: "Shift+:", defaultSecondary: ";", defaultUseBoth: true },
|
||||
{ id: "clone", label: "Clone (duplicate / copy) selection", desc: "Default: Mod+Shift+D — clone selected notes (with their subtrees) as siblings.", defaultPrimary: "Mod+Shift+D" },
|
||||
{ id: "forkNote", label: "Fork note (copy under a chosen parent)", desc: "Duplicate the cursor row (with its subtree) as a variant and pick which parent it nests under. No default chord.", defaultPrimary: "" },
|
||||
{ id: "insertTemplate", label: "Insert template (clone an existing note)", desc: "Pick any note in this Stashpad; clone it (with subtree + attachments) into the current view, retimestamped.", defaultPrimary: "" },
|
||||
{ id: "toggleExpand", label: "Show more / show less (expand toggle)", desc: "Default: Shift+? — toggle the clamp on the cursor row (or every selected row).", defaultPrimary: "Shift+?" },
|
||||
{ id: "expandAll", label: "Expand all (show every note's full body)", desc: "Un-clamp every note in the current list at once.", defaultPrimary: "" },
|
||||
{ id: "collapseAll", label: "Collapse all (clamp every note's body)", desc: "Re-clamp every note in the current list at once.", defaultPrimary: "" },
|
||||
{ id: "exportStash", label: "Export selection to .stash", desc: "Export the selected subtree(s) as a .stash bundle (notes + attachments).", defaultPrimary: "" },
|
||||
{ id: "importStash", label: "Import .stash file", desc: "Open the .stash bundle picker and import its notes into this Stashpad.", defaultPrimary: "" },
|
||||
{ id: "pickFolder", label: "Open / switch / create Stashpad folder", desc: "Default: Mod+S — opens the unified folder picker (reveal, switch, create, convert).", defaultPrimary: "Mod+S" },
|
||||
|
|
@ -129,6 +132,7 @@ export const COMMAND_META: CommandMeta[] = [
|
|||
{ id: "copyCodeBlock", label: "Copy code from codeblock", desc: "Default: { — copy the contents of the cursor row's first codeblock (or pick one when multiple exist).", defaultPrimary: "{" },
|
||||
{ id: "swapWithParent", label: "Swap with parent (ouroboros)", desc: "Promote the cursor row above its current parent; the parent slides under it (carrying its other children). No default — bind in this tab.", defaultPrimary: "" },
|
||||
{ id: "togglePin", label: "Pin / unpin selected note", desc: "Default: P — toggle the sidebar pin state of the cursor row (or focused note).", defaultPrimary: "P" },
|
||||
{ id: "listPin", label: "Pin / unpin to top of list", desc: "Float the cursor row (or selection) to the TOP of its list — distinct from the sidebar pin. No default chord.", defaultPrimary: "" },
|
||||
{ id: "toggleTask", label: "Toggle task (todo)", desc: "Default: H — mark the selection (or cursor row) as a task / todo, or clear it. Tasks appear in the Tasks panel.", defaultPrimary: "H" },
|
||||
{ id: "setDue", label: "Set due date…", desc: "Default: D — open a date+time picker to set (or clear) the due date on the selection. Setting a due date also marks the note as a task.", defaultPrimary: "D" },
|
||||
{ id: "jumpToTop", label: "Jump to top of list", desc: "Default: Home — move the cursor to the first note in the current list.", defaultPrimary: "Home" },
|
||||
|
|
@ -386,6 +390,12 @@ export interface StashpadSettings {
|
|||
* effect that vanishes the moment the cursor moves. Off by
|
||||
* default. */
|
||||
autoExpandCursorRow: boolean;
|
||||
/** When on, note bodies render fully expanded by default; the
|
||||
* per-note "Show more / show less" toggle and the expand/collapse-all
|
||||
* commands then act as a *collapse* opt-out (the expandedNotes Set is
|
||||
* interpreted as "differs from this default"). Off = current behavior
|
||||
* (bodies clamp by default, expand is opt-in). */
|
||||
expandBodiesByDefault: boolean;
|
||||
/** 0.74.1: auto-open the right-sidebar detail panel whenever a
|
||||
* Stashpad view becomes active. Off by default — opt in via this
|
||||
* toggle or the matching palette command. */
|
||||
|
|
@ -394,6 +404,11 @@ export interface StashpadSettings {
|
|||
* focus/open it — navigate into it, same as ArrowRight or the
|
||||
* enter arrow. On by default. Single click still just selects. */
|
||||
doubleClickToFocus: boolean;
|
||||
/** 0.107.0: enable "Sheet versions" — treat notes sharing a `sheet-group`
|
||||
* frontmatter id as alternate versions of one item (only the active one
|
||||
* shows as a row; siblings collapse into a tab bar). Off by default so the
|
||||
* collapse filter never touches anyone who hasn't opted in. */
|
||||
enableSheetVersions: boolean;
|
||||
/** 0.76.6: how dates (due dates, created/modified) display across
|
||||
* the Tasks panel + detail panel. One of locale / iso / us / eu /
|
||||
* long. Default "locale". */
|
||||
|
|
@ -522,8 +537,10 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
|
|||
autoNavOnMoveIn: false,
|
||||
autoNavOnMoveOut: false,
|
||||
autoExpandCursorRow: false,
|
||||
expandBodiesByDefault: false,
|
||||
autoOpenDetailPanel: false,
|
||||
doubleClickToFocus: true,
|
||||
enableSheetVersions: false,
|
||||
dateDisplayFormat: "locale",
|
||||
dateDisplayTimezone: "",
|
||||
jdIndexScope: "vault",
|
||||
|
|
@ -943,10 +960,14 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
() => this.plugin.settings.autoNavOnMoveOut, (v) => { this.plugin.settings.autoNavOnMoveOut = v; }, ["navigate", "move", "out"]));
|
||||
items.push(toggle("Double-click a note to open it", "Double-click (or double-tap on mobile) a note in the list to focus/open it — the same as pressing → or clicking the enter arrow. Single click still just selects. On by default.",
|
||||
() => this.plugin.settings.doubleClickToFocus, (v) => { this.plugin.settings.doubleClickToFocus = v; }, ["double", "click", "open", "focus"]));
|
||||
items.push(toggle("Sheet versions (alternate drafts)", "Treat notes that share a 'sheet-group' frontmatter id as alternate versions of one item: only the active version shows as a row, and its siblings collapse into a tab bar at the bottom of that row. Use \"Fork as version\" on a note to start. Off by default — when off, no note is ever hidden by this feature and the commands do nothing.",
|
||||
() => this.plugin.settings.enableSheetVersions, (v) => { this.plugin.settings.enableSheetVersions = v; }, ["sheet", "version", "draft", "alternate", "fork"]));
|
||||
items.push(toggle("Auto-open the detail panel", "Open the right-sidebar Stashpad detail panel automatically whenever a Stashpad view becomes active. The panel shows the cursored note's body, metadata, and children. Off = open manually via ribbon or command palette.",
|
||||
() => this.plugin.settings.autoOpenDetailPanel, (v) => { this.plugin.settings.autoOpenDetailPanel = v; }, ["detail", "panel", "sidebar"]));
|
||||
items.push(toggle("Expand the cursor row's body automatically", "As you arrow-key through the list, the row under the cursor temporarily un-clamps to show its full body. Moving away re-collapses it. Doesn't affect the persistent 'Show more' state — this is a transient view-only effect.",
|
||||
() => this.plugin.settings.autoExpandCursorRow, (v) => { this.plugin.settings.autoExpandCursorRow = v; }, ["expand", "cursor", "body"]));
|
||||
items.push(toggle("Expand note bodies by default", "Show every note's full body by default instead of clamping long notes. The per-note 'Show more / show less' toggle and the Expand-all / Collapse-all commands then work in reverse — they let you collapse individual notes back down. Off = bodies clamp by default (expand is opt-in).",
|
||||
() => this.plugin.settings.expandBodiesByDefault, (v) => { this.plugin.settings.expandBodiesByDefault = v; }, ["expand", "collapse", "default", "body", "clamp"]));
|
||||
items.push(toggle("Confirm cross-parent drag-and-drop", "When dragging notes onto a note that has a different parent, ask before re-parenting (turn off to allow direct moves).",
|
||||
() => this.plugin.settings.confirmCrossParentDrag, (v) => { this.plugin.settings.confirmCrossParentDrag = v; }, ["confirm", "drag", "drop", "reparent"]));
|
||||
items.push(toggle("Confirm bulk deletes", "Warn before deletes that affect more than one note — multi-selection delete OR deleting a note that has descendants. A single childless note with no attachments never prompts. Off = those deletes apply immediately (undo still recovers everything).",
|
||||
|
|
|
|||
90
src/sheets-versions.ts
Normal file
90
src/sheets-versions.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Sheet "versions" — several notes sharing a `sheet-group` id are treated as
|
||||
// alternate versions of one item. Only the active version shows as a row; its
|
||||
// siblings collapse into a small tab bar on that row. The frontmatter keys are
|
||||
// shared with the standalone Sheets plugin so the same notes work in both.
|
||||
//
|
||||
// The group key is `sheet-group` (NOT the generic `sheet`) to avoid colliding
|
||||
// with a user's own unrelated frontmatter, AND a note only counts as a version
|
||||
// when it carries BOTH `sheet-group` and a numeric `sheet-order` (see
|
||||
// isVersionMember) — so a stray single key can never silently hide a note.
|
||||
import { App, TFile } from "obsidian";
|
||||
import { TreeNode } from "./types";
|
||||
|
||||
export const SHEET_KEY = "sheet-group"; // group id shared by all versions (string)
|
||||
export const SHEET_ORDER_KEY = "sheet-order"; // numeric order within the group
|
||||
export const SHEET_FINAL_KEY = "sheet-final"; // true on the chosen version
|
||||
export const SHEET_LABEL_KEY = "sheet-label"; // optional custom tab label
|
||||
|
||||
type Fm = Record<string, unknown> | null | undefined;
|
||||
|
||||
export function sheetIdOf(fm: Fm): string | null {
|
||||
if (!fm) return null;
|
||||
const v = fm[SHEET_KEY];
|
||||
return typeof v === "string" && v.trim() ? v : null;
|
||||
}
|
||||
|
||||
export function sheetOrderOf(fm: Fm): number | null {
|
||||
const v = fm?.[SHEET_ORDER_KEY];
|
||||
return typeof v === "number" ? v : null;
|
||||
}
|
||||
|
||||
/** A note is a version-group member only when it carries BOTH a group id and a
|
||||
* numeric order. Requiring two keys makes accidental collisions (a user with
|
||||
* an unrelated `sheet-group` key) essentially impossible — the collapse filter
|
||||
* ignores anything that isn't a deliberate, fully-stamped version. */
|
||||
export function isVersionMember(fm: Fm): boolean {
|
||||
return sheetIdOf(fm) !== null && sheetOrderOf(fm) !== null;
|
||||
}
|
||||
|
||||
export function sheetIsFinal(fm: Fm): boolean {
|
||||
return fm?.[SHEET_FINAL_KEY] === true;
|
||||
}
|
||||
|
||||
export function sheetLabelOf(fm: Fm): string | null {
|
||||
const v = fm?.[SHEET_LABEL_KEY];
|
||||
return typeof v === "string" && v.trim() ? v : null;
|
||||
}
|
||||
|
||||
/** Generate a fresh, reasonably-unique group id (matches the Sheets plugin). */
|
||||
export function newSheetGroupId(): string {
|
||||
const t = Date.now().toString(36);
|
||||
const r = Math.floor(Math.random() * 1e6).toString(36);
|
||||
return `s-${t}-${r}`;
|
||||
}
|
||||
|
||||
/** Read a node's frontmatter via the metadata cache. */
|
||||
export function nodeFm(app: App, node: TreeNode): Fm {
|
||||
if (!node.file) return null;
|
||||
return app.metadataCache.getFileCache(node.file)?.frontmatter as Fm;
|
||||
}
|
||||
|
||||
/** Sort group members by order, then created, then id — stable + deterministic. */
|
||||
export function sortVersions(app: App, members: TreeNode[]): TreeNode[] {
|
||||
return members.slice().sort((a, b) => {
|
||||
const oa = sheetOrderOf(nodeFm(app, a));
|
||||
const ob = sheetOrderOf(nodeFm(app, b));
|
||||
if (oa != null && ob != null && oa !== ob) return oa - ob;
|
||||
if (oa != null && ob == null) return -1;
|
||||
if (oa == null && ob != null) return 1;
|
||||
const ca = a.created ?? "";
|
||||
const cb = b.created ?? "";
|
||||
if (ca !== cb) return ca < cb ? -1 : 1;
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** The default active version of a group: the final pick, else the first by
|
||||
* order. `members` should already be the ones present in the current list. */
|
||||
export function defaultActive(app: App, members: TreeNode[]): TreeNode | null {
|
||||
if (members.length === 0) return null;
|
||||
const sorted = sortVersions(app, members);
|
||||
return sorted.find((m) => sheetIsFinal(nodeFm(app, m))) ?? sorted[0];
|
||||
}
|
||||
|
||||
/** A short label for a tab: the custom label, else the note's first body line
|
||||
* (the title Stashpad shows), falling back to the basename. */
|
||||
export function tabTitle(app: App, node: TreeNode, fallbackTitle: string): string {
|
||||
const label = sheetLabelOf(nodeFm(app, node));
|
||||
if (label) return label;
|
||||
return fallbackTitle;
|
||||
}
|
||||
|
|
@ -107,6 +107,9 @@ export const RESERVED_FRONTMATTER: readonly string[] = [
|
|||
// 0.86.3: sidebar pin state lives on the note (so it SYNCS with the note
|
||||
// across devices). Stashpad-managed; clones/templates must not inherit it.
|
||||
"pinned", "pinnedAt",
|
||||
// 0.105.0: list pin — floats a note to the TOP of its sibling list (distinct
|
||||
// from the sidebar pin above). Stashpad-managed; not inherited by clones.
|
||||
"listPinned", "listPinnedAt",
|
||||
// 0.88.0: marks a note that came in via import (used by the "imported only"
|
||||
// view filter). Stashpad-managed; a clone of an imported note isn't imported.
|
||||
"imported",
|
||||
|
|
|
|||
|
|
@ -157,3 +157,51 @@ export function arraysEqual<T>(a: T[], b: T[]): boolean {
|
|||
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Tag filter: special sentinel modes + ranked matching --------------------
|
||||
|
||||
/** Sentinel `tagFilter` values for the "has any tag" / "has no tags" filter
|
||||
* modes. The `__stashpad:` prefix can't collide with a real tag name. */
|
||||
export const TAG_FILTER_TAGGED = "__stashpad:tagged__";
|
||||
export const TAG_FILTER_UNTAGGED = "__stashpad:untagged__";
|
||||
|
||||
/** Score a tag name against a query for the tag-filter search box.
|
||||
* Higher = closer match; -1 = no match. Tiers (closest first):
|
||||
* exact (1000) > prefix (800) > word-boundary substring (≈600) >
|
||||
* mid-word substring (≈500) > subsequence/fuzzy (200).
|
||||
* Substring tiers subtract the match index so earlier hits rank higher. */
|
||||
export function rankTagMatch(query: string, tag: string): number {
|
||||
const q = query.toLowerCase().trim();
|
||||
const t = tag.toLowerCase();
|
||||
if (!q) return 0;
|
||||
if (t === q) return 1000;
|
||||
if (t.startsWith(q)) return 800;
|
||||
const idx = t.indexOf(q);
|
||||
if (idx > 0) {
|
||||
const prev = t[idx - 1];
|
||||
const boundary = !/[a-z0-9]/.test(prev); // after a separator (-, _, /, space)
|
||||
return (boundary ? 600 : 500) - Math.min(idx, 99);
|
||||
}
|
||||
// Subsequence fuzzy: every query char appears in order somewhere.
|
||||
let qi = 0;
|
||||
for (let i = 0; i < t.length && qi < q.length; i++) {
|
||||
if (t[i] === q[qi]) qi++;
|
||||
}
|
||||
return qi === q.length ? 200 : -1;
|
||||
}
|
||||
|
||||
/** Rank + filter a tag list by `query`. Empty query returns the list
|
||||
* unchanged (caller's original frequency/alpha order). Otherwise drops
|
||||
* non-matches and sorts by score desc, then count desc, then label. */
|
||||
export function rankTags<T extends { raw: string; label: string; count: number }>(
|
||||
query: string,
|
||||
tags: T[],
|
||||
): T[] {
|
||||
const q = query.trim();
|
||||
if (!q) return tags;
|
||||
return tags
|
||||
.map((t) => ({ t, s: rankTagMatch(q, t.raw) }))
|
||||
.filter((x) => x.s >= 0)
|
||||
.sort((a, b) => b.s - a.s || b.t.count - a.t.count || a.t.label.localeCompare(b.t.label))
|
||||
.map((x) => x.t);
|
||||
}
|
||||
|
|
|
|||
761
src/view.ts
761
src/view.ts
|
|
@ -22,16 +22,29 @@ import { getSettings, getTemplatesFormats, onSettingsChange } from "./settings";
|
|||
import { StashpadSuggest } from "./note-picker";
|
||||
import { StashpadCommandPalette } from "./command-palette";
|
||||
import { setActiveView, clearActiveView } from "./active-view";
|
||||
import { AssignModal, ColorPickerModal, ConfirmDeleteModal, ConfirmModal, DueDatePickerModal, SplitNoteModal } from "./modals";
|
||||
import { ColorPickerModal, ConfirmDeleteModal, ConfirmModal, DueDatePickerModal, SplitNoteModal } from "./modals";
|
||||
import { ComposerAutocomplete } from "./composer-autocomplete";
|
||||
import { matchBinding, humanCombo } from "./view-keys";
|
||||
import { AuthorshipTracker } from "./authorship-tracker";
|
||||
import { ViewDnD } from "./view-dnd";
|
||||
import { NoteBodyRenderer } from "./note-body-renderer";
|
||||
import { computeSortedIds } from "./view-sort";
|
||||
import {
|
||||
SHEET_KEY,
|
||||
SHEET_ORDER_KEY,
|
||||
SHEET_FINAL_KEY,
|
||||
sheetIdOf,
|
||||
isVersionMember,
|
||||
sheetIsFinal,
|
||||
newSheetGroupId,
|
||||
nodeFm,
|
||||
sortVersions,
|
||||
defaultActive,
|
||||
tabTitle,
|
||||
} from "./sheets-versions";
|
||||
import * as clipboardCmds from "./commands/clipboard-cmds";
|
||||
import * as ioCmds from "./commands/io-cmds";
|
||||
import { setIconSafe, isAnyModalOpen, properCaseFolderPath, computeReorder, arraysEqual, splitIntoChunks, SPLIT_MODE_LABELS, type SplitMode } from "./view-helpers";
|
||||
import { setIconSafe, isAnyModalOpen, properCaseFolderPath, computeReorder, arraysEqual, splitIntoChunks, SPLIT_MODE_LABELS, type SplitMode, rankTags, TAG_FILTER_TAGGED, TAG_FILTER_UNTAGGED } from "./view-helpers";
|
||||
import type StashpadPlugin from "./main";
|
||||
|
||||
const IMG_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp", "avif"]);
|
||||
|
|
@ -251,6 +264,10 @@ export class StashpadView extends ItemView {
|
|||
* (lazy-loaded) on reload. 0.91.0. */
|
||||
private lastSelectionByFocus = new Map<StashpadId, StashpadId[]>();
|
||||
private expandedNotes = new Set<StashpadId>();
|
||||
/** Sheet versions: which version (note id) of a `sheet:` group is currently
|
||||
* shown as the row. View-state only (not persisted) — falls back to the
|
||||
* final pick / first-by-order when unset. */
|
||||
private activeVersionByGroup = new Map<string, StashpadId>();
|
||||
private focusComposerOnNextRender = false;
|
||||
/** 0.76.21: timestamp until which the activation auto-focus
|
||||
* (focusComposer) is suppressed. Set after actions that close a
|
||||
|
|
@ -310,8 +327,8 @@ export class StashpadView extends ItemView {
|
|||
this.tree.setOrderProvider((parentId) => {
|
||||
const folder = this.noteFolder;
|
||||
const mode = this.sortStore.getMode(folder, parentId);
|
||||
if (mode === "manual") return this.order.getOrder(folder, parentId);
|
||||
return computeSortedIds(this, parentId, mode);
|
||||
const base = mode === "manual" ? this.order.getOrder(folder, parentId) : computeSortedIds(this, parentId, mode);
|
||||
return this.hoistListPinned(parentId, base);
|
||||
});
|
||||
this.debouncedRender = debounce(() => { if (this.renderSuppressed()) return; this.render(); }, 80);
|
||||
this.authorship = new AuthorshipTracker(this);
|
||||
|
|
@ -568,6 +585,8 @@ export class StashpadView extends ItemView {
|
|||
for (const [focusId, noteId] of loaded) this.lastCursorByFocus.set(focusId, noteId);
|
||||
// 0.91.0: hydrate the persisted multi-selection alongside the cursor.
|
||||
this.lastSelectionByFocus = this.plugin.loadLastSelection(this.noteFolder);
|
||||
// Sheet versions: restore which version of each group is shown.
|
||||
this.activeVersionByGroup = this.plugin.loadActiveVersions(this.noteFolder);
|
||||
} catch { /* ignore */ }
|
||||
// On a fresh mount (app reload, tab restore, first-ever open), scroll
|
||||
// to the end of the list so the newest notes are visible. Once the
|
||||
|
|
@ -898,6 +917,8 @@ export class StashpadView extends ItemView {
|
|||
for (const [focusId, noteId] of loaded) this.lastCursorByFocus.set(focusId, noteId);
|
||||
// 0.91.0: re-hydrate the persisted multi-selection for the new folder.
|
||||
this.lastSelectionByFocus = this.plugin.loadLastSelection(this.noteFolder);
|
||||
// Sheet versions: re-hydrate active versions for the new folder.
|
||||
this.activeVersionByGroup = this.plugin.loadActiveVersions(this.noteFolder);
|
||||
} catch { /* ignore */ }
|
||||
const savedCursorId = this.lastCursorByFocus.get(this.focusId);
|
||||
let policy: ScrollPolicy;
|
||||
|
|
@ -1893,6 +1914,10 @@ export class StashpadView extends ItemView {
|
|||
}
|
||||
|
||||
private filterChildren(children: TreeNode[]): TreeNode[] {
|
||||
// Sheet versions collapse FIRST and unconditionally (before the
|
||||
// no-filters early-return below): non-active versions of a group are
|
||||
// hidden so only the active one shows as a row.
|
||||
children = this.collapseVersions(children);
|
||||
const cutoff = this.timeFilterCutoff();
|
||||
const tag = this.tagFilter?.toLowerCase();
|
||||
const color = this.colorFilter?.toLowerCase() ?? null;
|
||||
|
|
@ -1918,7 +1943,9 @@ export class StashpadView extends ItemView {
|
|||
}
|
||||
if (tag) {
|
||||
if (!n.file) return false;
|
||||
if (!this.nodeHasTag(n, tag)) return false;
|
||||
if (this.tagFilter === TAG_FILTER_TAGGED) { if (!this.nodeHasAnyTag(n)) return false; }
|
||||
else if (this.tagFilter === TAG_FILTER_UNTAGGED) { if (this.nodeHasAnyTag(n)) return false; }
|
||||
else if (!this.nodeHasTag(n, tag)) return false;
|
||||
}
|
||||
if (color) {
|
||||
const c = this.colorForNode(n)?.toLowerCase() ?? null;
|
||||
|
|
@ -1936,6 +1963,70 @@ export class StashpadView extends ItemView {
|
|||
});
|
||||
}
|
||||
|
||||
/** Collapse `sheet:` version groups so only the active version remains in
|
||||
* the list. Notes without a sheet id pass through untouched. */
|
||||
private collapseVersions(children: TreeNode[]): TreeNode[] {
|
||||
// Feature is opt-in: when off, never hide anything.
|
||||
if (!this.plugin.settings.enableSheetVersions) return children;
|
||||
// Bucket nodes by group id; non-sheet nodes go straight through.
|
||||
const groups = new Map<string, TreeNode[]>();
|
||||
let anyGroup = false;
|
||||
for (const n of children) {
|
||||
const fm = nodeFm(this.app, n);
|
||||
if (!isVersionMember(fm)) continue; // needs BOTH sheet-group + sheet-order
|
||||
anyGroup = true;
|
||||
const gid = sheetIdOf(fm)!;
|
||||
const arr = groups.get(gid);
|
||||
if (arr) arr.push(n);
|
||||
else groups.set(gid, [n]);
|
||||
}
|
||||
if (!anyGroup) return children;
|
||||
// For each group, decide the single active member to keep.
|
||||
const keep = new Set<StashpadId>();
|
||||
for (const [gid, members] of groups) {
|
||||
const active = this.activeVersionNode(gid, members);
|
||||
if (active) keep.add(active.id);
|
||||
}
|
||||
return children.filter((n) => {
|
||||
// Only fully-stamped version members are eligible to be hidden.
|
||||
if (!isVersionMember(nodeFm(this.app, n))) return true;
|
||||
return keep.has(n.id);
|
||||
});
|
||||
}
|
||||
|
||||
/** The version node to show for a group, honoring an explicit user choice
|
||||
* when that choice is still present in the current set. */
|
||||
private activeVersionNode(groupId: string, members: TreeNode[]): TreeNode | null {
|
||||
const chosen = this.activeVersionByGroup.get(groupId);
|
||||
if (chosen) {
|
||||
const hit = members.find((m) => m.id === chosen);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return defaultActive(this.app, members);
|
||||
}
|
||||
|
||||
/** Switch which version of a group is shown, persist it, then repaint. */
|
||||
private setActiveVersion(groupId: string, id: StashpadId): void {
|
||||
this.activeVersionByGroup.set(groupId, id);
|
||||
this.plugin.saveActiveVersion(this.noteFolder, groupId, id);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** True if `node`'s file carries ANY tag (inline or frontmatter) —
|
||||
* backs the "Tagged"/"Untagged" filter modes. */
|
||||
private nodeHasAnyTag(node: TreeNode): boolean {
|
||||
if (!node.file) return false;
|
||||
const cache = this.app.metadataCache.getFileCache(node.file);
|
||||
if (!cache) return false;
|
||||
if (cache.tags && cache.tags.length > 0) return true;
|
||||
const fmTags = cache.frontmatter?.tags;
|
||||
if (fmTags) {
|
||||
const arr = Array.isArray(fmTags) ? fmTags : [fmTags];
|
||||
if (arr.some((t) => typeof t === "string" && t.trim().length > 0)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** True if `node`'s file carries `tag` (case-insensitive) — checks
|
||||
* inline tags AND frontmatter `tags`. */
|
||||
private nodeHasTag(node: TreeNode, tagLower: string): boolean {
|
||||
|
|
@ -2743,6 +2834,10 @@ export class StashpadView extends ItemView {
|
|||
// group so it's visible without scrolling past the selection commands.
|
||||
menu.addItem((it: any) => it.setTitle("Reload without saving").setIcon("rotate-ccw").onClick(() => this.plugin.reloadAppForUpdate()));
|
||||
menu.addSeparator();
|
||||
// List-wide expand/collapse — operate on every note, independent of selection.
|
||||
menu.addItem((it: any) => it.setTitle("Expand all").setIcon("unfold-vertical").onClick(() => this.cmdExpandAll()));
|
||||
menu.addItem((it: any) => it.setTitle("Collapse all").setIcon("fold-vertical").onClick(() => this.cmdCollapseAll()));
|
||||
menu.addSeparator();
|
||||
menu.addItem((it: any) => it.setTitle("Open in new Stashpad tab").setIcon("list-tree").setDisabled(!hasTargets).onClick(() => this.cmdOpenInNewStashpadTab()));
|
||||
menu.addItem((it: any) => it.setTitle("Open in editor").setIcon("pencil").setDisabled(!hasTargets).onClick(() => this.cmdOpenInEditor()));
|
||||
menu.addSeparator();
|
||||
|
|
@ -2758,6 +2853,10 @@ export class StashpadView extends ItemView {
|
|||
menu.addItem((it: any) => it.setTitle("Copy").setIcon("copy").setDisabled(!hasTargets).onClick(() => void this.cmdCopy()));
|
||||
menu.addItem((it: any) => it.setTitle("Copy tree").setIcon("copy-plus").setDisabled(!hasTargets).onClick(() => void this.cmdCopyTree()));
|
||||
menu.addItem((it: any) => it.setTitle("Clone (duplicate / copy)").setIcon("files").setDisabled(!hasTargets).onClick(() => void this.cmdClone()));
|
||||
if (this.plugin.settings.enableSheetVersions) {
|
||||
menu.addItem((it: any) => it.setTitle("Fork as version").setIcon("git-fork").setDisabled(!hasTargets || !exactlyOne).onClick(() => void this.cmdForkVersion()));
|
||||
menu.addItem((it: any) => it.setTitle("Mark version as final").setIcon("star").setDisabled(!hasTargets || !exactlyOne).onClick(() => void this.cmdMarkVersionFinal()));
|
||||
}
|
||||
menu.addItem((it: any) => it.setTitle("Insert template…").setIcon("file-plus-2").onClick(() => this.cmdInsertTemplate()));
|
||||
menu.addItem((it: any) => it.setTitle("Merge").setIcon("merge").setDisabled(this.selection.size < 2).onClick(() => void this.cmdMerge()));
|
||||
// Split only operates on a single note — the cmdSplit modal would
|
||||
|
|
@ -2786,27 +2885,131 @@ export class StashpadView extends ItemView {
|
|||
new StashpadCommandPalette(this.app).open();
|
||||
}
|
||||
|
||||
/** Render the tag-filter <select>. Folder tags are tallied + sorted
|
||||
* here on each render so newly-added tags appear without a refresh. */
|
||||
/** Human label for the current tag filter (button text). */
|
||||
private tagFilterLabel(): string {
|
||||
if (this.tagFilter === TAG_FILTER_TAGGED) return "Tagged";
|
||||
if (this.tagFilter === TAG_FILTER_UNTAGGED) return "Untagged";
|
||||
if (this.tagFilter) return `#${this.formatTagLabel(this.tagFilter)}`;
|
||||
return this.collectFolderTags().length === 0 ? "No tags" : "All tags";
|
||||
}
|
||||
|
||||
/** 0.104.x: the tag filter is a custom button + searchable popover
|
||||
* (fused from the iOS/macOS Drafts design) instead of a native
|
||||
* <select>. Clicking opens a popover with a search box (ranked, X to
|
||||
* clear), the "Tagged"/"Untagged" special modes, and every folder tag —
|
||||
* navigable by ↑/↓ + Enter. Tags are tallied each render so new ones
|
||||
* appear without a refresh. */
|
||||
private renderTagFilterDropdown(bar: HTMLElement): void {
|
||||
const sel = bar.createEl("select", { cls: "stashpad-tag-filter-select" });
|
||||
const all = sel.createEl("option", { text: "All tags" });
|
||||
all.value = "";
|
||||
if (!this.tagFilter) all.selected = true;
|
||||
|
||||
const tags = this.collectFolderTags();
|
||||
if (tags.length === 0) {
|
||||
sel.disabled = true;
|
||||
all.text = "No tags";
|
||||
} else {
|
||||
for (const t of tags) {
|
||||
const opt = sel.createEl("option", { text: `${t.label} (${t.count})` });
|
||||
opt.value = t.raw;
|
||||
if (this.tagFilter && this.tagFilter.toLowerCase() === t.raw.toLowerCase()) opt.selected = true;
|
||||
}
|
||||
}
|
||||
const btn = bar.createDiv({ cls: "stashpad-tag-filter-btn" });
|
||||
btn.setAttribute("role", "button");
|
||||
btn.setAttribute("tabindex", "0");
|
||||
const icon = btn.createSpan({ cls: "stashpad-tag-filter-btn-icon" });
|
||||
setIconSafe(icon, "tag", "#");
|
||||
btn.createSpan({ cls: "stashpad-tag-filter-label", text: this.tagFilterLabel() });
|
||||
if (tags.length === 0 && !this.tagFilter) btn.addClass("is-disabled");
|
||||
const open = (e: Event): void => {
|
||||
e.preventDefault();
|
||||
// Allow opening with a stale filter active even if no tags remain, so
|
||||
// it's always recoverable via the "All tags" reset.
|
||||
if (tags.length === 0 && !this.tagFilter) return;
|
||||
this.openTagFilterMenu(btn);
|
||||
};
|
||||
btn.onclick = open;
|
||||
btn.onkeydown = (e) => { if (e.key === "Enter" || e.key === " ") open(e); };
|
||||
}
|
||||
|
||||
sel.onchange = () => this.setTagFilter(sel.value || null);
|
||||
/** Searchable tag-filter popover anchored beneath `anchor`. Mirrors the
|
||||
* color-filter popover's outside-click + Escape teardown. */
|
||||
private openTagFilterMenu(anchor: HTMLElement): void {
|
||||
const doc = anchor.ownerDocument ?? document;
|
||||
doc.querySelectorAll(".stashpad-tag-filter-popover").forEach((el) => el.remove());
|
||||
|
||||
const pop = doc.body.createDiv({ cls: "stashpad-tag-filter-popover" });
|
||||
const r = anchor.getBoundingClientRect();
|
||||
pop.setCssStyles({
|
||||
left: `${Math.max(8, r.left)}px`,
|
||||
top: `${r.bottom + 4}px`,
|
||||
minWidth: `${Math.max(r.width, 200)}px`,
|
||||
maxWidth: "min(320px, calc(100vw - 16px))",
|
||||
width: "max-content",
|
||||
});
|
||||
|
||||
const scope = new Scope((this.app as any).scope);
|
||||
const close = (): void => {
|
||||
pop.remove();
|
||||
doc.removeEventListener("mousedown", outside, true);
|
||||
try { (this.app as any).keymap?.popScope(scope); } catch { /* ignore */ }
|
||||
};
|
||||
const outside = (ev: MouseEvent): void => {
|
||||
if (!pop.contains(ev.target as Node) && ev.target !== anchor && !anchor.contains(ev.target as Node)) close();
|
||||
};
|
||||
|
||||
// Search row: icon + input + (conditionally) an X-to-clear button.
|
||||
const searchRow = pop.createDiv({ cls: "stashpad-tag-filter-search" });
|
||||
const sIcon = searchRow.createSpan({ cls: "stashpad-tag-filter-search-icon" });
|
||||
setIconSafe(sIcon, "search", "⌕");
|
||||
const input = searchRow.createEl("input", { type: "text", cls: "stashpad-tag-filter-input", attr: { placeholder: "Filter tags…" } });
|
||||
const clearX = searchRow.createSpan({ cls: "stashpad-tag-filter-clear", attr: { "aria-label": "Clear" } });
|
||||
setIconSafe(clearX, "x", "×");
|
||||
clearX.setCssStyles({ display: "none" });
|
||||
|
||||
const list = pop.createDiv({ cls: "stashpad-tag-filter-list" });
|
||||
const allTags = this.collectFolderTags();
|
||||
let rows: Array<{ value: string | null; el: HTMLElement }> = [];
|
||||
let highlight = 0;
|
||||
|
||||
const select = (value: string | null): void => { this.setTagFilter(value); close(); };
|
||||
const setHighlight = (i: number): void => {
|
||||
if (rows.length === 0) { highlight = 0; return; }
|
||||
highlight = (i + rows.length) % rows.length;
|
||||
rows.forEach((row, k) => row.el.toggleClass("is-highlighted", k === highlight));
|
||||
rows[highlight].el.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
const addRow = (label: string, value: string | null, count: number | undefined, active: boolean): void => {
|
||||
const row = list.createDiv({ cls: "stashpad-tag-filter-row" });
|
||||
if (active) row.addClass("is-active");
|
||||
row.createSpan({ cls: "stashpad-tag-filter-row-label", text: label });
|
||||
if (typeof count === "number") row.createSpan({ cls: "stashpad-tag-filter-row-count", text: String(count) });
|
||||
const idx = rows.length;
|
||||
rows.push({ value, el: row });
|
||||
row.onmousedown = (e) => { e.preventDefault(); select(value); };
|
||||
row.onmouseenter = () => setHighlight(idx);
|
||||
};
|
||||
const renderList = (): void => {
|
||||
const q = input.value;
|
||||
clearX.setCssStyles({ display: q ? "" : "none" });
|
||||
list.empty();
|
||||
rows = [];
|
||||
const activeRaw = this.tagFilter?.toLowerCase();
|
||||
if (!q.trim()) {
|
||||
// Empty query: special modes pinned on top, then every tag.
|
||||
addRow("All tags", null, undefined, !this.tagFilter);
|
||||
addRow("Tagged", TAG_FILTER_TAGGED, undefined, this.tagFilter === TAG_FILTER_TAGGED);
|
||||
addRow("Untagged", TAG_FILTER_UNTAGGED, undefined, this.tagFilter === TAG_FILTER_UNTAGGED);
|
||||
for (const t of allTags) addRow(`#${t.label}`, t.raw, t.count, activeRaw === t.raw.toLowerCase());
|
||||
} else {
|
||||
// Typing: hide the specials so ↑/↓ jump straight to ranked tags.
|
||||
const ranked = rankTags(q, allTags);
|
||||
if (ranked.length === 0) list.createDiv({ cls: "stashpad-tag-filter-empty", text: "No matching tags" });
|
||||
else for (const t of ranked) addRow(`#${t.label}`, t.raw, t.count, activeRaw === t.raw.toLowerCase());
|
||||
}
|
||||
setHighlight(0);
|
||||
};
|
||||
|
||||
clearX.onclick = () => { input.value = ""; renderList(); input.focus(); };
|
||||
input.addEventListener("input", renderList);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "ArrowDown") { e.preventDefault(); setHighlight(highlight + 1); }
|
||||
else if (e.key === "ArrowUp") { e.preventDefault(); setHighlight(highlight - 1); }
|
||||
else if (e.key === "Enter") { e.preventDefault(); if (rows[highlight]) select(rows[highlight].value); }
|
||||
});
|
||||
|
||||
scope.register([], "Escape", (ev: KeyboardEvent) => { ev.preventDefault(); close(); return false; });
|
||||
(this.app as any).keymap?.pushScope(scope);
|
||||
|
||||
renderList();
|
||||
setTimeout(() => { doc.addEventListener("mousedown", outside, true); input.focus(); }, 0);
|
||||
}
|
||||
|
||||
/** Color filter — custom button + popover. Native <select> is unable
|
||||
|
|
@ -3524,6 +3727,8 @@ export class StashpadView extends ItemView {
|
|||
};
|
||||
};
|
||||
addRow(tags.length === 0 ? "No tags" : "All tags", null);
|
||||
addRow("Tagged", TAG_FILTER_TAGGED);
|
||||
addRow("Untagged", TAG_FILTER_UNTAGGED);
|
||||
for (const t of tags) addRow(`${t.label} (${t.count})`, t.raw);
|
||||
}
|
||||
|
||||
|
|
@ -4250,6 +4455,7 @@ export class StashpadView extends ItemView {
|
|||
if (isCursor && this.plugin.settings.autoExpandCursorRow) row.addClass("is-cursor-expanded");
|
||||
if (isPickTarget) row.addClass("is-pick-target");
|
||||
if (this.isCompleted(node)) row.addClass("is-completed");
|
||||
if (this.isListPinned(node.id)) row.addClass("is-list-pinned");
|
||||
// 0.99.5: ghost rows that are sitting on a pending CUT (note clipboard),
|
||||
// mirroring a file manager — they're about to move/be-extracted on paste.
|
||||
if (this.isCutPending(node.id)) row.addClass("is-cut-pending");
|
||||
|
|
@ -4314,8 +4520,16 @@ export class StashpadView extends ItemView {
|
|||
// horizontal line below the timestamp — the mobile checkbox sits just to the
|
||||
// LEFT of the arrow (see the desktop addTaskCheckbox call above).
|
||||
const mobileTask = showCheckbox && Platform.isMobile;
|
||||
if (childCount > 0 || mobileTask) {
|
||||
const isPinnedRow = this.isListPinned(node.id);
|
||||
if (childCount > 0 || mobileTask || isPinnedRow) {
|
||||
const metaBottom = meta.createDiv({ cls: "stashpad-note-meta-bottom" });
|
||||
// 0.106.x: list-pin indicator — a lucide pin icon under the timestamp,
|
||||
// placed before the children-count arrow.
|
||||
if (isPinnedRow) {
|
||||
const pin = metaBottom.createSpan({ cls: "stashpad-note-listpin" });
|
||||
setIcon(pin, "pin");
|
||||
pin.setAttr("aria-label", "Pinned to top of list");
|
||||
}
|
||||
if (mobileTask) this.addTaskCheckbox(metaBottom, node);
|
||||
if (childCount > 0) {
|
||||
const enter = metaBottom.createSpan({ cls: "stashpad-note-enter" });
|
||||
|
|
@ -4387,9 +4601,54 @@ export class StashpadView extends ItemView {
|
|||
// Show More toggle into that cluster (anchored before the first button).
|
||||
this.renderNoteBody(bodyContent, node, { clamp: true, toggleHost: actions, toggleAnchor });
|
||||
|
||||
// Sheet version tabs (only when this row is part of a multi-version group).
|
||||
this.renderVersionTabs(body, node);
|
||||
|
||||
row.oncontextmenu = (evt) => { evt.preventDefault(); this.openNoteMenu(evt, node); };
|
||||
}
|
||||
|
||||
/** Render the version tab bar at the bottom of a row whose note belongs to a
|
||||
* `sheet:` group with more than one version. */
|
||||
private renderVersionTabs(body: HTMLElement, node: TreeNode): void {
|
||||
if (!this.plugin.settings.enableSheetVersions) return;
|
||||
if (!isVersionMember(nodeFm(this.app, node))) return;
|
||||
const gid = sheetIdOf(nodeFm(this.app, node))!;
|
||||
const parent = node.parent ?? this.focusId;
|
||||
const siblings = this.tree.getChildren(parent);
|
||||
const members = sortVersions(
|
||||
this.app,
|
||||
siblings.filter((s) => {
|
||||
const fm = nodeFm(this.app, s);
|
||||
return isVersionMember(fm) && sheetIdOf(fm) === gid;
|
||||
}),
|
||||
);
|
||||
if (members.length < 2) return;
|
||||
|
||||
const bar = body.createDiv({ cls: "stashpad-version-tabs" });
|
||||
for (const m of members) {
|
||||
const isActive = m.id === node.id;
|
||||
const isFinal = sheetIsFinal(nodeFm(this.app, m));
|
||||
const tab = bar.createDiv({
|
||||
cls:
|
||||
"stashpad-version-tab" +
|
||||
(isActive ? " is-active" : "") +
|
||||
(isFinal ? " is-final" : ""),
|
||||
});
|
||||
if (isFinal) tab.createSpan({ cls: "stashpad-version-star", text: "★" });
|
||||
tab.createSpan({
|
||||
cls: "stashpad-version-tab-label",
|
||||
text: tabTitle(this.app, m, this.titleForNode(m).trim() || "Untitled"),
|
||||
});
|
||||
tab.title = this.titleForNode(m).trim();
|
||||
if (!isActive) {
|
||||
tab.onclick = (e) => { e.stopPropagation(); this.setActiveVersion(gid, m.id); };
|
||||
}
|
||||
}
|
||||
const add = bar.createEl("button", { cls: "stashpad-version-add", text: "+" });
|
||||
add.title = "New version";
|
||||
add.onclick = (e) => { e.stopPropagation(); void this.cmdForkVersion(node, true); };
|
||||
}
|
||||
|
||||
/** Create + wire the task checkbox (used at the row's left edge on desktop,
|
||||
* or inside the meta column on mobile). 0.87.1. */
|
||||
private addTaskCheckbox(parent: HTMLElement, node: TreeNode): void {
|
||||
|
|
@ -4455,7 +4714,7 @@ export class StashpadView extends ItemView {
|
|||
// appending fresh nodes.
|
||||
container.empty();
|
||||
const textEl = container.createDiv({ cls: "stashpad-note-text" });
|
||||
const expanded = this.expandedNotes.has(node.id);
|
||||
const expanded = this.isNoteExpanded(node.id);
|
||||
if (opts.clamp && !expanded) textEl.addClass("is-clamped");
|
||||
// 0.71.23: in compact/tiny modes the row is too short to host
|
||||
// rendered markdown — headings overflow, code blocks get clipped
|
||||
|
|
@ -4557,8 +4816,7 @@ export class StashpadView extends ItemView {
|
|||
}
|
||||
toggle.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.expandedNotes.has(node.id)) this.expandedNotes.delete(node.id);
|
||||
else this.expandedNotes.add(node.id);
|
||||
this.setNoteExpanded(node.id, !this.isNoteExpanded(node.id));
|
||||
// Re-render just this body in place to preserve list scroll.
|
||||
container.empty();
|
||||
this.renderNoteBody(container, node, opts);
|
||||
|
|
@ -5524,9 +5782,13 @@ export class StashpadView extends ItemView {
|
|||
if (matchBinding(e, sb.openTab)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdOpenInNewStashpadTab(); return; }
|
||||
if (matchBinding(e, sb.split)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdSplit(); return; }
|
||||
if (matchBinding(e, sb.clone)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdClone(); return; }
|
||||
if (matchBinding(e, sb.forkNote)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdForkNote(); return; }
|
||||
if (matchBinding(e, sb.insertTemplate)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdInsertTemplate(); return; }
|
||||
if (matchBinding(e, sb.toggleExpand)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdToggleExpand(); return; }
|
||||
if (matchBinding(e, sb.expandAll)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdExpandAll(); return; }
|
||||
if (matchBinding(e, sb.collapseAll)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdCollapseAll(); return; }
|
||||
if (matchBinding(e, sb.togglePin)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdTogglePin(); return; }
|
||||
if (matchBinding(e, sb.listPin)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdToggleListPin(); return; }
|
||||
if (matchBinding(e, sb.toggleTask)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdToggleTask(); return; }
|
||||
if (matchBinding(e, sb.setDue)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdSetDue(); return; }
|
||||
}
|
||||
|
|
@ -6855,11 +7117,37 @@ export class StashpadView extends ItemView {
|
|||
cmdToggleExpand(): void {
|
||||
const targets = this.getActionTargets();
|
||||
if (!targets.length) return;
|
||||
const anyCollapsed = targets.some((t) => !this.expandedNotes.has(t.id));
|
||||
for (const t of targets) {
|
||||
if (anyCollapsed) this.expandedNotes.add(t.id);
|
||||
else this.expandedNotes.delete(t.id);
|
||||
}
|
||||
const anyCollapsed = targets.some((t) => !this.isNoteExpanded(t.id));
|
||||
for (const t of targets) this.setNoteExpanded(t.id, anyCollapsed);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** Is this note's body currently shown expanded (un-clamped)?
|
||||
* The expandedNotes Set stores ids that DIFFER from the default:
|
||||
* when expandBodiesByDefault is off (default), membership = expanded;
|
||||
* when on, membership = collapsed. This lets one Set serve both modes. */
|
||||
isNoteExpanded(id: StashpadId): boolean {
|
||||
const differsFromDefault = this.expandedNotes.has(id);
|
||||
return this.plugin.settings.expandBodiesByDefault ? !differsFromDefault : differsFromDefault;
|
||||
}
|
||||
|
||||
/** Set a note's expanded/collapsed state, normalizing against the
|
||||
* default so the Set only ever holds the non-default ids. */
|
||||
setNoteExpanded(id: StashpadId, expanded: boolean): void {
|
||||
const defaultExpanded = this.plugin.settings.expandBodiesByDefault;
|
||||
if (expanded === defaultExpanded) this.expandedNotes.delete(id);
|
||||
else this.expandedNotes.add(id);
|
||||
}
|
||||
|
||||
/** Expand every note in the current list (un-clamp all bodies). */
|
||||
cmdExpandAll(): void {
|
||||
for (const n of this.currentChildren) this.setNoteExpanded(n.id, true);
|
||||
this.render();
|
||||
}
|
||||
|
||||
/** Collapse every note in the current list (re-clamp all bodies). */
|
||||
cmdCollapseAll(): void {
|
||||
for (const n of this.currentChildren) this.setNoteExpanded(n.id, false);
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -7023,6 +7311,187 @@ export class StashpadView extends ItemView {
|
|||
});
|
||||
}
|
||||
|
||||
/** Fork a single note: duplicate its subtree as a standalone "variant" and
|
||||
* place it under a parent chosen via the picker. The current parent is
|
||||
* named as the default in the placeholder; Esc cancels. Same-folder only
|
||||
* for now (cross-folder forking deferred). NOTE: distinct from the sheet
|
||||
* "Fork as version" (cmdForkVersion) — that makes a draft within a sheet
|
||||
* group; this makes a separate note you can re-home. */
|
||||
cmdForkNote(): void {
|
||||
const node = this.getActionTargets()[0];
|
||||
if (!node?.file) { new Notice("Nothing to fork."); return; }
|
||||
const curParent = node.parent ?? ROOT_ID;
|
||||
const curNode = curParent === ROOT_ID ? null : this.tree.get(curParent);
|
||||
const curLabel = curNode ? this.titleForNode(curNode) : "Home";
|
||||
new StashpadSuggest(this.app, this.tree, (n) => this.titleForNode(n), {
|
||||
mode: "pick",
|
||||
allowCreate: true,
|
||||
placeholder: `Fork "${this.titleForNode(node)}" under… (default: ${curLabel})`,
|
||||
onPick: (item) => {
|
||||
if (item.crossFolder) { new Notice("Fork stays in this folder for now."); return; }
|
||||
void this.forkNoteUnder(node, item.id);
|
||||
},
|
||||
onCreate: (q) => { void (async () => { const id = await this.createNoteUnder(q, this.focusId); if (id) await this.forkNoteUnder(node, id); })(); },
|
||||
}).open();
|
||||
}
|
||||
|
||||
/** Clone `node`'s subtree under `parentId`, focus the new root, with undo.
|
||||
* Mirrors cmdClone's clone+snapshot+undo machinery for a single node. */
|
||||
private async forkNoteUnder(node: TreeNode, parentId: StashpadId): Promise<void> {
|
||||
if (!node.file) return;
|
||||
const folder = this.noteFolder;
|
||||
const createdPaths: string[] = [];
|
||||
const newRootId = await this.cloneSubtree(node, parentId, createdPaths);
|
||||
if (!newRootId) return;
|
||||
this.tree.rebuild(folder);
|
||||
this.pendingFocusIds = [newRootId];
|
||||
this.render();
|
||||
const snapNodes: TreeNode[] = createdPaths
|
||||
.map((p) => this.app.vault.getAbstractFileByPath(p))
|
||||
.filter((f): f is TFile => !!f && (f as any).extension === "md")
|
||||
.map((file) => ({ id: parseIdFromFilename(file.basename) ?? file.basename, parent: null, children: [], file, created: new Date().toISOString() }));
|
||||
const snap = await this.snapshotNotes(snapNodes, false);
|
||||
this.plugin.getUndoStack(folder).push({
|
||||
label: "Fork note",
|
||||
undo: async () => {
|
||||
for (const p of [...createdPaths].reverse()) {
|
||||
const f = this.app.vault.getAbstractFileByPath(p) as TFile | null;
|
||||
if (f) { try { await this.app.fileManager.trashFile(f); } catch { /* ignore */ } }
|
||||
}
|
||||
this.tree.rebuild(folder);
|
||||
this.render();
|
||||
},
|
||||
redo: async () => { await this.restoreSnapshots(snap, [newRootId]); },
|
||||
});
|
||||
const forked = this.tree.get(newRootId);
|
||||
this.plugin.notifications.show({
|
||||
message: forked
|
||||
? `Forked "${this.titleForNode(node)}" → "${this.titleForNode(forked)}" (${createdPaths.length} file${createdPaths.length === 1 ? "" : "s"})`
|
||||
: "Forked note",
|
||||
kind: "success",
|
||||
category: "clone",
|
||||
affectedIds: [newRootId],
|
||||
folder,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Sheet versions ---
|
||||
|
||||
/** Ensure a note carries a `sheet:` group id, stamping a fresh one (and an
|
||||
* initial order of 0) if it doesn't. Returns the group id. */
|
||||
private async ensureSheetGroup(file: TFile): Promise<string> {
|
||||
const existing = sheetIdOf(this.app.metadataCache.getFileCache(file)?.frontmatter);
|
||||
if (existing) return existing;
|
||||
const gid = newSheetGroupId();
|
||||
await this.app.fileManager.processFrontMatter(file, (m: any) => {
|
||||
m[SHEET_KEY] = gid;
|
||||
if (typeof m[SHEET_ORDER_KEY] !== "number") m[SHEET_ORDER_KEY] = 0;
|
||||
});
|
||||
return gid;
|
||||
}
|
||||
|
||||
/** Create a new version of `source` as a sibling sharing its sheet group.
|
||||
* `blank` → empty body; otherwise the source body is duplicated (a fork).
|
||||
* The new version becomes the shown one. Used by the row "+" button, the
|
||||
* context menu, and the command. */
|
||||
async cmdForkVersion(source?: TreeNode, blank = false): Promise<void> {
|
||||
if (!this.plugin.settings.enableSheetVersions) {
|
||||
new Notice("Sheet versions are off — enable them in Settings → General.");
|
||||
return;
|
||||
}
|
||||
const src = source ?? this.getActionTargets()[0];
|
||||
if (!src?.file) { new Notice("Select a note to version."); return; }
|
||||
const folder = this.noteFolder;
|
||||
const gid = await this.ensureSheetGroup(src.file);
|
||||
const parent = src.parent ?? this.focusId;
|
||||
|
||||
// Next order = one past the current highest in the group.
|
||||
const siblings = this.tree.getChildren(parent)
|
||||
.filter((s) => sheetIdOf(nodeFm(this.app, s)) === gid);
|
||||
let maxOrder = -1;
|
||||
for (const s of siblings) {
|
||||
const o = this.app.metadataCache.getFileCache(s.file!)?.frontmatter?.[SHEET_ORDER_KEY];
|
||||
if (typeof o === "number" && o > maxOrder) maxOrder = o;
|
||||
}
|
||||
const order = maxOrder + 1;
|
||||
|
||||
const body = blank ? "" : this.stripFrontmatter(await this.app.vault.read(src.file));
|
||||
const cloneId = newId();
|
||||
const slug = bodyToSlug(body, this.activeStopwords());
|
||||
const filename = buildFilename(slug, cloneId);
|
||||
const path = `${folder}/${filename}`;
|
||||
const created = new Date().toISOString();
|
||||
const attachments = this.extractAttachments(body);
|
||||
|
||||
const fmInit = ["---", `id: ${cloneId}`, `parent: ${parent}`, `created: ${created}`];
|
||||
if (attachments.length > 0) {
|
||||
fmInit.push("attachments:");
|
||||
for (const a of attachments) fmInit.push(` - "${a.replace(/"/g, '\\"')}"`);
|
||||
} else {
|
||||
fmInit.push("attachments: []");
|
||||
}
|
||||
fmInit.push("---", body);
|
||||
await this.ensureFolder(folder);
|
||||
await this.app.vault.create(path, fmInit.join("\n"));
|
||||
|
||||
const newFile = this.app.vault.getAbstractFileByPath(path) as TFile | null;
|
||||
if (!newFile) { new Notice("Sheets: could not create version."); return; }
|
||||
await this.app.fileManager.processFrontMatter(newFile, (m: any) => {
|
||||
m[SHEET_KEY] = gid;
|
||||
m[SHEET_ORDER_KEY] = order;
|
||||
delete m[SHEET_FINAL_KEY];
|
||||
});
|
||||
try {
|
||||
this.tree.insertSynthetic({ id: cloneId, parent, children: [], file: newFile, created });
|
||||
} catch { /* ignore */ }
|
||||
this.fmSync.scheduleParentChange(cloneId, null, parent);
|
||||
|
||||
this.tree.rebuild(folder);
|
||||
this.activeVersionByGroup.set(gid, cloneId); // show the new version
|
||||
this.plugin.saveActiveVersion(folder, gid, cloneId);
|
||||
this.render();
|
||||
|
||||
this.plugin.getUndoStack(folder).push({
|
||||
label: blank ? "New version" : "Fork version",
|
||||
undo: async () => {
|
||||
const f = this.app.vault.getAbstractFileByPath(path) as TFile | null;
|
||||
if (f) { try { await this.app.fileManager.trashFile(f); } catch { /* ignore */ } }
|
||||
this.activeVersionByGroup.delete(gid);
|
||||
this.tree.rebuild(folder);
|
||||
this.render();
|
||||
},
|
||||
redo: async () => { /* best-effort: re-running the command recreates it */ },
|
||||
});
|
||||
new Notice(blank ? "New version added" : "Forked a new version");
|
||||
}
|
||||
|
||||
/** Toggle the "final" flag on a version, clearing it from its group-mates. */
|
||||
async cmdMarkVersionFinal(target?: TreeNode): Promise<void> {
|
||||
if (!this.plugin.settings.enableSheetVersions) {
|
||||
new Notice("Sheet versions are off — enable them in Settings → General.");
|
||||
return;
|
||||
}
|
||||
const node = target ?? this.getActionTargets()[0];
|
||||
if (!node?.file) return;
|
||||
const gid = sheetIdOf(nodeFm(this.app, node));
|
||||
if (!gid) { new Notice("Not a versioned note."); return; }
|
||||
const makeFinal = !sheetIsFinal(nodeFm(this.app, node));
|
||||
const parent = node.parent ?? this.focusId;
|
||||
const members = this.tree.getChildren(parent)
|
||||
.filter((s) => sheetIdOf(nodeFm(this.app, s)) === gid);
|
||||
for (const m of members) {
|
||||
if (!m.file) continue;
|
||||
const wasFinal = sheetIsFinal(nodeFm(this.app, m));
|
||||
const shouldBeFinal = makeFinal && m.id === node.id;
|
||||
if (wasFinal === shouldBeFinal) continue;
|
||||
await this.app.fileManager.processFrontMatter(m.file, (fm: any) => {
|
||||
if (shouldBeFinal) fm[SHEET_FINAL_KEY] = true;
|
||||
else delete fm[SHEET_FINAL_KEY];
|
||||
});
|
||||
}
|
||||
this.render();
|
||||
}
|
||||
|
||||
// ---- 0.99.0: note clipboard — copy / cut / paste of note BLOCKS ----
|
||||
// Runs in parallel with the system clipboard: copy/cut also put the bodies
|
||||
// on the system clipboard as text (so pasting in the composer or any app
|
||||
|
|
@ -7372,7 +7841,9 @@ export class StashpadView extends ItemView {
|
|||
// Clear an active tag/color filter if the new subtree doesn't
|
||||
// contain it — otherwise we'd show "All …" in the dropdown while
|
||||
// a hidden filter empties the list.
|
||||
if (this.tagFilter) {
|
||||
if (this.tagFilter && this.tagFilter !== TAG_FILTER_TAGGED && this.tagFilter !== TAG_FILTER_UNTAGGED) {
|
||||
// Sentinel modes (Tagged/Untagged) are always valid; only real tags
|
||||
// get cleared when the new subtree doesn't contain them.
|
||||
const wanted = this.tagFilter.toLowerCase();
|
||||
const present = this.collectFolderTags().some((t) => t.raw.toLowerCase() === wanted);
|
||||
if (!present) this.tagFilter = null;
|
||||
|
|
@ -7909,6 +8380,92 @@ export class StashpadView extends ItemView {
|
|||
else if (unpinned > 0) new Notice(`Unpinned ${unpinned} note${unpinned === 1 ? "" : "s"} from sidebar.`);
|
||||
}
|
||||
|
||||
/** Is this note list-pinned (floated to the top of its sibling list)?
|
||||
* Distinct from the sidebar pin (plugin.isPinned). Reads frontmatter. */
|
||||
isListPinned(id: StashpadId): boolean {
|
||||
const node = this.tree.get(id);
|
||||
if (!node?.file) return false;
|
||||
const ov = this.listPinnedState.get(node.file.path);
|
||||
if (ov) return ov.pinned;
|
||||
return this.app.metadataCache.getFileCache(node.file)?.frontmatter?.listPinned === true;
|
||||
}
|
||||
private listPinnedAt(id: StashpadId): number {
|
||||
const node = this.tree.get(id);
|
||||
if (!node?.file) return 0;
|
||||
const ov = this.listPinnedState.get(node.file.path);
|
||||
if (ov) return ov.at;
|
||||
const v = this.app.metadataCache.getFileCache(node.file)?.frontmatter?.listPinnedAt;
|
||||
const t = typeof v === "string" ? Date.parse(v) : NaN;
|
||||
return Number.isNaN(t) ? 0 : t;
|
||||
}
|
||||
/** Float list-pinned children to the top of `base` (in pin order). Returns
|
||||
* `base` unchanged when nothing in this parent is pinned — so unpinned
|
||||
* lists keep EXACTLY their prior ordering/behavior (zero regression risk). */
|
||||
private hoistListPinned(parentId: StashpadId, base: StashpadId[]): StashpadId[] {
|
||||
const childIds = this.tree.getChildren(parentId).map((n) => n.id);
|
||||
if (!childIds.some((id) => this.isListPinned(id))) return base;
|
||||
// base may be empty (manual order unset) — fall back to the tree's order.
|
||||
const seen = new Set(base);
|
||||
const full = [...base, ...childIds.filter((id) => !seen.has(id))];
|
||||
const pinned = full.filter((id) => this.isListPinned(id)).sort((a, b) => this.listPinnedAt(a) - this.listPinnedAt(b));
|
||||
const rest = full.filter((id) => !this.isListPinned(id));
|
||||
return [...pinned, ...rest];
|
||||
}
|
||||
|
||||
/** Toggle the list pin (float-to-top) on the action targets. Distinct from
|
||||
* the sidebar pin (cmdTogglePin). Writes listPinned/listPinnedAt + undo. */
|
||||
async cmdToggleListPin(): Promise<void> {
|
||||
let targets = this.getActionTargets();
|
||||
if (targets.length === 0) {
|
||||
const focused = this.tree.get(this.focusId);
|
||||
if (focused?.file) targets = [focused];
|
||||
}
|
||||
if (targets.length === 0) { new Notice("Nothing to pin."); return; }
|
||||
const anyUnpinned = targets.some((t) => !this.isListPinned(t.id));
|
||||
const prior: { path: string; listPinned: unknown; listPinnedAt: unknown }[] = [];
|
||||
const stamp = new Date().toISOString();
|
||||
const changed: StashpadId[] = [];
|
||||
for (const t of targets) {
|
||||
if (!t.file) continue;
|
||||
const fm = this.app.metadataCache.getFileCache(t.file)?.frontmatter as any;
|
||||
prior.push({ path: t.file.path, listPinned: fm?.listPinned, listPinnedAt: fm?.listPinnedAt });
|
||||
const at = anyUnpinned ? (typeof fm?.listPinnedAt === "string" ? Date.parse(fm.listPinnedAt) || Date.parse(stamp) : Date.parse(stamp)) : 0;
|
||||
// Override now so the re-sort below reflects the new state immediately
|
||||
// (the metadata cache lags the frontmatter write by a tick).
|
||||
this.listPinnedState.set(t.file.path, { pinned: anyUnpinned, at });
|
||||
await this.app.fileManager.processFrontMatter(t.file, (m) => {
|
||||
if (anyUnpinned) { m.listPinned = true; if (!m.listPinnedAt) m.listPinnedAt = stamp; }
|
||||
else { delete m.listPinned; delete m.listPinnedAt; }
|
||||
});
|
||||
changed.push(t.id);
|
||||
}
|
||||
const folder = this.noteFolder;
|
||||
this.tree.rebuild(folder);
|
||||
this.render();
|
||||
this.plugin.notifications.show({
|
||||
message: anyUnpinned ? `Pinned ${changed.length} to top of list` : `Unpinned ${changed.length} from list`,
|
||||
kind: "success", category: "edit", affectedIds: changed, folder,
|
||||
});
|
||||
this.plugin.getUndoStack(folder).push({
|
||||
label: anyUnpinned ? `Pin to top (${changed.length})` : `Unpin from top (${changed.length})`,
|
||||
undo: async () => {
|
||||
for (const p of prior) {
|
||||
const f = this.app.vault.getAbstractFileByPath(p.path) as TFile | null;
|
||||
if (!f) continue;
|
||||
await this.app.fileManager.processFrontMatter(f, (m) => {
|
||||
if (p.listPinned === undefined) delete m.listPinned; else m.listPinned = p.listPinned;
|
||||
if (p.listPinnedAt === undefined) delete m.listPinnedAt; else m.listPinnedAt = p.listPinnedAt;
|
||||
});
|
||||
const wasPinned = p.listPinned === true;
|
||||
const at = typeof p.listPinnedAt === "string" ? (Date.parse(p.listPinnedAt) || 0) : 0;
|
||||
this.listPinnedState.set(p.path, { pinned: wasPinned, at });
|
||||
}
|
||||
this.tree.rebuild(folder);
|
||||
this.render();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async cmdToggleComplete(): Promise<void> {
|
||||
let targets = this.getActionTargets();
|
||||
if (targets.length === 0) {
|
||||
|
|
@ -8032,6 +8589,12 @@ export class StashpadView extends ItemView {
|
|||
* back to the live cache when there's no override. */
|
||||
private taskTaggedState = new Map<string, boolean>();
|
||||
|
||||
/** 0.105.0: list-pin override per path (pinned flag + pin timestamp), so a
|
||||
* pin/unpin re-sorts the list on THIS render rather than n+1 (the metadata
|
||||
* cache lags a frontmatter write). Reads fall back to the live cache when
|
||||
* there's no override. */
|
||||
private listPinnedState = new Map<string, { pinned: boolean; at: number }>();
|
||||
|
||||
private isCompleted(node: TreeNode): boolean {
|
||||
if (!node.file) return false;
|
||||
const override = this.completedState.get(node.file.path);
|
||||
|
|
@ -8146,9 +8709,12 @@ export class StashpadView extends ItemView {
|
|||
});
|
||||
}
|
||||
|
||||
/** 0.78.3: standalone assign command — assign people to the target
|
||||
* task(s) without touching the due date. Opens AssignModal pre-filled
|
||||
* with the first target's current assignees. */
|
||||
/** 0.104.x: "Assign to" opens the SAME unified modal as "Set due date"
|
||||
* (date+time picker + assignee picker). Both commands now share one
|
||||
* modal — only the title differs. Pre-fills the first target's current
|
||||
* due date AND assignees, and commits through applyDue (which flips
|
||||
* `task: true` on any due/assignment — so assigning or scheduling a
|
||||
* plain note auto-converts it to a task). */
|
||||
cmdAssign(): void {
|
||||
let targets = this.getActionTargets();
|
||||
if (targets.length === 0) {
|
||||
|
|
@ -8158,76 +8724,12 @@ export class StashpadView extends ItemView {
|
|||
if (targets.length === 0) { new Notice("Nothing to assign."); return; }
|
||||
const first = targets[0];
|
||||
const curFm = first.file ? this.app.metadataCache.getFileCache(first.file)?.frontmatter as any : null;
|
||||
const current = curFm && (typeof curFm.due === "string" || typeof curFm.due === "number") ? String(curFm.due) : null;
|
||||
const knownAuthors = this.plugin.collectKnownAuthors();
|
||||
const currentAssignees = parseAssignees(curFm ?? {});
|
||||
new AssignModal(this.app, { knownAuthors, currentAssignees }, (assignees) => {
|
||||
void this.applyAssignees(targets, assignees);
|
||||
}).open();
|
||||
}
|
||||
|
||||
/** Write `assignedTo`/`assignedBy` across `targets` (without touching
|
||||
* `due`), with undo. An empty list clears the assignment. Assigning
|
||||
* also flips `task: true`. */
|
||||
private async applyAssignees(targets: TreeNode[], assignees: Array<{ id: string; name: string }>): Promise<void> {
|
||||
const me = this.authorship.currentAuthorLink();
|
||||
for (const a of assignees) {
|
||||
await this.plugin.ensureAuthorStubFor(this.noteFolder, a.id, a.name);
|
||||
}
|
||||
const assignLinks = assignees.map((a) => this.plugin.authorRefFor(this.noteFolder, a.id, a.name));
|
||||
const prior: { path: string; assignedTo: unknown; assignedBy: unknown; task: unknown; wasTagged: boolean }[] = [];
|
||||
const changedIds: StashpadId[] = [];
|
||||
for (const t of targets) {
|
||||
if (!t.file) continue;
|
||||
const fm = this.app.metadataCache.getFileCache(t.file)?.frontmatter as any;
|
||||
const wasTagged = this.isTaskTagged(t);
|
||||
prior.push({ path: t.file.path, assignedTo: fm?.assignedTo, assignedBy: fm?.assignedBy, task: fm?.task, wasTagged });
|
||||
await this.app.fileManager.processFrontMatter(t.file, (m) => {
|
||||
if (assignLinks.length > 0) {
|
||||
m.assignedTo = assignLinks;
|
||||
if (me) m.assignedBy = me.link;
|
||||
m.task = true;
|
||||
} else {
|
||||
delete m.assignedTo;
|
||||
delete m.assignedBy;
|
||||
}
|
||||
});
|
||||
// 0.85.1: assigning makes it a task; clearing leaves task-ness unchanged.
|
||||
this.taskTaggedState.set(t.file.path, assignLinks.length > 0 || wasTagged);
|
||||
changedIds.push(t.id);
|
||||
}
|
||||
this.render();
|
||||
if (changedIds.length > 0) {
|
||||
const nodes = changedIds.map((id) => this.tree.get(id)).filter((n): n is TreeNode => !!n);
|
||||
const names = assignees.map((a) => a.name).join(", ");
|
||||
this.plugin.notifications.show({
|
||||
message: this.bulkActionMessage({
|
||||
verb: assignLinks.length > 0 ? `Assigned to ${names}` : "Cleared assignment",
|
||||
nodes,
|
||||
}),
|
||||
kind: "success",
|
||||
category: "edit",
|
||||
affectedIds: changedIds,
|
||||
folder: this.noteFolder,
|
||||
});
|
||||
}
|
||||
const folder = this.noteFolder;
|
||||
this.plugin.getUndoStack(folder).push({
|
||||
label: assignLinks.length > 0 ? `Assign (${targets.length})` : `Clear assignment (${targets.length})`,
|
||||
undo: async () => {
|
||||
for (const p of prior) {
|
||||
const f = this.app.vault.getAbstractFileByPath(p.path) as TFile | null;
|
||||
if (!f) continue;
|
||||
await this.app.fileManager.processFrontMatter(f, (m) => {
|
||||
if (p.assignedTo === undefined) delete m.assignedTo; else m.assignedTo = p.assignedTo;
|
||||
if (p.assignedBy === undefined) delete m.assignedBy; else m.assignedBy = p.assignedBy;
|
||||
if (p.task === undefined) delete m.task; else m.task = p.task;
|
||||
});
|
||||
this.taskTaggedState.set(p.path, p.wasTagged); // 0.85.1
|
||||
}
|
||||
this.tree.rebuild(folder);
|
||||
this.render();
|
||||
},
|
||||
});
|
||||
new DueDatePickerModal(this.app, current, (result) => {
|
||||
void this.applyDue(targets, result.iso, result.assignees);
|
||||
}, { knownAuthors, currentAssignees, title: "Assign / schedule task" }).open();
|
||||
}
|
||||
|
||||
/** 0.76.3: a note is a task when it carries the `task` tag in
|
||||
|
|
@ -9943,6 +10445,10 @@ export class StashpadView extends ItemView {
|
|||
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
|
||||
void this.cmdClone();
|
||||
}));
|
||||
menu.addItem((it: any) => it.setTitle("Fork (copy under another parent)…").setIcon("git-branch").onClick(() => {
|
||||
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
|
||||
this.cmdForkNote();
|
||||
}));
|
||||
menu.addItem((it: any) => it.setTitle("Insert template…").setIcon("file-plus-2").onClick(() => this.cmdInsertTemplate()));
|
||||
menu.addItem((it: any) => it.setTitle("Export to .stash").setIcon("package").onClick(() => {
|
||||
// Multi-select normalisation (matches Clone / Delete / Set color):
|
||||
|
|
@ -9991,19 +10497,52 @@ export class StashpadView extends ItemView {
|
|||
if (pinned) await this.plugin.unpinNote(pinRef);
|
||||
else await this.plugin.pinNote(pinRef);
|
||||
}));
|
||||
// 0.105.0: list pin — float to the top of THIS list (distinct from sidebar).
|
||||
const listPinned = this.isListPinned(node.id);
|
||||
menu.addItem((it: any) => it
|
||||
.setTitle(listPinned ? "Unpin from top of list" : "Pin to top of list")
|
||||
.setIcon(listPinned ? "pin-off" : "arrow-up-to-line")
|
||||
.onClick(() => {
|
||||
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
|
||||
void this.cmdToggleListPin();
|
||||
}));
|
||||
menu.addItem((it: any) => it.setTitle("Set color…").setIcon("palette").onClick(() => {
|
||||
// Operate on the right-clicked row even if it isn't selected.
|
||||
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
|
||||
this.cmdSetColor();
|
||||
}));
|
||||
// 0.58.0: toggle complete — label flips based on current state of the
|
||||
// right-clicked node. Operates on the right-clicked row, normalising
|
||||
// selection first so cmdToggleComplete picks the right target.
|
||||
const isDone = this.isCompleted(node);
|
||||
menu.addItem((it: any) => it.setTitle(isDone ? "Mark incomplete" : "Mark complete").setIcon(isDone ? "circle" : "check-circle").onClick(() => {
|
||||
// 0.104.x: task actions grouped under a "Task ▸" submenu to keep the
|
||||
// right-click menu compact (Obsidian already repositions to stay
|
||||
// on-screen; height is the real lever). Task gating: completion is only
|
||||
// offered once a note IS a task — non-tasks show "Turn into task"; tasks
|
||||
// show "Mark complete" + "Remove from tasks". Right-click is single-node,
|
||||
// so the gate is unambiguous (the mobile ⚡ menu keeps its multi-select
|
||||
// toggles). Every entry normalises selection to the right-clicked row.
|
||||
// setSubmenu is internal/untyped — accessed via the existing `it: any`
|
||||
// pattern; falls back to opening the command palette if unavailable
|
||||
// (effectively never on the 1.13 minAppVersion floor).
|
||||
const focusClicked = (): void => {
|
||||
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
|
||||
void this.cmdToggleComplete();
|
||||
}));
|
||||
};
|
||||
const addTaskItems = (target: { addItem: (cb: (it: any) => unknown) => unknown }): void => {
|
||||
const isTaskNote = this.isTask(node);
|
||||
if (isTaskNote) {
|
||||
const isDone = this.isCompleted(node);
|
||||
target.addItem((it: any) => it.setTitle(isDone ? "Mark incomplete" : "Mark complete").setIcon(isDone ? "circle" : "check-circle").onClick(() => { focusClicked(); void this.cmdToggleComplete(); }));
|
||||
} else {
|
||||
target.addItem((it: any) => it.setTitle("Turn into task").setIcon("check-square").onClick(() => { focusClicked(); void this.cmdToggleTask(); }));
|
||||
}
|
||||
target.addItem((it: any) => it.setTitle("Assign / schedule…").setIcon("user-plus").onClick(() => { focusClicked(); this.cmdAssign(); }));
|
||||
if (isTaskNote) {
|
||||
target.addItem((it: any) => it.setTitle("Remove from tasks").setIcon("square").onClick(() => { focusClicked(); void this.cmdToggleTask(); }));
|
||||
}
|
||||
};
|
||||
menu.addItem((it: any) => {
|
||||
it.setTitle("Task").setIcon("check-square");
|
||||
const sub = typeof it.setSubmenu === "function" ? it.setSubmenu() : null;
|
||||
if (sub && typeof sub.addItem === "function") addTaskItems(sub);
|
||||
else it.onClick(() => this.openCommandPalette()); // degraded fallback
|
||||
});
|
||||
menu.addSeparator();
|
||||
menu.addItem((it: any) => it.setTitle("Delete").setIcon("trash").onClick(async () => {
|
||||
// Route through cmdDelete (not deleteNote directly) so the encryptTrash
|
||||
|
|
|
|||
212
styles.css
212
styles.css
|
|
@ -704,6 +704,109 @@
|
|||
.stashpad-color-filter-popover-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Tag filter: custom button + searchable popover (0.104.x) --- */
|
||||
.stashpad-tag-filter-btn {
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
font-size: var(--font-ui-smaller);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stashpad-tag-filter-btn:hover { background: var(--background-modifier-hover); }
|
||||
.stashpad-tag-filter-btn.is-disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.stashpad-tag-filter-btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stashpad-tag-filter-btn-icon .svg-icon { width: 14px; height: 14px; }
|
||||
.stashpad-tag-filter-label {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 16ch;
|
||||
}
|
||||
.stashpad-view.is-mobile .stashpad-tag-filter-btn { height: 28px; }
|
||||
|
||||
.stashpad-tag-filter-popover {
|
||||
position: fixed;
|
||||
z-index: var(--layer-popover, 30);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||
font-size: var(--font-ui-small);
|
||||
max-height: 340px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stashpad-tag-filter-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.stashpad-tag-filter-search-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stashpad-tag-filter-search-icon .svg-icon { width: 14px; height: 14px; }
|
||||
.stashpad-tag-filter-input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-normal);
|
||||
font-size: var(--font-ui-small);
|
||||
outline: none;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.stashpad-tag-filter-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 2px;
|
||||
}
|
||||
.stashpad-tag-filter-clear:hover { color: var(--text-normal); background: var(--background-modifier-hover); }
|
||||
.stashpad-tag-filter-clear .svg-icon { width: 13px; height: 13px; }
|
||||
.stashpad-tag-filter-list {
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.stashpad-tag-filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 5px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stashpad-tag-filter-row.is-highlighted { background: var(--background-modifier-hover); }
|
||||
.stashpad-tag-filter-row.is-active { font-weight: var(--font-semibold); }
|
||||
.stashpad-tag-filter-row.is-active .stashpad-tag-filter-row-label::after {
|
||||
content: "✓";
|
||||
margin-left: 6px;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
.stashpad-tag-filter-row-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.stashpad-tag-filter-row-count { color: var(--text-muted); font-size: var(--font-ui-smaller); flex: 0 0 auto; }
|
||||
.stashpad-tag-filter-empty { padding: 8px 10px; color: var(--text-muted); }
|
||||
@container (max-width: 360px) {
|
||||
.stashpad-time-filter-btns { display: none; }
|
||||
.stashpad-time-filter-select { display: block; }
|
||||
|
|
@ -1036,6 +1139,22 @@
|
|||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* 0.105.0 / 0.106.x: list-pinned note — floated to the top of its list. Left
|
||||
accent bar; the pin indicator is a lucide icon in the meta column (under the
|
||||
timestamp, before the children-count arrow) — see .stashpad-note-listpin. */
|
||||
.stashpad-note.is-list-pinned {
|
||||
box-shadow: inset 3px 0 0 var(--text-accent);
|
||||
}
|
||||
.stashpad-note-listpin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
.stashpad-note-listpin svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
/* 0.99.5: a note sitting on a pending CUT (note clipboard) — ghosted + dashed
|
||||
outline, like a cut file in a file manager. Cleared when the cut is pasted
|
||||
or replaced. */
|
||||
|
|
@ -2636,6 +2755,20 @@
|
|||
.stashpad-due-field-icon:hover { color: var(--text-normal); background: var(--interactive-hover); }
|
||||
.stashpad-due-field-icon:active { background: var(--background-modifier-active-hover); }
|
||||
.stashpad-due-field-icon svg { width: 16px; height: 16px; }
|
||||
/* 0.104.x: dedicated × to clear a single field (date or time). */
|
||||
.stashpad-due-clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-faint);
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.stashpad-due-clear:hover { color: var(--text-normal); background: var(--background-modifier-hover); }
|
||||
.stashpad-due-clear svg { width: 13px; height: 13px; }
|
||||
/* 0.76.8: hide the native right-side picker indicator — our left
|
||||
icon is the picker button now (calls showPicker). */
|
||||
.stashpad-due-date::-webkit-calendar-picker-indicator,
|
||||
|
|
@ -4439,6 +4572,40 @@ body.stashpad-folderpanel-resizing { cursor: row-resize; user-select: none; }
|
|||
color: var(--text-accent);
|
||||
}
|
||||
.stashpad-folderpanel-pinmark svg { width: 12px; height: 12px; }
|
||||
/* 0.105.1: folder-row chevron + inline list of the folder's Home list-pinned
|
||||
notes (revealed on click). */
|
||||
.stashpad-folderpanel-pinreveal {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stashpad-folderpanel-pinreveal:hover { color: var(--text-normal); }
|
||||
.stashpad-folderpanel-pinlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 0 4px 28px;
|
||||
}
|
||||
.stashpad-folderpanel-pinrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.stashpad-folderpanel-pinrow:hover { background: var(--background-modifier-hover); }
|
||||
.stashpad-folderpanel-pinrow-icon { display: inline-flex; flex: 0 0 auto; color: var(--text-accent); }
|
||||
.stashpad-folderpanel-pinrow-icon svg { width: 13px; height: 13px; }
|
||||
.stashpad-folderpanel-pinrow-label {
|
||||
flex: 1 1 auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
/* 0.98.25: archive-folder badge (auto-encrypt incoming). */
|
||||
.stashpad-folderpanel-archive-badge {
|
||||
flex: 0 0 auto; display: inline-flex; align-items: center;
|
||||
|
|
@ -4489,3 +4656,48 @@ body.stashpad-folderpanel-resizing { cursor: row-resize; user-select: none; }
|
|||
.stashpad-split-quick-label { font-size: var(--font-ui-smaller); color: var(--text-muted); margin-right: 2px; }
|
||||
.stashpad-split-quick-btn { font-size: var(--font-ui-smaller); padding: 2px 8px; }
|
||||
.stashpad-split-quick-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* --- Sheet version tabs (a row's alternate versions) --- */
|
||||
.stashpad-version-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.stashpad-version-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 18ch;
|
||||
padding: 1px 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: var(--radius-s);
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.stashpad-version-tab:hover { color: var(--text-normal); background: var(--background-modifier-hover); }
|
||||
.stashpad-version-tab.is-active {
|
||||
color: var(--text-normal);
|
||||
background: var(--background-primary);
|
||||
border-color: var(--interactive-accent);
|
||||
font-weight: var(--font-semibold);
|
||||
cursor: default;
|
||||
}
|
||||
.stashpad-version-tab.is-final { border-color: var(--text-accent); }
|
||||
.stashpad-version-tab-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.stashpad-version-star { color: var(--text-accent); font-size: 0.85em; line-height: 1; }
|
||||
.stashpad-version-add {
|
||||
font-size: var(--font-ui-smaller);
|
||||
line-height: 1;
|
||||
padding: 1px 7px;
|
||||
height: auto;
|
||||
box-shadow: none;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stashpad-version-add:hover { color: var(--text-normal); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue