@anchrd/intel-ui 0.8.4 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.8.4",
3
+ "version": "0.8.5",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -9,7 +9,7 @@ import { filterSuggestionItems } from "@blocknote/core";
9
9
  import { SuggestionMenuController, useCreateBlockNote } from "@blocknote/react";
10
10
  import { useMutation, useQuery } from "@tanstack/react-query";
11
11
  import { Link2 } from "lucide-react";
12
- import { useEffect, useMemo, useState } from "react";
12
+ import { useEffect, useMemo, useRef, useState } from "react";
13
13
  import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
14
14
  import { BlockNoteView, defaultSlashMenuItems } from "@/blocknote-view/blocknote-view.tsx";
15
15
  import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
@@ -34,6 +34,28 @@ function storedBlocks(content: string | null): IntelEditorPartialBlock[] | undef
34
34
  }
35
35
  }
36
36
 
37
+ /**
38
+ * What the editor holds, in a form two states can be compared by.
39
+ *
40
+ * ⚠️ This is what "unsaved" is answered with, and it deliberately is not "`onChange` fired". The
41
+ * editor changes for reasons that are not edits — loading the content is one (`replaceBlocks`
42
+ * below), and BlockNote normalizing a freshly mounted document is another. Neither is anything a
43
+ * reader did, and a warning that appears on every open is a warning nobody reads on the one day it
44
+ * is true. The canvas next door draws the same line with `editsNodes`/`editsEdges`.
45
+ *
46
+ * `id`s are part of it on purpose: BlockNote keeps them stable across edits within a session, and
47
+ * dropping them would call two different paragraphs equal whenever their text happens to match.
48
+ *
49
+ * ⚠️ It runs on every keystroke, and it walks the whole document to do so. That is affordable
50
+ * because `documentLinkIds` beside it already does, and because it is what buys the case a one-way
51
+ * flag cannot have: typing something and taking it back leaves nothing to save. There is no early
52
+ * exit — checking "already dirty, skip" is exactly what would lose that case. If documents ever
53
+ * grow far enough for this to be felt, the answer is to compare less often, not less carefully.
54
+ */
55
+ function editorSnapshot(blocks: unknown): string {
56
+ return JSON.stringify(blocks);
57
+ }
58
+
37
59
  export function KnowledgeEditor({
38
60
  data,
39
61
  document,
@@ -57,6 +79,13 @@ export function KnowledgeEditor({
57
79
  // conditional that this repo's `exactOptionalPropertyTypes` defeats, so it hands back the
58
80
  // default-schema editor even though it built Intel's. Same upstream seam as `BlockNoteView`.
59
81
  }) as unknown as IntelEditor;
82
+ // The content as it stands on the server, in comparable form. Every question of "is there
83
+ // anything to save" is answered against this, and saving moves it forward.
84
+ //
85
+ // ⚠️ A ref, not state: it is read inside `onChange` on every keystroke and must be the current
86
+ // value there, not the one captured when that handler was created. Nothing renders from it — what
87
+ // renders is `dirty` — so there is nothing to re-render when it moves.
88
+ const savedSnapshot = useRef(editorSnapshot(editor.document));
60
89
  const [dirty, setDirty] = useState(false);
61
90
  const [picking, setPicking] = useState(false);
62
91
  // Which documents the text links to right now. It is recomputed from the editor rather than from
@@ -69,25 +98,37 @@ export function KnowledgeEditor({
69
98
  queryFn: () => data.resolveKnowledgeLinks({ nodeIds: linkedIds }),
70
99
  enabled: linkedIds.length > 0,
71
100
  });
101
+ // One place decides whether there is anything to save, so the answer cannot differ between the
102
+ // button, the guard and the save itself.
103
+ function settle(): void {
104
+ setDirty(editorSnapshot(editor.document) !== savedSnapshot.current);
105
+ }
106
+
72
107
  const save = useMutation({
73
108
  mutationFn: async () => {
109
+ const blocks = editor.document;
74
110
  const payload = BlockNoteDocument.parse({
75
111
  format: "blocknote",
76
112
  schemaVersion: 1,
77
- blocks: editor.document,
78
- markdown: await editor.blocksToMarkdownLossy(editor.document),
113
+ blocks,
114
+ markdown: await editor.blocksToMarkdownLossy(blocks),
79
115
  });
80
- return await data.saveKnowledge({
116
+ const node = await data.saveKnowledge({
81
117
  nodeId: document.node.id,
82
118
  baseVersionId: document.version?.id ?? null,
83
119
  content: JSON.stringify(payload),
84
120
  mediaType: BlockNoteMediaType,
85
121
  idempotencyKey: crypto.randomUUID(),
86
122
  });
123
+ // ⚠️ The snapshot of what was SENT, not of what stands in the editor when the answer comes
124
+ // back. Saving crosses the network, and typing during it is normal — taking the state at
125
+ // arrival would call that typing saved and lose it at the next navigation.
126
+ return { node, snapshot: editorSnapshot(blocks) };
87
127
  },
88
- onSuccess: (saved) => {
89
- setDirty(false);
90
- onSaved(saved);
128
+ onSuccess: ({ node, snapshot }) => {
129
+ savedSnapshot.current = snapshot;
130
+ settle();
131
+ onSaved(node);
91
132
  },
92
133
  });
93
134
 
@@ -95,6 +136,11 @@ export function KnowledgeEditor({
95
136
  if (!document.content || initialBlocks) return;
96
137
  const blocks = editor.tryParseMarkdownToBlocks(document.content);
97
138
  editor.replaceBlocks(editor.document, blocks);
139
+ // ⚠️ Loading the content IS a change to the editor, and `onChange` reports it as one. This is
140
+ // the line that keeps a document nobody has touched from asking to be saved: what was just
141
+ // loaded is by definition what is stored.
142
+ savedSnapshot.current = editorSnapshot(editor.document);
143
+ setDirty(false);
98
144
  }, [document.content, editor, initialBlocks]);
99
145
 
100
146
  function insertLink(nodeId: string): void {
@@ -105,7 +151,7 @@ export function KnowledgeEditor({
105
151
  " ",
106
152
  ]);
107
153
  setPicking(false);
108
- setDirty(true);
154
+ settle();
109
155
  setLinkedIds(documentLinkIds(editor.document));
110
156
  }
111
157
 
@@ -135,7 +181,7 @@ export function KnowledgeEditor({
135
181
  <BlockNoteView
136
182
  editor={editor}
137
183
  onChange={() => {
138
- setDirty(true);
184
+ settle();
139
185
  setLinkedIds(documentLinkIds(editor.document));
140
186
  }}
141
187
  >