@pixerate/editor 0.145.0 → 0.146.0

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.
@@ -1,3 +1,12 @@
1
+ import {
2
+ EDITOR_DOCUMENT_FORMAT,
3
+ EditorDocumentLoadError,
4
+ defineEditorDocument,
5
+ isEditorDocumentFile,
6
+ parseEditorDocument,
7
+ serializeEditorDocument,
8
+ toEditorDocumentFile
9
+ } from "../chunk-HRLT5BYO.js";
1
10
  import {
2
11
  createLoadReport,
3
12
  hasLoadProblems,
@@ -651,10 +660,288 @@ function createRevisionTracker(options = {}) {
651
660
  }
652
661
  };
653
662
  }
663
+
664
+ // src/persistence/editorAutosave.ts
665
+ function createEditorAutosave(doc, options) {
666
+ const { dirty, key, ...rest } = options;
667
+ const revisions = createRevisionTracker({ history: doc, dirty });
668
+ const controller = createAutosaveController({
669
+ ...rest,
670
+ key: key ?? doc.kind,
671
+ getSnapshot: () => toEditorDocumentFile(doc),
672
+ deserialize: (text) => parseEditorDocument(text, { kind: doc.kind }),
673
+ revision: revisions.revision,
674
+ savedRevision: revisions.savedRevision
675
+ });
676
+ const unsubscribe = revisions.subscribe(() => controller.notify());
677
+ return {
678
+ controller,
679
+ revisions,
680
+ checkRecovery: () => controller.checkRecovery(),
681
+ restore(recovery) {
682
+ if (!recovery.snapshot) throw recovery.error ?? new Error("The recovered snapshot could not be read.");
683
+ const report = doc.loadDocument(recovery.snapshot, { history: "reset" });
684
+ controller.ready();
685
+ return report;
686
+ },
687
+ async discard() {
688
+ await controller.discardRecovery();
689
+ controller.ready();
690
+ },
691
+ async markClean() {
692
+ revisions.markSaved();
693
+ await controller.markClean();
694
+ },
695
+ destroy() {
696
+ unsubscribe();
697
+ revisions.destroy();
698
+ controller.destroy();
699
+ }
700
+ };
701
+ }
702
+
703
+ // src/persistence/documentStore.ts
704
+ function slugifyDocumentName(name) {
705
+ const slug = name.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80);
706
+ return slug || "untitled";
707
+ }
708
+ function createEditorDocumentStore(options) {
709
+ const { storage } = options;
710
+ const ns = options.namespace ?? "pixerate.documents";
711
+ const now = options.now ?? Date.now;
712
+ const indexKey = `${ns}:index`;
713
+ const fileKey = (key) => `${ns}:file:${key}`;
714
+ let index = null;
715
+ let queue = Promise.resolve();
716
+ const run = (task) => {
717
+ const result = queue.then(task, task);
718
+ queue = result.catch(() => void 0);
719
+ return result;
720
+ };
721
+ const loadIndex = async () => {
722
+ if (index) return index;
723
+ const raw = await storage.get(indexKey);
724
+ let items = [];
725
+ if (raw) {
726
+ try {
727
+ const data = JSON.parse(raw);
728
+ if (data && Array.isArray(data.items)) {
729
+ items = data.items.filter((item) => item && typeof item.key === "string" && typeof item.kind === "string");
730
+ }
731
+ } catch {
732
+ items = [];
733
+ }
734
+ }
735
+ index = items;
736
+ return index;
737
+ };
738
+ const saveIndex = async (items) => {
739
+ await storage.set(indexKey, JSON.stringify({ version: 1, items }));
740
+ index = items;
741
+ };
742
+ const newestFirst = (a, b) => a.savedAt < b.savedAt ? 1 : a.savedAt > b.savedAt ? -1 : 0;
743
+ const readFile = async (key) => {
744
+ const raw = await storage.get(fileKey(key));
745
+ return raw === null ? null : parseEditorDocument(raw);
746
+ };
747
+ return {
748
+ save: (doc, name) => run(async () => {
749
+ const trimmed = name.trim() || "Untitled";
750
+ const entry = {
751
+ key: `${doc.kind}/${slugifyDocumentName(trimmed)}`,
752
+ kind: doc.kind,
753
+ name: trimmed,
754
+ formatVersion: doc.formatVersion,
755
+ savedAt: new Date(now()).toISOString()
756
+ };
757
+ await storage.set(fileKey(entry.key), JSON.stringify(toEditorDocumentFile(doc, trimmed)));
758
+ await saveIndex([...(await loadIndex()).filter((item) => item.key !== entry.key), entry]);
759
+ return entry;
760
+ }),
761
+ list: (kind) => run(async () => (await loadIndex()).filter((item) => !kind || item.kind === kind).sort(newestFirst)),
762
+ read: (key) => run(() => readFile(key)),
763
+ open: (doc, key, openOptions = {}) => run(async () => {
764
+ const entry = (await loadIndex()).find((item) => item.key === key);
765
+ const file = entry ? await readFile(key) : null;
766
+ if (!entry || !file) {
767
+ const keys = (await loadIndex()).filter((item) => item.kind === doc.kind).sort(newestFirst).map((item) => item.key);
768
+ throw new EditorDocumentLoadError(
769
+ `There is no saved document "${key}". ${keys.length ? `Saved ${doc.kind} documents: ${keys.join(", ")}.` : `No ${doc.kind} documents are saved yet.`}`
770
+ );
771
+ }
772
+ const report = doc.loadDocument(file, { history: openOptions.history ?? "reset" });
773
+ return { ...entry, report };
774
+ }),
775
+ remove: (key) => run(async () => {
776
+ const items = await loadIndex();
777
+ if (!items.some((item) => item.key === key)) return false;
778
+ await saveIndex(items.filter((item) => item.key !== key));
779
+ await storage.remove(fileKey(key));
780
+ return true;
781
+ })
782
+ };
783
+ }
784
+
785
+ // src/persistence/documentTools.ts
786
+ var READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
787
+ var WRITE = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false };
788
+ var REPLACE = { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false };
789
+ var OVERWRITE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false };
790
+ var object = (properties, required = []) => ({
791
+ type: "object",
792
+ properties,
793
+ ...required.length ? { required } : {},
794
+ additionalProperties: false
795
+ });
796
+ var reportOut = (report) => ({
797
+ ...report.migratedFrom !== null ? { migratedFrom: report.migratedFrom } : {},
798
+ ...report.problems.length ? { problems: report.problems } : {},
799
+ ...report.unknownKeys.length ? { unknownKeys: report.unknownKeys } : {}
800
+ });
801
+ function createDocumentToolProvider(doc, options = {}) {
802
+ const { kind } = doc;
803
+ const noun = options.noun ?? kind.replace(/_/g, " ");
804
+ const maxChars = options.maxChars ?? 2e5;
805
+ const store = options.store;
806
+ const writable = () => {
807
+ const reason = doc.readOnly();
808
+ if (reason) throw new Error(`${reason} Read tools still work; write tools are refused.`);
809
+ };
810
+ const historyOut = () => {
811
+ const h = doc.history.getState();
812
+ return {
813
+ revision: doc.revision(),
814
+ canUndo: h.canUndo,
815
+ canRedo: h.canRedo,
816
+ ...h.undoName ? { undoName: h.undoName } : {},
817
+ ...h.redoName ? { redoName: h.redoName } : {}
818
+ };
819
+ };
820
+ const loaded = (report) => ({
821
+ revision: doc.revision(),
822
+ undoable: doc.recordsLoads,
823
+ ...reportOut(report)
824
+ });
825
+ const get = {
826
+ name: `get_${kind}_document`,
827
+ title: `Get the ${noun} document`,
828
+ description: `Returns the whole ${noun} as plain JSON: { kind, formatVersion, revision, document }. Use it to keep a copy, compare versions or pass the document to load_${kind}_document later. For reading or editing parts of the ${noun}, prefer the editor's own read tools: this result can be large.`,
829
+ inputSchema: object({}),
830
+ annotations: { title: `Get the ${noun} document`, ...READ },
831
+ handler: () => {
832
+ const document = doc.getDocument();
833
+ const size = JSON.stringify(document)?.length ?? 0;
834
+ if (size > maxChars) {
835
+ throw new Error(`The ${noun} document is ${size} characters of JSON, over the ${maxChars} limit for this tool. Use the editor's read tools to look at parts of it.`);
836
+ }
837
+ return { kind, formatVersion: doc.formatVersion, revision: doc.revision(), document };
838
+ }
839
+ };
840
+ const load = {
841
+ name: `load_${kind}_document`,
842
+ title: `Load a ${noun} document`,
843
+ description: `Replaces the whole ${noun} with a document from get_${kind}_document (its "document" value, or the whole result as a saved file). The document is checked first: when it does not fit, nothing changes and the error says why. Returns { revision, undoable, migratedFrom?, problems?, unknownKeys? }; undoable: true means undo_${kind} brings the previous ${noun} back.`,
844
+ inputSchema: object(
845
+ {
846
+ document: {
847
+ type: "object",
848
+ description: `The ${noun} document: the "document" value from get_${kind}_document, or a saved file ({ format, kind, formatVersion, document }).`
849
+ }
850
+ },
851
+ ["document"]
852
+ ),
853
+ annotations: { title: `Load a ${noun} document`, ...REPLACE },
854
+ handler: ({ document }) => {
855
+ writable();
856
+ return loaded(doc.loadDocument(document, { history: "record" }));
857
+ }
858
+ };
859
+ const undo = {
860
+ name: `undo_${kind}`,
861
+ title: `Undo a ${noun} edit`,
862
+ description: `Undoes the last ${noun} edit (one tool call or one user gesture). Returns { undone, revision, canUndo, canRedo, undoName?, redoName? }; undone is the name of the step that was undone.`,
863
+ inputSchema: object({}),
864
+ annotations: { title: `Undo a ${noun} edit`, ...WRITE },
865
+ handler: () => {
866
+ writable();
867
+ const name = doc.history.getState().undoName;
868
+ if (!doc.history.getState().canUndo || !doc.history.undo()) throw new Error("Nothing to undo.");
869
+ return { undone: name ?? true, ...historyOut() };
870
+ }
871
+ };
872
+ const redo = {
873
+ name: `redo_${kind}`,
874
+ title: `Redo a ${noun} edit`,
875
+ description: `Redoes the last undone ${noun} edit. Returns { redone, revision, canUndo, canRedo, undoName?, redoName? }; redone is the name of the step that was redone.`,
876
+ inputSchema: object({}),
877
+ annotations: { title: `Redo a ${noun} edit`, ...WRITE },
878
+ handler: () => {
879
+ writable();
880
+ const name = doc.history.getState().redoName;
881
+ if (!doc.history.getState().canRedo || !doc.history.redo()) throw new Error("Nothing to redo.");
882
+ return { redone: name ?? true, ...historyOut() };
883
+ }
884
+ };
885
+ const storeTools = (s) => [
886
+ {
887
+ name: `save_${kind}_document`,
888
+ title: `Save the ${noun}`,
889
+ description: `Saves the current ${noun} under a name in the host's document storage. Saving the same name again replaces that saved copy. Returns { key, name, savedAt, revision }; pass key to open_saved_${kind}_document to load it again.`,
890
+ inputSchema: object({
891
+ name: {
892
+ type: "string",
893
+ minLength: 1,
894
+ maxLength: 200,
895
+ description: `Name to save under (default: the ${noun}'s current name, or "Untitled ${noun}").`
896
+ }
897
+ }),
898
+ annotations: { title: `Save the ${noun}`, ...OVERWRITE },
899
+ handler: async ({ name }) => {
900
+ const entry = await s.save(doc, name ?? options.name?.() ?? `Untitled ${noun}`);
901
+ return { key: entry.key, name: entry.name, savedAt: entry.savedAt, revision: doc.revision() };
902
+ }
903
+ },
904
+ {
905
+ name: `list_saved_${kind}_documents`,
906
+ title: `List saved ${noun} documents`,
907
+ description: `Lists the ${noun} documents in the host's document storage, newest first. Returns { documents: [{ key, name, savedAt, formatVersion }] }.`,
908
+ inputSchema: object({}),
909
+ annotations: { title: `List saved ${noun} documents`, ...READ },
910
+ handler: async () => ({
911
+ documents: (await s.list(kind)).map(({ key, name, savedAt, formatVersion }) => ({ key, name, savedAt, formatVersion }))
912
+ })
913
+ },
914
+ {
915
+ name: `open_saved_${kind}_document`,
916
+ title: `Open a saved ${noun}`,
917
+ description: `Replaces the current ${noun} with a saved one. Unsaved changes stay reachable through undo_${kind} when undoable is true. Returns { key, name, revision, undoable, migratedFrom?, problems?, unknownKeys? }.`,
918
+ inputSchema: object({ key: { type: "string", minLength: 1, description: `A key from list_saved_${kind}_documents or save_${kind}_document.` } }, ["key"]),
919
+ annotations: { title: `Open a saved ${noun}`, ...REPLACE },
920
+ handler: async ({ key }) => {
921
+ writable();
922
+ const entry = await s.open(doc, key, { history: "record" });
923
+ return { key: entry.key, name: entry.name, ...loaded(entry.report) };
924
+ }
925
+ }
926
+ ];
927
+ return {
928
+ id: options.id ?? `${kind}-document`,
929
+ ...options.namespace !== void 0 ? { namespace: options.namespace } : {},
930
+ title: options.title ?? `${noun.charAt(0).toUpperCase()}${noun.slice(1)} document`,
931
+ description: `Get, load${options.history === false ? "" : ", undo, redo"}${store ? ", save and open" : ""} the whole ${noun}.`,
932
+ ...options.skill ? { skill: options.skill } : {},
933
+ tools: [get, load, ...options.history === false ? [] : [undo, redo], ...store ? storeTools(store) : []]
934
+ };
935
+ }
654
936
  export {
937
+ EDITOR_DOCUMENT_FORMAT,
938
+ EditorDocumentLoadError,
655
939
  InjectedStorageError,
656
940
  StorageUnavailableError,
657
941
  createAutosaveController,
942
+ createDocumentToolProvider,
943
+ createEditorAutosave,
944
+ createEditorDocumentStore,
658
945
  createIndexedDbAdapter,
659
946
  createLoadReport,
660
947
  createLocalStorageAdapter,
@@ -662,15 +949,21 @@ export {
662
949
  createOpfsAdapter,
663
950
  createRecoveryPolicy,
664
951
  createRevisionTracker,
952
+ defineEditorDocument,
665
953
  exponentialBackoff,
666
954
  formatRecoveryMessage,
667
955
  hasLoadProblems,
956
+ isEditorDocumentFile,
668
957
  isIndexedDbAvailable,
669
958
  isLocalStorageAvailable,
670
959
  isOpfsAvailable,
671
960
  loadDocument,
672
961
  loadSections,
673
962
  migrateDocument,
963
+ parseEditorDocument,
674
964
  recoveryMetaKey,
965
+ serializeEditorDocument,
966
+ slugifyDocumentName,
967
+ toEditorDocumentFile,
675
968
  withStorageFailures
676
969
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor",
3
- "version": "0.145.0",
3
+ "version": "0.146.0",
4
4
  "description": "UI-agnostic rich text and media editing core library",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -61,6 +61,27 @@ editor's, or (in discovery mode) only `list_toolsets`, `search_tools`, `enable_t
61
61
  6. **Undo your own mistakes** with the domain's `undo_*` tool or the `history.undo` command, not
62
62
  by hand-reverting with more edits.
63
63
 
64
+ ## Saving, loading and undo
65
+
66
+ Every editor can share the same document tools, named after its kind (`diagram`, `chart`,
67
+ `spreadsheet`...). The editor's skill lists the ones it has.
68
+
69
+ - `get_<kind>_document` returns the whole document as plain JSON
70
+ (`{ kind, formatVersion, revision, document }`). Use it to keep a copy before a risky change
71
+ or to hand the document on, not to read it: the editor's own read tools are smaller and more
72
+ precise. It refuses very large documents and says so.
73
+ - `load_<kind>_document` puts such a copy back. The document is checked first and nothing
74
+ changes when it does not fit. It is one undo step when the result says `undoable: true`.
75
+ - `undo_<kind>` / `redo_<kind>` step through the editor's history. They fail with
76
+ "Nothing to undo." / "Nothing to redo." when there is nothing to step over.
77
+ - With host storage: `save_<kind>_document` (by `name`; the same name replaces that copy),
78
+ `list_saved_<kind>_documents`, then `open_saved_<kind>_document` with a `key` from either.
79
+ Save when the user asks. Opening replaces the current document, so ask first when it has
80
+ changes the user has not saved.
81
+
82
+ A read-only document keeps its read tools; its write tools fail with the reason. `revision`
83
+ goes up on every change, so comparing two results tells you whether the document changed.
84
+
64
85
  ## Playback
65
86
 
66
87
  The host may act your calls out on screen (agent playback): your cursor glides to what a call
@@ -111,6 +111,19 @@ text-only families.
111
111
  | `undo_diagram` | Undo the last diagram edit; returns its label. |
112
112
  | `redo_diagram` | Redo the last undone diagram edit. |
113
113
 
114
+ ### Save and open
115
+
116
+ The host may add the shared document tools (see `pixerate-agent-tools`). A diagram document is
117
+ `{ source }`: the Mermaid text with its `%% @pixerate` layout comment.
118
+
119
+ | Tool | Use |
120
+ | --- | --- |
121
+ | `get_diagram_document` | Take a copy of the whole diagram as `{ kind, formatVersion, revision, document }` before a risky rewrite, or to hand it to another session. To read the diagram, use `get_diagram`. |
122
+ | `load_diagram_document` | Put back a copy from `get_diagram_document` (its `document`, or the whole result). Checked first; one undo step (`undo_diagram` brings the previous diagram back). For new Mermaid text use `load_diagram_mermaid`. |
123
+ | `save_diagram_document` | Save the diagram under a `name` in the host's storage (same name replaces that copy). Returns the `key`. Only listed when the host has storage. Save when the user asks, not after every edit. |
124
+ | `list_saved_diagram_documents` | List saved diagrams (`key`, `name`, `savedAt`), newest first. |
125
+ | `open_saved_diagram_document` | Replace the diagram with a saved one by `key` from the list. One undo step. Ask before opening over unsaved work the user has not saved. |
126
+
114
127
  ## Pitfalls
115
128
 
116
129
  - Ids are Mermaid ids, not labels. Always take them from `get_diagram` or from what a write