mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.169.4: Split note rework — edit-while-splitting, live diff, redesigned dialog, pop-out tab
This commit is contained in:
parent
282c66830d
commit
4783a88bbc
8 changed files with 803 additions and 227 deletions
164
main.js
164
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "stashpad",
|
||||
"name": "Stashpad",
|
||||
"version": "0.167.1",
|
||||
"version": "0.169.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.167.1",
|
||||
"version": "0.169.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
|
|||
38
release-notes/0.169.4.md
Normal file
38
release-notes/0.169.4.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# 0.169.4
|
||||
|
||||
A big rework of **Split note** — editing while you split, a live diff, a redesigned
|
||||
dialog, and the ability to pop the whole thing out into a full tab.
|
||||
|
||||
## Edit while you split (cursor mode)
|
||||
|
||||
- **The cursor split now acts on the text you see** — if you edit the text box before
|
||||
splitting, the split uses your edited version (previously it split the note's
|
||||
original text, ignoring edits).
|
||||
- **See what changed**: when you edit, a read-only **Original** panel and a **Changes**
|
||||
panel appear above the editor. Changes is a word-level diff — deletions struck
|
||||
through, insertions highlighted — so you can review before committing. The three
|
||||
sections (Original / Changes / Your edit) are consistent collapsible framed panels;
|
||||
on mobile the review panels start collapsed to save space.
|
||||
|
||||
## Redesigned dialog
|
||||
|
||||
- **One row of split methods**: `Line`, `Cursor`, `Each line`, `Blank line(s)`,
|
||||
`Headings`. Every method now **previews below and confirms with one Split button** —
|
||||
the paragraph/line/heading presets no longer fire instantly on click; they show a
|
||||
preview of the resulting parts (Part 1, Part 2, …) and split only when you confirm.
|
||||
- **Bottom action bar**: Cancel (Esc) on the left; a **Nest under original** checkbox +
|
||||
the primary **Split** on the right. Nesting (off by default) makes the new part(s)
|
||||
children of the original instead of siblings, and applies to every split method.
|
||||
- **Copyable previews**: the Original and preset-part previews are selectable, each
|
||||
with a copy-to-clipboard button.
|
||||
- Smaller touches: the split-point cursor uses your accent color; **⌘/Ctrl+Enter** (or
|
||||
the Split button) confirms in every mode; Tab cycles through the controls and wraps
|
||||
around; a disabled preset shows no count.
|
||||
|
||||
## Pop out into a tab
|
||||
|
||||
- **"Open in a tab"** moves the split into a full pane — great for long notes and for
|
||||
mobile, where a dialog is cramped. It continues exactly where the dialog left off
|
||||
(your edits, chosen method, nest setting), with roomier previews.
|
||||
- When you split from the tab, it shows a brief **"Split complete"** with a countdown
|
||||
and closes itself, returning you to the tab you came from.
|
||||
20
src/main.ts
20
src/main.ts
|
|
@ -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 } from "./modals";
|
||||
import { EncryptionPasswordModal, ConfirmModal, ReEncryptReviewModal, OpenDeepLinkModal, SplitNoteView, SPLIT_VIEW_TYPE, type SplitCommandCallbacks, type SplitUIState } from "./modals";
|
||||
import {
|
||||
DEFAULT_SETTINGS, StashpadSettings, StashpadSettingTab, setSettings, SETTINGS_TABS,
|
||||
buildDefaultBindings, COMMAND_META, type CommandBindingMap,
|
||||
|
|
@ -984,6 +984,11 @@ export default class StashpadPlugin extends Plugin {
|
|||
STASHPAD_FOLDER_PANEL_VIEW_TYPE,
|
||||
(leaf: WorkspaceLeaf) => new StashpadFolderPanelView(leaf, this),
|
||||
);
|
||||
// 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),
|
||||
);
|
||||
// Deep links: `obsidian://stashpad?folder=…¬e=<id>&run=reveal[,open]`.
|
||||
// Routes into the Stashpad view, reveals a note, runs a small macro. See
|
||||
// `docs/deep-links-plan.md`. (Obsidian only allows an action under its own
|
||||
|
|
@ -3977,6 +3982,19 @@ export default class StashpadPlugin extends Plugin {
|
|||
* protected `.stash` — original blob untouched (feedback #4 / Option B). */
|
||||
exportLockedSubtree(blobPath: string): Promise<void> { return cmdExportLockedBlob(this, blobPath); }
|
||||
|
||||
/** 0.169.0: "pop out" the Split-note UI into a full tab. Opens a fresh leaf and
|
||||
* 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> {
|
||||
// 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 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 });
|
||||
this.app.workspace.revealLeaf(leaf);
|
||||
}
|
||||
|
||||
/** Does `folder` contain (or sit within, or contain) Stashpad notes? This is the
|
||||
* CONTENT-based discriminator for "lock the notes" vs "bundle arbitrary files" — it
|
||||
* deliberately does NOT consult the key registry, because a folder keeps its key after
|
||||
|
|
|
|||
569
src/modals.ts
569
src/modals.ts
|
|
@ -1,4 +1,4 @@
|
|||
import { App, Modal, Platform, moment, Notice, setIcon, type SecretStorage } from "obsidian";
|
||||
import { App, Modal, ItemView, WorkspaceLeaf, Platform, moment, Notice, setIcon, type SecretStorage } from "obsidian";
|
||||
import { splitIntoChunks, SPLIT_MODE_LABELS, type SplitMode } from "./view-helpers";
|
||||
import { buildTimePickerInto } from "./time-picker";
|
||||
import { siftMatch } from "./types";
|
||||
|
|
@ -423,71 +423,142 @@ export class ConfirmDeleteModal extends Modal {
|
|||
onClose(): void { this.contentEl.empty(); }
|
||||
}
|
||||
|
||||
export class SplitNoteModal extends Modal {
|
||||
/** 0.168.1: word-level diff (LCS over whitespace-delimited tokens) for the split
|
||||
* modal's read-only "Changes" panel. Returns runs of equal / inserted / deleted
|
||||
* text; whitespace is kept as its own tokens so the reconstruction reads naturally
|
||||
* (newlines included, rendered with `white-space: pre-wrap`). */
|
||||
type SplitDiffPart = { t: "eq" | "ins" | "del"; s: string };
|
||||
function splitWordDiff(a: string, b: string): SplitDiffPart[] {
|
||||
const ax = a.split(/(\s+)/);
|
||||
const bx = b.split(/(\s+)/);
|
||||
const n = ax.length, m = bx.length;
|
||||
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
for (let j = m - 1; j >= 0; j--) {
|
||||
dp[i][j] = ax[i] === bx[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
|
||||
}
|
||||
}
|
||||
const out: SplitDiffPart[] = [];
|
||||
const push = (t: SplitDiffPart["t"], s: string): void => {
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.t === t) last.s += s; else out.push({ t, s });
|
||||
};
|
||||
let i = 0, j = 0;
|
||||
while (i < n && j < m) {
|
||||
if (ax[i] === bx[j]) { push("eq", ax[i]); i++; j++; }
|
||||
else if (dp[i + 1][j] >= dp[i][j + 1]) { push("del", ax[i]); i++; }
|
||||
else { push("ins", bx[j]); j++; }
|
||||
}
|
||||
while (i < n) { push("del", ax[i]); i++; }
|
||||
while (j < m) { push("ins", bx[j]); j++; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
mode: "line" | "cursor" | "preset";
|
||||
presetMode: SplitMode;
|
||||
nest: boolean;
|
||||
cursorText: string;
|
||||
lineCursorIdx: number;
|
||||
}
|
||||
|
||||
export interface SplitUICallbacks {
|
||||
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>;
|
||||
/** 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). */
|
||||
onDone: () => void;
|
||||
/** When present, an "Open in a tab" button appears; called with the live state. */
|
||||
popOut?: (state: SplitUIState) => 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 {
|
||||
private lines: string[];
|
||||
/** Line mode: index of the FIRST line of the second part (1..lines.length-1). */
|
||||
private lineCursorIdx: number;
|
||||
private mode: "line" | "cursor" = "line";
|
||||
private mode: "line" | "cursor" | "preset" = "line";
|
||||
private presetMode: SplitMode = "paragraphs";
|
||||
private nest = false;
|
||||
private cursorTextarea: HTMLTextAreaElement | null = null;
|
||||
private cursorText: string;
|
||||
private collapsed: { orig: boolean; changes: boolean; edit: boolean };
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private app: App,
|
||||
private host: HTMLElement,
|
||||
private body: string,
|
||||
private onSplitAtLine: (firstLineOfSecondPart: number) => void,
|
||||
private onSplitAtChar: (charIndex: number) => void,
|
||||
private onSplitMany: (parts: string[]) => void,
|
||||
init: Partial<SplitUIState>,
|
||||
private cb: SplitUICallbacks,
|
||||
) {
|
||||
super(app);
|
||||
this.cursorText = init.cursorText ?? body;
|
||||
this.collapsed = { orig: Platform.isMobile, changes: Platform.isMobile, edit: false };
|
||||
this.lines = body.replace(/\r\n/g, "\n").split("\n");
|
||||
this.lineCursorIdx = Math.max(1, Math.min(this.lines.length - 1, Math.floor(this.lines.length / 2)));
|
||||
// Single-line notes can only be split via cursor mode.
|
||||
if (this.lines.length < 2) this.mode = "cursor";
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.titleEl.setText("Split note");
|
||||
this.modalEl.addClass("stashpad-split-modal");
|
||||
this.lineCursorIdx = init.lineCursorIdx ?? Math.max(1, Math.min(this.lines.length - 1, Math.floor(this.lines.length / 2)));
|
||||
if (init.mode) this.mode = init.mode;
|
||||
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;
|
||||
// 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).
|
||||
this.host.addEventListener("keydown", (e) => this.onTabKey(e));
|
||||
this.render();
|
||||
// Tab toggles modes when both are available.
|
||||
this.scope.register([], "Tab", (e) => {
|
||||
if (this.lines.length < 2) return;
|
||||
e.preventDefault();
|
||||
this.mode = this.mode === "line" ? "cursor" : "line";
|
||||
this.render();
|
||||
});
|
||||
// Enter (no mods): commit in line mode; in cursor mode, let textarea insert a newline.
|
||||
this.scope.register([], "Enter", (e) => {
|
||||
if (this.mode !== "line") return; // pass through to textarea
|
||||
e.preventDefault();
|
||||
this.commitLine();
|
||||
});
|
||||
// Mod+Enter: commit in cursor mode.
|
||||
this.scope.register(["Mod"], "Enter", (e) => {
|
||||
if (this.mode !== "cursor") return;
|
||||
e.preventDefault();
|
||||
this.commitCursor();
|
||||
});
|
||||
// Arrows: in line mode, move the divider. In cursor mode, let textarea handle them.
|
||||
this.scope.register([], "ArrowUp", (e) => {
|
||||
if (this.mode !== "line") return;
|
||||
e.preventDefault();
|
||||
this.lineCursorIdx = Math.max(1, this.lineCursorIdx - 1);
|
||||
this.render();
|
||||
});
|
||||
this.scope.register([], "ArrowDown", (e) => {
|
||||
if (this.mode !== "line") return;
|
||||
e.preventDefault();
|
||||
this.lineCursorIdx = Math.min(this.lines.length - 1, this.lineCursorIdx + 1);
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private commitLine(): void {
|
||||
const idx = this.lineCursorIdx;
|
||||
this.close();
|
||||
this.onSplitAtLine(idx);
|
||||
private visibleFocusables(): HTMLElement[] {
|
||||
const sel = 'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
return Array.from(this.host.querySelectorAll<HTMLElement>(sel)).filter((el) => el.offsetParent !== null);
|
||||
}
|
||||
|
||||
private commitCursor(): void {
|
||||
private onTabKey(e: KeyboardEvent): void {
|
||||
if (e.key !== "Tab") return;
|
||||
const items = this.visibleFocusables();
|
||||
if (items.length === 0) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (e.shiftKey && active === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && active === last) { e.preventDefault(); first.focus(); }
|
||||
}
|
||||
|
||||
/** Snapshot for handoff to the popped-out tab. */
|
||||
getState(): SplitUIState {
|
||||
return {
|
||||
mode: this.mode, presetMode: this.presetMode, nest: this.nest,
|
||||
cursorText: this.cursorTextarea?.value ?? this.cursorText,
|
||||
lineCursorIdx: this.lineCursorIdx,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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.mode === "line") await this.commitLine();
|
||||
else if (this.mode === "cursor") await this.commitCursor();
|
||||
else await this.commitPreset();
|
||||
}
|
||||
|
||||
/** Move the line-mode divider by ±1. Returns true if it applied (line mode). */
|
||||
moveDivider(delta: number): boolean {
|
||||
if (this.mode !== "line") return false;
|
||||
this.lineCursorIdx = Math.max(1, Math.min(this.lines.length - 1, this.lineCursorIdx + delta));
|
||||
this.render();
|
||||
return true;
|
||||
}
|
||||
|
||||
private async commitLine(): Promise<void> {
|
||||
await this.cb.onSplitAtLine(this.lineCursorIdx, this.nest);
|
||||
this.cb.onDone();
|
||||
}
|
||||
|
||||
private async commitCursor(): Promise<void> {
|
||||
const ta = this.cursorTextarea;
|
||||
if (!ta) return;
|
||||
const ch = ta.selectionStart;
|
||||
|
|
@ -495,74 +566,127 @@ export class SplitNoteModal extends Modal {
|
|||
new Notice("Move the cursor inside the text — neither end can be empty.");
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.onSplitAtChar(ch);
|
||||
// 0.168.0: split the CURRENT (possibly edited) textarea content at the cursor.
|
||||
await this.cb.onSplitAtChar(ta.value, ch, this.nest);
|
||||
this.cb.onDone();
|
||||
}
|
||||
|
||||
private async commitPreset(): Promise<void> {
|
||||
const chunks = splitIntoChunks(this.body, this.presetMode);
|
||||
if (chunks.length < 2) { new Notice("That delimiter wouldn't split this note."); return; }
|
||||
await this.cb.onSplitMany(chunks, this.nest);
|
||||
this.cb.onDone();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
this.contentEl.empty();
|
||||
this.host.empty();
|
||||
|
||||
// Quick multi-split: break the whole note into many parts at once, instead
|
||||
// of placing a single divider. One button per delimiter; disabled when it
|
||||
// wouldn't yield 2+ parts. The count preview shows how many you'd get.
|
||||
const quick = this.contentEl.createDiv({ cls: "stashpad-split-quick" });
|
||||
quick.createSpan({ cls: "stashpad-split-quick-label", text: "Split by:" });
|
||||
(["lines", "paragraphs", "headings"] as SplitMode[]).forEach((m) => {
|
||||
const parts = splitIntoChunks(this.body, m);
|
||||
const btn = quick.createEl("button", {
|
||||
cls: "stashpad-split-quick-btn",
|
||||
text: `${SPLIT_MODE_LABELS[m]} (${parts.length})`,
|
||||
});
|
||||
btn.disabled = parts.length < 2;
|
||||
btn.onmousedown = (e) => e.preventDefault();
|
||||
btn.onclick = () => {
|
||||
const chunks = splitIntoChunks(this.body, m);
|
||||
if (chunks.length < 2) { new Notice("That delimiter wouldn't split this note."); return; }
|
||||
this.close();
|
||||
this.onSplitMany(chunks);
|
||||
};
|
||||
});
|
||||
|
||||
// Top bar: mode toggle on the left, Confirm button on the right.
|
||||
// The confirm button is essential on mobile where Enter is hijacked
|
||||
// by the textarea (cursor mode) or doesn't have a physical key
|
||||
// (some on-screen keyboards send "Done" instead).
|
||||
const bar = this.contentEl.createDiv({ cls: "stashpad-split-toggle-bar" });
|
||||
if (this.lines.length >= 2) {
|
||||
const lineBtn = bar.createEl("button", { text: "Line split", cls: "stashpad-split-mode-btn" });
|
||||
if (this.mode === "line") lineBtn.addClass("is-active");
|
||||
lineBtn.onclick = () => { this.mode = "line"; this.render(); };
|
||||
}
|
||||
const curBtn = bar.createEl("button", { text: "Cursor split", cls: "stashpad-split-mode-btn" });
|
||||
if (this.mode === "cursor") curBtn.addClass("is-active");
|
||||
curBtn.onclick = () => { this.mode = "cursor"; this.render(); };
|
||||
|
||||
const confirmBtn = bar.createEl("button", {
|
||||
text: "Split", cls: "stashpad-split-confirm-btn mod-cta",
|
||||
});
|
||||
confirmBtn.onmousedown = (e) => e.preventDefault(); // don't blur the textarea
|
||||
confirmBtn.onclick = () => {
|
||||
if (this.mode === "line") this.commitLine();
|
||||
else this.commitCursor();
|
||||
// 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
|
||||
// and PREVIEWS below; the one bottom Split button commits — no more instant,
|
||||
// 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 });
|
||||
b.toggleClass("is-active", active);
|
||||
b.disabled = disabled;
|
||||
b.onmousedown = (e) => e.preventDefault();
|
||||
if (!disabled) b.onclick = onPick;
|
||||
};
|
||||
|
||||
if (this.mode === "line") this.renderLineMode();
|
||||
else this.renderCursorMode();
|
||||
|
||||
const help = this.contentEl.createDiv({ cls: "stashpad-split-help" });
|
||||
if (Platform.isMobile) {
|
||||
help.setText(this.mode === "line"
|
||||
? "Tap a line to position the divider, then Split."
|
||||
: "Tap inside the text to position the cursor, then Split.");
|
||||
} else {
|
||||
help.setText(this.mode === "line"
|
||||
? "↑/↓ pick split line · Enter or Split confirm · Tab → cursor mode · Esc cancel · Children stay with the first part"
|
||||
: "Click or arrow to position cursor · Mod+Enter or Split confirm · Tab → line mode · Esc cancel · Children stay with the first part");
|
||||
if (this.lines.length >= 2) {
|
||||
modeBtn("Line", this.mode === "line", () => { this.mode = "line"; this.render(); });
|
||||
}
|
||||
modeBtn("Cursor", this.mode === "cursor", () => { this.mode = "cursor"; this.render(); });
|
||||
(["lines", "paragraphs", "headings"] as SplitMode[]).forEach((m) => {
|
||||
const n = splitIntoChunks(this.body, m).length;
|
||||
const disabled = n < 2;
|
||||
// 0.168.4: shorter label for the paragraph split in this modal only (the
|
||||
// shared SPLIT_MODE_LABELS is still used by menus/settings).
|
||||
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);
|
||||
});
|
||||
|
||||
// Preview for the active mode.
|
||||
if (this.mode === "line") this.renderLineMode();
|
||||
else if (this.mode === "cursor") this.renderCursorMode();
|
||||
else this.renderPresetMode();
|
||||
|
||||
const help = this.host.createDiv({ cls: "stashpad-split-help" });
|
||||
const setHelp = (): void => {
|
||||
// 0.168.4: hidden on PHONE (cramped, no keyboard); shown on tablet/iPad +
|
||||
// desktop (someone may pair a keyboard). Esc + Tab hints dropped (Esc has the
|
||||
// false-button by the ×; Tab now just cycles focus). Nest phrased by result.
|
||||
if (Platform.isPhone) { help.setCssStyles({ display: "none" }); return; }
|
||||
help.setCssStyles({ display: "" });
|
||||
const nest1 = this.nest ? "New part nested under the original." : "New part added as a sibling.";
|
||||
const nestN = this.nest ? "New parts nested under the original." : "New parts added as siblings.";
|
||||
const confirm = "⌘/Ctrl+Enter or Split to confirm";
|
||||
help.setText(
|
||||
this.mode === "line" ? `↑/↓ or click to pick the split line · ${confirm} · ${nest1}`
|
||||
: this.mode === "cursor" ? `Click or arrow to position the cursor · ${confirm} · ${nest1}`
|
||||
: `Preview of the resulting parts · ${confirm} · ${nestN}`);
|
||||
};
|
||||
setHelp();
|
||||
|
||||
// 0.168.3: bottom action bar — Cancel on the left; Nest checkbox + primary
|
||||
// Split on the right. One confirm for every mode.
|
||||
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)" });
|
||||
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());
|
||||
}
|
||||
|
||||
const right = actions.createDiv({ cls: "stashpad-split-actions-right" });
|
||||
const nestWrap = right.createEl("label", { cls: "stashpad-split-nest" });
|
||||
const nestCb = nestWrap.createEl("input", { type: "checkbox" });
|
||||
nestCb.checked = this.nest;
|
||||
nestWrap.createSpan({ text: "Nest under original" });
|
||||
// Update just the help line — a full re-render would drop the cursor caret.
|
||||
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",
|
||||
});
|
||||
splitBtn.onmousedown = (e) => e.preventDefault(); // don't blur the textarea
|
||||
splitBtn.onclick = () => this.commit();
|
||||
}
|
||||
|
||||
private renderPresetMode(): void {
|
||||
const chunks = splitIntoChunks(this.body, this.presetMode);
|
||||
const list = this.host.createDiv({ cls: "stashpad-split-preset-list" });
|
||||
if (chunks.length < 2) {
|
||||
list.createDiv({ cls: "stashpad-split-empty", text: "This delimiter wouldn't split the note into more than one part." });
|
||||
return;
|
||||
}
|
||||
chunks.forEach((c, i) => {
|
||||
const card = list.createDiv({ cls: "stashpad-split-part-card" });
|
||||
const head = card.createDiv({ cls: "stashpad-split-part-num" });
|
||||
head.createSpan({ cls: "stashpad-split-part-title", text: `Part ${i + 1}` });
|
||||
head.appendChild(this.makeCopyButton(() => c, `Copy part ${i + 1}`));
|
||||
card.createDiv({ cls: "stashpad-split-part-body", text: c });
|
||||
});
|
||||
}
|
||||
|
||||
private renderLineMode(): void {
|
||||
const list = this.contentEl.createDiv({ cls: "stashpad-split-list" });
|
||||
const list = this.host.createDiv({ cls: "stashpad-split-list" });
|
||||
let divider: HTMLElement | null = null;
|
||||
for (let i = 0; i < this.lines.length; i++) {
|
||||
if (i === this.lineCursorIdx) {
|
||||
|
|
@ -588,12 +712,82 @@ export class SplitNoteModal extends Modal {
|
|||
if (divider) window.requestAnimationFrame(() => divider.scrollIntoView({ block: "center" }));
|
||||
}
|
||||
|
||||
/** 0.168.2: a framed section with a tucked header that is itself the
|
||||
* expand/collapse button (whole header tappable). Returns the section + its
|
||||
* body host. Collapsed state is read/written on `this.collapsed[key]` so it
|
||||
* persists across re-renders. */
|
||||
private buildSplitSection(host: HTMLElement, key: "orig" | "changes" | "edit", label: string): { section: HTMLElement; body: HTMLElement } {
|
||||
const section = host.createDiv({ cls: `stashpad-split-section stashpad-split-${key}` });
|
||||
const header = section.createEl("button", { cls: "stashpad-split-section-header" });
|
||||
header.setAttr("type", "button");
|
||||
const chev = header.createSpan({ cls: "stashpad-split-chevron" });
|
||||
setIcon(chev, "chevron-down");
|
||||
header.createSpan({ cls: "stashpad-split-section-title", text: label });
|
||||
const body = section.createDiv({ cls: "stashpad-split-section-body" });
|
||||
const apply = (): void => {
|
||||
section.toggleClass("is-collapsed", this.collapsed[key]);
|
||||
header.setAttr("aria-expanded", String(!this.collapsed[key]));
|
||||
};
|
||||
header.onmousedown = (e) => e.preventDefault(); // don't blur the textarea
|
||||
header.onclick = (e) => { e.preventDefault(); this.collapsed[key] = !this.collapsed[key]; apply(); };
|
||||
apply();
|
||||
return { section, body };
|
||||
}
|
||||
|
||||
/** 0.169.1: a copy-to-clipboard icon button. `onmousedown`/`stopPropagation` keep
|
||||
* it from toggling a collapse header or blurring the textarea. */
|
||||
private makeCopyButton(getText: () => string, label: string, cls = "stashpad-split-copy-btn"): HTMLButtonElement {
|
||||
const btn = createEl("button", { cls });
|
||||
setIcon(btn, "copy");
|
||||
btn.setAttr("aria-label", label);
|
||||
btn.onmousedown = (e) => e.preventDefault();
|
||||
btn.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const text = getText();
|
||||
void navigator.clipboard?.writeText(text).then(
|
||||
() => new Notice("Copied to clipboard."),
|
||||
() => new Notice("Couldn't access the clipboard."),
|
||||
);
|
||||
};
|
||||
return btn;
|
||||
}
|
||||
|
||||
private renderCursorMode(): void {
|
||||
const wrap = this.contentEl.createDiv({ cls: "stashpad-split-cursor-wrap" });
|
||||
const ta = wrap.createEl("textarea", { cls: "stashpad-split-cursor-ta" });
|
||||
ta.value = this.body;
|
||||
// 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).
|
||||
const orig = this.buildSplitSection(this.host, "orig", "Original — will be replaced");
|
||||
orig.body.createDiv({ cls: "stashpad-split-panel-body", text: this.body });
|
||||
// 0.169.1: copy the original text (the header is a full-width collapse button,
|
||||
// so the copy button overlays its right edge as a section sibling).
|
||||
orig.section.appendChild(this.makeCopyButton(() => this.body, "Copy the original text", "stashpad-split-copy-btn stashpad-split-section-copy"));
|
||||
|
||||
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 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.
|
||||
ta.value = this.cursorText;
|
||||
ta.readOnly = false;
|
||||
this.cursorTextarea = ta;
|
||||
|
||||
const renderDiff = (): void => {
|
||||
diffBody.empty();
|
||||
for (const part of splitWordDiff(this.body, ta.value)) {
|
||||
const cls = part.t === "ins" ? "stashpad-diff-ins" : part.t === "del" ? "stashpad-diff-del" : "stashpad-diff-eq";
|
||||
diffBody.createSpan({ cls, text: part.s });
|
||||
}
|
||||
};
|
||||
const syncEdited = (): void => {
|
||||
const edited = ta.value !== this.body;
|
||||
orig.section.setCssStyles({ display: edited ? "" : "none" });
|
||||
changes.section.setCssStyles({ display: edited ? "" : "none" });
|
||||
if (edited) renderDiff();
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
|
@ -604,17 +798,154 @@ export class SplitNoteModal extends Modal {
|
|||
const needed = Math.min(ta.scrollHeight, lineHeight * maxLines + 16);
|
||||
ta.setCssStyles({ height: `${Math.max(needed, lineHeight * minLines + 16)}px` });
|
||||
};
|
||||
// Set the initial edited-state synchronously (doesn't need layout) so the
|
||||
// panels are correctly hidden on a fresh/unedited cursor render — the rAF-only
|
||||
// path didn't reliably stick. fit() still runs in the rAF (needs layout).
|
||||
syncEdited();
|
||||
requestAnimationFrame(() => {
|
||||
fit();
|
||||
const mid = Math.floor(ta.value.length / 2);
|
||||
ta.focus();
|
||||
ta.setSelectionRange(mid, mid);
|
||||
});
|
||||
ta.addEventListener("input", fit);
|
||||
ta.addEventListener("input", () => { this.cursorText = ta.value; fit(); syncEdited(); });
|
||||
}
|
||||
}
|
||||
|
||||
/** Callbacks a split host is handed (the host fills in `close`/`onDone`). */
|
||||
export type SplitCommandCallbacks = Omit<SplitUICallbacks, "close" | "onDone">;
|
||||
|
||||
export const SPLIT_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) {
|
||||
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, {}, {
|
||||
onSplitAtLine: this.cbs.onSplitAtLine,
|
||||
onSplitAtChar: this.cbs.onSplitAtChar,
|
||||
onSplitMany: this.cbs.onSplitMany,
|
||||
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,
|
||||
});
|
||||
// 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(); });
|
||||
}
|
||||
onClose(): void { this.ui = null; this.contentEl.empty(); }
|
||||
}
|
||||
|
||||
/** Context injected into a popped-out SplitNoteView. `prevLeaf` is the tab to
|
||||
* refocus once the split's done. */
|
||||
export interface SplitViewContext {
|
||||
body: string;
|
||||
cbs: SplitCommandCallbacks;
|
||||
init: Partial<SplitUIState>;
|
||||
prevLeaf?: WorkspaceLeaf | null;
|
||||
}
|
||||
|
||||
/** 0.169.0: full-leaf host around SplitUI ("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;
|
||||
private prevLeaf: WorkspaceLeaf | null = null;
|
||||
private autoCloseTimer: number | null = null;
|
||||
constructor(leaf: WorkspaceLeaf) { super(leaf); }
|
||||
getViewType(): string { return SPLIT_VIEW_TYPE; }
|
||||
getDisplayText(): string { return "Split note"; }
|
||||
getIcon(): string { return "split"; }
|
||||
|
||||
setContext(ctx: SplitViewContext): void {
|
||||
this.ctx = ctx;
|
||||
this.prevLeaf = ctx.prevLeaf ?? null;
|
||||
this.renderUI();
|
||||
}
|
||||
async onOpen(): Promise<void> { this.renderUI(); }
|
||||
|
||||
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, {
|
||||
onSplitAtLine: this.ctx.cbs.onSplitAtLine,
|
||||
onSplitAtChar: this.ctx.cbs.onSplitAtChar,
|
||||
onSplitMany: this.ctx.cbs.onSplitMany,
|
||||
close: () => this.leaf.detach(),
|
||||
onDone: () => this.finishAndClose(),
|
||||
});
|
||||
// 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(); }
|
||||
else if (e.key === "ArrowUp") { if (this.ui?.moveDivider(-1)) e.preventDefault(); }
|
||||
else if (e.key === "ArrowDown") { if (this.ui?.moveDivider(1)) e.preventDefault(); }
|
||||
});
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.cursorTextarea = null;
|
||||
/** 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 {
|
||||
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." });
|
||||
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" });
|
||||
const keep = btns.createEl("button", { text: "Keep tab open" });
|
||||
|
||||
let n = 4;
|
||||
const paint = (): void => count.setText(`Closing this tab in ${n}…`);
|
||||
const stop = (): void => {
|
||||
if (this.autoCloseTimer != null) { window.clearInterval(this.autoCloseTimer); this.autoCloseTimer = null; }
|
||||
};
|
||||
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");
|
||||
};
|
||||
}
|
||||
|
||||
private leafStillOpen(leaf: WorkspaceLeaf): boolean {
|
||||
let ok = false;
|
||||
this.app.workspace.iterateAllLeaves((l) => { if (l === leaf) ok = true; });
|
||||
return ok;
|
||||
}
|
||||
|
||||
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; }
|
||||
this.ui = null;
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
37
src/view.ts
37
src/view.ts
|
|
@ -10769,13 +10769,14 @@ export class StashpadView extends ItemView {
|
|||
if (body.trim().length < 2) { new Notice("Note is too short to split."); return; }
|
||||
const originalContent = md;
|
||||
const originalPath = file.path;
|
||||
const performSplit = async (firstBody: string, secondBody: string, payload: Record<string, unknown>) => {
|
||||
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 {
|
||||
const fm = md.startsWith("---") ? md.slice(0, md.indexOf("\n---", 3) + 4) : "";
|
||||
const newOriginal = fm + (fm ? "\n" : "") + firstBody + "\n";
|
||||
await this.app.vault.modify(file, newOriginal);
|
||||
const parentId = target.parent ?? ROOT_ID;
|
||||
// 0.168.3: nest → the new part becomes a CHILD of the original; otherwise a sibling.
|
||||
const parentId = nest ? target.id : (target.parent ?? ROOT_ID);
|
||||
// Don't record the createNoteUnder action — the split itself
|
||||
// becomes one combined undo entry. Inherit the source note's
|
||||
// `created` time PLUS 1 ms so the second half sorts immediately
|
||||
|
|
@ -10849,7 +10850,7 @@ export class StashpadView extends ItemView {
|
|||
// Multi-split: the original keeps part 1; parts 2..N become new siblings
|
||||
// (in order, via incrementing createdOverride). One bulk-render window + one
|
||||
// grouped undo, same as the composer batch.
|
||||
const performMultiSplit = async (parts: string[]): Promise<void> => {
|
||||
const performMultiSplit = async (parts: string[], nest = false): Promise<void> => {
|
||||
if (parts.length < 2) return;
|
||||
try {
|
||||
const fm = md.startsWith("---") ? md.slice(0, md.indexOf("\n---", 3) + 4) : "";
|
||||
|
|
@ -10857,7 +10858,8 @@ export class StashpadView extends ItemView {
|
|||
if (!firstBody.trim()) { new Notice("Split would leave the first part empty."); return; }
|
||||
const newOriginal = fm + (fm ? "\n" : "") + firstBody + "\n";
|
||||
await this.app.vault.modify(file, newOriginal);
|
||||
const parentId = target.parent ?? ROOT_ID;
|
||||
// 0.168.3: nest → parts 2..N become CHILDREN of the original; else siblings.
|
||||
const parentId = nest ? target.id : (target.parent ?? ROOT_ID);
|
||||
const baseTime = Date.parse(target.created || "");
|
||||
const base = Number.isFinite(baseTime) ? baseTime : Date.now();
|
||||
const collected: Array<{ path: string; content: string }> = [];
|
||||
|
|
@ -10912,21 +10914,26 @@ export class StashpadView extends ItemView {
|
|||
}
|
||||
};
|
||||
|
||||
new SplitNoteModal(
|
||||
this.app,
|
||||
body,
|
||||
async (lineIdx) => {
|
||||
// 0.169.0: the split handlers, shared by the modal AND the popped-out tab.
|
||||
const splitCore = {
|
||||
onSplitAtLine: async (lineIdx: number, nest: boolean) => {
|
||||
const firstBody = lines.slice(0, lineIdx).join("\n").replace(/\s+$/, "");
|
||||
const secondBody = lines.slice(lineIdx).join("\n").replace(/^\s+|\s+$/g, "");
|
||||
await performSplit(firstBody, secondBody, { mode: "line", splitAtLine: lineIdx });
|
||||
await performSplit(firstBody, secondBody, { mode: "line", splitAtLine: lineIdx, nest }, nest);
|
||||
},
|
||||
async (charIdx) => {
|
||||
const firstBody = body.slice(0, charIdx).replace(/\s+$/, "");
|
||||
const secondBody = body.slice(charIdx).replace(/^\s+|\s+$/g, "");
|
||||
await performSplit(firstBody, secondBody, { mode: "cursor", splitAtChar: charIdx });
|
||||
onSplitAtChar: async (text: string, charIdx: number, nest: boolean) => {
|
||||
// 0.168.0: split the (possibly edited) text from the modal, not the
|
||||
// original body — so edits made in the cursor textarea are honored.
|
||||
const firstBody = text.slice(0, charIdx).replace(/\s+$/, "");
|
||||
const secondBody = text.slice(charIdx).replace(/^\s+|\s+$/g, "");
|
||||
await performSplit(firstBody, secondBody, { mode: "cursor", splitAtChar: charIdx, edited: text !== body, nest }, nest);
|
||||
},
|
||||
async (parts) => { await performMultiSplit(parts); },
|
||||
).open();
|
||||
onSplitMany: async (parts: string[], nest: boolean) => { await performMultiSplit(parts, nest); },
|
||||
};
|
||||
new SplitNoteModal(this.app, body, {
|
||||
...splitCore,
|
||||
popOut: (state) => { void this.plugin.openSplitView(body, splitCore, state); },
|
||||
}).open();
|
||||
}
|
||||
|
||||
cmdOpenInNewStashpadTab(node?: TreeNode): void {
|
||||
|
|
|
|||
198
styles.css
198
styles.css
|
|
@ -1954,16 +1954,44 @@ body.is-phone .modal.stashpad-compact-modal.stashpad-breadcrumb-modal {
|
|||
color: var(--text-on-accent);
|
||||
border-color: var(--interactive-accent);
|
||||
}
|
||||
.stashpad-split-cursor-wrap {
|
||||
/* 0.168.2: cursor-split — Original / Changes / Your-edit are three consistent
|
||||
framed, COLLAPSIBLE sections. The whole tucked header is the toggle button; the
|
||||
body connects seamlessly below it (one rounded frame). */
|
||||
.stashpad-split-section {
|
||||
margin-top: 8px;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stashpad-split-cursor-ta {
|
||||
.stashpad-split-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
/* Height is set inline by the modal's auto-fit code (capped at
|
||||
~3 lines on mobile, ~12 on desktop). Don't enforce a min-height
|
||||
in CSS — let the JS sizing govern. */
|
||||
padding: 5px 10px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-muted);
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
.stashpad-split-section-header:hover { color: var(--text-normal); background: var(--background-modifier-hover); }
|
||||
.stashpad-split-section-title { flex: 1; }
|
||||
.stashpad-split-chevron { display: inline-flex; color: var(--text-faint); transition: transform 120ms ease; }
|
||||
.stashpad-split-chevron svg { width: 14px; height: 14px; }
|
||||
.stashpad-split-section.is-collapsed .stashpad-split-chevron { transform: rotate(-90deg); }
|
||||
.stashpad-split-section.is-collapsed .stashpad-split-section-header { border-bottom: 0; }
|
||||
.stashpad-split-section.is-collapsed .stashpad-split-section-body { display: none; }
|
||||
/* Editor textarea fills its section body; the section provides the frame. */
|
||||
.stashpad-split-cursor-ta {
|
||||
display: block;
|
||||
width: 100%;
|
||||
/* Height is set inline by the modal's auto-fit code (capped at ~3 lines on
|
||||
mobile, ~12 on desktop). Let the JS sizing govern. */
|
||||
padding: 10px 12px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
|
|
@ -1975,12 +2003,166 @@ body.is-phone .modal.stashpad-compact-modal.stashpad-breadcrumb-modal {
|
|||
line-height: 1.5;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.stashpad-split-panel-body {
|
||||
/* 0.168.5: fully font-relative so the box is a precise ~2 text lines at any font
|
||||
size (line-height 1.5 → each line = 1.5em; padding in em too). Scrolls for more;
|
||||
long text gets room via the pop-out tab. */
|
||||
line-height: 1.5;
|
||||
padding: 0.4em 10px;
|
||||
max-height: calc(1.5em * 2 + 0.8em);
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stashpad-split-orig .stashpad-split-panel-body { color: var(--text-faint); }
|
||||
/* 0.169.1: read-only previews are selectable/copyable. */
|
||||
.stashpad-split-panel-body,
|
||||
.stashpad-split-part-body {
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
/* 0.168.5→0.169.1: Cancel reads "Cancel (Esc)" in the normal text color (no chip). */
|
||||
.stashpad-split-cancel-btn { color: var(--text-normal); }
|
||||
/* 0.169.1: copy-to-clipboard icon buttons (part headers + Original section). */
|
||||
.stashpad-split-copy-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2px;
|
||||
height: 22px;
|
||||
width: 22px;
|
||||
color: var(--text-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stashpad-split-copy-btn:hover { color: var(--text-normal); background: var(--background-modifier-hover); }
|
||||
.stashpad-split-copy-btn svg { width: 14px; height: 14px; }
|
||||
.stashpad-split-section { position: relative; }
|
||||
.stashpad-split-section-copy { position: absolute; top: 3px; right: 6px; z-index: 2; }
|
||||
.stashpad-diff-eq { color: var(--text-muted); }
|
||||
.stashpad-diff-ins {
|
||||
color: var(--text-success, var(--color-green, #3fb950));
|
||||
background: rgba(63, 185, 80, 0.13);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.stashpad-diff-del {
|
||||
color: var(--text-error, var(--color-red, #f85149));
|
||||
background: rgba(248, 81, 73, 0.13);
|
||||
text-decoration: line-through;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.stashpad-split-confirm-btn {
|
||||
/* Push to the right edge of the toggle bar so it reads as a clear
|
||||
primary action. */
|
||||
margin-left: auto;
|
||||
padding: 4px 14px;
|
||||
}
|
||||
/* 0.168.3: unified mode row (Line / Cursor / preset methods) + bottom action bar. */
|
||||
.stashpad-split-modes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.stashpad-split-mode-btn:disabled { opacity: 0.45; cursor: default; }
|
||||
/* Split point caret uses the theme accent so it's easy to spot. */
|
||||
.stashpad-split-cursor-ta { caret-color: var(--interactive-accent); }
|
||||
.stashpad-split-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.stashpad-split-actions-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.stashpad-split-nest {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.stashpad-split-nest input { cursor: pointer; }
|
||||
/* Preset preview — read-only cards, one per resulting part. */
|
||||
.stashpad-split-preset-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.stashpad-split-part-card {
|
||||
/* 0.169.2: don't let the column-flex list squish cards when it overflows — the
|
||||
list should SCROLL, with each card keeping its content height. */
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stashpad-split-part-num {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 6px 2px 10px;
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
background: var(--background-secondary);
|
||||
border-bottom: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.stashpad-split-part-title { flex: 1; }
|
||||
.stashpad-split-part-body {
|
||||
padding: 8px 10px;
|
||||
max-height: calc(1.5em * 6 + 16px);
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font-family: var(--font-monospace);
|
||||
font-size: var(--font-ui-smaller);
|
||||
line-height: 1.5;
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.stashpad-split-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-faint);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
/* 0.169.0: "Open in a tab" button + the full-leaf ("popped out") layout. */
|
||||
.stashpad-split-popout-btn { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.stashpad-split-popout-icon { display: inline-flex; }
|
||||
.stashpad-split-popout-icon svg { width: 14px; height: 14px; }
|
||||
.stashpad-split-view {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
/* In a full tab there's room — let the read-only previews and the preset list
|
||||
breathe (they stay short/scrolly inside the cramped modal). */
|
||||
.stashpad-split-view .stashpad-split-panel-body { max-height: calc(1.5em * 10 + 0.8em); }
|
||||
.stashpad-split-view .stashpad-split-preset-list { max-height: 62vh; }
|
||||
/* 0.169.3: post-split "done" panel with the live auto-close countdown. */
|
||||
.stashpad-split-done {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 48px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.stashpad-split-done-title { font-size: var(--font-ui-large); color: var(--text-success, var(--color-green)); }
|
||||
.stashpad-split-countdown { color: var(--text-muted); font-variant-numeric: tabular-nums; }
|
||||
.stashpad-split-done-btns { display: flex; gap: 8px; margin-top: 4px; }
|
||||
/* Mobile: tighter modal padding, smaller line list. */
|
||||
.is-mobile .stashpad-split-modal { width: min(420px, 96vw); }
|
||||
.is-mobile .stashpad-split-list { max-height: 40vh; font-size: var(--font-ui-small); }
|
||||
|
|
|
|||
Loading…
Reference in a new issue