Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
d852c29
feat(capture): add targeted canvas node capture
chhoumann Feb 25, 2026
70ec2e6
chore(capture): apply review polish
chhoumann Feb 25, 2026
908011d
fix(capture): abort .canvas capture without node id
chhoumann Feb 25, 2026
66965a0
fix(capture): prevent concurrent canvas overwrite
chhoumann Feb 25, 2026
74fec55
fix(capture): allow vault picker and clear stale canvas node ids
chhoumann Feb 25, 2026
01d80d6
codex: address PR review feedback (#1124)
chhoumann Feb 25, 2026
2eee632
codex: fix PR #1124 CI regressions
chhoumann Feb 25, 2026
de8e841
codex: avoid duplicate capture path formatting (#1124)
chhoumann Feb 25, 2026
e5a287e
codex: fix capture success notice placement (#1124)
chhoumann Feb 25, 2026
49c2eae
codex: guard canvas file-card cursor fallback (#1124)
chhoumann Feb 25, 2026
9f69be8
codex: restrict picker to capturable canvas nodes (#1124)
chhoumann Feb 25, 2026
94e9a28
codex: remove redundant canvas destination source-path reset (#1124)
chhoumann Feb 25, 2026
cef9a59
codex: fix picker button disable typing (#1124)
chhoumann Feb 26, 2026
a63cce7
codex: address PR review feedback (#1124)
chhoumann Feb 26, 2026
e5bb166
codex: address PR review feedback (#1124)
chhoumann Feb 26, 2026
3e8d167
codex: fix canvas capture correctness regressions (#1124)
chhoumann Feb 26, 2026
1aea168
codex: harden stale canvas node config fallback (#1124)
chhoumann Feb 26, 2026
577dea5
codex: add canvas file-card regressions and remove dead helpers (#1124)
chhoumann Feb 26, 2026
4bb7627
fix(capture): abort invalid insert-after and align canvas link behavior
chhoumann Feb 27, 2026
7885014
test(capture): assert choice abort notices are always visible
chhoumann Feb 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 69 additions & 1 deletion docs/docs/Choices/CaptureChoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ If you have a tag called `#people`, and you type `#people` in the _Capture To_ f
- _Create file if it doesn't exist_ will do as the name implies - you can also create the file from a template, if you specify the template (the input box will appear below the setting).
- _Task_ will format your captured text as a task.
- _Use editor selection as default value_ controls whether the current editor selection is used as `{{VALUE}}`. Choose **Follow global setting**, **Use selection**, or **Ignore selection** (global default lives in Settings > Input). This does not affect `{{SELECTED}}`.
- _Write to bottom of file_ will put whatever you enter at the bottom of the file.
- _Write position_ controls where Capture writes: top, bottom, after line, and active-file cursor modes.
- _Append link_ will append a link to the file you have open in the file you're capturing to. You can choose between three modes:
- **Enabled (requires active file)** – keeps the legacy behavior and throws an error if no note is focused
- **Enabled (skip if no active file)** – inserts the link when possible and silently drops `{{LINKCURRENT}}` if nothing is open
Expand All @@ -60,6 +60,74 @@ If you have a tag called `#people`, and you type `#people` in the _Capture To_ f
- **End of line** - Places the link at the end of the current line
- **New line** - Places the link on a new line below the cursor

## Canvas Capture Notes

QuickAdd supports two Canvas capture workflows:

- Capture to one selected card in the active Canvas view
- Capture to a specific card in a specific `.canvas` file

### 1) Capture to selected card in active Canvas

This mode is enabled when **Capture to active file** is on and the active leaf
is a Canvas.

Supported card targets:

- Text cards
- File cards that point to markdown files

### 2) Capture to specific card in specific `.canvas` file

This mode is enabled when **Capture to active file** is off, the capture path
resolves to a `.canvas` file, and **Target canvas node** is set.

When the capture path is a `.canvas` file, QuickAdd shows a node picker that
helps you choose a node id directly from that board.

### Write position support in Canvas

- Text cards support: **Top of file**, **Bottom of file**, **After line...**
- File cards (markdown targets) support: **Top of file**, **Bottom of file**, **After line...**
- Canvas does not support cursor-based modes: **At cursor**, **New line above cursor**, **New line below cursor**

If **Capture to active file** is enabled and you leave the default write
position at **At cursor**, capture will abort in Canvas until you switch to a
supported mode.

Canvas capture requires exactly one selected card in selected-card mode. If the
selection is missing, multi-select, or unsupported, QuickAdd aborts with a
notice instead of writing to the wrong place.

A dedicated Canvas walkthrough page will return in a future update.

### Canvas Capture FAQ

**Why did my capture abort in Canvas?**

Most often one of these is true:

- No card is selected
- More than one card is selected
- The selected card type is unsupported
- The selected write mode is cursor-based

**Can I target a specific card in a Canvas file?**

Yes. Set capture path to a `.canvas` file and choose a **Target canvas node**.

**Does "At cursor" work in Canvas cards?**

No. Use top, bottom, or insert-after placement.

**Can I capture to a file card that points to a Canvas file?**

No. File-card capture supports markdown targets only.

**Can I still create new Canvas files from templates?**

Yes. Template choices support `.canvas` templates.

## Insert after

Insert After will allow you to insert the text after some line with the specified text.
Expand Down
187 changes: 163 additions & 24 deletions src/engine/CaptureChoiceEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ import { ChoiceAbortError } from "../errors/ChoiceAbortError";
import { MacroAbortError } from "../errors/MacroAbortError";
import { SingleTemplateEngine } from "./SingleTemplateEngine";
import { getCaptureAction, type CaptureAction } from "./captureAction";
import {
getCanvasTextCaptureContent,
resolveActiveCanvasCaptureTarget,
resolveConfiguredCanvasCaptureTarget,
setCanvasTextCaptureContent,
type CanvasTextCaptureTarget,
type ConfiguredCanvasCaptureTarget,
} from "./canvasCapture";
import { handleMacroAbort } from "../utils/macroAbortHandler";

const DEFAULT_NOTICE_DURATION = 4000;
Expand Down Expand Up @@ -81,12 +89,18 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
case "currentLine":
msg = `Captured to current line in ${fileName}`;
break;
case "newLineAbove":
msg = `Captured on a new line above cursor in ${fileName}`;
break;
case "newLineBelow":
msg = `Captured on a new line below cursor in ${fileName}`;
break;
case "prepend":
case "activeFileTop":
msg = `Captured to top of ${fileName}`;
break;
case "append":
msg = `Captured to ${fileName}`;
msg = `Captured to bottom of ${fileName}`;
break;
case "insertAfter": {
const heading = this.choice.insertAfter.after;
Expand All @@ -95,6 +109,9 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
: `Captured to ${fileName}`;
break;
}
default:
msg = `Captured to ${fileName}`;
break;
}

new Notice(msg, DEFAULT_NOTICE_DURATION);
Expand All @@ -119,39 +136,56 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
: globalSelectionAsValue;
this.formatter.setUseSelectionAsCaptureValue(useSelectionAsCaptureValue);

const filePath = await this.getFormattedPathToCaptureTo(
this.choice.captureToActiveFile,
);
const action = getCaptureAction(this.choice);
const activeCanvasTarget = this.choice.captureToActiveFile
? resolveActiveCanvasCaptureTarget(this.app, action)
: null;
const configuredCanvasTarget =
await this.resolveConfiguredCanvasTarget(action);
const canvasTarget = activeCanvasTarget ?? configuredCanvasTarget;

if (canvasTarget?.kind === "text") {
await this.handleCanvasTextCapture(canvasTarget, action, linkOptions);
return;
}

const filePath =
canvasTarget?.kind === "file"
? canvasTarget.targetFile.path
: await this.getFormattedPathToCaptureTo(this.choice.captureToActiveFile);
const content = this.getCaptureContent();

let getFileAndAddContentFn: typeof this.onFileExists;
type GetFileAndAddContentFn = (
path: string,
capture: string,
linkOptions?: AppendLinkOptions,
) => Promise<{ file: TFile; newFileContent: string; captureContent: string }>;
let getFileAndAddContentFn: GetFileAndAddContentFn;
const fileAlreadyExists = await this.fileExists(filePath);

if (fileAlreadyExists) {
getFileAndAddContentFn = this.onFileExists.bind(
this,
) as typeof this.onFileExists;
} else if (this.choice?.createFileIfItDoesntExist?.enabled) {
getFileAndAddContentFn = ((path, capture, _options) =>
this.onCreateFileIfItDoesntExist(path, capture, linkOptions)
) as typeof this.onCreateFileIfItDoesntExist;
} else {
throw new ChoiceAbortError(
`Target file missing: ${filePath}. Enable "Create file if it doesn't exist" or choose an existing file.`,
);
}
getFileAndAddContentFn =
this.onFileExists.bind(this) as GetFileAndAddContentFn;
} else if (this.choice?.createFileIfItDoesntExist?.enabled) {
getFileAndAddContentFn = ((path, capture, _options) =>
this.onCreateFileIfItDoesntExist(path, capture, linkOptions)
) as GetFileAndAddContentFn;
} else {
throw new ChoiceAbortError(
`Target file missing: ${filePath}. Enable "Create file if it doesn't exist" or choose an existing file.`,
);
}

const { file, newFileContent, captureContent } =
await getFileAndAddContentFn(filePath, content);

const action = getCaptureAction(this.choice);
const isEditorInsertionAction =
action === "currentLine" ||
action === "newLineAbove" ||
action === "newLineBelow";
const isEditorInsertionAction =
action === "currentLine" ||
action === "newLineAbove" ||
action === "newLineBelow";

// Handle capture to active file with special actions
if (isEditorInsertionAction) {
// Handle capture to active file with special actions
if (isEditorInsertionAction) {
Comment thread
chhoumann marked this conversation as resolved.
// Parse Templater syntax in the capture content.
// If Templater isn't installed, it just returns the capture content.
const content = await templaterParseTemplate(
Expand Down Expand Up @@ -217,6 +251,111 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
}
}

private async handleCanvasTextCapture(
target: CanvasTextCaptureTarget,
action: CaptureAction,
linkOptions: AppendLinkOptions,
): Promise<void> {
if (
action === "currentLine" ||
action === "newLineAbove" ||
action === "newLineBelow"
) {
throw new ChoiceAbortError(
"Canvas text cards support top, bottom, and insert-after positions only.",
);
}

if (
action === "insertAfter" &&
this.choice.insertAfter?.createIfNotFound &&
this.choice.insertAfter?.createIfNotFoundLocation === "cursor"
) {
throw new ChoiceAbortError(
"Canvas text cards do not support creating missing insert-after targets at cursor. Use top or bottom.",
);
}

const file = target.canvasFile;
this.formatter.setTitle(basenameWithoutMdOrCanvas(file.basename));
this.formatter.setDestinationFile(file);
this.formatter.setDestinationSourcePath(file.path);
Comment thread
chhoumann marked this conversation as resolved.
Outdated

const captureTemplate = this.getCaptureContent();
const existingText = getCanvasTextCaptureContent(target);
const nextText = await this.formatter.formatContentWithFile(
captureTemplate,
this.choice,
existingText,
file,
);

await setCanvasTextCaptureContent(this.app, target, nextText);

if (this.plugin.settings.showCaptureNotification) {
this.showSuccessNotice(file, {
wasNewFile: false,
action,
});
}

if (linkOptions.enabled) {
insertFileLinkToActiveView(this.app, file, linkOptions);
}

if (this.choice.openFile && file) {
const fileOpening = normalizeFileOpening(this.choice.fileOpening);
const focus = fileOpening.focus ?? true;
const openExistingTab = openExistingFileTab(this.app, file, focus);

if (!openExistingTab) {
await openFile(this.app, file, fileOpening);
}

await jumpToNextTemplaterCursorIfPossible(this.app, file);
}
}

private async resolveConfiguredCanvasTarget(
action: CaptureAction,
): Promise<ConfiguredCanvasCaptureTarget | null> {
if (this.choice.captureToActiveFile) {
return null;
}

const rawCaptureTo = this.choice.captureTo?.trim() ?? "";
if (!rawCaptureTo) {
return null;
}
Comment thread
chhoumann marked this conversation as resolved.

const targetPath = await this.formatFilePath(rawCaptureTo);
const isCanvasTarget = CANVAS_FILE_EXTENSION_REGEX.test(targetPath);
const nodeId = this.choice.captureToCanvasNodeId?.trim() ?? "";

if (!isCanvasTarget) {
if (!nodeId) {
return null;
}

throw new ChoiceAbortError(
"Canvas node capture requires the target path to resolve to a .canvas file.",
);
}

if (!nodeId) {
throw new ChoiceAbortError(
"Canvas node capture requires a target canvas node id.",
);
}

return await resolveConfiguredCanvasCaptureTarget(
this.app,
targetPath,
nodeId,
action,
);
}

private getCaptureContent(): string {
let content: string;

Expand Down
Loading