Compare commits

...

7 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
Quorafind
8cb09f6345 chore: bump version to 4.2.0 2026-03-17 14:55:15 +08:00
Quorafind
e1b2a6e2db chore: bump version to 4.2.0 2026-03-17 14:55:15 +08:00
Quorafind
3896bcfc34 feat: add CMDK quick search modal with progressive rendering
- Double-tap configurable modifier key (default: Shift) to invoke
  a SuggestModal-based quick search with real-time file preview
- Progressive heading search via chooser.addSuggestion() and batched
  setTimeout, using metadataCache for zero I/O
- File navigation via setViewState with heading subpath support
- Settings tab for trigger key selection and double-tap interval
- Fix inputEl timing race with retry logic and optional chaining
- Fix extra view creation when defaultViewType is window
- Add auto-preview on ArrowUp/ArrowDown in FloatSearchModal
2026-03-17 14:55:15 +08:00
6 changed files with 831 additions and 39 deletions

View file

@ -1,15 +1,10 @@
{
"id": "float-search",
"name": "Floating Search",
"version": "4.1.1",
"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",
"fundingUrl": {
"Buy Me a Coffee": "https://www.buymeacoffee.com/boninall",
"爱发电": "https://afdian.net/a/boninall",
"支付宝": "https://cdn.jsdelivr.net/gh/Quorafind/.github@main/IMAGE/%E6%94%AF%E4%BB%98%E5%AE%9D%E4%BB%98%E6%AC%BE%E7%A0%81.jpg"
},
"isDesktopOnly": false
}

View file

