Compare commits

..

4 commits

Author SHA1 Message Date
Quorafind
16fcf917a6 chore: update for version.json 2026-03-23 16:39:37 +08:00
Quorafind
fd4bf87a1f chore: update version spec 2026-03-19 09:39:16 +08:00
Quorafind
1e1ee35f9d chore: bump version to 4.3.0 2026-03-18 16:48:35 +08:00
Quorafind
e3201d9ed6 feat(cmdk): add content search and preview scroll-to-position
Add Phase 3 progressive content search to CMDK modal using
prepareSimpleSearch API for substring matching (CJK-friendly).
Uses adaptive yielding (5ms threshold) and offset-based line
computation to avoid costly line splitting.

Also fix preview scroll: pass full result to showPreview so
heading and content results navigate to correct position via
setViewState + setEphemeralState.
2026-03-18 16:47:57 +08:00
5 changed files with 320 additions and 14 deletions

View file

@ -1,10 +1,10 @@
{
"id": "float-search",
"name": "Floating Search",
"version": "4.2.0",
"minAppVersion": "0.15.0",
"version": "4.3.0",
"minAppVersion": "1.2.0",
"description": "You can use search view in modal/leaf/popout window now.",
"author": "Boninall",
"authorUrl": "https://github.com/Quorafind",
"isDesktopOnly": false
}
}

View file

