Skip to content

Commit 70bf684

Browse files
committed
fix(capture): support targeted canvas card capture
1 parent f2daeed commit 70bf684

File tree

13 files changed

+1965
-57
lines changed

13 files changed

+1965
-57
lines changed

docs/docs/Choices/CaptureChoice.md

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ If you have a tag called `#people`, and you type `#people` in the _Capture To_ f
4848
- _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).
4949
- _Task_ will format your captured text as a task.
5050
- _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}}`.
51-
- _Write to bottom of file_ will put whatever you enter at the bottom of the file.
51+
- _Write position_ controls where Capture writes: top, bottom, after line, and active-file cursor modes.
5252
- _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:
5353
- **Enabled (requires active file)** – keeps the legacy behavior and throws an error if no note is focused
5454
- **Enabled (skip if no active file)** – inserts the link when possible and silently drops `{{LINKCURRENT}}` if nothing is open
@@ -60,6 +60,74 @@ If you have a tag called `#people`, and you type `#people` in the _Capture To_ f
6060
- **End of line** - Places the link at the end of the current line
6161
- **New line** - Places the link on a new line below the cursor
6262

63+
## Canvas Capture Notes
64+
65+
QuickAdd supports two Canvas capture workflows:
66+
67+
- Capture to one selected card in the active Canvas view
68+
- Capture to a specific card in a specific `.canvas` file
69+
70+
### 1) Capture to selected card in active Canvas
71+
72+
This mode is enabled when **Capture to active file** is on and the active leaf
73+
is a Canvas.
74+
75+
Supported card targets:
76+
77+
- Text cards
78+
- File cards that point to markdown files
79+
80+
### 2) Capture to specific card in specific `.canvas` file
81+
82+
This mode is enabled when **Capture to active file** is off, the capture path
83+
resolves to a `.canvas` file, and **Target canvas node** is set.
84+
85+
When the capture path is a `.canvas` file, QuickAdd shows a node picker that
86+
helps you choose a node id directly from that board.
87+
88+
### Write position support in Canvas
89+
90+
- Text cards support: **Top of file**, **Bottom of file**, **After line...**
91+
- File cards (markdown targets) support: **Top of file**, **Bottom of file**, **After line...**
92+
- Canvas does not support cursor-based modes: **At cursor**, **New line above cursor**, **New line below cursor**
93+
94+
If **Capture to active file** is enabled and you leave the default write
95+
position at **At cursor**, capture will abort in Canvas until you switch to a
96+
supported mode.
97+
98+
Canvas capture requires exactly one selected card in selected-card mode. If the
99+
selection is missing, multi-select, or unsupported, QuickAdd aborts with a
100+
notice instead of writing to the wrong place.
101+
102+
A dedicated Canvas walkthrough page will return in a future update.
103+
104+
### Canvas Capture FAQ
105+
106+
**Why did my capture abort in Canvas?**
107+
108+
Most often one of these is true:
109+
110+
- No card is selected
111+
- More than one card is selected
112+
- The selected card type is unsupported
113+
- The selected write mode is cursor-based
114+
115+
**Can I target a specific card in a Canvas file?**
116+
117+
Yes. Set capture path to a `.canvas` file and choose a **Target canvas node**.
118+
119+
**Does "At cursor" work in Canvas cards?**
120+
121+
No. Use top, bottom, or insert-after placement.
122+
123+
**Can I capture to a file card that points to a Canvas file?**
124+
125+
No. File-card capture supports markdown targets only.
126+
127+
**Can I still create new Canvas files from templates?**
128+
129+
Yes. Template choices support `.canvas` templates.
130+
63131
## Insert after
64132

65133
Insert After will allow you to insert the text after some line with the specified text.

src/engine/CaptureChoiceEngine.ts

