0.170.5: in-app Edit surface (unified with Split), E→in-app editor, folder-panel button colors

This commit is contained in:
Human 2026-07-14 07:57:31 -07:00
parent 4783a88bbc
commit 52212d8d53
9 changed files with 589 additions and 188 deletions

159
main.js

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "stashpad",
"name": "Stashpad",
"version": "0.169.4",
"version": "0.170.5",
"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",

View file

@ -1,6 +1,6 @@
{
"name": "stashpad-obsidian",
"version": "0.169.4",
"version": "0.170.5",
"private": true,
"scripts": {
"dev": "node esbuild.config.mjs",

32
release-notes/0.170.5.md Normal file
View file

@ -0,0 +1,32 @@
# 0.170.5
Adds an in-app **Edit** surface for notes, unified with Split, plus small fixes.
## In-app editor (Edit ⇄ Split)
- **New: edit a note in-app** without opening a full Obsidian tab. Press **E** on a
note (or "Edit (in-app)…" in the note menu) to open a lightweight editor with a live
**word/char count**, a read-only **Original** panel and a word-level **Changes**
diff, and a **case-cycle** button (lower → UPPER → Title → Sentence). **Save**
(⌘/Ctrl+Enter) writes your changes as a single undo step and re-slugs the filename if
you edited the title line.
- **Edit and Split are one surface.** A top toggle (or **⌘/Ctrl+E** / **⌘/Ctrl+S**)
switches between Edit and Split, sharing the same text — so you can edit, then split
on exactly what you edited.
- **Open in a tab** pops the whole thing into a full pane for long notes / mobile;
**Obsidian editor** leaves for the real markdown editor. The tab auto-closes when
you're done (and returns you to the tab you came from), and a stale restored tab
closes itself.
- **Unsaved-edits guard**: closing with unsaved changes now asks before discarding.
- **Shift+E** edits the focused parent note in-app.
## Hotkey change
- **E** now opens the in-app editor. "Open in Obsidian editor" moved to
**Mod+Shift+E**. (Existing keymaps are migrated automatically.)
## Fixes
- The folder-panel header buttons (Pinned view options, New folder, Open trash) now use
the accent color on the glyph with a neutral border.
- Dropped a deprecated API call in the split/edit tab's focus handling.

View file

@ -14,7 +14,7 @@ import { TaskReviewModal } from "./task-review-modal";
import { StashpadFolderPanelView, openFolderPanelView } from "./folder-panel-view";
import { EncryptionService, defaultEncryptionConfig } from "./encryption-service";
import { lockSubtree, unlockBundle, readLockedMeta, type LockResult, deleteEncryptSubtree, restoreDeleted, listDeletedBlobs, readDeletedMeta, deletedRestoreDest, restoreRawTrash, purgeDeletedBlob, OBSIDIAN_TRASH_DIR, type DeletedMeta, collectSubtree, trashSubfolderOf, lockRawFolder, unlockRawFolder, rawFolderBlobIn, listRawFolderBlobs, deletePlaintextSubtree, restorePlaintextDeleted, listPlaintextTrashBundles, STASHPACK_EXT } from "./encryption-ops";
import { EncryptionPasswordModal, ConfirmModal, ReEncryptReviewModal, OpenDeepLinkModal, SplitNoteView, SPLIT_VIEW_TYPE, type SplitCommandCallbacks, type SplitUIState } from "./modals";
import { EncryptionPasswordModal, ConfirmModal, ReEncryptReviewModal, OpenDeepLinkModal, NoteWorkbenchView, WORKBENCH_VIEW_TYPE, type WorkbenchCommandCallbacks, type WorkbenchState } from "./modals";
import {
DEFAULT_SETTINGS, StashpadSettings, StashpadSettingTab, setSettings, SETTINGS_TABS,
buildDefaultBindings, COMMAND_META, type CommandBindingMap,
@ -986,8 +986,8 @@ export default class StashpadPlugin extends Plugin {
);
// 0.169.0: the "pop out" full-tab host for the Split-note UI (long text / mobile).
this.registerView(
SPLIT_VIEW_TYPE,
(leaf: WorkspaceLeaf) => new SplitNoteView(leaf),
WORKBENCH_VIEW_TYPE,
(leaf: WorkspaceLeaf) => new NoteWorkbenchView(leaf),
);
// Deep links: `obsidian://stashpad?folder=…&note=<id>&run=reveal[,open]`.
// Routes into the Stashpad view, reveals a note, runs a small macro. See
@ -1443,6 +1443,17 @@ export default class StashpadPlugin extends Plugin {
name: "Edit note in new tab (selection)",
callback: () => call("cmdOpenInEditor"),
});
this.addCommand({
// 0.170.0: the in-app editor (Edit ⇄ Split surface), not a full Obsidian tab.
id: "stashpad-edit-inapp",
name: "Edit note (in-app editor)…",
callback: () => call("cmdEdit"),
});
this.addCommand({
id: "stashpad-edit-parent-inapp",
name: "Edit parent note (in-app editor)…",
callback: () => call("cmdEditParent"),
});
this.addCommand({
id: "stashpad-edit-parent",
name: "Edit parent note in new tab",
@ -3986,12 +3997,12 @@ export default class StashpadPlugin extends Plugin {
* injects the live context (the split handlers are closures bound to the source
* note, so the tab must be seeded right away; a restored context-less view shows
* a "session ended" placeholder). */
async openSplitView(body: string, cbs: SplitCommandCallbacks, init: Partial<SplitUIState>): Promise<void> {
async openWorkbench(body: string, cbs: WorkbenchCommandCallbacks, init: Partial<WorkbenchState>): Promise<void> {
// Remember the tab we came from so the split tab can hand focus back on close.
const prevLeaf = this.app.workspace.getMostRecentLeaf() ?? this.app.workspace.activeLeaf;
const prevLeaf = this.app.workspace.getMostRecentLeaf();
const leaf = this.app.workspace.getLeaf("tab");
await leaf.setViewState({ type: SPLIT_VIEW_TYPE, active: true });
if (leaf.view instanceof SplitNoteView) leaf.view.setContext({ body, cbs, init, prevLeaf });
await leaf.setViewState({ type: WORKBENCH_VIEW_TYPE, active: true });
if (leaf.view instanceof NoteWorkbenchView) leaf.view.setContext({ body, cbs, init, prevLeaf });
this.app.workspace.revealLeaf(leaf);
}
@ -6221,6 +6232,11 @@ export default class StashpadPlugin extends Plugin {
&& typeof (data)?.jdIndexStashpadFolder !== "string") {
(data).jdIndexStashpadFolder = (data).jdIndexDestFolder;
}
// 0.170.2: "E" was reassigned from "Open in Obsidian editor" (openEditor) to the
// new in-app editor (edit). Move a STALE openEditor="E" (the old default) to its
// new chord so E doesn't fire two commands. Idempotent — only touches an exact "E".
if (data?.shortcuts && data.shortcuts.openEditor === "E") data.shortcuts.openEditor = "Mod+Shift+E";
if (data?.bindings?.openEditor && data.bindings.openEditor.primary === "E") data.bindings.openEditor.primary = "Mod+Shift+E";
this.settings = {
...DEFAULT_SETTINGS,
...data,

View file

@ -456,7 +456,9 @@ function splitWordDiff(a: string, b: string): SplitDiffPart[] {
/** 0.169.0: the split UI's live state carried to the popped-out tab so it
* continues where the modal left off. */
export interface SplitUIState {
export interface WorkbenchState {
/** 0.170.0: top-level surface — plain Edit, or the Split methods. Shares text. */
surface: "edit" | "split";
mode: "line" | "cursor" | "preset";
presetMode: SplitMode;
nest: boolean;
@ -464,25 +466,42 @@ export interface SplitUIState {
lineCursorIdx: number;
}
export interface SplitUICallbacks {
export interface WorkbenchCallbacks {
onSplitAtLine: (firstLineOfSecondPart: number, nest: boolean) => void | Promise<void>;
onSplitAtChar: (text: string, charIndex: number, nest: boolean) => void | Promise<void>;
onSplitMany: (parts: string[], nest: boolean) => void | Promise<void>;
/** 0.170.0: Edit surface — write the edited body back to the note. */
onSave: (text: string) => void | Promise<void>;
/** 0.170.1: open the note in a full Obsidian markdown tab (leaves the modal). */
onOpenExternal?: () => void;
/** Dismiss the host WITHOUT committing (Cancel / Esc). */
close: () => void;
/** Called AFTER a successful commit the host decides what happens (the modal
* closes; the popped-out tab runs a countdown then closes + refocuses). */
/** Called AFTER a successful commit/save the host decides what happens (the
* modal closes; the popped-out tab runs a countdown then closes + refocuses). */
onDone: () => void;
/** Host hook to reflect the current surface in its title. */
onTitle?: (title: string) => void;
/** When present, an "Open in a tab" button appears; called with the live state. */
popOut?: (state: SplitUIState) => void;
popOut?: (state: WorkbenchState) => void;
}
/** 0.169.0: the split UI extracted from the modal so it can render into EITHER a
* modal or a full leaf ("pop out"). Owns all state + rendering; the host wires
* keys to `commit()` / `moveDivider()`. */
export class SplitUI {
/** 0.170.3: case transforms for the edit surface's cycle button. */
function applyCase(s: string, form: "lower" | "upper" | "title" | "sentence"): string {
switch (form) {
case "lower": return s.toLowerCase();
case "upper": return s.toUpperCase();
case "title": return s.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase());
case "sentence": return s.toLowerCase().replace(/(^\s*\w|[.!?]\s+\w)/g, (c) => c.toUpperCase());
}
}
export class NoteWorkbench {
private lines: string[];
private lineCursorIdx: number;
private surface: "edit" | "split" = "split";
private mode: "line" | "cursor" | "preset" = "line";
private presetMode: SplitMode = "paragraphs";
private nest = false;
@ -494,8 +513,8 @@ export class SplitUI {
private app: App,
private host: HTMLElement,
private body: string,
init: Partial<SplitUIState>,
private cb: SplitUICallbacks,
init: Partial<WorkbenchState>,
private cb: WorkbenchCallbacks,
) {
this.cursorText = init.cursorText ?? body;
this.collapsed = { orig: Platform.isMobile, changes: Platform.isMobile, edit: false };
@ -505,6 +524,7 @@ export class SplitUI {
else if (this.lines.length < 2) this.mode = "cursor"; // single-line → cursor only
if (init.presetMode) this.presetMode = init.presetMode;
if (init.nest != null) this.nest = init.nest;
if (init.surface) this.surface = init.surface;
// 0.169.4: wrap Tab focus within the split content — Shift+Tab on the first
// control jumps to the last, Tab on the last wraps to the first (rather than
// escaping to the modal × or getting stuck).
@ -529,8 +549,9 @@ export class SplitUI {
}
/** Snapshot for handoff to the popped-out tab. */
getState(): SplitUIState {
getState(): WorkbenchState {
return {
surface: this.surface,
mode: this.mode, presetMode: this.presetMode, nest: this.nest,
cursorText: this.cursorTextarea?.value ?? this.cursorText,
lineCursorIdx: this.lineCursorIdx,
@ -540,11 +561,31 @@ export class SplitUI {
/** 0.168.3: single dispatch for the Split button + Mod+Enter. 0.169.3: awaits the
* split, then calls onDone so the host can act once the job is complete. */
async commit(): Promise<void> {
if (this.surface === "edit") { await this.saveEdit(); return; }
if (this.mode === "line") await this.commitLine();
else if (this.mode === "cursor") await this.commitCursor();
else await this.commitPreset();
}
private async saveEdit(): Promise<void> {
const ta = this.cursorTextarea;
if (!ta) return;
await this.cb.onSave(ta.value);
this.cb.onDone();
}
/** 0.170.5: the current text differs from the note's original body → unsaved. */
isDirty(): boolean {
return (this.cursorTextarea?.value ?? this.cursorText) !== this.body;
}
/** 0.170.3: switch surface (Mod+E / Mod+S host shortcuts). No-op if already there. */
setSurface(s: "edit" | "split"): void {
if (this.surface === s) return;
this.surface = s;
this.render();
}
/** Move the line-mode divider by ±1. Returns true if it applied (line mode). */
moveDivider(delta: number): boolean {
if (this.mode !== "line") return false;
@ -580,7 +621,114 @@ export class SplitUI {
private render(): void {
this.host.empty();
this.host.toggleClass("stashpad-edit-surface", this.surface === "edit");
this.cb.onTitle?.(this.surface === "edit" ? "Edit note" : "Split note");
this.renderSurfaceToggle();
if (this.surface === "edit") { this.renderEditSurface(); return; }
this.renderSplitSurface();
}
/** 0.170.0: top-level Edit Split toggle. Both surfaces share the text buffer,
* so edits carry over when you switch and the split acts on what you edited. */
private renderSurfaceToggle(): void {
const row = this.host.createDiv({ cls: "stashpad-split-surface" });
const modKey = Platform.isMacOS ? "⌘" : "Ctrl+";
const mk = (label: string, s: "edit" | "split", icon: string, key: string): void => {
const b = row.createEl("button", { cls: "stashpad-split-surface-btn" });
setIcon(b.createSpan({ cls: "stashpad-split-btn-icon" }), icon);
b.createSpan({ text: label });
b.createSpan({ cls: "stashpad-split-kbd-hint", text: `${modKey}${key}` });
b.toggleClass("is-active", this.surface === s);
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => this.setSurface(s);
};
mk("Edit", "edit", "pencil-line", "E");
mk("Split", "split", "split", "S");
}
/** 0.170.0: "Open in a tab" hand the live state to a full leaf. Only the modal
* provides popOut (the tab omits it). */
private renderPopOut(actions: HTMLElement): void {
if (!this.cb.popOut) return;
const pop = actions.createEl("button", { cls: "stashpad-split-popout-btn" });
setIcon(pop.createSpan({ cls: "stashpad-split-popout-icon" }), "maximize-2");
pop.createSpan({ text: "Open in a tab" });
pop.setAttr("aria-label", "Open in a full tab");
pop.onmousedown = (e) => e.preventDefault();
pop.onclick = () => this.cb.popOut!(this.getState());
}
/** 0.170.1: leave for a full Obsidian markdown editor tab. */
private renderOpenExternal(actions: HTMLElement): void {
if (!this.cb.onOpenExternal) return;
const b = actions.createEl("button", { cls: "stashpad-split-popout-btn" });
setIcon(b.createSpan({ cls: "stashpad-split-popout-icon" }), "pencil");
b.createSpan({ text: "Obsidian editor" });
b.setAttr("aria-label", "Open in a full Obsidian editor tab");
b.onmousedown = (e) => e.preventDefault();
b.onclick = () => { this.cb.onOpenExternal!(); this.cb.close(); };
}
/** 0.170.0: plain editing — the shared Original/Changes/editor sections + a Save. */
private renderEditSurface(): void {
this.renderEditorSections();
// 0.170.3: edit tools — live word/char count + a case-cycle button.
const tools = this.host.createDiv({ cls: "stashpad-split-edit-tools" });
const count = tools.createSpan({ cls: "stashpad-split-count" });
const updateCount = (): void => {
const t = this.cursorTextarea?.value ?? "";
const words = (t.trim().match(/\S+/g) || []).length;
count.setText(`${words} word${words === 1 ? "" : "s"} · ${t.length} char${t.length === 1 ? "" : "s"}`);
};
const caseBtn = tools.createEl("button", { cls: "stashpad-split-case-btn" });
setIcon(caseBtn.createSpan({ cls: "stashpad-split-btn-icon" }), "case-sensitive");
caseBtn.createSpan({ text: "Case" });
caseBtn.setAttr("aria-label", "Cycle case of the selection (or all text): lower → UPPER → Title → Sentence");
caseBtn.onmousedown = (e) => e.preventDefault();
caseBtn.onclick = () => this.cycleCase(updateCount);
this.cursorTextarea?.addEventListener("input", updateCount);
updateCount();
const help = this.host.createDiv({ cls: "stashpad-split-help" });
if (!Platform.isPhone) help.setText("Edit the note, then Save. · ⌘/Ctrl+Enter or Save to write · ⌘/Ctrl+S → Split · Esc discards.");
const actions = this.host.createDiv({ cls: "stashpad-split-actions" });
const cancel = actions.createEl("button", { cls: "stashpad-split-cancel-btn" });
setIcon(cancel.createSpan({ cls: "stashpad-split-btn-icon" }), "x");
cancel.createSpan({ text: "Cancel (Esc)" });
cancel.onmousedown = (e) => e.preventDefault();
cancel.onclick = () => this.cb.close();
this.renderPopOut(actions);
this.renderOpenExternal(actions);
const right = actions.createDiv({ cls: "stashpad-split-actions-right" });
const saveBtn = right.createEl("button", { cls: "stashpad-split-confirm-btn mod-cta" });
setIcon(saveBtn.createSpan({ cls: "stashpad-split-btn-icon" }), "save");
saveBtn.createSpan({ text: "Save" });
saveBtn.onmousedown = (e) => e.preventDefault();
saveBtn.onclick = () => void this.commit();
}
private caseCycleIndex = 0;
private cycleCase(after: () => void): void {
const ta = this.cursorTextarea;
if (!ta) return;
const hasSel = ta.selectionStart !== ta.selectionEnd;
const start = hasSel ? ta.selectionStart : 0;
const end = hasSel ? ta.selectionEnd : ta.value.length;
const seg = ta.value.slice(start, end);
const forms = ["lower", "upper", "title", "sentence"] as const;
const form = forms[this.caseCycleIndex % forms.length];
this.caseCycleIndex += 1;
const out = applyCase(seg, form);
ta.value = ta.value.slice(0, start) + out + ta.value.slice(end);
ta.setSelectionRange(start, start + out.length);
this.cursorText = ta.value;
ta.dispatchEvent(new Event("input")); // refresh diff + count
after();
ta.focus();
}
private renderSplitSurface(): void {
// 0.168.3: mode selection — one segmented row across ALL split methods. Line /
// Cursor place a single divider you position; the preset methods (Each line /
// Paragraphs / Headings) auto-split into many. Every method now SELECTS a mode
@ -588,17 +736,20 @@ export class SplitUI {
// inconsistent quick-splits. A greyed preset shows no count (a count of 1 means
// "can't split", which read as noise).
const modeRow = this.host.createDiv({ cls: "stashpad-split-modes" });
const modeBtn = (label: string, active: boolean, onPick: () => void, disabled = false): void => {
const b = modeRow.createEl("button", { cls: "stashpad-split-mode-btn", text: label });
const modeBtn = (label: string, active: boolean, onPick: () => void, icon: string, disabled = false): void => {
const b = modeRow.createEl("button", { cls: "stashpad-split-mode-btn" });
setIcon(b.createSpan({ cls: "stashpad-split-btn-icon" }), icon);
b.createSpan({ text: label });
b.toggleClass("is-active", active);
b.disabled = disabled;
b.onmousedown = (e) => e.preventDefault();
if (!disabled) b.onclick = onPick;
};
if (this.lines.length >= 2) {
modeBtn("Line", this.mode === "line", () => { this.mode = "line"; this.render(); });
modeBtn("Line", this.mode === "line", () => { this.mode = "line"; this.render(); }, "separator-horizontal");
}
modeBtn("Cursor", this.mode === "cursor", () => { this.mode = "cursor"; this.render(); });
modeBtn("Cursor", this.mode === "cursor", () => { this.mode = "cursor"; this.render(); }, "text-cursor-input");
const presetIcon: Record<SplitMode, string> = { lines: "list", paragraphs: "pilcrow", headings: "heading" };
(["lines", "paragraphs", "headings"] as SplitMode[]).forEach((m) => {
const n = splitIntoChunks(this.body, m).length;
const disabled = n < 2;
@ -607,12 +758,12 @@ export class SplitUI {
const base = m === "paragraphs" ? "Blank line(s)" : SPLIT_MODE_LABELS[m];
const label = disabled ? base : `${base} (${n})`;
modeBtn(label, this.mode === "preset" && this.presetMode === m,
() => { this.mode = "preset"; this.presetMode = m; this.render(); }, disabled);
() => { this.mode = "preset"; this.presetMode = m; this.render(); }, presetIcon[m], disabled);
});
// Preview for the active mode.
if (this.mode === "line") this.renderLineMode();
else if (this.mode === "cursor") this.renderCursorMode();
else if (this.mode === "cursor") this.renderEditorSections();
else this.renderPresetMode();
const help = this.host.createDiv({ cls: "stashpad-split-help" });
@ -637,20 +788,13 @@ export class SplitUI {
const actions = this.host.createDiv({ cls: "stashpad-split-actions" });
// 0.168.5: Cancel IS the escape action, so it carries the Esc hint (the separate
// corner chip was redundant with both Cancel and the ×).
const cancel = actions.createEl("button", { cls: "stashpad-split-cancel-btn", text: "Cancel (Esc)" });
const cancel = actions.createEl("button", { cls: "stashpad-split-cancel-btn" });
setIcon(cancel.createSpan({ cls: "stashpad-split-btn-icon" }), "x");
cancel.createSpan({ text: "Cancel (Esc)" });
cancel.onmousedown = (e) => e.preventDefault();
cancel.onclick = () => this.cb.close();
// 0.169.0: "Open in a tab" — hand the current state to a full leaf, great for
// long text / mobile. Only offered by the modal (the tab itself omits it).
if (this.cb.popOut) {
const pop = actions.createEl("button", { cls: "stashpad-split-popout-btn" });
setIcon(pop.createSpan({ cls: "stashpad-split-popout-icon" }), "maximize-2");
pop.createSpan({ text: "Open in a tab" });
pop.setAttr("aria-label", "Open this split in a full tab");
pop.onmousedown = (e) => e.preventDefault();
pop.onclick = () => this.cb.popOut!(this.getState());
}
this.renderPopOut(actions);
this.renderOpenExternal(actions);
const right = actions.createDiv({ cls: "stashpad-split-actions-right" });
const nestWrap = right.createEl("label", { cls: "stashpad-split-nest" });
@ -661,10 +805,9 @@ export class SplitUI {
nestCb.onchange = () => { this.nest = nestCb.checked; setHelp(); };
const splitCount = this.mode === "preset" ? splitIntoChunks(this.body, this.presetMode).length : 0;
const splitBtn = right.createEl("button", {
cls: "stashpad-split-confirm-btn mod-cta",
text: this.mode === "preset" && splitCount >= 2 ? `Split into ${splitCount}` : "Split",
});
const splitBtn = right.createEl("button", { cls: "stashpad-split-confirm-btn mod-cta" });
setIcon(splitBtn.createSpan({ cls: "stashpad-split-btn-icon" }), "split");
splitBtn.createSpan({ text: this.mode === "preset" && splitCount >= 2 ? `Split into ${splitCount}` : "Split" });
splitBtn.onmousedown = (e) => e.preventDefault(); // don't blur the textarea
splitBtn.onclick = () => this.commit();
}
@ -753,7 +896,7 @@ export class SplitUI {
return btn;
}
private renderCursorMode(): void {
private renderEditorSections(): void {
// 0.168.1/0.168.2: when the text has been edited, a read-only ORIGINAL section
// and a read-only word-level DIFF section appear above the editor. All three are
// consistent collapsible framed sections (whole header = the toggle button).
@ -766,7 +909,7 @@ export class SplitUI {
const changes = this.buildSplitSection(this.host, "changes", "Changes");
const diffBody = changes.body.createDiv({ cls: "stashpad-split-panel-body stashpad-split-diff-body" });
const edit = this.buildSplitSection(this.host, "edit", "Your edit — the split uses this");
const edit = this.buildSplitSection(this.host, "edit", this.surface === "edit" ? "Your edit" : "Your edit — the split uses this");
const ta = edit.body.createEl("textarea", { cls: "stashpad-split-cursor-ta" });
// Seed from the persisted (possibly edited) text so toggling modes doesn't
// discard edits; the split acts on exactly what's shown here.
@ -791,7 +934,9 @@ export class SplitUI {
// Auto-size the textarea to fit content. Cap at 3 lines on mobile,
// 12 lines on desktop. Recomputed on input in case the user edits.
const lineHeight = parseFloat(getComputedStyle(ta).lineHeight) || 22;
const maxLines = Platform.isMobile ? 3 : 12;
// 0.170.3: the Edit surface is a real writing space — let the editor grow much
// taller than in Split mode (where it stays compact next to the split controls).
const maxLines = this.surface === "edit" ? (Platform.isMobile ? 14 : 40) : (Platform.isMobile ? 3 : 12);
const minLines = 2;
const fit = (): void => {
ta.setCssStyles({ height: "auto" });
@ -812,99 +957,156 @@ export class SplitUI {
}
}
/** Callbacks a split host is handed (the host fills in `close`/`onDone`). */
export type SplitCommandCallbacks = Omit<SplitUICallbacks, "close" | "onDone">;
/** Callbacks a split host is handed (the host fills in `close`/`onDone`/`onTitle`). */
export type WorkbenchCommandCallbacks = Omit<WorkbenchCallbacks, "close" | "onDone" | "onTitle">;
export const SPLIT_VIEW_TYPE = "stashpad-split-view";
export const WORKBENCH_VIEW_TYPE = "stashpad-split-view";
/** 0.169.0: thin modal host around SplitUI. */
export class SplitNoteModal extends Modal {
private ui: SplitUI | null = null;
constructor(app: App, private body: string, private cbs: SplitCommandCallbacks) {
/** 0.169.0: thin modal host around NoteWorkbench. */
export class NoteWorkbenchModal extends Modal {
private ui: NoteWorkbench | null = null;
/** 0.170.5: set when the close is intentional (Save/Split done, or pop-out) so the
* unsaved-edits guard is skipped. */
private committing = false;
constructor(app: App, private body: string, private cbs: WorkbenchCommandCallbacks, private init: Partial<WorkbenchState> = {}) {
super(app);
}
onOpen(): void {
this.titleEl.setText("Split note");
this.modalEl.addClass("stashpad-split-modal");
this.ui = new SplitUI(this.app, this.contentEl, this.body, {}, {
this.ui = new NoteWorkbench(this.app, this.contentEl, this.body, this.init, {
onSplitAtLine: this.cbs.onSplitAtLine,
onSplitAtChar: this.cbs.onSplitAtChar,
onSplitMany: this.cbs.onSplitMany,
onSave: this.cbs.onSave,
onOpenExternal: this.cbs.onOpenExternal,
close: () => this.close(),
onDone: () => this.close(), // split ran → just dismiss the modal
// Popping out closes the modal first, then opens the tab with the live state.
popOut: this.cbs.popOut ? (state) => { this.close(); this.cbs.popOut!(state); } : undefined,
onDone: () => { this.committing = true; this.close(); }, // split/save ran → dismiss
onTitle: (t) => this.titleEl.setText(t),
// Popping out closes the modal first, then opens the tab with the live state
// (the edits go with it, so no discard guard).
popOut: this.cbs.popOut ? (state) => { this.committing = true; this.close(); this.cbs.popOut!(state); } : undefined,
});
// Mod+Enter commits in every mode (see 0.168.5). Arrows move the line divider.
this.scope.register(["Mod"], "Enter", (e) => { e.preventDefault(); void this.ui?.commit(); });
this.scope.register([], "ArrowUp", (e) => { if (this.ui?.moveDivider(-1)) e.preventDefault(); });
this.scope.register([], "ArrowDown", (e) => { if (this.ui?.moveDivider(1)) e.preventDefault(); });
// 0.170.3: Mod+E / Mod+S toggle the Edit / Split surface while open.
this.scope.register(["Mod"], "e", (e) => { e.preventDefault(); this.ui?.setSurface("edit"); });
this.scope.register(["Mod"], "s", (e) => { e.preventDefault(); this.ui?.setSurface("split"); });
}
// 0.170.5: dirty guard — Esc / click-outside / × / Cancel all route through close();
// if there are unsaved edits (and this isn't a deliberate Save/Split/pop-out), confirm.
close(): void {
if (this.committing || !this.ui?.isDirty()) { super.close(); return; }
new ConfirmModal(
this.app,
"Discard unsaved edits?",
"You've edited this note but haven't saved. Close and discard your changes?",
"Discard",
(ok) => { if (ok) { this.committing = true; this.close(); } },
"Keep editing",
true,
).open();
}
onClose(): void { this.ui = null; this.contentEl.empty(); }
}
/** Context injected into a popped-out SplitNoteView. `prevLeaf` is the tab to
/** Context injected into a popped-out NoteWorkbenchView. `prevLeaf` is the tab to
* refocus once the split's done. */
export interface SplitViewContext {
export interface WorkbenchViewContext {
body: string;
cbs: SplitCommandCallbacks;
init: Partial<SplitUIState>;
cbs: WorkbenchCommandCallbacks;
init: Partial<WorkbenchState>;
prevLeaf?: WorkspaceLeaf | null;
}
/** 0.169.0: full-leaf host around SplitUI ("pop out"). Ephemeral needs its
/** 0.169.0: full-leaf host around NoteWorkbench ("pop out"). Ephemeral needs its
* context injected via setContext right after creation; a restored-but-context-less
* view shows a "session ended" placeholder. 0.169.3: once the split commits, it
* runs a live countdown and then closes + refocuses the previous tab. */
export class SplitNoteView extends ItemView {
private ui: SplitUI | null = null;
private ctx: SplitViewContext | null = null;
export class NoteWorkbenchView extends ItemView {
private ui: NoteWorkbench | null = null;
private ctx: WorkbenchViewContext | null = null;
private prevLeaf: WorkspaceLeaf | null = null;
private autoCloseTimer: number | null = null;
private expiredGrace: number | null = null;
private title = "Note";
private viewIcon = "pencil-line";
constructor(leaf: WorkspaceLeaf) { super(leaf); }
getViewType(): string { return SPLIT_VIEW_TYPE; }
getDisplayText(): string { return "Split note"; }
getIcon(): string { return "split"; }
getViewType(): string { return WORKBENCH_VIEW_TYPE; }
getDisplayText(): string { return this.title; }
getIcon(): string { return this.viewIcon; }
setContext(ctx: SplitViewContext): void {
private setHeader(title: string, icon: string): void {
this.title = title;
this.viewIcon = icon;
// Undocumented but real: refresh the tab header so the title/icon update live.
(this.leaf as unknown as { updateHeader?: () => void }).updateHeader?.();
}
setContext(ctx: WorkbenchViewContext): void {
if (this.expiredGrace != null) { window.clearTimeout(this.expiredGrace); this.expiredGrace = null; }
this.ctx = ctx;
this.prevLeaf = ctx.prevLeaf ?? null;
const isEdit = ctx.init.surface === "edit";
this.setHeader(isEdit ? "Edit note" : "Split note", isEdit ? "pencil-line" : "split");
this.renderUI();
}
async onOpen(): Promise<void> { this.renderUI(); }
async onOpen(): Promise<void> {
// Fresh pop-outs get their context via setContext within a tick. A RESTORED tab
// (workspace reload) never will — so if no context arrives shortly, it's an
// orphan: show the expired panel and auto-close it. The grace period avoids
// flashing/auto-closing the transient no-context state during a fresh open.
this.contentEl.addClass("stashpad-split-modal", "stashpad-split-view");
if (!this.ctx) {
this.expiredGrace = window.setTimeout(() => { this.expiredGrace = null; if (!this.ctx) this.renderExpired(); }, 800);
}
}
private renderUI(): void {
const c = this.contentEl;
c.empty();
c.addClass("stashpad-split-modal", "stashpad-split-view");
if (!this.ctx) {
c.createDiv({ cls: "stashpad-split-empty", text: "This split session has ended — close this tab and run “Split note” again." });
return;
}
this.ui = new SplitUI(this.app, c, this.ctx.body, this.ctx.init, {
if (!this.ctx) return;
this.ui = new NoteWorkbench(this.app, c, this.ctx.body, this.ctx.init, {
onSplitAtLine: this.ctx.cbs.onSplitAtLine,
onSplitAtChar: this.ctx.cbs.onSplitAtChar,
onSplitMany: this.ctx.cbs.onSplitMany,
close: () => this.leaf.detach(),
onDone: () => this.finishAndClose(),
onSave: this.ctx.cbs.onSave,
onOpenExternal: this.ctx.cbs.onOpenExternal,
close: () => this.guardedDetach(),
onDone: () => this.startClosePanel("✓ Done.", null, true),
onTitle: (t) => this.setHeader(t, t.startsWith("Edit") ? "pencil-line" : "split"),
});
// Mod+Enter commits; arrows move the line divider (only acts in line mode, so
// arrows in the cursor textarea fall through to the textarea).
this.registerDomEvent(c, "keydown", (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { e.preventDefault(); void this.ui?.commit(); }
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key === "Enter") { e.preventDefault(); void this.ui?.commit(); }
else if (mod && (e.key === "e" || e.key === "E")) { e.preventDefault(); this.ui?.setSurface("edit"); }
else if (mod && (e.key === "s" || e.key === "S")) { e.preventDefault(); this.ui?.setSurface("split"); }
else if (e.key === "ArrowUp") { if (this.ui?.moveDivider(-1)) e.preventDefault(); }
else if (e.key === "ArrowDown") { if (this.ui?.moveDivider(1)) e.preventDefault(); }
});
}
/** After the split ran: swap the UI for a "done" panel with a live countdown,
* then auto-close the tab and refocus the previously-active tab. */
private finishAndClose(): void {
/** Orphaned restored tab — can't be revived; auto-close it. */
private renderExpired(): void {
this.setHeader("Expired", "clock");
this.startClosePanel("This session has expired.", "It can't be restored — closing this tab…", false);
}
/** Shared close panel: a title + a live countdown that auto-closes, plus Close
* now / Keep tab open. `refocus` returns to the previous tab (done) vs just
* closing (expired orphan). */
private startClosePanel(titleText: string, subText: string | null, refocus: boolean): void {
const c = this.contentEl;
c.empty();
this.ui = null;
const box = c.createDiv({ cls: "stashpad-split-done" });
box.createDiv({ cls: "stashpad-split-done-title", text: "✓ Split complete." });
box.createDiv({ cls: "stashpad-split-done-title", text: titleText });
if (subText) box.createDiv({ cls: "stashpad-split-countdown", text: subText });
const count = box.createDiv({ cls: "stashpad-split-countdown" });
const btns = box.createDiv({ cls: "stashpad-split-done-btns" });
const closeNow = btns.createEl("button", { cls: "mod-cta", text: "Close now" });
@ -915,18 +1117,26 @@ export class SplitNoteView extends ItemView {
const stop = (): void => {
if (this.autoCloseTimer != null) { window.clearInterval(this.autoCloseTimer); this.autoCloseTimer = null; }
};
const close = (): void => { stop(); if (refocus) this.closeAndRefocus(); else this.leaf.detach(); };
paint();
this.autoCloseTimer = window.setInterval(() => {
n -= 1;
if (n <= 0) { stop(); this.closeAndRefocus(); } else paint();
}, 1000);
closeNow.onclick = () => { stop(); this.closeAndRefocus(); };
keep.onclick = () => {
stop();
count.setText("Done — close this tab whenever you're ready.");
keep.remove();
closeNow.setText("Close tab");
};
this.autoCloseTimer = window.setInterval(() => { n -= 1; if (n <= 0) close(); else paint(); }, 1000);
closeNow.onclick = close;
keep.onclick = () => { stop(); count.setText("Close this tab whenever you're ready."); keep.remove(); closeNow.setText("Close tab"); };
}
/** 0.170.5: Cancel in the tab confirms if there are unsaved edits before detaching.
* (A native tab close / Cmd+W can't be intercepted — that path isn't guarded.) */
private guardedDetach(): void {
if (!this.ui?.isDirty()) { this.leaf.detach(); return; }
new ConfirmModal(
this.app,
"Discard unsaved edits?",
"You've edited this note but haven't saved. Close this tab and discard your changes?",
"Discard",
(ok) => { if (ok) this.leaf.detach(); },
"Keep editing",
true,
).open();
}
private leafStillOpen(leaf: WorkspaceLeaf): boolean {
@ -938,13 +1148,12 @@ export class SplitNoteView extends ItemView {
private closeAndRefocus(): void {
const prev = this.prevLeaf;
this.leaf.detach();
// Return the user to the tab they came from (falls back to Obsidian's default
// adjacent-tab focus if it's since been closed).
if (prev && this.leafStillOpen(prev)) this.app.workspace.setActiveLeaf(prev, { focus: true });
}
async onClose(): Promise<void> {
if (this.autoCloseTimer != null) { window.clearInterval(this.autoCloseTimer); this.autoCloseTimer = null; }
if (this.expiredGrace != null) { window.clearTimeout(this.expiredGrace); this.expiredGrace = null; }
this.ui = null;
this.contentEl.empty();
}

View file

@ -28,6 +28,8 @@ export interface ShortcutMap {
openEditor: string; // E — open in regular Obsidian markdown tab
openTab: string; // T — open in a new Stashpad tab
split: string; // (empty by default) — split selected note into two
edit: string; // E — edit selected note in the in-app editor
editParent: string; // Shift+E — edit the focused parent note in the in-app editor
copyOutline: string; // (empty by default) — copy selection as nested embed outline
}
@ -51,7 +53,7 @@ export interface ModShortcuts {
* descriptions live in COMMAND_META below. */
export type CommandId =
| "move" | "pickMove" | "merge" | "copy" | "copyTree" | "copyLink" | "openEditor" | "openTab"
| "split" | "copyOutline"
| "split" | "edit" | "editParent" | "copyOutline"
| "toggleSplit" | "pickDestination" | "search" | "searchInParent" | "delete" | "undo" | "redo"
| "toggleComplete" | "moveUp" | "moveDown" | "moveToTop" | "moveToBottom"
| "outdent" | "setColor"
@ -101,9 +103,11 @@ export const COMMAND_META: CommandMeta[] = [
{ id: "copy", label: "Copy", desc: "Copy selected note bodies to clipboard.", defaultPrimary: "C" },
{ id: "copyTree", label: "Copy tree", desc: "Copy the focused note + all descendants, indented.", defaultPrimary: "Y" },
{ id: "copyLink", label: "Copy Stashpad link", desc: "Copy an obsidian://stashpad deep link to the cursor row (or first selected note) — click it anywhere to jump back. No default chord.", defaultPrimary: "" },
{ id: "openEditor", label: "Open in editor", desc: "Open the cursor row (or focused note) in a regular Obsidian markdown tab.", defaultPrimary: "E" },
{ id: "openEditor", label: "Open in Obsidian editor", desc: "Open the cursor row (or focused note) in a regular Obsidian markdown tab.", defaultPrimary: "Mod+Shift+E" },
{ id: "openTab", label: "Open in new Stashpad tab", desc: "Open the cursor row (or focused note) in a new Stashpad tab focused on it.", defaultPrimary: "T" },
{ id: "split", label: "Split note", desc: "Split the cursor row (or focused note) into two notes at a chosen line.", defaultPrimary: "S" },
{ id: "edit", label: "Edit note (in-app)", desc: "Edit the cursor row (or focused note) in Stashpad's in-app editor (with a Split toggle) instead of a full Obsidian tab.", defaultPrimary: "E" },
{ id: "editParent", label: "Edit parent note (in-app)", desc: "Edit the focused parent note in Stashpad's in-app editor.", defaultPrimary: "Shift+E" },
{ id: "copyOutline", label: "Copy as outline", desc: "Copy selection (or cursor row) as a nested ![[embed]] outline.", defaultPrimary: "L" },
{ id: "toggleSplit", label: "Toggle split-on-newlines", desc: "Default: Mod+/", defaultPrimary: "Mod+/" },
{ id: "pickDestination", label: "Pick destination", desc: "Default: Mod+D", defaultPrimary: "Mod+D" },
@ -650,7 +654,7 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
slugStopWords: [], // empty → DEFAULT_STOPWORDS used at runtime
searchIncludedFolders: [],
searchExcludedFolders: [],
shortcuts: { move: "M", pickMove: "O", merge: "&", copy: "C", copyTree: "Y", openEditor: "E", openTab: "T", split: "S", copyOutline: "L" },
shortcuts: { move: "M", pickMove: "O", merge: "&", copy: "C", copyTree: "Y", openEditor: "Mod+Shift+E", openTab: "T", split: "S", edit: "E", editParent: "Shift+E", copyOutline: "L" },
mod: {
toggleSplit: "Mod+/", pickDestination: "Mod+D", search: "Mod+F",
delete: "Mod+Backspace", undo: "Mod+Z", redo: "Mod+Shift+Z",

View file

@ -26,7 +26,7 @@ import { buildStashpadLink } from "./deep-link";
import { populateLockedMenu } from "./locked-menu";
import { StashpadCommandPalette } from "./command-palette";
import { setActiveView, clearActiveView } from "./active-view";
import { BreadcrumbLevelsModal, type BreadcrumbLevel, ColorPickerModal, ConfirmDeleteModal, ConfirmModal, DueDatePickerModal, SplitNoteModal } from "./modals";
import { BreadcrumbLevelsModal, type BreadcrumbLevel, ColorPickerModal, ConfirmDeleteModal, ConfirmModal, DueDatePickerModal, NoteWorkbenchModal } from "./modals";
import { ComposerAutocomplete } from "./composer-autocomplete";
import { matchBinding, humanCombo } from "./view-keys";
import { openAggregateView } from "./aggregate-view";
@ -6577,6 +6577,11 @@ 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; }
// editParent (Shift+E) is checked BEFORE edit (E): a plain-letter binding also
// fires on its shifted form (matchKey ignores Shift), so Shift+E must consume
// the event first or "E" would swallow it. (The shifted-key trap.)
if (matchBinding(e, sb.editParent)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdEditParent(); return; }
if (matchBinding(e, sb.edit)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdEdit(); 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; }
@ -10759,16 +10764,63 @@ export class StashpadView extends ItemView {
/** Split the cursor row (or focused/passed) note in two at a chosen line.
* First part keeps the original note's id, file, and children.
* Second part becomes a new sibling with no children. */
async cmdSplit(node?: TreeNode): Promise<void> {
/** 0.170.0: also the entry for the in-app EDIT surface `surface: "edit"` opens
* the same modal on the Edit tab (edits + Save), which can toggle to Split. */
cmdEdit(node?: TreeNode): Promise<void> { return this.cmdSplit(node, "edit"); }
/** 0.170.2: edit the focused parent note in the in-app editor (Shift+E). */
cmdEditParent(): Promise<void> {
const focused = this.tree.get(this.focusId);
if (!focused?.file) { new Notice("No focused parent to edit."); return Promise.resolve(); }
return this.cmdEdit(focused);
}
async cmdSplit(node?: TreeNode, surface: "edit" | "split" = "split"): Promise<void> {
const target = node ?? this.resolveActionTarget();
if (!target?.file) { new Notice("Pick a note to split."); return; }
if (!target?.file) { new Notice(surface === "edit" ? "Pick a note to edit." : "Pick a note to split."); return; }
const file = target.file;
const md = await this.app.vault.read(file);
const body = this.stripFrontmatter(md).replace(/\s+$/, "");
const lines = body.split(/\r?\n/);
if (body.trim().length < 2) { new Notice("Note is too short to split."); return; }
// Split needs ≥2 chars to be meaningful; editing has no such floor.
if (surface === "split" && body.trim().length < 2) { new Notice("Note is too short to split."); return; }
const originalContent = md;
const originalPath = file.path;
// 0.170.0: Edit-surface Save — write the edited body back to the note (frontmatter
// preserved), as one undo entry.
const performEdit = async (newBody: string): Promise<void> => {
const nb = newBody.replace(/\s+$/, "");
if (!nb.trim()) { new Notice("Can't save an empty note."); return; }
const fm = md.startsWith("---") ? md.slice(0, md.indexOf("\n---", 3) + 4) : "";
const newContent = fm + (fm ? "\n" : "") + nb + "\n";
if (newContent === originalContent) return; // no change
await this.app.vault.modify(file, newContent);
// 0.170.2: re-slug the filename to match the new first line (user chose auto-rename).
const renamedTo = await this.reslugFile(file, nb);
const finalPath = renamedTo ?? originalPath;
this.tree.rebuild(this.noteFolder);
this.render();
this.plugin.notifications.show({
message: `Saved "${this.titleForNode(target)}"`, kind: "success", category: "split",
affectedIds: [target.id], folder: this.noteFolder,
});
const folder = this.noteFolder;
this.plugin.getUndoStack(folder).push({
label: "Edit note",
undo: async () => {
let f = this.app.vault.getAbstractFileByPath(finalPath) as TFile | null;
if (f && finalPath !== originalPath) { try { await this.app.fileManager.renameFile(f, originalPath); } catch { /* ignore */ } }
f = this.app.vault.getAbstractFileByPath(originalPath) as TFile | null;
if (f) await this.app.vault.modify(f, originalContent);
this.tree.rebuild(folder); this.render();
},
redo: async () => {
const f = this.app.vault.getAbstractFileByPath(originalPath) as TFile | null;
if (f) { await this.app.vault.modify(f, newContent); if (finalPath !== originalPath) { try { await this.app.fileManager.renameFile(f, finalPath); } catch { /* ignore */ } } }
this.tree.rebuild(folder); this.render();
},
});
};
const performSplit = async (firstBody: string, secondBody: string, payload: Record<string, unknown>, nest = false) => {
if (!firstBody.trim() || !secondBody.trim()) { new Notice("Split would leave one part empty."); return; }
try {
@ -10929,11 +10981,13 @@ export class StashpadView extends ItemView {
await performSplit(firstBody, secondBody, { mode: "cursor", splitAtChar: charIdx, edited: text !== body, nest }, nest);
},
onSplitMany: async (parts: string[], nest: boolean) => { await performMultiSplit(parts, nest); },
onSave: performEdit,
onOpenExternal: () => { void this.openFileAtEnd(file); },
};
new SplitNoteModal(this.app, body, {
new NoteWorkbenchModal(this.app, body, {
...splitCore,
popOut: (state) => { void this.plugin.openSplitView(body, splitCore, state); },
}).open();
popOut: (state) => { void this.plugin.openWorkbench(body, splitCore, state); },
}, { surface }).open();
}
cmdOpenInNewStashpadTab(node?: TreeNode): void {
@ -11473,6 +11527,28 @@ export class StashpadView extends ItemView {
this.slugDebouncers.set(file.path, d);
d();
}
/** 0.170.2: rename `file` so its slug matches `body`'s first line. Returns the new
* path if it renamed, else null. Like maybeRenameForSlug but takes a known body
* (used by the Edit-surface Save so it doesn't wait on the cache). */
private async reslugFile(file: TFile, body: string): Promise<string | null> {
const fmIdRaw = this.app.metadataCache.getFileCache(file)?.frontmatter?.id;
const fmId = typeof fmIdRaw === "string" ? fmIdRaw : null;
const fnId = parseIdFromFilename(file.basename);
const id = fnId ?? (isNoteId(fmId ?? "") ? fmId : null);
if (!id || id === ROOT_ID) return null;
if (fmId !== id) return null;
const desired = buildFilename(bodyToSlug(body, this.activeStopwords()), id);
if (file.name === desired) return null;
const newPath = file.parent ? `${file.parent.path}/${desired}` : desired;
if (this.app.vault.getAbstractFileByPath(newPath)) return null;
const oldPath = file.path;
try {
await this.app.fileManager.renameFile(file, newPath);
await this.log.append({ type: "rename", id, payload: { from: oldPath, to: newPath } });
return newPath;
} catch { return null; }
}
private async maybeRenameForSlug(file: TFile): Promise<void> {
const fmIdRaw = this.app.metadataCache.getFileCache(file)?.frontmatter?.id;
const fmId = typeof fmIdRaw === "string" ? fmIdRaw : null;
@ -11707,6 +11783,7 @@ export class StashpadView extends ItemView {
}));
menu.addItem((it: any) => it.setTitle("Focus in Stashpad").setIcon("arrow-right").onClick(() => this.navigateTo(node.id)));
menu.addSeparator();
menu.addItem((it: any) => it.setTitle("Edit (in-app)…").setIcon("pencil-line").onClick(() => void this.cmdEdit(node)));
menu.addItem((it: any) => it.setTitle("Split note…").setIcon("split").onClick(() => void this.cmdSplit(node)));
// 0.122.2 (#9): copy the note's text. `focusClicked` (defined below)
// normalises selection to the right-clicked row.

View file

@ -2061,6 +2061,61 @@ body.is-phone .modal.stashpad-compact-modal.stashpad-breadcrumb-modal {
.stashpad-split-confirm-btn {
padding: 4px 14px;
}
/* 0.170.0: top-level Edit ⇄ Split surface toggle. */
.stashpad-split-surface {
display: flex;
gap: 4px;
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid var(--background-modifier-border);
}
.stashpad-split-surface-btn {
flex: 1;
padding: 5px 12px;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background: var(--background-secondary);
color: var(--text-muted);
cursor: pointer;
font-size: var(--font-ui-small);
box-shadow: none;
}
.stashpad-split-surface-btn.is-active {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
/* 0.170.3: icons inside the split/edit buttons + the ⌘E/⌘S hints on the toggle. */
.stashpad-split-surface-btn,
.stashpad-split-mode-btn,
.stashpad-split-cancel-btn,
.stashpad-split-confirm-btn,
.stashpad-split-case-btn { display: inline-flex; align-items: center; gap: 6px; }
.stashpad-split-btn-icon { display: inline-flex; flex: 0 0 auto; }
.stashpad-split-btn-icon svg { width: 14px; height: 14px; }
.stashpad-split-kbd-hint { margin-left: auto; padding-left: 6px; opacity: 0.6; font-size: var(--font-ui-smaller); }
/* 0.170.3: edit-surface footer — live word/char count + case-cycle. */
.stashpad-split-edit-tools {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.stashpad-split-count { color: var(--text-faint); font-size: var(--font-ui-smaller); font-variant-numeric: tabular-nums; }
.stashpad-split-case-btn {
margin-left: auto;
padding: 3px 10px;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
background: var(--background-secondary);
color: var(--text-muted);
cursor: pointer;
font-size: var(--font-ui-smaller);
box-shadow: none;
}
.stashpad-split-case-btn:hover { color: var(--text-normal); }
/* 0.170.3: the Edit surface is a writing space — give the editor + review room. */
.stashpad-edit-surface .stashpad-split-panel-body { max-height: calc(1.5em * 8 + 0.8em); }
/* 0.168.3: unified mode row (Line / Cursor / preset methods) + bottom action bar. */
.stashpad-split-modes {
display: flex;
@ -5478,14 +5533,19 @@ body.stashpad-folderpanel-resizing { cursor: row-resize; user-select: none; }
}
.stashpad-folderpanel-heading-title { flex: 1 1 auto; }
.stashpad-folderpanel-heading-row .stashpad-folderpanel-iconbtn { height: 20px; width: 20px; }
/* 0.164.5: the three section-header buttons (Pinned view options, New folder,
Open trash) get a thin border in the user's accent color so they read as
buttons rather than bare glyphs. 0.164.6: ONLY the border carries the accent
the glyph stays the plain muted icon color (and normal hover), so the accent
reads as an outline, not a tint. Scoped to the heading row so note-row icon
buttons are untouched. */
/* 0.164.50.164.6: the three section-header buttons (Pinned view options, New
folder, Open trash) framed with a border. 0.169.6: flipped the GLYPH now carries
the accent color and the border is a neutral grey, so the accent reads as the icon
tint rather than an outline. Scoped to the heading row so note-row icon buttons are
untouched. */
.stashpad-folderpanel-heading-row .stashpad-folderpanel-iconbtn {
border: 1px solid var(--interactive-accent);
border: 1px solid var(--background-modifier-border);
color: var(--interactive-accent);
}
.stashpad-folderpanel-heading-row .stashpad-folderpanel-iconbtn:hover {
color: var(--interactive-accent);
border-color: var(--text-muted);
background: var(--background-modifier-hover);
}
/* 0.98.37: the Folders heading doubles as the folder-switcher button. */
.stashpad-folderpanel-heading-switch { cursor: pointer; border-radius: 4px; padding: 1px 4px; margin: -1px -4px; }