@ -1,6 +1,6 @@
{
"name": "float-search",
"version": "4.2.0",
"version": "4.3.0",
"description": "You can use search view in modal/leaf/popout window now.",
"main": "main.js",
"scripts": {

View file

@ -12,6 +12,7 @@ import {
PaneType,
Plugin,
prepareFuzzySearch,
prepareSimpleSearch,
renderResults,
SearchResult,
requireApiVersion,
@ -67,6 +68,9 @@ interface FloatSearchSettings {
defaultViewType: searchType;
cmdkTriggerKey: CmdkTriggerKey;
cmdkDoubleTapInterval: number;
cmdkQuickCreate: boolean;
cmdkQuickCreateFolder: string;
cmdkQuickCreateTitleFormat: string;
}
const DEFAULT_SETTINGS: FloatSearchSettings = {
@ -83,6 +87,9 @@ const DEFAULT_SETTINGS: FloatSearchSettings = {
defaultViewType: "modal",
cmdkTriggerKey: "Shift",
cmdkDoubleTapInterval: 300,
cmdkQuickCreate: false,
cmdkQuickCreateFolder: "",
cmdkQuickCreateTitleFormat: "YYYYMMDDHHmmss",
};
const allViews: viewType[] = [
@ -1377,6 +1384,54 @@ class FloatSearchSettingTab extends PluginSettingTab {
await this.plugin.saveSettings();
});
});
containerEl.createEl("h3", { text: "Quick Create" });
new Setting(containerEl)
.setName("Enable quick create")
.setDesc(
"When no exact match is found in quick search, show an option to create a new note with the search text as content."
)
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.cmdkQuickCreate)
.onChange(async (value) => {
this.plugin.settings.cmdkQuickCreate = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Quick create folder")
.setDesc(
"Folder to create new notes in. Leave empty for vault root."
)
.addText((text) => {
text.setPlaceholder("e.g. Inbox")
.setValue(this.plugin.settings.cmdkQuickCreateFolder)
.onChange(async (value) => {
this.plugin.settings.cmdkQuickCreateFolder =
value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Title format")
.setDesc(
"Timestamp format for the note title. Tokens: YYYY, MM, DD, HH, mm, ss."
)
.addText((text) => {
text.setPlaceholder("YYYYMMDDHHmmss")
.setValue(
this.plugin.settings.cmdkQuickCreateTitleFormat
)
.onChange(async (value) => {
this.plugin.settings.cmdkQuickCreateTitleFormat =
value;
await this.plugin.saveSettings();
});
});
}
}
@ -1896,7 +1951,11 @@ interface CmdkResult {
pathMatch: SearchResult | null;
heading?: string;
headingMatch?: SearchResult | null;
type: "file" | "heading";
content?: string;
contentMatch?: SearchResult | null;
line?: number;
type: "file" | "heading" | "content" | "create";
createQuery?: string;
}
class FloatSearchCmdkModal extends SuggestModal<CmdkResult> {
@ -1998,8 +2057,31 @@ class FloatSearchCmdkModal extends SuggestModal<CmdkResult> {
return sb - sa;
});
// Check if there is an exact name match (for quick-create gating)
const queryLower = query.trim().toLowerCase();
const hasExactMatch = fileResults.some(
(r) => r.file.basename.toLowerCase() === queryLower
);
const resultsToShow = fileResults.slice(0, this.limit);
// Append "quick create" option when enabled and no exact match
if (
this.plugin.settings.cmdkQuickCreate &&
!hasExactMatch &&
query.trim().length > 0
) {
resultsToShow.push({
file: null as any,
nameMatch: null,
pathMatch: null,
type: "create",
createQuery: query.trim(),
});
}
// Render file results immediately
chooser.setSuggestions(fileResults.slice(0, this.limit));
chooser.setSuggestions(resultsToShow);
// Phase 2: heading search — progressive, batched via setTimeout
if (query.trim().length >= 2) {
@ -2009,6 +2091,14 @@ class FloatSearchCmdkModal extends SuggestModal<CmdkResult> {
chooser,
signal
);
// Phase 3: content search — progressive, async (reads file content)
this.progressiveContentSearch(
files,
query,
chooser,
signal
);
}
}
@ -2061,11 +2151,114 @@ class FloatSearchCmdkModal extends SuggestModal<CmdkResult> {
setTimeout(processBatch, 0);
}
private progressiveContentSearch(
files: TFile[],
query: string,
chooser: any,
signal: AbortSignal
) {
const BATCH_SIZE = 50;
const MAX_CONTENT_RESULTS = 20;
const DURATION_LIMIT = 5; // ms before yielding
const simpleSearch = prepareSimpleSearch(query);
let idx = 0;
let added = 0;
const processBatch = async () => {
if (signal.aborted) return;
const start = performance.now();
for (; idx < files.length; idx++) {
if (signal.aborted || added >= MAX_CONTENT_RESULTS) return;
// Adaptive yielding: check time every BATCH_SIZE items
if (idx % BATCH_SIZE === 0 && idx > 0) {
if (performance.now() - start > DURATION_LIMIT) {
setTimeout(processBatch, 0);
return;
}
}
const file = files[idx];
if (file.extension !== "md") continue;
let text: string;
try {
text = await this.app.vault.cachedRead(file);
} catch {
continue;
}
const result = simpleSearch(text);
if (!result) continue;
// Compute line number and context from first match offset
const matchStart = result.matches[0][0];
let line = 0;
let lineStart = 0;
for (let i = 0; i < matchStart; i++) {
if (text.charCodeAt(i) === 10) {
line++;
lineStart = i + 1;
}
}
let lineEnd = text.indexOf("\n", lineStart);
if (lineEnd === -1) lineEnd = text.length;
const lineText = text.substring(lineStart, lineEnd).trim();
// Recompute match on the line for highlight offsets
const lineMatch = simpleSearch(lineText);
chooser.addSuggestion({
file,
nameMatch: null,
pathMatch: null,
content: lineText,
contentMatch: lineMatch,
line,
type: "content",
} as CmdkResult);
added++;
if (added >= MAX_CONTENT_RESULTS) return;
}
};
// Start after heading search has a chance to render
setTimeout(processBatch, 50);
}
renderSuggestion(result: CmdkResult, el: HTMLElement) {
el.addClass("mod-complex");
if (result.type === "create") {
el.addClass("float-search-cmdk-create-item");
const contentEl = el.createDiv("suggestion-content");
const titleEl = contentEl.createDiv("suggestion-title");
titleEl.setText("Create new note");
const noteEl = contentEl.createDiv("suggestion-note");
const folder =
this.plugin.settings.cmdkQuickCreateFolder || "/";
noteEl.setText(
`"${result.createQuery}" → ${folder}`
);
const auxEl = el.createDiv("suggestion-aux");
const flair = auxEl.createSpan("suggestion-flair");
setIcon(flair, "plus");
return;
}
const contentEl = el.createDiv("suggestion-content");
if (result.type === "heading" && result.heading) {
if (result.type === "content" && result.content) {
const titleEl = contentEl.createDiv("suggestion-title");
if (result.contentMatch) {
renderResults(titleEl, result.content, result.contentMatch);
} else {
titleEl.setText(result.content);
}
const noteEl = contentEl.createDiv("suggestion-note");
noteEl.setText(result.file.path);
} else if (result.type === "heading" && result.heading) {
const titleEl = contentEl.createDiv("suggestion-title");
if (result.headingMatch) {
renderResults(titleEl, result.heading, result.headingMatch);
@ -2106,6 +2299,11 @@ class FloatSearchCmdkModal extends SuggestModal<CmdkResult> {
result: CmdkResult,
evt: MouseEvent | KeyboardEvent
) {
if (result.type === "create") {
this.quickCreateNote(result.createQuery ?? "", evt);
return;
}
const leaf = Keymap.isModEvent(evt)
? this.app.workspace.getLeaf("tab")
: this.app.workspace.getMostRecentLeaf() ??
@ -2113,8 +2311,9 @@ class FloatSearchCmdkModal extends SuggestModal<CmdkResult> {
const eState: Record<string, any> = {};
if (result.type === "heading" && result.heading) {
// Use subpath to jump to heading
eState.subpath = "#" + result.heading;
} else if (result.type === "content" && result.line != null) {
eState.line = result.line;
}
leaf.setViewState({
@ -2122,28 +2321,96 @@ class FloatSearchCmdkModal extends SuggestModal<CmdkResult> {
state: { file: result.file.path },
active: true,
}).then(() => {
if (eState.subpath) {
// Apply eState after view is ready
if (eState.subpath || eState.line != null) {
leaf.setEphemeralState(eState);
}
});
}
private async quickCreateNote(
content: string,
evt: MouseEvent | KeyboardEvent
) {
const settings = this.plugin.settings;
const fmt = settings.cmdkQuickCreateTitleFormat || "YYYYMMDDHHmmss";
const title = this.formatTimestamp(new Date(), fmt);
// Resolve target folder
let folderPath = settings.cmdkQuickCreateFolder.trim();
if (!folderPath || folderPath === "/") {
folderPath = "";
}
// Ensure folder exists
if (folderPath) {
const folder =
this.app.vault.getAbstractFileByPath(folderPath);
if (!folder) {
await this.app.vault.createFolder(folderPath);
}
}
const filePath = folderPath
? `${folderPath}/${title}.md`
: `${title}.md`;
const file = await this.app.vault.create(filePath, content);
const leaf = Keymap.isModEvent(evt)
? this.app.workspace.getLeaf("tab")
: this.app.workspace.getMostRecentLeaf() ??
this.app.workspace.getLeaf();
await leaf.setViewState({
type: "markdown",
state: { file: file.path },
active: true,
});
}
private formatTimestamp(date: Date, fmt: string): string {
const pad = (n: number, len = 2) =>
String(n).padStart(len, "0");
const tokens: Record<string, string> = {
YYYY: String(date.getFullYear()),
YY: String(date.getFullYear()).slice(-2),
MM: pad(date.getMonth() + 1),
DD: pad(date.getDate()),
HH: pad(date.getHours()),
mm: pad(date.getMinutes()),
ss: pad(date.getSeconds()),
};
let result = fmt;
// Replace longest tokens first to avoid partial matches
for (const token of ["YYYY", "YY", "MM", "DD", "HH", "mm", "ss"]) {
result = result.split(token).join(tokens[token]);
}
return result;
}
// Called by internal Chooser on selection change
// @ts-ignore
onSelectedChange = debounce(
(result: CmdkResult, _evt: Event | null) => {
if (result?.file) this.showPreview(result.file);
if (result?.type === "create" || !result?.file) {
this.hidePreview();
} else {
this.showPreview(result);
}
},
100
);
async showPreview(file: TFile) {
private hidePreview() {
if (this.previewEl) {
this.previewEl.hide();
}
}
async showPreview(result: CmdkResult) {
if (!this.previewEl) {
this.previewEl = this.bodyEl.createDiv(
"float-search-cmdk-preview"
);
this.modalEl.addClass("float-search-cmdk-expanded");
const [leaf, view] = spawnLeafView(
this.plugin,
this.previewEl
@ -2152,7 +2419,29 @@ class FloatSearchCmdkModal extends SuggestModal<CmdkResult> {
this.fileEmbeddedView = view;
this.fileLeaf.setPinned(true);
}
this.previewEl.show();
this.modalEl.addClass("float-search-cmdk-expanded");
const file = result.file;
await this.fileLeaf!.openFile(file, { active: false });
const eState: Record<string, any> = {};
if (result.type === "heading" && result.heading) {
eState.subpath = "#" + result.heading;
} else if (result.type === "content" && result.line != null) {
eState.line = result.line;
}
if (eState.subpath || eState.line != null) {
this.fileLeaf!.setViewState({
type: file.extension === "pdf" ? "pdf" : "markdown",
state: { file: file.path },
}).then(() => {
this.fileLeaf?.setEphemeralState(eState);
});
}
this.inputEl.focus();
}

View file

@ -213,3 +213,19 @@ body:not(.show-file-path) .search-result-file-title .search-result-file-path {
.modal-container.float-search-cmdk-container.mod-dim {
z-index: 30;
}
/* ── Quick Create item ── */
.float-search-cmdk-create-item {
border-top: 1px solid var(--background-modifier-border);
}
.float-search-cmdk-create-item .suggestion-title {
color: var(--text-accent);
font-weight: var(--font-semibold);
}
.float-search-cmdk-create-item .suggestion-flair {
display: flex;
align-items: center;
color: var(--text-accent);
}

View file

@ -33,5 +33,6 @@
"4.0.0": "0.15.0",
"4.1.0": "0.15.0",
"4.1.1": "0.15.0",
"4.2.0": "0.15.0"
"4.2.0": "1.2.0",
"4.3.0": "1.2.0"
}