Lines changed: 145 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ import { ChoiceAbortError } from "../errors/ChoiceAbortError";
3939
import { MacroAbortError } from "../errors/MacroAbortError";
4040
import { SingleTemplateEngine } from "./SingleTemplateEngine";
4141
import { getCaptureAction, type CaptureAction } from "./captureAction";
42+
import {
43+
getCanvasTextCaptureContent,
44+
resolveActiveCanvasCaptureTarget,
45+
resolveConfiguredCanvasCaptureTarget,
46+
setCanvasTextCaptureContent,
47+
type CanvasTextCaptureTarget,
48+
type ConfiguredCanvasCaptureTarget,
49+
} from "./canvasCapture";
4250
import { handleMacroAbort } from "../utils/macroAbortHandler";
4351

4452
const DEFAULT_NOTICE_DURATION = 4000;
@@ -119,39 +127,56 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
119127
: globalSelectionAsValue;
120128
this.formatter.setUseSelectionAsCaptureValue(useSelectionAsCaptureValue);
121129

122-
const filePath = await this.getFormattedPathToCaptureTo(
123-
this.choice.captureToActiveFile,
124-
);
130+
const action = getCaptureAction(this.choice);
131+
const activeCanvasTarget = this.choice.captureToActiveFile
132+
? resolveActiveCanvasCaptureTarget(this.app, action)
133+
: null;
134+
const configuredCanvasTarget =
135+
await this.resolveConfiguredCanvasTarget(action);
136+
const canvasTarget = activeCanvasTarget ?? configuredCanvasTarget;
137+
138+
if (canvasTarget?.kind === "text") {
139+
await this.handleCanvasTextCapture(canvasTarget, action, linkOptions);
140+
return;
141+
}
142+
143+
const filePath =
144+
canvasTarget?.kind === "file"
145+
? canvasTarget.targetFile.path
146+
: await this.getFormattedPathToCaptureTo(this.choice.captureToActiveFile);
125147
const content = this.getCaptureContent();
126148

127-
let getFileAndAddContentFn: typeof this.onFileExists;
149+
type GetFileAndAddContentFn = (
150+
path: string,
151+
capture: string,
152+
linkOptions?: AppendLinkOptions,
153+
) => Promise<{ file: TFile; newFileContent: string; captureContent: string }>;
154+
let getFileAndAddContentFn: GetFileAndAddContentFn;
128155
const fileAlreadyExists = await this.fileExists(filePath);
129156

130157
if (fileAlreadyExists) {
131-
getFileAndAddContentFn = this.onFileExists.bind(
132-
this,
133-
) as typeof this.onFileExists;
134-
} else if (this.choice?.createFileIfItDoesntExist?.enabled) {
135-
getFileAndAddContentFn = ((path, capture, _options) =>
136-
this.onCreateFileIfItDoesntExist(path, capture, linkOptions)
137-
) as typeof this.onCreateFileIfItDoesntExist;
138-
} else {
139-
throw new ChoiceAbortError(
140-
`Target file missing: ${filePath}. Enable "Create file if it doesn't exist" or choose an existing file.`,
141-
);
142-
}
158+
getFileAndAddContentFn =
159+
this.onFileExists.bind(this) as GetFileAndAddContentFn;
160+
} else if (this.choice?.createFileIfItDoesntExist?.enabled) {
161+
getFileAndAddContentFn = ((path, capture, _options) =>
162+
this.onCreateFileIfItDoesntExist(path, capture, linkOptions)
163+
) as GetFileAndAddContentFn;
164+
} else {
165+
throw new ChoiceAbortError(
166+
`Target file missing: ${filePath}. Enable "Create file if it doesn't exist" or choose an existing file.`,
167+
);
168+
}
143169

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

147-
const action = getCaptureAction(this.choice);
148-
const isEditorInsertionAction =
149-
action === "currentLine" ||
150-
action === "newLineAbove" ||
151-
action === "newLineBelow";
173+
const isEditorInsertionAction =
174+
action === "currentLine" ||
175+
action === "newLineAbove" ||
176+
action === "newLineBelow";
152177

