mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.108.4: mobile tap fixes, fork provenance, diagnostics toggle
This commit is contained in:
parent
81157657fa
commit
bc27121c17
8 changed files with 465 additions and 142 deletions
179
main.js
179
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "stashpad",
|
||||
"name": "Stashpad",
|
||||
"version": "0.107.1",
|
||||
"version": "0.108.4",
|
||||
"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.107.1",
|
||||
"version": "0.108.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
|
|||
24
release-notes/0.108.4.md
Normal file
24
release-notes/0.108.4.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# 0.108.4 — Mobile tap fixes, fork provenance, diagnostics
|
||||
|
||||
## Mobile: tap the note you meant
|
||||
Fixes a long-standing mobile annoyance where, with the keyboard up and the composer
|
||||
lifted, tapping the note just above the composer would open a *different* note. The
|
||||
cause was the list reflowing as the keyboard dismissed — rows slid out from under
|
||||
your finger mid-gesture. Now a double-tap opens the note you actually aimed at, in
|
||||
one fluid motion, and a tap that only dismisses the keyboard no longer opens anything
|
||||
by accident.
|
||||
|
||||
## Note forking
|
||||
- Forks now record where they came from: a `forked-from` link back to the original,
|
||||
with every member of a fork family kept in sync.
|
||||
- Forking (and cloning, and inserting templates) produces a clean standalone note —
|
||||
version/sheet metadata is no longer copied across, so a duplicate can't inherit a
|
||||
stray "final" marker or ordering.
|
||||
- Deleting a note prunes it from its fork family automatically.
|
||||
- Clearer labels distinguish **Fork as a version** (a draft within a tabbed group)
|
||||
from **Fork into a separate note…** (a re-homed standalone copy).
|
||||
|
||||
## Diagnostics
|
||||
- New **Debug trace** toggle (Settings → Diagnostics) for capturing low-level
|
||||
diagnostic lines while reproducing an issue, with Copy/Clear buttons. Local only —
|
||||
no network, no file writes, and off by default.
|
||||
61
src/main.ts
61
src/main.ts
|
|
@ -1,4 +1,5 @@
|
|||
import { Notice, Platform, Plugin, SuggestModal, TFile, TFolder, WorkspaceLeaf, setIcon } from "obsidian";
|
||||
import { Notice, Platform, Plugin, SuggestModal, TFile, TFolder, WorkspaceLeaf, setIcon, debounce } from "obsidian";
|
||||
import { SIBLINGS_KEY, wikilinkName } from "./sheets-versions";
|
||||
import { STASHPAD_DETAIL_VIEW_TYPE, STASHPAD_FOLDER_PANEL_VIEW_TYPE, STASHPAD_PANELS_VIEW_TYPE, STASHPAD_VIEW_TYPE, parseAuthorRef, toAttachmentLink, isInReservedSubfolder, type PinnedNoteRef, type StashpadId } from "./types";
|
||||
import { StashpadDetailView, openStashpadDetailView } from "./detail-view";
|
||||
import { StashpadView, properCaseFolderPath, DeletedTrashSuggestModal } from "./view";
|
||||
|
|
@ -41,6 +42,30 @@ interface FileSnapshot { path: string; binary: boolean; text?: string; data?: Ar
|
|||
|
||||
export default class StashpadPlugin extends Plugin {
|
||||
settings: StashpadSettings = { ...DEFAULT_SETTINGS };
|
||||
/** 0.108.2: in-memory debug trace ring buffer. Populated by trace() only
|
||||
* when settings.debugTrace is on; copied out from the Diagnostics tab.
|
||||
* Purely local — no network, no file writes. Capped so a long session
|
||||
* can't grow it unbounded. */
|
||||
private debugBuffer: string[] = [];
|
||||
private static readonly DEBUG_BUFFER_MAX = 300;
|
||||
/** Record a structured diagnostic line. No-op (and zero cost beyond the
|
||||
* flag check) unless debug tracing is enabled. Hard to misuse: data is
|
||||
* JSON-stringified defensively so a circular/huge value can't throw out
|
||||
* of a hot path. */
|
||||
trace(category: string, data?: Record<string, unknown>): void {
|
||||
if (!this.settings.debugTrace) return;
|
||||
let payload = "";
|
||||
if (data) { try { payload = " " + JSON.stringify(data); } catch { payload = " [unserializable]"; } }
|
||||
// performance.now() is monotonic and devtools-free — fine on mobile.
|
||||
const t = Math.round(performance.now());
|
||||
this.debugBuffer.push(`+${t}ms ${category}${payload}`);
|
||||
if (this.debugBuffer.length > StashpadPlugin.DEBUG_BUFFER_MAX) {
|
||||
this.debugBuffer.splice(0, this.debugBuffer.length - StashpadPlugin.DEBUG_BUFFER_MAX);
|
||||
}
|
||||
}
|
||||
/** Current trace lines joined for copy/clear from the Diagnostics tab. */
|
||||
getDebugTrace(): string { return this.debugBuffer.join("\n"); }
|
||||
clearDebugTrace(): void { this.debugBuffer = []; }
|
||||
private undoStacks = new Map<string, UndoStack>();
|
||||
/** Most-recently-active Stashpad leaf — set on active-leaf-change.
|
||||
* Used by sidebar panel actions (Search, Home) so they target the
|
||||
|
|
@ -701,6 +726,14 @@ export default class StashpadPlugin extends Plugin {
|
|||
// last readable plaintext copy, sitting in render-cache.json.
|
||||
this.registerEvent(this.app.vault.on("delete", (f) => this.renderCacheStore.evict(f.path)));
|
||||
this.registerEvent(this.app.vault.on("rename", (_f, oldPath) => this.renderCacheStore.evict(oldPath)));
|
||||
// Fork siblings: when a family member is deleted (single / subtree / multi /
|
||||
// fork-undo), drop it from every other member's `fork-siblings`. Debounced
|
||||
// so a burst of deletes triggers one vault scan. (Renames are handled by
|
||||
// Obsidian's own wikilink updater.)
|
||||
const pruneForkSiblings = debounce(() => void this.flushForkSiblingPrune(), 250, true);
|
||||
this.registerEvent(this.app.vault.on("delete", (f) => {
|
||||
if (f instanceof TFile && f.extension === "md") { this.pendingForkDeletes.add(f.basename); pruneForkSiblings(); }
|
||||
}));
|
||||
// 0.102.x: OKF auto-rebuild — when a note is added/deleted/moved in an OKF
|
||||
// folder, refresh that folder's OKF frontmatter + index.md (debounced). Gated
|
||||
// through okfActiveFolders so it never runs when OKF is off / for non-OKF /
|
||||
|
|
@ -1036,7 +1069,7 @@ export default class StashpadPlugin extends Plugin {
|
|||
});
|
||||
this.addCommand({
|
||||
id: "stashpad-fork-version",
|
||||
name: "Fork as version (alternate draft / sheet)",
|
||||
name: "Fork as a version (alternate draft in this sheet)",
|
||||
callback: () => call("cmdForkVersion"),
|
||||
});
|
||||
this.addCommand({
|
||||
|
|
@ -1200,7 +1233,7 @@ 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-fork-note", name: "Fork into a separate note (copy 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") });
|
||||
|
|
@ -4921,6 +4954,28 @@ export default class StashpadPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
/** Basenames of recently-deleted notes awaiting a fork-siblings prune. */
|
||||
private pendingForkDeletes = new Set<string>();
|
||||
/** Remove the deleted notes from every other note's `fork-siblings`. */
|
||||
private async flushForkSiblingPrune(): Promise<void> {
|
||||
const names = new Set(this.pendingForkDeletes);
|
||||
this.pendingForkDeletes.clear();
|
||||
if (!names.size) return;
|
||||
for (const f of this.app.vault.getMarkdownFiles()) {
|
||||
const sibs = this.app.metadataCache.getFileCache(f)?.frontmatter?.[SIBLINGS_KEY];
|
||||
if (!Array.isArray(sibs)) continue;
|
||||
if (!sibs.some((s) => names.has(wikilinkName(s) ?? ""))) continue;
|
||||
try {
|
||||
await this.app.fileManager.processFrontMatter(f, (m: any) => {
|
||||
const arr = Array.isArray(m[SIBLINGS_KEY]) ? m[SIBLINGS_KEY] : [];
|
||||
const next = arr.filter((s: unknown) => !names.has(wikilinkName(s) ?? ""));
|
||||
if (next.length) m[SIBLINGS_KEY] = next;
|
||||
else delete m[SIBLINGS_KEY];
|
||||
});
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ 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: "forkNote", label: "Fork into a separate note (under a chosen parent)", desc: "Duplicate the cursor row (with its subtree) as a separate note and pick which parent it nests under. Distinct from \"Fork as a version\" (a draft within a sheet group). 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: "" },
|
||||
|
|
@ -192,6 +192,12 @@ export interface StashpadSettings {
|
|||
* timing so the "Dump performance profile" command reports where the
|
||||
* time goes on a slow vault. Off by default. */
|
||||
enablePerfProfiling: boolean;
|
||||
/** 0.108.2: opt-in local debug tracing — records structured diagnostic
|
||||
* lines (e.g. tap coordinates vs resolved row) to an in-memory ring buffer
|
||||
* you can copy from the Diagnostics tab. Purely local, no network, no file
|
||||
* writes; a no-op when off. Ships dormant — never needs stripping for the
|
||||
* store / pristine. */
|
||||
debugTrace: boolean;
|
||||
/** 0.83.1: maintain the redundant `parentLink`/`children` recovery
|
||||
* fields on every move. Default true. Turning it off skips those writes
|
||||
* entirely — a big speedup on slow/network drives (each is a full
|
||||
|
|
@ -483,6 +489,7 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
|
|||
folderPanelHidden: [],
|
||||
folderPanelPinnedGrouping: "pin-order",
|
||||
enablePerfProfiling: false,
|
||||
debugTrace: false,
|
||||
writeRecoveryLinks: true,
|
||||
useTemplatesFormat: false,
|
||||
prefixTimestampsOnCopy: true,
|
||||
|
|
@ -736,6 +743,23 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
this.plugin.settings.enablePerfProfiling = v; await this.plugin.saveSettings();
|
||||
})), ["perf", "profiling", "timing", "slow"]),
|
||||
|
||||
this.renderDef("Debug trace", "Record low-level diagnostic lines (e.g. tap coordinates vs the row they resolve to) to an in-memory buffer while you reproduce a bug, then copy them below to share. Local only — no network, no file writes; zero overhead when off.", (s) =>
|
||||
s.addToggle((t) => t.setValue(this.plugin.settings.debugTrace).onChange(async (v) => {
|
||||
this.plugin.settings.debugTrace = v; await this.plugin.saveSettings();
|
||||
})), ["debug", "trace", "diagnostics", "tap", "log"]),
|
||||
|
||||
this.renderDef("Copy / clear debug trace", "Copy the captured debug lines to the clipboard (paste them back to share), or clear the buffer to start a fresh capture.", (s) => {
|
||||
s.addButton((b) => b.setButtonText("Copy").onClick(async () => {
|
||||
const text = this.plugin.getDebugTrace();
|
||||
if (!text) { new Notice("Debug trace is empty — enable it and reproduce the issue first."); return; }
|
||||
try { await navigator.clipboard.writeText(text); new Notice("Debug trace copied."); }
|
||||
catch { new Notice("Couldn't access clipboard."); }
|
||||
}));
|
||||
s.addButton((b) => b.setButtonText("Clear").onClick(() => {
|
||||
this.plugin.clearDebugTrace(); new Notice("Debug trace cleared.");
|
||||
}));
|
||||
}, ["debug", "trace", "copy", "clear"]),
|
||||
|
||||
this.renderDef("Open log file", "Append-only history of creates, deletes, parent changes, renames. Stored alongside the plugin's other private files.", (s) =>
|
||||
s.addButton((b) => b.setButtonText("Open log").onClick(async () => {
|
||||
const adapter = this.app.vault.adapter;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,17 @@ export const SHEET_KEY = "sheet-group"; // group id shared by all versions (stri
|
|||
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
|
||||
export const SHEET_ORIGIN_KEY = "sheet-origin"; // true on the original (group seed)
|
||||
export const FORKED_FROM_KEY = "forked-from"; // provenance: id of the note this was forked from
|
||||
export const SIBLINGS_KEY = "fork-siblings"; // wikilinks to all other members of the fork family
|
||||
|
||||
/** Keys that mark a note as a sheet version or record its provenance. They are
|
||||
* stripped when a note is DUPLICATED by clone / fork-note / insert-template —
|
||||
* a copy is a brand-new note, never a silent member of the source's version
|
||||
* group and never an "original" or "final". */
|
||||
export const SHEET_COPY_SKIP_KEYS: readonly string[] = [
|
||||
SHEET_KEY, SHEET_ORDER_KEY, SHEET_FINAL_KEY, SHEET_LABEL_KEY, SHEET_ORIGIN_KEY, FORKED_FROM_KEY, SIBLINGS_KEY,
|
||||
];
|
||||
|
||||
type Fm = Record<string, unknown> | null | undefined;
|
||||
|
||||
|
|
@ -40,6 +51,29 @@ export function sheetIsFinal(fm: Fm): boolean {
|
|||
return fm?.[SHEET_FINAL_KEY] === true;
|
||||
}
|
||||
|
||||
/** True on the original note that seeded the group (never on a fork). */
|
||||
export function isOriginal(fm: Fm): boolean {
|
||||
return fm?.[SHEET_ORIGIN_KEY] === true;
|
||||
}
|
||||
|
||||
/** The raw `forked-from` value (a `[[wikilink]]`), if any. */
|
||||
export function forkedFromOf(fm: Fm): string | null {
|
||||
const v = fm?.[FORKED_FROM_KEY];
|
||||
return typeof v === "string" && v.trim() ? v : null;
|
||||
}
|
||||
|
||||
/** The bare note name inside a `[[wikilink]]` (strips brackets, alias, subpath). */
|
||||
export function wikilinkName(raw: unknown): string | null {
|
||||
if (typeof raw !== "string") return null;
|
||||
const inner = raw.replace(/^\[\[/, "").replace(/\]\]$/, "");
|
||||
return inner.split("|")[0].split("#")[0].trim() || null;
|
||||
}
|
||||
|
||||
/** The bare note name behind `forked-from`. */
|
||||
export function forkedFromName(fm: Fm): string | null {
|
||||
return wikilinkName(forkedFromOf(fm));
|
||||
}
|
||||
|
||||
export function sheetLabelOf(fm: Fm): string | null {
|
||||
const v = fm?.[SHEET_LABEL_KEY];
|
||||
return typeof v === "string" && v.trim() ? v : null;
|
||||
|
|
|
|||
279
src/view.ts
279
src/view.ts
|
|
@ -33,8 +33,14 @@ import {
|
|||
SHEET_KEY,
|
||||
SHEET_ORDER_KEY,
|
||||
SHEET_FINAL_KEY,
|
||||
SHEET_ORIGIN_KEY,
|
||||
FORKED_FROM_KEY,
|
||||
SIBLINGS_KEY,
|
||||
SHEET_COPY_SKIP_KEYS,
|
||||
sheetIdOf,
|
||||
isVersionMember,
|
||||
isOriginal,
|
||||
forkedFromName,
|
||||
sheetIsFinal,
|
||||
newSheetGroupId,
|
||||
nodeFm,
|
||||
|
|
@ -241,6 +247,19 @@ export class StashpadView extends ItemView {
|
|||
* "moving" on every composer interaction). During this window we
|
||||
* let the browser's own reflow settle without fighting it. */
|
||||
private keyboardTransitionUntil = 0;
|
||||
/** 0.108.3: while `Date.now() < this`, a mobile row tap only dismisses the
|
||||
* keyboard (no select/open). Set on composer blur to cover the keyboard-
|
||||
* dismiss reflow, during which rows slide out from under the finger — see
|
||||
* shouldAbsorbDismissTap. */
|
||||
private tapSettleUntil = 0;
|
||||
/** 0.108.4: the note under the finger on the FIRST tap of a double-tap that
|
||||
* began during the keyboard-dismiss reflow (keyboard-up layout — what the
|
||||
* user actually aimed at). The double-tap opens THIS, not the row that slid
|
||||
* under the second tap. Window-gated so a lone dismiss tap can't later be
|
||||
* "opened" by an unrelated double-tap. */
|
||||
private aimedTapTargetId: string | null = null;
|
||||
private aimedTapAt = 0;
|
||||
private static readonly AIMED_TAP_WINDOW_MS = 600;
|
||||
/** Per-row ResizeObserver attached during scrollListToBottom — re-pins
|
||||
* the list to the bottom whenever a row's height changes. Survives
|
||||
* past the initial paint so cold-cache markdown / late font loads
|
||||
|
|
@ -2854,7 +2873,7 @@ export class StashpadView extends ItemView {
|
|||
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("Fork as a version (draft)").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()));
|
||||
|
|
@ -4478,6 +4497,22 @@ export class StashpadView extends ItemView {
|
|||
// browser makes on double-click so it doesn't flash before nav.
|
||||
row.addEventListener("dblclick", (e) => {
|
||||
if (!this.plugin.settings.doubleClickToFocus) return;
|
||||
// Mobile: if this double-tap began during the keyboard-dismiss reflow,
|
||||
// open the note that was under the finger when it STARTED (recorded on
|
||||
// the first, dismissing tap) — not the row that slid under the reflowed
|
||||
// second tap. One fluid double-tap on the note above the composer.
|
||||
const absorbed = this.shouldAbsorbDismissTap();
|
||||
if (absorbed && this.aimedTapTargetId && Date.now() - this.aimedTapAt <= StashpadView.AIMED_TAP_WINDOW_MS) {
|
||||
const aimed = this.aimedTapTargetId;
|
||||
this.aimedTapTargetId = null;
|
||||
this.traceTap("dblclick", e, idx, false);
|
||||
e.preventDefault();
|
||||
window.getSelection()?.removeAllRanges();
|
||||
this.navigateTo(aimed);
|
||||
return;
|
||||
}
|
||||
this.traceTap("dblclick", e, idx, absorbed);
|
||||
if (absorbed) { e.preventDefault(); return; }
|
||||
const t = e.target as HTMLElement | null;
|
||||
// 0.76.12: also skip the task checkbox — double-clicking it
|
||||
// should toggle, never navigate.
|
||||
|
|
@ -4627,19 +4662,23 @@ export class StashpadView extends ItemView {
|
|||
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 mFm = nodeFm(this.app, m);
|
||||
const isFinal = sheetIsFinal(mFm);
|
||||
const orig = isOriginal(mFm);
|
||||
const tab = bar.createDiv({
|
||||
cls:
|
||||
"stashpad-version-tab" +
|
||||
(isActive ? " is-active" : "") +
|
||||
(isFinal ? " is-final" : ""),
|
||||
(isFinal ? " is-final" : "") +
|
||||
(orig ? " is-original" : ""),
|
||||
});
|
||||
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();
|
||||
const title = this.titleForNode(m).trim();
|
||||
tab.title = orig ? `${title} (original)` : `${title} (forked)`;
|
||||
if (!isActive) {
|
||||
tab.onclick = (e) => { e.stopPropagation(); this.setActiveVersion(gid, m.id); };
|
||||
}
|
||||
|
|
@ -4932,6 +4971,9 @@ export class StashpadView extends ItemView {
|
|||
ta.addEventListener("blur", () => {
|
||||
document.body.classList.remove("stashpad-keyboard-open");
|
||||
keyboardTransition();
|
||||
// Guard row taps until the keyboard-dismiss reflow settles (rows slide
|
||||
// ~336px under the finger over ~120ms; 300ms covers slower devices).
|
||||
this.tapSettleUntil = Date.now() + 300;
|
||||
// 0.89.0: tapping the list to dismiss the composer should leave the
|
||||
// selected note visible (it may have been hidden behind the composer).
|
||||
// Re-reveal after the keyboard's close animation settles the layout.
|
||||
|
|
@ -5364,6 +5406,25 @@ export class StashpadView extends ItemView {
|
|||
}
|
||||
|
||||
private handleRowClick(e: MouseEvent, idx: number, node: TreeNode): void {
|
||||
// Mobile: a tap during the keyboard-dismiss reflow only dismisses the
|
||||
// keyboard — acting on it would select whatever row slid under the finger.
|
||||
const absorbed = this.shouldAbsorbDismissTap();
|
||||
this.traceTap("click", e, idx, absorbed);
|
||||
if (absorbed) {
|
||||
// Remember the note under the finger on this dismissing tap (keyboard-up
|
||||
// layout = what the user aimed at) so a double-tap that began here opens
|
||||
// THAT note, not whatever slides under the reflowed second tap. Keep the
|
||||
// earliest tap of the burst — don't let the second tap overwrite it.
|
||||
if (!this.aimedTapTargetId || Date.now() - this.aimedTapAt > StashpadView.AIMED_TAP_WINDOW_MS) {
|
||||
this.aimedTapTargetId = node.id;
|
||||
this.aimedTapAt = Date.now();
|
||||
}
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
// Normal (settled) tap — drop any stale aimed target so it can't leak into
|
||||
// a later unrelated double-tap.
|
||||
this.aimedTapTargetId = null;
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
// Tag click → open global search filtered by that tag.
|
||||
const tag = targetEl?.closest?.(".tag") as HTMLElement | null;
|
||||
|
|
@ -5501,6 +5562,44 @@ export class StashpadView extends ItemView {
|
|||
setTimeout(doReveal, 200);
|
||||
}
|
||||
|
||||
/** Mobile only. True if a row tap should be absorbed (dismiss-the-keyboard
|
||||
* only, no select/open). When the soft keyboard dismisses, Obsidian regrows
|
||||
* the leaf by the keyboard height a beat later; the list stays pinned to the
|
||||
* bottom, so existing rows slide DOWN under a stationary finger (measured:
|
||||
* ~336px over ~120ms). A tap that lands during that reflow — including the
|
||||
* second tap of a quick double-tap that began while the keyboard was up —
|
||||
* opens whatever row slid under the finger, not the one the user aimed at.
|
||||
* So while the list height is still settling we treat the tap as a pure
|
||||
* keyboard-dismiss and let the user re-aim on the stable list (which is the
|
||||
* manual workaround, now automatic). */
|
||||
private shouldAbsorbDismissTap(): boolean {
|
||||
return Platform.isMobile && Date.now() < this.tapSettleUntil;
|
||||
}
|
||||
|
||||
/** Diagnostics: record the tap's coordinate picture so a tap-vs-row offset
|
||||
* can be measured from a real device. No-op (and no layout reads) unless
|
||||
* debugTrace is on. */
|
||||
private traceTap(type: string, e: MouseEvent, idx: number, absorbed: boolean): void {
|
||||
if (!this.plugin.settings.debugTrace || !this.listEl) return;
|
||||
const list = this.listEl;
|
||||
const vv = window.visualViewport;
|
||||
const dRect = list.querySelector<HTMLElement>(`.stashpad-note[data-idx="${idx}"]`)?.getBoundingClientRect();
|
||||
this.plugin.trace(`tap:${type}`, {
|
||||
absorbed,
|
||||
clientY: Math.round(e.clientY), clientX: Math.round(e.clientX),
|
||||
idx, rowTop: dRect ? Math.round(dRect.top) : null,
|
||||
listTop: Math.round(list.getBoundingClientRect().top),
|
||||
listScrollTop: Math.round(list.scrollTop),
|
||||
listScrollH: Math.round(list.scrollHeight),
|
||||
listClientH: Math.round(list.clientHeight),
|
||||
innerH: window.innerHeight,
|
||||
vvHeight: vv ? Math.round(vv.height) : null,
|
||||
vvOffsetTop: vv ? Math.round(vv.offsetTop) : null,
|
||||
tapSettleInMs: Math.max(0, this.tapSettleUntil - Date.now()),
|
||||
kbOpen: document.body.classList.contains("stashpad-keyboard-open"),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Document-level keyboard ---
|
||||
|
||||
private onDocKeyDown = (e: KeyboardEvent): void => {
|
||||
|
|
@ -7218,6 +7317,10 @@ export class StashpadView extends ItemView {
|
|||
await this.app.fileManager.processFrontMatter(newFile, (m: any) => {
|
||||
for (const [k, v] of Object.entries(sourceFm)) {
|
||||
if (RESERVED_FRONTMATTER.includes(k)) continue;
|
||||
// A duplicate is a NEW note, not a member of the source's version
|
||||
// group — never inherit sheet/provenance keys (would falsely mark
|
||||
// it original/final or collide on order).
|
||||
if (SHEET_COPY_SKIP_KEYS.includes(k)) continue;
|
||||
m[k] = v;
|
||||
}
|
||||
});
|
||||
|
|
@ -7343,9 +7446,20 @@ export class StashpadView extends ItemView {
|
|||
const createdPaths: string[] = [];
|
||||
const newRootId = await this.cloneSubtree(node, parentId, createdPaths);
|
||||
if (!newRootId) return;
|
||||
// Provenance: the forked root records the note it was forked from. (The
|
||||
// sheet/origin keys were stripped by cloneSubtree, so this is the only
|
||||
// version marker a re-homed fork carries.)
|
||||
const rootFile = createdPaths[0] ? this.app.vault.getAbstractFileByPath(createdPaths[0]) as TFile | null : null;
|
||||
if (rootFile) {
|
||||
try {
|
||||
await this.app.fileManager.processFrontMatter(rootFile, (m: any) => { m[FORKED_FROM_KEY] = `[[${node.file!.basename}]]`; });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
this.tree.rebuild(folder);
|
||||
this.pendingFocusIds = [newRootId];
|
||||
this.render();
|
||||
// Cross-link the lineage: the origin + all its separate-note forks.
|
||||
await this.syncForkSiblings(this.forkFamilyFiles({ originName: node.file.basename, include: [node.file, rootFile] }));
|
||||
const snapNodes: TreeNode[] = createdPaths
|
||||
.map((p) => this.app.vault.getAbstractFileByPath(p))
|
||||
.filter((f): f is TFile => !!f && (f as any).extension === "md")
|
||||
|
|
@ -7386,90 +7500,160 @@ export class StashpadView extends ItemView {
|
|||
await this.app.fileManager.processFrontMatter(file, (m: any) => {
|
||||
m[SHEET_KEY] = gid;
|
||||
if (typeof m[SHEET_ORDER_KEY] !== "number") m[SHEET_ORDER_KEY] = 0;
|
||||
// This note seeded the group → it's the original. Forks never get this.
|
||||
m[SHEET_ORIGIN_KEY] = true;
|
||||
});
|
||||
return gid;
|
||||
}
|
||||
|
||||
/** All notes in a fork family: either a sheet group's members (by `gid`) or
|
||||
* a separate-note lineage (the origin `originId` + everything forked from
|
||||
* it). `include` force-adds freshly-written files the cache may still lag. */
|
||||
private forkFamilyFiles(opts: { gid?: string | null; originName?: string | null; include?: (TFile | null)[] }): TFile[] {
|
||||
const set = new Map<string, TFile>();
|
||||
const add = (f: TFile | null) => { if (f) set.set(f.path, f); };
|
||||
for (const f of this.app.vault.getMarkdownFiles()) {
|
||||
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
|
||||
if (opts.gid && sheetIdOf(fm) === opts.gid) add(f);
|
||||
if (opts.originName) {
|
||||
// The origin note itself, plus everything whose forked-from links to it.
|
||||
if (f.basename === opts.originName || forkedFromName(fm) === opts.originName) add(f);
|
||||
}
|
||||
}
|
||||
for (const f of opts.include ?? []) add(f);
|
||||
return [...set.values()];
|
||||
}
|
||||
|
||||
/** Write each family member's `fork-siblings` to wikilinks of the others. */
|
||||
private async syncForkSiblings(family: TFile[]): Promise<void> {
|
||||
for (const f of family) {
|
||||
const others = family.filter((o) => o.path !== f.path).map((o) => `[[${o.basename}]]`);
|
||||
await this.app.fileManager.processFrontMatter(f, (m: any) => {
|
||||
if (others.length) m[SIBLINGS_KEY] = others;
|
||||
else delete m[SIBLINGS_KEY];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 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> {
|
||||
// Explicitly invoking a version command opts you in — turn the feature on
|
||||
// rather than nagging about a setting.
|
||||
if (!this.plugin.settings.enableSheetVersions) {
|
||||
new Notice("Sheet versions are off — enable them in Settings → General.");
|
||||
return;
|
||||
this.plugin.settings.enableSheetVersions = true;
|
||||
await this.plugin.saveSettings();
|
||||
new Notice("Sheet versions enabled.");
|
||||
}
|
||||
const src = source ?? this.getActionTargets()[0];
|
||||
if (!src?.file) { new Notice("Select a note to version."); return; }
|
||||
const folder = this.noteFolder;
|
||||
// Snapshot the source frontmatter BEFORE ensureSheetGroup mutates it — the
|
||||
// metadata cache lags processFrontMatter, so reading it afterward returns
|
||||
// stale/partial data (drops color/tags, miscounts order).
|
||||
const srcFm = { ...(this.app.metadataCache.getFileCache(src.file)?.frontmatter ?? {}) } as Record<string, any>;
|
||||
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];
|
||||
// Next order = one past the current highest in the group. Start from the
|
||||
// source's own order (from the snapshot — its cache entry is now stale),
|
||||
// then fold in the other members' cached orders.
|
||||
let maxOrder = typeof srcFm[SHEET_ORDER_KEY] === "number" ? srcFm[SHEET_ORDER_KEY] : 0;
|
||||
for (const s of this.tree.getChildren(parent)) {
|
||||
if (s.id === src.id) continue;
|
||||
const fm = nodeFm(this.app, s);
|
||||
if (sheetIdOf(fm) !== gid) continue;
|
||||
const o = fm?.[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 createdPaths: string[] = [];
|
||||
let newRootId: StashpadId;
|
||||
|
||||
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, '\\"')}"`);
|
||||
if (blank) {
|
||||
// Blank new version: a single empty note (no subtree).
|
||||
const cloneId = newId();
|
||||
const filename = buildFilename(bodyToSlug("", this.activeStopwords()), cloneId);
|
||||
const path = `${folder}/${filename}`;
|
||||
const created = new Date().toISOString();
|
||||
await this.ensureFolder(folder);
|
||||
await this.app.vault.create(path, ["---", `id: ${cloneId}`, `parent: ${parent}`, `created: ${created}`, "attachments: []", "---", ""].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) => {
|
||||
// Carry the source's styling/metadata (color, tags, custom keys) so a
|
||||
// version looks like its sibling — but never its sheet/provenance keys.
|
||||
for (const [k, v] of Object.entries(srcFm)) {
|
||||
if (RESERVED_FRONTMATTER.includes(k)) continue;
|
||||
if (SHEET_COPY_SKIP_KEYS.includes(k)) continue;
|
||||
m[k] = v;
|
||||
}
|
||||
m[SHEET_KEY] = gid;
|
||||
m[SHEET_ORDER_KEY] = order;
|
||||
delete m[SHEET_FINAL_KEY];
|
||||
delete m[SHEET_ORIGIN_KEY];
|
||||
m[FORKED_FROM_KEY] = `[[${src.file!.basename}]]`;
|
||||
});
|
||||
try {
|
||||
this.tree.insertSynthetic({ id: cloneId, parent, children: [], file: newFile, created });
|
||||
} catch { /* ignore */ }
|
||||
this.fmSync.scheduleParentChange(cloneId, null, parent);
|
||||
createdPaths.push(path);
|
||||
newRootId = cloneId;
|
||||
} else {
|
||||
fmInit.push("attachments: []");
|
||||
// Fork: duplicate the whole subtree as a sibling. cloneSubtree already
|
||||
// copies color/tags/custom keys and STRIPS the sheet/provenance keys
|
||||
// (SHEET_COPY_SKIP_KEYS) from every copied node, so only the new root
|
||||
// becomes a version — its children are plain copies.
|
||||
const rootId = await this.cloneSubtree(src, parent, createdPaths);
|
||||
if (!rootId || !createdPaths.length) { new Notice("Sheets: could not create version."); return; }
|
||||
newRootId = rootId;
|
||||
const rootFile = this.app.vault.getAbstractFileByPath(createdPaths[0]) as TFile | null;
|
||||
if (rootFile) {
|
||||
await this.app.fileManager.processFrontMatter(rootFile, (m: any) => {
|
||||
m[SHEET_KEY] = gid;
|
||||
m[SHEET_ORDER_KEY] = order;
|
||||
delete m[SHEET_FINAL_KEY];
|
||||
delete m[SHEET_ORIGIN_KEY]; // a fork is never the original…
|
||||
m[FORKED_FROM_KEY] = `[[${src.file!.basename}]]`; // …and records what it came from
|
||||
});
|
||||
}
|
||||
}
|
||||
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.activeVersionByGroup.set(gid, newRootId); // show the new version
|
||||
this.plugin.saveActiveVersion(folder, gid, newRootId);
|
||||
this.render();
|
||||
|
||||
// Keep every group member's `fork-siblings` list current.
|
||||
const newRootFile = this.app.vault.getAbstractFileByPath(createdPaths[0]) as TFile | null;
|
||||
await this.syncForkSiblings(this.forkFamilyFiles({ gid, include: [src.file, newRootFile] }));
|
||||
|
||||
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 */ } }
|
||||
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.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");
|
||||
const n = createdPaths.length;
|
||||
new Notice(blank ? "New version added" : `Forked a new version${n > 1 ? ` (${n} notes)` : ""}`);
|
||||
}
|
||||
|
||||
/** 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;
|
||||
this.plugin.settings.enableSheetVersions = true;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
const node = target ?? this.getActionTargets()[0];
|
||||
if (!node?.file) return;
|
||||
|
|
@ -9915,6 +10099,7 @@ export class StashpadView extends ItemView {
|
|||
await this.app.fileManager.processFrontMatter(f as TFile, (m: any) => {
|
||||
for (const [k, v] of Object.entries(templateFm)) {
|
||||
if (RESERVED_FRONTMATTER.includes(k)) continue;
|
||||
if (SHEET_COPY_SKIP_KEYS.includes(k)) continue; // templates don't seed version groups
|
||||
if (m[k] === undefined) m[k] = v;
|
||||
}
|
||||
});
|
||||
|
|
@ -10445,7 +10630,7 @@ 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(() => {
|
||||
menu.addItem((it: any) => it.setTitle("Fork into a separate note…").setIcon("git-branch").onClick(() => {
|
||||
if (!this.selection.has(node.id)) { this.selection.clear(); this.selection.add(node.id); this.lastSelected = node.id; }
|
||||
this.cmdForkNote();
|
||||
}));
|
||||
|
|
|
|||
Loading…
Reference in a new issue