@ -1,6 +1,6 @@
{
"name": "float-search",
"version": "4.1.1",
"version": "4.3.0",
"description": "You can use search view in modal/leaf/popout window now.",
"main": "main.js",
"scripts": {
@ -17,7 +17,7 @@
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.14.47",
"obsidian": "^1.7.2",
"obsidian": "^1.12.3",
"tslib": "2.4.0",
"typescript": "4.7.4"
},

View file

@ -28,8 +28,8 @@ importers:
specifier: 0.14.47
version: 0.14.47
obsidian:
specifier: ^1.7.2
version: 1.7.2(@codemirror/state@6.2.1)(@codemirror/view@6.18.1)
specifier: ^1.12.3
version: 1.12.3(@codemirror/state@6.2.1)(@codemirror/view@6.18.1)
tslib:
specifier: 2.4.0
version: 2.4.0
@ -486,7 +486,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@13.24.0:
resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
@ -602,11 +602,11 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
obsidian@1.7.2:
resolution: {integrity: sha512-k9hN9brdknJC+afKr5FQzDRuEFGDKbDjfCazJwpgibwCAoZNYHYV8p/s3mM8I6AsnKrPKNXf8xGuMZ4enWelZQ==}
obsidian@1.12.3:
resolution: {integrity: sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==}
peerDependencies:
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@codemirror/state': 6.5.0
'@codemirror/view': 6.38.6
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@ -1316,7 +1316,7 @@ snapshots:
natural-compare@1.4.0: {}
obsidian@1.7.2(@codemirror/state@6.2.1)(@codemirror/view@6.18.1):
obsidian@1.12.3(@codemirror/state@6.2.1)(@codemirror/view@6.18.1):
dependencies:
'@codemirror/state': 6.2.1
'@codemirror/view': 6.18.1

View file

@ -11,11 +11,17 @@ import {
OpenViewState,
PaneType,
Plugin,
prepareFuzzySearch,
prepareSimpleSearch,
renderResults,
SearchResult,
requireApiVersion,
Scope,
SearchView,
setIcon,
PluginSettingTab,
Setting,
SuggestModal,
TAbstractFile,
TFile,
ViewStateResult,
@ -53,11 +59,18 @@ interface searchState extends Record<string, unknown> {
current?: boolean;
}
type CmdkTriggerKey = "Shift" | "Control" | "Alt" | "Meta" | "none";
interface FloatSearchSettings {
searchViewState: searchState;
showFilePath: boolean;
showInstructions: boolean;
defaultViewType: searchType;
cmdkTriggerKey: CmdkTriggerKey;
cmdkDoubleTapInterval: number;
cmdkQuickCreate: boolean;
cmdkQuickCreateFolder: string;
cmdkQuickCreateTitleFormat: string;
}
const DEFAULT_SETTINGS: FloatSearchSettings = {
@ -72,6 +85,11 @@ const DEFAULT_SETTINGS: FloatSearchSettings = {
showFilePath: false,
showInstructions: true,
defaultViewType: "modal",
cmdkTriggerKey: "Shift",
cmdkDoubleTapInterval: 300,
cmdkQuickCreate: false,
cmdkQuickCreateFolder: "",
cmdkQuickCreateTitleFormat: "YYYYMMDDHHmmss",
};
const allViews: viewType[] = [
@ -127,6 +145,7 @@ export default class FloatSearchPlugin extends Plugin {
settings: FloatSearchSettings;
private state: searchState;
private modal: FloatSearchModal;
private cmdkModal: FloatSearchCmdkModal;
allLoaded: boolean = false;
queryLoaded: boolean = false;
@ -168,6 +187,7 @@ export default class FloatSearchPlugin extends Plugin {
this.patchSearchView();
this.patchVchildren();
this.patchDragManager();
this.registerDoubleKeyHandler();
});
this.registerObsidianURIHandler();
@ -194,6 +214,7 @@ export default class FloatSearchPlugin extends Plugin {
}
);
this.updateFilePathVisibility();
this.addSettingTab(new FloatSearchSettingTab(this.app, this));
}
onunload() {
@ -201,6 +222,47 @@ export default class FloatSearchPlugin extends Plugin {
this.modal?.close();
}
registerDoubleKeyHandler() {
let lastKeyUp = 0;
let keyOnly = true;
this.registerDomEvent(document, "keydown", (e: KeyboardEvent) => {
const triggerKey = this.settings.cmdkTriggerKey;
if (triggerKey === "none" || e.key !== triggerKey) {
keyOnly = false;
}
});
this.registerDomEvent(document, "keyup", (e: KeyboardEvent) => {
const triggerKey = this.settings.cmdkTriggerKey;
if (triggerKey === "none") return;
if (e.key === triggerKey) {
const now = Date.now();
if (
keyOnly &&
now - lastKeyUp < this.settings.cmdkDoubleTapInterval &&
!document.querySelector(
".float-search-cmdk-container"
)
) {
lastKeyUp = 0;
this.openCmdkModal();
} else {
lastKeyUp = now;
keyOnly = true;
}
}
});
}
openCmdkModal() {
if (this.cmdkModal) {
this.cmdkModal.close();
}
this.cmdkModal = new FloatSearchCmdkModal(this);
this.cmdkModal.open();
}
updateFilePathVisibility() {
const { showFilePath } = this.settings;
document.body.toggleClass("show-file-path", showFilePath);
@ -1265,6 +1327,114 @@ export default class FloatSearchPlugin extends Plugin {
}
}
const TRIGGER_KEY_OPTIONS: Record<CmdkTriggerKey, string> = {
Shift: "Double Shift",
Control: "Double Ctrl",
Alt: "Double Alt",
Meta: "Double Meta (Cmd/Win)",
none: "Disabled",
};
class FloatSearchSettingTab extends PluginSettingTab {
plugin: FloatSearchPlugin;
constructor(app: App, plugin: FloatSearchPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
containerEl.createEl("h2", { text: "Float Search Settings" });
new Setting(containerEl)
.setName("Quick search trigger")
.setDesc(
"Double-tap this key to open the quick search modal (CMDK)."
)
.addDropdown((dropdown) => {
for (const [value, label] of Object.entries(
TRIGGER_KEY_OPTIONS
)) {
dropdown.addOption(value, label);
}
dropdown.setValue(this.plugin.settings.cmdkTriggerKey);
dropdown.onChange(async (value) => {
this.plugin.settings.cmdkTriggerKey =
value as CmdkTriggerKey;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName("Double-tap interval (ms)")
.setDesc(
"Maximum time between two key presses to trigger quick search. Default: 300ms."
)
.addSlider((slider) => {
slider
.setLimits(150, 600, 50)
.setValue(this.plugin.settings.cmdkDoubleTapInterval)
.setDynamicTooltip()
.onChange(async (value) => {
this.plugin.settings.cmdkDoubleTapInterval =
value;
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();
});
});
}
}
function createInstructionElement(
parentEl: HTMLElement,
divCls: string,
@ -1305,6 +1475,10 @@ class FloatSearchModal extends Modal {
private focusdItem: any;
private debouncedAutoPreview = debounce(() => {
this.autoPreviewFocusedItem();
}, 150);
constructor(
cb: (state: any) => void,
plugin: FloatSearchPlugin,
@ -1422,30 +1596,37 @@ class FloatSearchModal extends Modal {
this.searchLeaf.setPinned(true);
await this.searchLeaf.setViewState({
type: "search",
state: this.state,
state: { ...this.state, triggerBySelf: true },
});
setTimeout(async () => {
await this.searchLeaf.view.setState(this.state, {
history: false,
});
await this.searchLeaf.view.setState(
{ ...this.state, triggerBySelf: true },
{ history: false }
);
const searchComponent = (this.searchLeaf.view as SearchView)
.searchComponent;
if (searchComponent?.inputEl) {
this.state?.current
? (
this.searchLeaf.view as SearchView
).searchComponent.inputEl.setSelectionRange(0, 0)
: (
this.searchLeaf.view as SearchView
).searchComponent.inputEl.setSelectionRange(
? searchComponent.inputEl.setSelectionRange(0, 0)
: searchComponent.inputEl.setSelectionRange(
0,
this.state?.query?.length
);
}
}, 0);
return;
}
initInput() {
initInput(retries = 10) {
const inputEl = this.contentEl.getElementsByTagName("input")[0];
if (!inputEl) {
if (retries > 0) {
setTimeout(() => this.initInput(retries - 1), 50);
}
return;
}
inputEl.focus();
inputEl.onkeydown = (e) => {
const currentView = this.searchLeaf.view as SearchView;
@ -1466,6 +1647,7 @@ class FloatSearchModal extends Modal {
} else {
currentView.onKeyArrowDownInFocus(e);
this.focusdItem = currentView.dom.focusedItem;
this.debouncedAutoPreview();
}
break;
case "ArrowUp":
@ -1487,6 +1669,7 @@ class FloatSearchModal extends Modal {
if (!currentView.dom.focusedItem.content) {
this.focusdItem = undefined;
}
this.debouncedAutoPreview();
}
break;
case "ArrowLeft":
@ -1565,7 +1748,7 @@ class FloatSearchModal extends Modal {
setTimeout(() => {
(
this.searchLeaf.view as SearchView
).searchComponent.inputEl.focus();
).searchComponent?.inputEl?.focus();
}, 0);
}
}
@ -1589,6 +1772,30 @@ class FloatSearchModal extends Modal {
};
}
private autoPreviewFocusedItem() {
const currentView = this.searchLeaf.view as SearchView;
const item = currentView.dom?.focusedItem;
if (!item) return;
const file =
item.parent?.file instanceof TFile
? item.parent.file
: item.file;
if (!(file instanceof TFile)) return;
const state =
item.parent?.file instanceof TFile
? {
match: {
content: item.content,
matches: item.matches,
},
}
: undefined;
this.initFileView(file, state);
}
initContent() {
const { contentEl } = this;
contentEl.onclick = (e) => {
@ -1652,7 +1859,7 @@ class FloatSearchModal extends Modal {
this.initFileView(file, undefined);
(
this.searchLeaf.view as SearchView
).searchComponent.inputEl.focus();
).searchComponent?.inputEl?.focus();
}
}
@ -1686,7 +1893,7 @@ class FloatSearchModal extends Modal {
setTimeout(() => {
(
this.searchLeaf.view as SearchView
).searchComponent.inputEl.focus();
).searchComponent?.inputEl?.focus();
}, 0);
}
@ -1705,7 +1912,7 @@ class FloatSearchModal extends Modal {
(
this.searchLeaf.view as SearchView
).searchComponent.inputEl.focus();
).searchComponent?.inputEl?.focus();
}
if (e.key === "Tab" && e.ctrlKey) {
@ -1714,7 +1921,7 @@ class FloatSearchModal extends Modal {
(
this.searchLeaf.view as SearchView
).searchComponent.inputEl.focus();
).searchComponent?.inputEl?.focus();
}
};
@ -1734,6 +1941,512 @@ class FloatSearchModal extends Modal {
});
this.fileState = state;
(this.searchLeaf.view as SearchView).searchComponent.inputEl.focus();
(this.searchLeaf.view as SearchView).searchComponent?.inputEl?.focus();
}
}
interface CmdkResult {
file: TFile;
nameMatch: SearchResult | null;
pathMatch: SearchResult | null;
heading?: string;
headingMatch?: SearchResult | null;
content?: string;
contentMatch?: SearchResult | null;
line?: number;
type: "file" | "heading" | "content" | "create";
createQuery?: string;
}
class FloatSearchCmdkModal extends SuggestModal<CmdkResult> {
plugin: FloatSearchPlugin;
private bodyEl: HTMLElement;
private previewEl: HTMLElement | undefined;
private fileLeaf: WorkspaceLeaf | undefined;
private fileEmbeddedView: EmbeddedView | undefined;
private searchAbort: AbortController | null = null;
constructor(plugin: FloatSearchPlugin) {
super(plugin.app);
this.plugin = plugin;
this.limit = 50;
this.setPlaceholder("Search files and content...");
this.setInstructions([
{ command: "↑↓", purpose: "Navigate" },
{ command: "↵", purpose: "Open" },
{ command: "Shift ↵", purpose: "New tab" },
{ command: "esc", purpose: "Close" },
]);
this.modalEl.addClass("float-search-cmdk");
this.containerEl.addClass("float-search-cmdk-container");
}
onOpen() {
super.onOpen();
this.bodyEl = createDiv("float-search-cmdk-body");
this.modalEl.insertBefore(
this.bodyEl,
(this as any).resultContainerEl
);
this.bodyEl.appendChild((this as any).resultContainerEl);
}
// Required by SuggestModal but unused — we drive the chooser directly
getSuggestions(_query: string): CmdkResult[] {
return [];
}
// Override to use progressive rendering via chooser.addSuggestion
updateSuggestions() {
// Cancel previous in-flight search
this.searchAbort?.abort();
const abort = (this.searchAbort = new AbortController());
const { signal } = abort;
const chooser = (this as any).chooser;
const query = this.inputEl.value;
const files = this.app.vault.getFiles().filter(
(f: TFile) =>
f.extension === "md" ||
f.extension === "canvas" ||
f.extension === "pdf"
);
// Empty query — show recent files
if (!query.trim()) {
const recent = files
.sort((a: TFile, b: TFile) => b.stat.mtime - a.stat.mtime)
.slice(0, this.limit)
.map((file: TFile) => ({
file,
nameMatch: null,
pathMatch: null,
type: "file" as const,
}));
chooser.setSuggestions(recent);
return;
}
// Phase 1: file name/path fuzzy (sync, instant)
const fuzzy = prepareFuzzySearch(query);
const fileResults: CmdkResult[] = [];
for (const file of files) {
const nameMatch = fuzzy(file.basename);
const pathMatch = fuzzy(file.path);
if (nameMatch || pathMatch) {
fileResults.push({
file,
nameMatch,
pathMatch,
type: "file",
});
}
}
fileResults.sort((a, b) => {
const sa = Math.max(
a.nameMatch?.score ?? -Infinity,
(a.pathMatch?.score ?? -Infinity) * 0.5
);
const sb = Math.max(
b.nameMatch?.score ?? -Infinity,
(b.pathMatch?.score ?? -Infinity) * 0.5
);
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(resultsToShow);
// Phase 2: heading search — progressive, batched via setTimeout
if (query.trim().length >= 2) {
this.progressiveHeadingSearch(
files,
fuzzy,
chooser,
signal
);
// Phase 3: content search — progressive, async (reads file content)
this.progressiveContentSearch(
files,
query,
chooser,
signal
);
}
}
private progressiveHeadingSearch(
files: TFile[],
fuzzy: (text: string) => SearchResult | null,
chooser: any,
signal: AbortSignal
) {
const BATCH_SIZE = 50; // files per tick
let idx = 0;
let added = 0;
const MAX_HEADING_RESULTS = 20;
const processBatch = () => {
if (signal.aborted) return;
const end = Math.min(idx + BATCH_SIZE, files.length);
for (; idx < end; idx++) {
if (added >= MAX_HEADING_RESULTS) return;
const file = files[idx];
const cache =
this.app.metadataCache.getFileCache(file);
if (!cache?.headings) continue;
for (const h of cache.headings) {
const headingMatch = fuzzy(h.heading);
if (headingMatch) {
chooser.addSuggestion({
file,
nameMatch: null,
pathMatch: null,
heading: h.heading,
headingMatch,
type: "heading",
});
added++;
if (added >= MAX_HEADING_RESULTS) return;
}
}
}
// More files to process — yield to event loop
if (idx < files.length && added < MAX_HEADING_RESULTS) {
setTimeout(processBatch, 0);
}
};
// Start first batch on next microtask so file results render first
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 === "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);
} else {
titleEl.setText(result.heading);
}
const noteEl = contentEl.createDiv("suggestion-note");
noteEl.setText(result.file.path);
} else {
const titleEl = contentEl.createDiv("suggestion-title");
if (result.nameMatch) {
renderResults(
titleEl,
result.file.basename,
result.nameMatch
);
} else {
titleEl.setText(result.file.basename);
}
const noteEl = contentEl.createDiv("suggestion-note");
const parentPath = result.file.parent?.path || "/";
if (result.pathMatch) {
renderResults(noteEl, parentPath, result.pathMatch);
} else {
noteEl.setText(parentPath);
}
}
if (result.file.extension !== "md") {
el.createDiv("suggestion-aux")
.createSpan("suggestion-flair")
.setText(result.file.extension);
}
}
onChooseSuggestion(
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() ??
this.app.workspace.getLeaf();
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;
}
leaf.setViewState({
type: result.file.extension === "pdf" ? "pdf" : "markdown",
state: { file: result.file.path },
active: true,
}).then(() => {
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?.type === "create" || !result?.file) {
this.hidePreview();
} else {
this.showPreview(result);
}
},
100
);
private hidePreview() {
if (this.previewEl) {
this.previewEl.hide();
}
}
async showPreview(result: CmdkResult) {
if (!this.previewEl) {
this.previewEl = this.bodyEl.createDiv(
"float-search-cmdk-preview"
);
const [leaf, view] = spawnLeafView(
this.plugin,
this.previewEl
);
this.fileLeaf = leaf;
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();
}
onClose() {
this.fileLeaf?.detach();
this.fileEmbeddedView?.unload();
}
}

View file

@ -9,6 +9,7 @@ If your plugin does not need CSS, delete this file.
.float-search-modal {
width: 700px;
padding-bottom: 0;
transition: width 0.15s ease;
}
.float-search-modal.float-search-width {
@ -147,3 +148,84 @@ body:not(.show-file-path) .search-result-file-title .search-result-file-path {
.fs-leaf-view .workspace-split {
background-color: var(--background-primary);
}
/* ── CMDK Modal ── */
.prompt.float-search-cmdk {
width: 700px;
max-width: 90vw;
transition: width 0.15s ease;
}
.prompt.float-search-cmdk.float-search-cmdk-expanded {
width: 1100px;
}
.float-search-cmdk-body {
display: flex;
flex-direction: row;
height: 50vh;
max-height: 600px;
overflow: hidden;
}
.float-search-cmdk-body .prompt-results {
max-height: none;
height: 100%;
overflow-y: auto;
flex: 1 1 auto;
}
.float-search-cmdk-expanded .float-search-cmdk-body .prompt-results {
flex: 0 0 40%;
border-right: 1px solid var(--background-modifier-border);
}
.float-search-cmdk-preview {
flex: 1;
overflow: hidden;
height: 100%;
}
.float-search-cmdk-preview .view-header {
display: none;
}
.float-search-cmdk-preview .view-content {
height: 100%;
}
.float-search-cmdk-preview .fs-block {
height: 100% !important;
}
.float-search-cmdk-preview .fs-content {
height: 100%;
}
.float-search-cmdk-preview .fs-content .workspace-split.mod-vertical {
height: 100%;
}
.float-search-cmdk-preview .workspace-leaf-resize-handle {
display: none;
}
.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

@ -32,5 +32,7 @@
"3.5.1": "0.15.0",
"4.0.0": "0.15.0",
"4.1.0": "0.15.0",
"4.1.1": "0.15.0"
"4.1.1": "0.15.0",
"4.2.0": "1.2.0",
"4.3.0": "1.2.0"
}