153-
// Handle capture to active file with special actions
154-
if (isEditorInsertionAction) {
178+
// Handle capture to active file with special actions
179+
if (isEditorInsertionAction) {
155180
// Parse Templater syntax in the capture content.
156181
// If Templater isn't installed, it just returns the capture content.
157182
const content = await templaterParseTemplate(
@@ -217,6 +242,103 @@ export class CaptureChoiceEngine extends QuickAddChoiceEngine {
217242
}
218243
}
219244

245+
private async handleCanvasTextCapture(
246+
target: CanvasTextCaptureTarget,
247+
action: CaptureAction,
248+
linkOptions: AppendLinkOptions,
249+
): Promise<void> {
250+
if (
251+
action === "currentLine" ||
252+
action === "newLineAbove" ||
253+
action === "newLineBelow"
254+
) {
255+
throw new ChoiceAbortError(
256+
"Canvas text cards support top, bottom, and insert-after positions only.",
257+
);
258+
}
259+
260+
if (
261+
action === "insertAfter" &&
262+
this.choice.insertAfter?.createIfNotFound &&
263+
this.choice.insertAfter?.createIfNotFoundLocation === "cursor"
264+
) {
265+
throw new ChoiceAbortError(
266+
"Canvas text cards do not support creating missing insert-after targets at cursor. Use top or bottom.",
267+
);
268+
}
269+
270+
const file = target.canvasFile;
271+
this.formatter.setTitle(basenameWithoutMdOrCanvas(file.basename));
272+
this.formatter.setDestinationFile(file);
273+
this.formatter.setDestinationSourcePath(file.path);
274+
275+
const captureTemplate = this.getCaptureContent();
276+
const existingText = getCanvasTextCaptureContent(target);
277+
const nextText = await this.formatter.formatContentWithFile(
278+
captureTemplate,
279+
this.choice,
280+
existingText,
281+
file,
282+
);
283+
284+
await setCanvasTextCaptureContent(this.app, target, nextText);
285+
286+
if (this.plugin.settings.showCaptureNotification) {
287+
this.showSuccessNotice(file, {
288+
wasNewFile: false,
289+
action,
290+
});
291+
}
292+
293+
if (linkOptions.enabled) {
294+
insertFileLinkToActiveView(this.app, file, linkOptions);
295+
}
296+
297+
if (this.choice.openFile && file) {
298+
const fileOpening = normalizeFileOpening(this.choice.fileOpening);
299+
const focus = fileOpening.focus ?? true;
300+
const openExistingTab = openExistingFileTab(this.app, file, focus);
301+
302+
if (!openExistingTab) {
303+
await openFile(this.app, file, fileOpening);
304+
}
305+
306+
await jumpToNextTemplaterCursorIfPossible(this.app, file);
307+
}
308+
}
309+
310+
private async resolveConfiguredCanvasTarget(
311+
action: CaptureAction,
312+
): Promise<ConfiguredCanvasCaptureTarget | null> {
313+
if (this.choice.captureToActiveFile) {
314+
return null;
315+
}
316+
317+
const nodeId = this.choice.captureToCanvasNodeId?.trim() ?? "";
318+
if (!nodeId) {
319+
return null;
320+
}
321+
322+
invariant(
323+
this.choice.captureTo?.trim().length > 0,
324+
"Canvas node capture requires a target .canvas file path.",
325+
);
326+
327+
const targetPath = await this.formatFilePath(this.choice.captureTo);
328+
if (!CANVAS_FILE_EXTENSION_REGEX.test(targetPath)) {
329+
throw new ChoiceAbortError(
330+
"Canvas node capture requires the target path to resolve to a .canvas file.",
331+
);
332+
}
333+
334+
return await resolveConfiguredCanvasCaptureTarget(
335+
this.app,
336+
targetPath,
337+
nodeId,
338+
action,
339+
);
340+
}
341+
220342
private getCaptureContent(): string {
221343
let content: string;
222344

0 commit comments

Comments
 (0)