Skip to content

Commit 8c62672

Browse files
committed
Release v0.2.1
1 parent 907d14a commit 8c62672

File tree

9 files changed

+194
-113
lines changed

9 files changed

+194
-113
lines changed

main.js

Lines changed: 91 additions & 58 deletions
Large diffs are not rendered by default.

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"id": "discourse-graphs",
33
"name": "Discourse Graph",
4-
"version": "0.1.3",
4+
"version": "0.2.1",
55
"minAppVersion": "1.7.0",
66
"description": "Discourse Graph Plugin for Obsidian",
77
"author": "Discourse Graphs",

src/components/canvas/ExistingNodeSearch.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const ExistingNodeSearch = ({
6666
});
6767
editor.markHistoryStoppingPoint("add existing discourse node");
6868
editor.setSelectedShapes([id]);
69+
editor.setCurrentTool("select");
6970
} catch (error) {
7071
console.error("Error in handleSelect:", error);
7172
}

src/components/canvas/TldrawView.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export class TldrawView extends TextFileView {
1414
private reactRoot?: Root;
1515
private store: TLStore | null = null;
1616
private assetStore: ObsidianTLAssetStore | null = null;
17+
private canvasUuid: string | null = null;
1718
private onUnloadCallbacks: (() => void)[] = [];
1819

1920
constructor(leaf: WorkspaceLeaf, plugin: DiscourseGraphPlugin) {
@@ -108,6 +109,11 @@ export class TldrawView extends TextFileView {
108109
console.warn("Invalid tldraw data format - missing raw field");
109110
return;
110111
}
112+
if (data.meta?.uuid) {
113+
this.canvasUuid = data.meta.uuid;
114+
} else {
115+
this.canvasUuid = window.crypto.randomUUID();
116+
}
111117

112118
if (!this.file) {
113119
console.warn("TldrawView not initialized: missing file");
@@ -142,6 +148,8 @@ export class TldrawView extends TextFileView {
142148
throw new Error("TldrawView not initialized: missing assetStore");
143149
if (!this.store)
144150
throw new Error("TldrawView not initialized: missing store");
151+
if (!this.canvasUuid)
152+
throw new Error("TldrawView not initialized: missing canvas UUID");
145153

146154
if (!this.assetStore) {
147155
console.warn("Asset store is not set");
@@ -155,6 +163,7 @@ export class TldrawView extends TextFileView {
155163
store={store}
156164
file={this.file}
157165
assetStore={this.assetStore}
166+
canvasUuid={this.canvasUuid}
158167
/>
159168
</PluginProvider>
160169
</React.StrictMode>,

src/components/canvas/TldrawViewComponent.tsx

Lines changed: 44 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,18 +55,21 @@ type TldrawPreviewProps = {
5555
store: TLStore;
5656
file: TFile;
5757
assetStore: ObsidianTLAssetStore;
58+
canvasUuid: string;
5859
};
5960

6061
export const TldrawPreviewComponent = ({
6162
store,
6263
file,
6364
assetStore,
65+
canvasUuid,
6466
}: TldrawPreviewProps) => {
6567
const containerRef = useRef<HTMLDivElement>(null);
6668
const [currentStore, setCurrentStore] = useState<TLStore>(store);
6769
const [isReady, setIsReady] = useState(false);
6870
const isCreatingRelationRef = useRef(false);
6971
const saveTimeoutRef = useRef<NodeJS.Timeout>(null);
72+
const isSavingRef = useRef<boolean>(false);
7073
const lastShiftClickRef = useRef<number>(0);
7174
const SHIFT_CLICK_DEBOUNCE_MS = 300; // Prevent double clicks within 300ms
7275
const lastSavedDataRef = useRef<string>("");
@@ -99,10 +102,21 @@ export const TldrawPreviewComponent = ({
99102
}, []);
100103

101104
const saveChanges = useCallback(async () => {
105+
// Prevent concurrent saves
106+
if (isSavingRef.current) {
107+
return;
108+
}
109+
110+
if (!canvasUuid) {
111+
return;
112+
}
113+
114+
isSavingRef.current = true;
115+
102116
const newData = getTLDataTemplate({
103117
pluginVersion: plugin.manifest.version,
104118
tldrawFile: createRawTldrawFile(currentStore),
105-
uuid: window.crypto.randomUUID(),
119+
uuid: canvasUuid,
106120
});
107121
const stringifiedData = JSON.stringify(newData, null, "\t");
108122

@@ -131,8 +145,21 @@ export const TldrawPreviewComponent = ({
131145
),
132146
);
133147

134-
if (!verifyMatch || verifyMatch[1]?.trim() !== stringifiedData.trim()) {
135-
throw new Error("Failed to verify saved TLDraw data");
148+
if (!verifyMatch) {
149+
throw new Error(
150+
"Failed to verify saved TLDraw data: Could not find data block",
151+
);
152+
}
153+
154+
const savedData = JSON.parse(verifyMatch[1]?.trim() ?? "{}") as TLData;
155+
const expectedData = JSON.parse(
156+
stringifiedData?.trim() ?? "{}",
157+
) as TLData;
158+
159+
if (JSON.stringify(savedData) !== JSON.stringify(expectedData)) {
160+
console.warn(
161+
"Saved data differs from expected (this is normal during concurrent operations)",
162+
);
136163
}
137164

138165
lastSavedDataRef.current = stringifiedData;
@@ -155,18 +182,26 @@ export const TldrawPreviewComponent = ({
155182
setCurrentStore(newStore);
156183
}
157184
}
158-
}, [file, plugin, currentStore, assetStore]);
185+
isSavingRef.current = false;
186+
}, [file, plugin, currentStore, assetStore, canvasUuid]);
159187

160188
useEffect(() => {
161189
const unsubscribe = currentStore.listen(
162190
() => {
163191
if (saveTimeoutRef.current) {
164192
clearTimeout(saveTimeoutRef.current);
165193
}
166-
saveTimeoutRef.current = setTimeout(
167-
() => void saveChanges(),
168-
DEFAULT_SAVE_DELAY,
169-
);
194+
saveTimeoutRef.current = setTimeout(() => {
195+
// If a save is already in progress, schedule another save after it completes
196+
if (isSavingRef.current) {
197+
saveTimeoutRef.current = setTimeout(
198+
() => void saveChanges(),
199+
DEFAULT_SAVE_DELAY,
200+
);
201+
} else {
202+
void saveChanges();
203+
}
204+
}, DEFAULT_SAVE_DELAY);
170205
},
171206
{ source: "user", scope: "document" },
172207
);
@@ -366,7 +401,7 @@ export const TldrawPreviewComponent = ({
366401
<TldrawUiMenuItem
367402
id="discourse-node"
368403
icon="discourseNodeIcon"
369-
label="Discourse Node"
404+
label="Discourse Graph"
370405
onSelect={() => {
371406
if (editorRef.current) {
372407
editorRef.current.setCurrentTool("discourse-node");

src/components/canvas/shapes/DiscourseRelationShape.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1134,7 +1134,7 @@ export class DiscourseRelationUtil extends ShapeUtil<DiscourseRelationShape> {
11341134
}
11351135

11361136
// Add the bidirectional relation to frontmatter
1137-
await addRelationToFrontmatter({
1137+
const { alreadyExisted } = await addRelationToFrontmatter({
11381138
app: this.options.app,
11391139
plugin: this.options.plugin,
11401140
sourceFile,
@@ -1147,7 +1147,7 @@ export class DiscourseRelationUtil extends ShapeUtil<DiscourseRelationShape> {
11471147
(rt) => rt.id === shape.props.relationTypeId,
11481148
);
11491149

1150-
if (relationType) {
1150+
if (relationType && !alreadyExisted) {
11511151
showToast({
11521152
severity: "success",
11531153
title: "Relation Created",

src/components/canvas/stores/assetStore.ts

Lines changed: 17 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,10 @@ export const addWikilinkBlockrefForFile = async ({
4141
linkedFile,
4242
canvasFile.path,
4343
);
44-
const content = `[[${linkText}]]\n^${blockRefId}`;
44+
const content = `[[${linkText}]]\n^${blockRefId}\n`;
4545

4646
await app.vault.process(canvasFile, (data: string) => {
47-
const fileCache = app.metadataCache.getFileCache(canvasFile);
48-
const { start, end } =
49-
fileCache?.frontmatterPosition ??
50-
({
51-
start: { offset: 0 },
52-
end: { offset: 0 },
53-
} as { start: { offset: number }; end: { offset: number } });
54-
55-
const frontmatter = data.slice(start.offset, end.offset);
56-
const rest = data.slice(end.offset);
57-
return `${frontmatter}\n${content}\n${rest}`;
47+
return `${data}\n${content}`;
5848
});
5949

6050
return `asset:${ASSET_PREFIX}${blockRefId}`;
@@ -157,18 +147,11 @@ export const ensureBlockRefForFile = async ({
157147
canvasFile.path,
158148
);
159149
const internalLink = `[[${linkText}]]`;
160-
const linkBlock = `${internalLink}\n^${blockRefId}`;
150+
const linkBlock = `${internalLink}\n^${blockRefId}\n`;
161151

162-
// Insert right after frontmatter
152+
// Append to end of file to avoid corrupting tldraw data
163153
await app.vault.process(canvasFile, (data: string) => {
164-
const cache = app.metadataCache.getFileCache(canvasFile);
165-
const { start, end } = cache?.frontmatterPosition ?? {
166-
start: { offset: 0 },
167-
end: { offset: 0 },
168-
};
169-
const frontmatter = data.slice(start.offset, end.offset);
170-
const rest = data.slice(end.offset);
171-
return `${frontmatter}\n${linkBlock}\n${rest}`;
154+
return `${data}\n${linkBlock}`;
172155
});
173156

174157
return blockRefId;
@@ -253,9 +236,9 @@ class ObsidianMarkdownFileTLAssetStoreProxy {
253236
this.file.path,
254237
);
255238
const internalLink = `[[${linkText}]]`;
256-
const linkBlock = `${internalLink}\n^${blockRefId}`;
239+
const linkBlock = `${internalLink}\n^${blockRefId}\n`;
257240

258-
await this.addToTopOfFile(linkBlock);
241+
await this.appendToEndOfFile(linkBlock);
259242

260243
const assetDataUri = URL.createObjectURL(file);
261244
const assetId = `${ASSET_PREFIX}${blockRefId}` as BlockRefAssetId;
@@ -292,17 +275,13 @@ class ObsidianMarkdownFileTLAssetStoreProxy {
292275
this.resolvedAssetDataCache.clear();
293276
};
294277

295-
private addToTopOfFile = async (content: string) => {
278+
/**
279+
* Append asset references to the end of the file.
280+
* This avoids corrupting the tldraw JSON data block and frontmatter.
281+
*/
282+
private appendToEndOfFile = async (content: string) => {
296283
await this.app.vault.process(this.file, (data: string) => {
297-
const fileCache = this.app.metadataCache.getFileCache(this.file);
298-
const { start, end } = fileCache?.frontmatterPosition ?? {
299-
start: { offset: 0 },
300-
end: { offset: 0 },
301-
};
302-
303-
const frontmatter = data.slice(start.offset, end.offset);
304-
const rest = data.slice(end.offset);
305-
return `${frontmatter}\n${content}\n${rest}`;
284+
return `${data}\n${content}`;
306285
});
307286
};
308287

@@ -311,8 +290,10 @@ class ObsidianMarkdownFileTLAssetStoreProxy {
311290
): Promise<ArrayBuffer | null> => {
312291
try {
313292
const blockRef = extractBlockRefId(blockRefAssetId);
293+
if (!blockRef) return null;
294+
314295
const canvasFileCache = this.app.metadataCache.getFileCache(this.file);
315-
if (!blockRef || !canvasFileCache) return null;
296+
if (!canvasFileCache) return null;
316297

317298
const linkedFile = await resolveLinkedTFileByBlockRef({
318299
app: this.app,
@@ -360,6 +341,7 @@ export class ObsidianTLAssetStore implements Required<TLAssetStore> {
360341

361342
resolve = async (
362343
asset: TLAsset,
344+
// eslint-disable-next-line @typescript-eslint/naming-convention
363345
_ctx: TLAssetContext,
364346
): Promise<string | null> => {
365347
try {

src/components/canvas/utils/frontmatterUtils.ts

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type DiscourseGraphPlugin from "~/index";
44
/**
55
* Adds bidirectional relation links to the frontmatter of both files.
66
* This follows the same pattern as RelationshipSection.tsx
7+
*
8+
* @returns Object indicating whether the relation already existed
79
*/
810
export const addRelationToFrontmatter = async ({
911
app,
@@ -17,21 +19,26 @@ export const addRelationToFrontmatter = async ({
1719
sourceFile: TFile;
1820
targetFile: TFile;
1921
relationTypeId: string;
20-
}): Promise<void> => {
22+
}): Promise<{ alreadyExisted: boolean }> => {
2123
const relationType = plugin.settings.relationTypes.find(
2224
(r) => r.id === relationTypeId,
2325
);
2426

2527
if (!relationType) {
2628
console.error(`Relation type ${relationTypeId} not found`);
27-
return;
29+
return { alreadyExisted: false };
2830
}
2931

3032
try {
33+
let sourceToTargetExisted = false;
34+
let targetToSourceExisted = false;
35+
3136
const appendLinkToFrontmatter = async (
3237
fileToMutate: TFile,
3338
targetFile: TFile,
34-
) => {
39+
): Promise<boolean> => {
40+
let linkAlreadyExists = false;
41+
3542
await app.fileManager.processFrontMatter(
3643
fileToMutate,
3744
(fm: FrontMatterCache) => {
@@ -67,15 +74,28 @@ export const addRelationToFrontmatter = async ({
6774
const normalizedExistingLinks = existingLinks.map(normalizeLink);
6875
const normalizedLinkToAdd = normalizeLink(linkToAdd);
6976

70-
if (!normalizedExistingLinks.includes(normalizedLinkToAdd)) {
71-
fm[relationType.id] = [...existingLinks, linkToAdd];
72-
}
77+
linkAlreadyExists =
78+
normalizedExistingLinks.includes(normalizedLinkToAdd);
79+
if (!linkAlreadyExists) {
80+
fm[relationType.id] = [...existingLinks, linkToAdd];
81+
}
7382
},
7483
);
84+
85+
return linkAlreadyExists;
7586
};
7687

77-
await appendLinkToFrontmatter(sourceFile, targetFile);
78-
await appendLinkToFrontmatter(targetFile, sourceFile);
88+
sourceToTargetExisted = await appendLinkToFrontmatter(
89+
sourceFile,
90+
targetFile,
91+
);
92+
targetToSourceExisted = await appendLinkToFrontmatter(
93+
targetFile,
94+
sourceFile,
95+
);
96+
97+
// Consider the relation as "already existed" if both directions existed
98+
return { alreadyExisted: sourceToTargetExisted && targetToSourceExisted };
7999
} catch (error) {
80100
console.error("Failed to add relation to frontmatter:", error);
81101
throw error;

src/components/canvas/utils/nodeCreationFlow.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const openCreateDiscourseNodeAt = (args: CreateNodeAtArgs): void => {
5959
`create discourse node ${selectedNodeType.id}`,
6060
);
6161
tldrawEditor.setSelectedShapes([shapeId]);
62+
tldrawEditor.setCurrentTool("select");
6263
} catch (error) {
6364
console.error("Error creating discourse node:", error);
6465
showToast({

0 commit comments

Comments
 (0)