editor-shell 0.31.0 → 0.32.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.
- package/README.md +37 -0
- package/dist/edit-lock/index.d.ts +256 -0
- package/dist/edit-lock/index.js +109 -0
- package/dist/edit-lock/index.js.map +1 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -191,6 +191,43 @@ earlier than the transparent box on top of it and every following line would be
|
|
|
191
191
|
drawn over the wrong text. The real weight and slant appear on the page the
|
|
192
192
|
moment the merchant clicks away.
|
|
193
193
|
|
|
194
|
+
## The edit lock — two operators, one document
|
|
195
|
+
|
|
196
|
+
`editor-shell/edit-lock` (added in 0.32.0) is the answer to "is the save path
|
|
197
|
+
allowed to run", shared by every editor in the house. It is pure TypeScript: no
|
|
198
|
+
React, no browser global, no dependency at all, so a server component may import
|
|
199
|
+
it.
|
|
200
|
+
|
|
201
|
+
It exists because the storefront editor and the campaign designer had written
|
|
202
|
+
the same rules twice, in two repos, and the two copies had already drifted — the
|
|
203
|
+
take-over half only existed on one side and the claim driver only on the other.
|
|
204
|
+
Both are here now, and neither editor keeps a copy.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
import { createEditLock, canWriteDocument, documentPermissions } from 'editor-shell/edit-lock';
|
|
208
|
+
|
|
209
|
+
// The only two things that differ between one editor and the next.
|
|
210
|
+
export const pageEditLock = createEditLock({
|
|
211
|
+
readFailure: (err) =>
|
|
212
|
+
err instanceof ApiError ? { status: err.status, data: err.data } : null,
|
|
213
|
+
conflictCode: 'page_changed_elsewhere',
|
|
214
|
+
});
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The plain half needs no binding: `canWriteDocument`, `holderOf`,
|
|
218
|
+
`readLockHolder`, `accessForNewDocument`, `documentPermissions`, `describeAge`,
|
|
219
|
+
`nameOrSomeone`, `newEditorClientId`, `shouldReleaseHeldWrite`. The bound half —
|
|
220
|
+
`accessAfterClaimFailure`, `accessAfterTakeoverFailure`,
|
|
221
|
+
`accessAfterHeartbeatFailure`, `isAuthRejection`, `readSaveConflict`,
|
|
222
|
+
`makeClaimBeat` — comes back from `createEditLock`.
|
|
223
|
+
|
|
224
|
+
**Two things this module will not do.** It never takes the ability to edit away
|
|
225
|
+
because a courtesy call failed: every claim failure that is not a 409 naming a
|
|
226
|
+
holder falls back to `editing`, because the save-time version check is what
|
|
227
|
+
actually protects the work. And a failed TAKE-OVER is a different question from a
|
|
228
|
+
failed first claim — it keeps the state it was pressed from, so a take-over that
|
|
229
|
+
did not land never puts two people in one document with the holder untold.
|
|
230
|
+
|
|
194
231
|
## Develop
|
|
195
232
|
|
|
196
233
|
No Node on the dev Mac — everything runs in Docker:
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may WRITE the open document, and what to tell the operator when they may
|
|
3
|
+
* not — for EVERY editor in the house.
|
|
4
|
+
*
|
|
5
|
+
* This module is the merge of two files that had grown side by side and said
|
|
6
|
+
* the same things in two places (card 11161):
|
|
7
|
+
*
|
|
8
|
+
* * `efficient-admin-portal/src/components/email/campaignEditLock.ts` — the
|
|
9
|
+
* campaign designer's copy, which alone had the TAKE-OVER half;
|
|
10
|
+
* * `efficient-shop/lib/editor-page-lock.ts` — the storefront editor's copy,
|
|
11
|
+
* which alone had the claim/heartbeat driver, the auth-rejection rule and
|
|
12
|
+
* the "a template has no lock" rule.
|
|
13
|
+
*
|
|
14
|
+
* Neither was a subset of the other, so this file keeps EVERY capability of
|
|
15
|
+
* both, and both editors now import it instead of carrying a copy.
|
|
16
|
+
*
|
|
17
|
+
* TWO INDEPENDENT PROTECTIONS, and it matters which is which:
|
|
18
|
+
*
|
|
19
|
+
* * the SAVE-TIME version check is what actually protects work. The backend
|
|
20
|
+
* refuses a document write whose base version has moved on, so no amount of
|
|
21
|
+
* client-side confusion can erase a colleague's edits. Nothing here can
|
|
22
|
+
* weaken it — the token travels with the write and the server decides.
|
|
23
|
+
* * this advisory LOCK only stops two people STARTING, and is soft on
|
|
24
|
+
* purpose: it expires, it can always be taken over, and — see
|
|
25
|
+
* `accessAfterClaimFailure` — it FAILS OPEN. A lock that could refuse to
|
|
26
|
+
* let anybody edit would be worse than no lock at all.
|
|
27
|
+
*
|
|
28
|
+
* The backend rule is one rule shared by both (efficient/editlock.py). These
|
|
29
|
+
* are two CONSUMERS of one server-side contract, which is exactly why one
|
|
30
|
+
* client module can serve them.
|
|
31
|
+
*
|
|
32
|
+
* WHAT IS NOT SHARED, and why there is a factory rather than plain functions:
|
|
33
|
+
* the two editors talk to the backend through different HTTP clients, so a
|
|
34
|
+
* failure arrives as an `AxiosError` in one and as an `ApiError` in the other,
|
|
35
|
+
* and the refused-save payload carries a different `code`. Those two facts —
|
|
36
|
+
* and nothing else — are handed in by the consumer through `createEditLock`.
|
|
37
|
+
* The module itself imports no HTTP client and knows about no repo.
|
|
38
|
+
*
|
|
39
|
+
* LEAF DISCIPLINE: pure TypeScript. No React, no `document`/`window`, no
|
|
40
|
+
* dependency at all. `crypto` is touched inside a function, never at module
|
|
41
|
+
* eval, so a Next server component may import this module safely.
|
|
42
|
+
*/
|
|
43
|
+
/** The `edit-lock` body — identical for every editable document. */
|
|
44
|
+
type EditLockBody = {
|
|
45
|
+
held_by_you: boolean;
|
|
46
|
+
holder_name: string;
|
|
47
|
+
client_id: string;
|
|
48
|
+
last_seen_at: string;
|
|
49
|
+
seconds_since_seen: number;
|
|
50
|
+
seconds_since_held: number;
|
|
51
|
+
heartbeat_seconds: number;
|
|
52
|
+
expiry_seconds: number;
|
|
53
|
+
};
|
|
54
|
+
/** The other operator, as the notices need to describe them. */
|
|
55
|
+
type LockHolder = {
|
|
56
|
+
name: string;
|
|
57
|
+
/** Seconds since their last heartbeat, as the SERVER measured it. */
|
|
58
|
+
secondsSinceSeen: number;
|
|
59
|
+
/** Seconds since they TOOK the document, as the SERVER measured it. Not the
|
|
60
|
+
* same number: an active holder's heartbeat keeps `secondsSinceSeen` near
|
|
61
|
+
* zero, so only this one can say "took over 20 seconds ago". */
|
|
62
|
+
secondsSinceHeld: number;
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Whether the open document may be written, and why not when it may not.
|
|
66
|
+
*
|
|
67
|
+
* `editing` is the ONLY writable state, and `canWriteDocument` is the only place
|
|
68
|
+
* that says so — so a new state added later is read-only until someone
|
|
69
|
+
* deliberately makes it writable, rather than writable until someone remembers
|
|
70
|
+
* to block it.
|
|
71
|
+
*/
|
|
72
|
+
type EditAccess =
|
|
73
|
+
/** The claim is still in flight. Holding writes for the moment it takes is
|
|
74
|
+
* what stops a fast typist's first autosave landing before we know whether
|
|
75
|
+
* somebody else is in here. */
|
|
76
|
+
{
|
|
77
|
+
kind: 'checking';
|
|
78
|
+
}
|
|
79
|
+
/** You hold the document. */
|
|
80
|
+
| {
|
|
81
|
+
kind: 'editing';
|
|
82
|
+
}
|
|
83
|
+
/** Somebody else holds it and you have not chosen yet — view, or take over. */
|
|
84
|
+
| {
|
|
85
|
+
kind: 'blocked';
|
|
86
|
+
holder: LockHolder;
|
|
87
|
+
}
|
|
88
|
+
/** You chose to look without touching. */
|
|
89
|
+
| {
|
|
90
|
+
kind: 'readonly';
|
|
91
|
+
holder: LockHolder;
|
|
92
|
+
}
|
|
93
|
+
/** You HAD it and somebody took it. Your work is still on screen. */
|
|
94
|
+
| {
|
|
95
|
+
kind: 'takenover';
|
|
96
|
+
holder: LockHolder;
|
|
97
|
+
};
|
|
98
|
+
/** The one gate on the document write path. */
|
|
99
|
+
declare const canWriteDocument: (access: EditAccess) => boolean;
|
|
100
|
+
/**
|
|
101
|
+
* The access a freshly opened document starts from.
|
|
102
|
+
*
|
|
103
|
+
* A document WITH a lock starts `checking` and the claim answers within a round
|
|
104
|
+
* trip. One WITHOUT — the storefront's templates — has no claim effect at all,
|
|
105
|
+
* so `checking` there is not a question awaiting an answer, it is a state
|
|
106
|
+
* nothing will ever leave, and every write for the rest of the session is
|
|
107
|
+
* refused: the merchant's edits and, first of all, the pinned Header/Footer seed
|
|
108
|
+
* that runs in the chrome's mount commit. The editor's MOUNT has always known
|
|
109
|
+
* this; a document SWITCH did not, which is what card 1096 found.
|
|
110
|
+
*
|
|
111
|
+
* Not a loosening of `canWriteDocument`: it says which QUESTION a document opens
|
|
112
|
+
* with, and a document with no lock was never asking one.
|
|
113
|
+
*/
|
|
114
|
+
declare function accessForNewDocument(hasEditLock: boolean): EditAccess;
|
|
115
|
+
/** The holder named by a state that has one (for the banner / the notices). */
|
|
116
|
+
declare const holderOf: (access: EditAccess) => LockHolder | null;
|
|
117
|
+
/** Read the `edit-lock` body — from a 200 or from a 409's error payload. */
|
|
118
|
+
declare function readLockHolder(data: unknown): LockHolder | null;
|
|
119
|
+
/** The five capabilities Puck's `permissions` prop carries for the canvas. */
|
|
120
|
+
type DocumentPermissions = {
|
|
121
|
+
edit: boolean;
|
|
122
|
+
insert: boolean;
|
|
123
|
+
delete: boolean;
|
|
124
|
+
duplicate: boolean;
|
|
125
|
+
drag: boolean;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* The canvas's Puck `permissions`, derived from the write gate.
|
|
129
|
+
*
|
|
130
|
+
* BOTH branches name ALL FIVE keys — and that completeness is the whole point,
|
|
131
|
+
* not tidiness. Puck does not replace this prop when it changes; it MERGES it
|
|
132
|
+
* over the permissions it already holds (store/slices/permissions.ts,
|
|
133
|
+
* `useRegisterPermissionsSlice`: `{ ...existingGlobalPermissions, ...prop }`),
|
|
134
|
+
* and a field's editability is the live `getPermissions().edit`. Puck mounts
|
|
135
|
+
* while the lock claim is still `checking`, so `canEdit` is false and the store
|
|
136
|
+
* latches every key false. A PARTIAL writable value (say `{ drag: false }`)
|
|
137
|
+
* would then merge WITHOUT an `edit` key and leave `edit:false` stuck — every
|
|
138
|
+
* inspector field disabled for good, even after the claim resolves to
|
|
139
|
+
* `editing`. Spelling out edit/insert/delete/duplicate on the writable branch
|
|
140
|
+
* resets them, so the panel comes back the instant `canEdit` flips true.
|
|
141
|
+
*
|
|
142
|
+
* `drag` stays false in EITHER state: both editors move blocks with native DnD
|
|
143
|
+
* rails of their own, so Puck's flaky cross-iframe canvas drag is always off.
|
|
144
|
+
*
|
|
145
|
+
* Write safety is unchanged: the `checking` window is still non-editable here,
|
|
146
|
+
* and the host's own write gate still drops any edit attempted before the claim
|
|
147
|
+
* resolves, so nothing is saved early.
|
|
148
|
+
*/
|
|
149
|
+
declare const documentPermissions: (canEdit: boolean) => DocumentPermissions;
|
|
150
|
+
/** "just now" / "2 minutes ago" — one phrasing, shared by every notice. */
|
|
151
|
+
declare function describeAge(seconds: number): string;
|
|
152
|
+
/** Names the person, or says so honestly when the backend could not. */
|
|
153
|
+
declare const nameOrSomeone: (name: string) => string;
|
|
154
|
+
/**
|
|
155
|
+
* The identity of THIS editor tab.
|
|
156
|
+
*
|
|
157
|
+
* Per-tab, not per-user: it is what lets the backend tell "me, in a second tab"
|
|
158
|
+
* (take my own lock over silently) from "me, here". Deliberately not persisted —
|
|
159
|
+
* a reload IS a new editor session, and a remembered id would let a closed tab's
|
|
160
|
+
* claim look alive.
|
|
161
|
+
*/
|
|
162
|
+
declare function newEditorClientId(): string;
|
|
163
|
+
/** A refused save: the stored document moved on since this editor loaded it. */
|
|
164
|
+
type SaveConflict = {
|
|
165
|
+
/** Who saved over you. Empty when the backend could not attribute the write. */
|
|
166
|
+
savedBy: string;
|
|
167
|
+
/** True when it was YOU, in another tab — never blame a colleague for it. */
|
|
168
|
+
savedByYou: boolean;
|
|
169
|
+
secondsAgo: number;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* A write the editor is HOLDING rather than sending, and the two other reasons
|
|
173
|
+
* it might be held. `onChange` never drops a refused write — the operator's
|
|
174
|
+
* screen is the only copy of it — so each hold owes a release.
|
|
175
|
+
*/
|
|
176
|
+
type HeldWrite = {
|
|
177
|
+
/** An unpersisted layout is waiting (the host's pending-data ref). */
|
|
178
|
+
pending: boolean;
|
|
179
|
+
/** Autosave paused: the merchant turned writes off, and the pill's resume is
|
|
180
|
+
* the only thing that may flush. */
|
|
181
|
+
autoSaveEnabled: boolean;
|
|
182
|
+
/** A refused save: their work sits behind a banner and only they decide what
|
|
183
|
+
* happens to it. Retrying behind their back is how it gets lost. */
|
|
184
|
+
saveConflict: boolean;
|
|
185
|
+
};
|
|
186
|
+
/** Everything the claim path reads and drives, passed in rather than imported so
|
|
187
|
+
* the path itself can be RUN in a test — a rule that only lives inside a React
|
|
188
|
+
* effect is a rule nothing can check. */
|
|
189
|
+
type ClaimBeatDeps = {
|
|
190
|
+
/** Claim the document (or take it). */
|
|
191
|
+
claim: (takeover: boolean) => Promise<{
|
|
192
|
+
heartbeat_seconds: number;
|
|
193
|
+
}>;
|
|
194
|
+
/** The effect's cleanup ran: a late answer must not grant a document this
|
|
195
|
+
* editor has already left, nor flush one that is no longer open. */
|
|
196
|
+
cancelled: () => boolean;
|
|
197
|
+
currentAccess: () => EditAccess;
|
|
198
|
+
heldWrite: () => HeldWrite;
|
|
199
|
+
setAccess: (access: EditAccess) => void;
|
|
200
|
+
/** Send the held write — the host's one write path, never a second one. */
|
|
201
|
+
releaseHeldWrite: () => void;
|
|
202
|
+
/** Start the heartbeat that KEEPS the document, at the backend's own interval. */
|
|
203
|
+
startHeartbeat: (intervalMs: number) => void;
|
|
204
|
+
/** Stop beating. Called when the SESSION is refused, never when a single
|
|
205
|
+
* request fails — see `isAuthRejection`. The effect re-runs and claims again
|
|
206
|
+
* the moment a fresh token arrives, so this ends a doomed loop rather than
|
|
207
|
+
* ending the editor's hold on the document. */
|
|
208
|
+
stopHeartbeat: () => void;
|
|
209
|
+
};
|
|
210
|
+
/**
|
|
211
|
+
* Must a write held ONLY because the document was not yet writable go out now?
|
|
212
|
+
*
|
|
213
|
+
* Keyed on the TRANSITION, not on the current state: `beat` also runs on every
|
|
214
|
+
* heartbeat, where access is already `editing`. Releasing on "is writable" would
|
|
215
|
+
* flush the merchant's in-progress edit on every beat and delete the debounce
|
|
216
|
+
* that keeps whole-document writes rare.
|
|
217
|
+
*
|
|
218
|
+
* Exported so the rule has a test of its own, and because it is the one piece of
|
|
219
|
+
* `makeClaimBeat` a caller can reason about without a driver.
|
|
220
|
+
*/
|
|
221
|
+
declare function shouldReleaseHeldWrite(previous: EditAccess, next: EditAccess, held: HeldWrite): boolean;
|
|
222
|
+
/** A failed request reduced to the only two things any decision here needs. */
|
|
223
|
+
type EditLockFailure = {
|
|
224
|
+
status: number;
|
|
225
|
+
data: unknown;
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* The two things that genuinely differ between one editor and the next.
|
|
229
|
+
*
|
|
230
|
+
* Kept to two on purpose. Anything a THIRD editor would also have to answer
|
|
231
|
+
* belongs in this module, not in another adapter.
|
|
232
|
+
*/
|
|
233
|
+
type EditLockAdapter = {
|
|
234
|
+
/**
|
|
235
|
+
* Pull `{ status, data }` out of whatever this repo's HTTP client threw, or
|
|
236
|
+
* null when the rejection is not a server answer at all (a dropped socket, a
|
|
237
|
+
* programming error). Every "fails open" branch below leans on that null.
|
|
238
|
+
*/
|
|
239
|
+
readFailure: (err: unknown) => EditLockFailure | null;
|
|
240
|
+
/** The `code` the backend puts in a refused-save body for THIS document type
|
|
241
|
+
* — `page_changed_elsewhere`, `campaign_changed_elsewhere`. */
|
|
242
|
+
conflictCode: string;
|
|
243
|
+
};
|
|
244
|
+
/** The decisions that need the adapter. Everything else above is plain. */
|
|
245
|
+
type EditLock = {
|
|
246
|
+
accessAfterClaimFailure: (err: unknown) => EditAccess;
|
|
247
|
+
accessAfterTakeoverFailure: (err: unknown, current: EditAccess) => EditAccess;
|
|
248
|
+
accessAfterHeartbeatFailure: (err: unknown, current: EditAccess) => EditAccess;
|
|
249
|
+
isAuthRejection: (err: unknown) => boolean;
|
|
250
|
+
readSaveConflict: (err: unknown) => SaveConflict | null;
|
|
251
|
+
makeClaimBeat: (deps: ClaimBeatDeps) => (takeover: boolean) => Promise<void>;
|
|
252
|
+
};
|
|
253
|
+
/** Bind the decisions to one editor's HTTP client and document type. */
|
|
254
|
+
declare function createEditLock(adapter: EditLockAdapter): EditLock;
|
|
255
|
+
|
|
256
|
+
export { type ClaimBeatDeps, type DocumentPermissions, type EditAccess, type EditLock, type EditLockAdapter, type EditLockBody, type EditLockFailure, type HeldWrite, type LockHolder, type SaveConflict, accessForNewDocument, canWriteDocument, createEditLock, describeAge, documentPermissions, holderOf, nameOrSomeone, newEditorClientId, readLockHolder, shouldReleaseHeldWrite };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// src/edit-lock/index.ts
|
|
2
|
+
var canWriteDocument = (access) => access.kind === "editing";
|
|
3
|
+
function accessForNewDocument(hasEditLock) {
|
|
4
|
+
return hasEditLock ? { kind: "checking" } : { kind: "editing" };
|
|
5
|
+
}
|
|
6
|
+
var holderOf = (access) => access.kind === "blocked" || access.kind === "readonly" || access.kind === "takenover" ? access.holder : null;
|
|
7
|
+
function readLockHolder(data) {
|
|
8
|
+
if (!data || typeof data !== "object") return null;
|
|
9
|
+
const lock = data;
|
|
10
|
+
if (typeof lock.holder_name !== "string") return null;
|
|
11
|
+
return {
|
|
12
|
+
name: lock.holder_name,
|
|
13
|
+
secondsSinceSeen: typeof lock.seconds_since_seen === "number" ? lock.seconds_since_seen : 0,
|
|
14
|
+
secondsSinceHeld: typeof lock.seconds_since_held === "number" ? lock.seconds_since_held : 0
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
var documentPermissions = (canEdit) => canEdit ? { edit: true, insert: true, delete: true, duplicate: true, drag: false } : { edit: false, insert: false, delete: false, duplicate: false, drag: false };
|
|
18
|
+
function describeAge(seconds) {
|
|
19
|
+
if (!Number.isFinite(seconds) || seconds < 45) return "just now";
|
|
20
|
+
const minutes = Math.round(seconds / 60);
|
|
21
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
22
|
+
const hours = Math.round(minutes / 60);
|
|
23
|
+
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
24
|
+
}
|
|
25
|
+
var nameOrSomeone = (name) => name.trim() || "Someone else";
|
|
26
|
+
function newEditorClientId() {
|
|
27
|
+
const rand = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : Math.random().toString(36).slice(2);
|
|
28
|
+
return `tab-${rand}`.slice(0, 64);
|
|
29
|
+
}
|
|
30
|
+
function shouldReleaseHeldWrite(previous, next, held) {
|
|
31
|
+
if (canWriteDocument(previous) || !canWriteDocument(next)) return false;
|
|
32
|
+
return held.pending && held.autoSaveEnabled && !held.saveConflict;
|
|
33
|
+
}
|
|
34
|
+
function createEditLock(adapter) {
|
|
35
|
+
const { readFailure, conflictCode } = adapter;
|
|
36
|
+
const holderFromConflict = (err) => {
|
|
37
|
+
const f = readFailure(err);
|
|
38
|
+
if (!f || f.status !== 409) return null;
|
|
39
|
+
return readLockHolder(f.data);
|
|
40
|
+
};
|
|
41
|
+
const accessAfterClaimFailure = (err) => {
|
|
42
|
+
const holder = holderFromConflict(err);
|
|
43
|
+
return holder ? { kind: "blocked", holder } : { kind: "editing" };
|
|
44
|
+
};
|
|
45
|
+
const accessAfterTakeoverFailure = (err, current) => {
|
|
46
|
+
const holder = holderFromConflict(err);
|
|
47
|
+
return holder ? { kind: "blocked", holder } : current;
|
|
48
|
+
};
|
|
49
|
+
const accessAfterHeartbeatFailure = (err, current) => {
|
|
50
|
+
const holder = holderFromConflict(err);
|
|
51
|
+
return holder ? { kind: "takenover", holder } : current;
|
|
52
|
+
};
|
|
53
|
+
const isAuthRejection = (err) => {
|
|
54
|
+
const f = readFailure(err);
|
|
55
|
+
return f !== null && (f.status === 401 || f.status === 403);
|
|
56
|
+
};
|
|
57
|
+
const readSaveConflict = (err) => {
|
|
58
|
+
const f = readFailure(err);
|
|
59
|
+
if (!f || f.status !== 409) return null;
|
|
60
|
+
if (!f.data || typeof f.data !== "object") return null;
|
|
61
|
+
const body = f.data;
|
|
62
|
+
if (body.code !== conflictCode) return null;
|
|
63
|
+
return {
|
|
64
|
+
savedBy: typeof body.saved_by === "string" ? body.saved_by : "",
|
|
65
|
+
// Compared with `=== true`: the wire value must be a real boolean. DRF
|
|
66
|
+
// coerces exception-detail leaves to strings, and "False" is truthy here —
|
|
67
|
+
// which would tell you that you overwrote your own document when a
|
|
68
|
+
// colleague did. Both backends assign the payload directly to avoid it.
|
|
69
|
+
savedByYou: body.saved_by_you === true,
|
|
70
|
+
secondsAgo: typeof body.seconds_ago === "number" ? body.seconds_ago : 0
|
|
71
|
+
};
|
|
72
|
+
};
|
|
73
|
+
const makeClaimBeat = (deps) => {
|
|
74
|
+
let heartbeatStarted = false;
|
|
75
|
+
return async (takeover) => {
|
|
76
|
+
let previous;
|
|
77
|
+
let next;
|
|
78
|
+
try {
|
|
79
|
+
const lock = await deps.claim(takeover);
|
|
80
|
+
if (deps.cancelled()) return;
|
|
81
|
+
previous = deps.currentAccess();
|
|
82
|
+
next = { kind: "editing" };
|
|
83
|
+
if (!heartbeatStarted && lock.heartbeat_seconds > 0) {
|
|
84
|
+
heartbeatStarted = true;
|
|
85
|
+
deps.startHeartbeat(lock.heartbeat_seconds * 1e3);
|
|
86
|
+
}
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (deps.cancelled()) return;
|
|
89
|
+
previous = deps.currentAccess();
|
|
90
|
+
next = takeover ? accessAfterTakeoverFailure(err, previous) : previous.kind === "checking" ? accessAfterClaimFailure(err) : accessAfterHeartbeatFailure(err, previous);
|
|
91
|
+
if (isAuthRejection(err)) deps.stopHeartbeat();
|
|
92
|
+
}
|
|
93
|
+
deps.setAccess(next);
|
|
94
|
+
if (shouldReleaseHeldWrite(previous, next, deps.heldWrite())) deps.releaseHeldWrite();
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
return {
|
|
98
|
+
accessAfterClaimFailure,
|
|
99
|
+
accessAfterTakeoverFailure,
|
|
100
|
+
accessAfterHeartbeatFailure,
|
|
101
|
+
isAuthRejection,
|
|
102
|
+
readSaveConflict,
|
|
103
|
+
makeClaimBeat
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export { accessForNewDocument, canWriteDocument, createEditLock, describeAge, documentPermissions, holderOf, nameOrSomeone, newEditorClientId, readLockHolder, shouldReleaseHeldWrite };
|
|
108
|
+
//# sourceMappingURL=index.js.map
|
|
109
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/edit-lock/index.ts"],"names":[],"mappings":"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","file":"index.js","sourcesContent":["/**\n * Who may WRITE the open document, and what to tell the operator when they may\n * not — for EVERY editor in the house.\n *\n * This module is the merge of two files that had grown side by side and said\n * the same things in two places (card 11161):\n *\n * * `efficient-admin-portal/src/components/email/campaignEditLock.ts` — the\n * campaign designer's copy, which alone had the TAKE-OVER half;\n * * `efficient-shop/lib/editor-page-lock.ts` — the storefront editor's copy,\n * which alone had the claim/heartbeat driver, the auth-rejection rule and\n * the \"a template has no lock\" rule.\n *\n * Neither was a subset of the other, so this file keeps EVERY capability of\n * both, and both editors now import it instead of carrying a copy.\n *\n * TWO INDEPENDENT PROTECTIONS, and it matters which is which:\n *\n * * the SAVE-TIME version check is what actually protects work. The backend\n * refuses a document write whose base version has moved on, so no amount of\n * client-side confusion can erase a colleague's edits. Nothing here can\n * weaken it — the token travels with the write and the server decides.\n * * this advisory LOCK only stops two people STARTING, and is soft on\n * purpose: it expires, it can always be taken over, and — see\n * `accessAfterClaimFailure` — it FAILS OPEN. A lock that could refuse to\n * let anybody edit would be worse than no lock at all.\n *\n * The backend rule is one rule shared by both (efficient/editlock.py). These\n * are two CONSUMERS of one server-side contract, which is exactly why one\n * client module can serve them.\n *\n * WHAT IS NOT SHARED, and why there is a factory rather than plain functions:\n * the two editors talk to the backend through different HTTP clients, so a\n * failure arrives as an `AxiosError` in one and as an `ApiError` in the other,\n * and the refused-save payload carries a different `code`. Those two facts —\n * and nothing else — are handed in by the consumer through `createEditLock`.\n * The module itself imports no HTTP client and knows about no repo.\n *\n * LEAF DISCIPLINE: pure TypeScript. No React, no `document`/`window`, no\n * dependency at all. `crypto` is touched inside a function, never at module\n * eval, so a Next server component may import this module safely.\n */\n\n/* ── The wire, and the people on the other end of it ─────────────────────── */\n\n/** The `edit-lock` body — identical for every editable document. */\nexport type EditLockBody = {\n held_by_you: boolean;\n holder_name: string;\n client_id: string;\n last_seen_at: string;\n seconds_since_seen: number;\n seconds_since_held: number;\n heartbeat_seconds: number;\n expiry_seconds: number;\n};\n\n/** The other operator, as the notices need to describe them. */\nexport type LockHolder = {\n name: string;\n /** Seconds since their last heartbeat, as the SERVER measured it. */\n secondsSinceSeen: number;\n /** Seconds since they TOOK the document, as the SERVER measured it. Not the\n * same number: an active holder's heartbeat keeps `secondsSinceSeen` near\n * zero, so only this one can say \"took over 20 seconds ago\". */\n secondsSinceHeld: number;\n};\n\n/**\n * Whether the open document may be written, and why not when it may not.\n *\n * `editing` is the ONLY writable state, and `canWriteDocument` is the only place\n * that says so — so a new state added later is read-only until someone\n * deliberately makes it writable, rather than writable until someone remembers\n * to block it.\n */\nexport type EditAccess =\n /** The claim is still in flight. Holding writes for the moment it takes is\n * what stops a fast typist's first autosave landing before we know whether\n * somebody else is in here. */\n | { kind: 'checking' }\n /** You hold the document. */\n | { kind: 'editing' }\n /** Somebody else holds it and you have not chosen yet — view, or take over. */\n | { kind: 'blocked'; holder: LockHolder }\n /** You chose to look without touching. */\n | { kind: 'readonly'; holder: LockHolder }\n /** You HAD it and somebody took it. Your work is still on screen. */\n | { kind: 'takenover'; holder: LockHolder };\n\n/** The one gate on the document write path. */\nexport const canWriteDocument = (access: EditAccess): boolean =>\n access.kind === 'editing';\n\n/**\n * The access a freshly opened document starts from.\n *\n * A document WITH a lock starts `checking` and the claim answers within a round\n * trip. One WITHOUT — the storefront's templates — has no claim effect at all,\n * so `checking` there is not a question awaiting an answer, it is a state\n * nothing will ever leave, and every write for the rest of the session is\n * refused: the merchant's edits and, first of all, the pinned Header/Footer seed\n * that runs in the chrome's mount commit. The editor's MOUNT has always known\n * this; a document SWITCH did not, which is what card 1096 found.\n *\n * Not a loosening of `canWriteDocument`: it says which QUESTION a document opens\n * with, and a document with no lock was never asking one.\n */\nexport function accessForNewDocument(hasEditLock: boolean): EditAccess {\n return hasEditLock ? { kind: 'checking' } : { kind: 'editing' };\n}\n\n/** The holder named by a state that has one (for the banner / the notices). */\nexport const holderOf = (access: EditAccess): LockHolder | null =>\n access.kind === 'blocked' || access.kind === 'readonly' || access.kind === 'takenover'\n ? access.holder\n : null;\n\n/** Read the `edit-lock` body — from a 200 or from a 409's error payload. */\nexport function readLockHolder(data: unknown): LockHolder | null {\n if (!data || typeof data !== 'object') return null;\n const lock = data as Partial<EditLockBody>;\n if (typeof lock.holder_name !== 'string') return null;\n return {\n name: lock.holder_name,\n secondsSinceSeen:\n typeof lock.seconds_since_seen === 'number' ? lock.seconds_since_seen : 0,\n secondsSinceHeld:\n typeof lock.seconds_since_held === 'number' ? lock.seconds_since_held : 0,\n };\n}\n\n/* ── What the canvas may do ──────────────────────────────────────────────── */\n\n/** The five capabilities Puck's `permissions` prop carries for the canvas. */\nexport type DocumentPermissions = {\n edit: boolean;\n insert: boolean;\n delete: boolean;\n duplicate: boolean;\n drag: boolean;\n};\n\n/**\n * The canvas's Puck `permissions`, derived from the write gate.\n *\n * BOTH branches name ALL FIVE keys — and that completeness is the whole point,\n * not tidiness. Puck does not replace this prop when it changes; it MERGES it\n * over the permissions it already holds (store/slices/permissions.ts,\n * `useRegisterPermissionsSlice`: `{ ...existingGlobalPermissions, ...prop }`),\n * and a field's editability is the live `getPermissions().edit`. Puck mounts\n * while the lock claim is still `checking`, so `canEdit` is false and the store\n * latches every key false. A PARTIAL writable value (say `{ drag: false }`)\n * would then merge WITHOUT an `edit` key and leave `edit:false` stuck — every\n * inspector field disabled for good, even after the claim resolves to\n * `editing`. Spelling out edit/insert/delete/duplicate on the writable branch\n * resets them, so the panel comes back the instant `canEdit` flips true.\n *\n * `drag` stays false in EITHER state: both editors move blocks with native DnD\n * rails of their own, so Puck's flaky cross-iframe canvas drag is always off.\n *\n * Write safety is unchanged: the `checking` window is still non-editable here,\n * and the host's own write gate still drops any edit attempted before the claim\n * resolves, so nothing is saved early.\n */\nexport const documentPermissions = (canEdit: boolean): DocumentPermissions =>\n canEdit\n ? { edit: true, insert: true, delete: true, duplicate: true, drag: false }\n : { edit: false, insert: false, delete: false, duplicate: false, drag: false };\n\n/* ── Words ───────────────────────────────────────────────────────────────── */\n\n/** \"just now\" / \"2 minutes ago\" — one phrasing, shared by every notice. */\nexport function describeAge(seconds: number): string {\n if (!Number.isFinite(seconds) || seconds < 45) return 'just now';\n const minutes = Math.round(seconds / 60);\n if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`;\n const hours = Math.round(minutes / 60);\n return `${hours} hour${hours === 1 ? '' : 's'} ago`;\n}\n\n/** Names the person, or says so honestly when the backend could not. */\nexport const nameOrSomeone = (name: string): string => name.trim() || 'Someone else';\n\n/**\n * The identity of THIS editor tab.\n *\n * Per-tab, not per-user: it is what lets the backend tell \"me, in a second tab\"\n * (take my own lock over silently) from \"me, here\". Deliberately not persisted —\n * a reload IS a new editor session, and a remembered id would let a closed tab's\n * claim look alive.\n */\nexport function newEditorClientId(): string {\n const rand =\n typeof crypto !== 'undefined' && 'randomUUID' in crypto\n ? crypto.randomUUID()\n : Math.random().toString(36).slice(2);\n return `tab-${rand}`.slice(0, 64);\n}\n\n/* ── A refused save ──────────────────────────────────────────────────────── */\n\n/** A refused save: the stored document moved on since this editor loaded it. */\nexport type SaveConflict = {\n /** Who saved over you. Empty when the backend could not attribute the write. */\n savedBy: string;\n /** True when it was YOU, in another tab — never blame a colleague for it. */\n savedByYou: boolean;\n secondsAgo: number;\n};\n\n/* ── The claim, and the write it was holding up ──────────────────────────── */\n\n/**\n * A write the editor is HOLDING rather than sending, and the two other reasons\n * it might be held. `onChange` never drops a refused write — the operator's\n * screen is the only copy of it — so each hold owes a release.\n */\nexport type HeldWrite = {\n /** An unpersisted layout is waiting (the host's pending-data ref). */\n pending: boolean;\n /** Autosave paused: the merchant turned writes off, and the pill's resume is\n * the only thing that may flush. */\n autoSaveEnabled: boolean;\n /** A refused save: their work sits behind a banner and only they decide what\n * happens to it. Retrying behind their back is how it gets lost. */\n saveConflict: boolean;\n};\n\n/** Everything the claim path reads and drives, passed in rather than imported so\n * the path itself can be RUN in a test — a rule that only lives inside a React\n * effect is a rule nothing can check. */\nexport type ClaimBeatDeps = {\n /** Claim the document (or take it). */\n claim: (takeover: boolean) => Promise<{ heartbeat_seconds: number }>;\n /** The effect's cleanup ran: a late answer must not grant a document this\n * editor has already left, nor flush one that is no longer open. */\n cancelled: () => boolean;\n currentAccess: () => EditAccess;\n heldWrite: () => HeldWrite;\n setAccess: (access: EditAccess) => void;\n /** Send the held write — the host's one write path, never a second one. */\n releaseHeldWrite: () => void;\n /** Start the heartbeat that KEEPS the document, at the backend's own interval. */\n startHeartbeat: (intervalMs: number) => void;\n /** Stop beating. Called when the SESSION is refused, never when a single\n * request fails — see `isAuthRejection`. The effect re-runs and claims again\n * the moment a fresh token arrives, so this ends a doomed loop rather than\n * ending the editor's hold on the document. */\n stopHeartbeat: () => void;\n};\n\n/**\n * Must a write held ONLY because the document was not yet writable go out now?\n *\n * Keyed on the TRANSITION, not on the current state: `beat` also runs on every\n * heartbeat, where access is already `editing`. Releasing on \"is writable\" would\n * flush the merchant's in-progress edit on every beat and delete the debounce\n * that keeps whole-document writes rare.\n *\n * Exported so the rule has a test of its own, and because it is the one piece of\n * `makeClaimBeat` a caller can reason about without a driver.\n */\nexport function shouldReleaseHeldWrite(\n previous: EditAccess,\n next: EditAccess,\n held: HeldWrite,\n): boolean {\n if (canWriteDocument(previous) || !canWriteDocument(next)) return false;\n return held.pending && held.autoSaveEnabled && !held.saveConflict;\n}\n\n/* ── The half that needs to know what an error looks like ────────────────── */\n\n/** A failed request reduced to the only two things any decision here needs. */\nexport type EditLockFailure = { status: number; data: unknown };\n\n/**\n * The two things that genuinely differ between one editor and the next.\n *\n * Kept to two on purpose. Anything a THIRD editor would also have to answer\n * belongs in this module, not in another adapter.\n */\nexport type EditLockAdapter = {\n /**\n * Pull `{ status, data }` out of whatever this repo's HTTP client threw, or\n * null when the rejection is not a server answer at all (a dropped socket, a\n * programming error). Every \"fails open\" branch below leans on that null.\n */\n readFailure: (err: unknown) => EditLockFailure | null;\n /** The `code` the backend puts in a refused-save body for THIS document type\n * — `page_changed_elsewhere`, `campaign_changed_elsewhere`. */\n conflictCode: string;\n};\n\n/** The decisions that need the adapter. Everything else above is plain. */\nexport type EditLock = {\n accessAfterClaimFailure: (err: unknown) => EditAccess;\n accessAfterTakeoverFailure: (err: unknown, current: EditAccess) => EditAccess;\n accessAfterHeartbeatFailure: (err: unknown, current: EditAccess) => EditAccess;\n isAuthRejection: (err: unknown) => boolean;\n readSaveConflict: (err: unknown) => SaveConflict | null;\n makeClaimBeat: (deps: ClaimBeatDeps) => (takeover: boolean) => Promise<void>;\n};\n\n/** Bind the decisions to one editor's HTTP client and document type. */\nexport function createEditLock(adapter: EditLockAdapter): EditLock {\n const { readFailure, conflictCode } = adapter;\n\n /** The holder named by a 409, or null for any other failure. */\n const holderFromConflict = (err: unknown): LockHolder | null => {\n const f = readFailure(err);\n if (!f || f.status !== 409) return null;\n return readLockHolder(f.data);\n };\n\n /**\n * What a FAILED claim means.\n *\n * A 409 naming a holder is the real answer: someone else is in here, so ask\n * the operator what they want to do. **Anything else fails OPEN** — an\n * unreachable backend, a 500, an older backend with no such endpoint (404), a\n * 403 — because this lock exists to be polite, and taking away the ability to\n * edit because a courtesy call failed would be a far worse bug than the one it\n * prevents. The version check still protects the work either way.\n *\n * The 404 case is not hypothetical: a client can ship BEFORE the backend that\n * serves `edit-lock`, and between the two deploys every claim 404s and\n * everybody must simply keep working.\n */\n const accessAfterClaimFailure = (err: unknown): EditAccess => {\n const holder = holderFromConflict(err);\n return holder ? { kind: 'blocked', holder } : { kind: 'editing' };\n };\n\n /**\n * What a failed **take-over** means, given where we were.\n *\n * NOT the same question as a failed first claim, and answering it with\n * `accessAfterClaimFailure` was a bug: that one fails OPEN, which is right\n * when nobody is known to hold the document, and badly wrong here. The\n * operator can only reach a take-over from a state that NAMES a holder, so\n * failing open would unlock the canvas, drop the banner, and leave two people\n * editing with one lock — while the person actually holding it is never told.\n *\n * So a take-over that did not land keeps the state it started from: the\n * document is still theirs, the banner still says so, and the button is still\n * there to press again. Only a 200 grants it.\n *\n * A 409 is a different, still-live holder (somebody took it in the gap) —\n * re-point the banner at whoever that now is.\n */\n const accessAfterTakeoverFailure = (err: unknown, current: EditAccess): EditAccess => {\n const holder = holderFromConflict(err);\n return holder ? { kind: 'blocked', holder } : current;\n };\n\n /**\n * What a failed HEARTBEAT means, given where we were.\n *\n * A 409 means somebody took the document: say so at once, because the\n * alternative is the operator editing into the void for half an hour. Any\n * other failure — a dropped connection, a sleeping laptop's first beat on\n * waking — keeps the current state: losing the document to a network blip\n * would be the same work-destroying surprise from the other direction.\n *\n * A rejected SESSION is neither of those, and reading it as a blip is what\n * `isAuthRejection` exists to stop. The ACCESS answer is still \"keep the\n * current state\" — see that function for why the beat, not the access, is the\n * thing that has to change.\n */\n const accessAfterHeartbeatFailure = (err: unknown, current: EditAccess): EditAccess => {\n const holder = holderFromConflict(err);\n return holder ? { kind: 'takenover', holder } : current;\n };\n\n /**\n * Is this failure the SESSION being refused rather than a request going wrong?\n *\n * The distinction earns its own function because the two look identical to\n * `accessAfterHeartbeatFailure` and are not identical at all. A blip is over by\n * the next beat. A 401 is not: the token this editor is sending has expired, so\n * every future beat carries the same rejected credential and is refused for the\n * same reason, thirty seconds apart, for as long as the tab stays open.\n * Production ran exactly that for eleven pages over two weeks, one session\n * reaching 2,730 consecutive rejections in 23.6 hours.\n *\n * What it must NOT do is take the document away. Failing open is right and\n * stays: the save-time version check is what actually protects the work, and\n * refusing to let somebody edit because a courtesy call was refused is the\n * worse bug. Granting access and continuing to BEAT are separable, which is the\n * whole insight — the beat stops, the access does not move, and the editor\n * recovers on its own the moment the host bridges a fresh token, because that\n * restarts the claim from the top.\n */\n const isAuthRejection = (err: unknown): boolean => {\n const f = readFailure(err);\n return f !== null && (f.status === 401 || f.status === 403);\n };\n\n /**\n * Read a refused save. Returns null for any other failure, so a network error\n * is never dressed up as a colleague's overwrite.\n */\n const readSaveConflict = (err: unknown): SaveConflict | null => {\n const f = readFailure(err);\n if (!f || f.status !== 409) return null;\n if (!f.data || typeof f.data !== 'object') return null;\n const body = f.data as Record<string, unknown>;\n if (body.code !== conflictCode) return null;\n return {\n savedBy: typeof body.saved_by === 'string' ? body.saved_by : '',\n // Compared with `=== true`: the wire value must be a real boolean. DRF\n // coerces exception-detail leaves to strings, and \"False\" is truthy here —\n // which would tell you that you overwrote your own document when a\n // colleague did. Both backends assign the payload directly to avoid it.\n savedByYou: body.saved_by_you === true,\n secondsAgo: typeof body.seconds_ago === 'number' ? body.seconds_ago : 0,\n };\n };\n\n /**\n * Build the editor's `beat`: claim the document, then keep it.\n *\n * ONE function serves the first claim, a takeover and every heartbeat, because\n * a claim made anywhere else left the taker holding a lock nothing refreshed.\n * The moment a claim GRANTS this editor the document, any write that was held\n * only because it did not have it yet goes out (card 1096) — that is the\n * release the `checking` hold never had.\n *\n * WHICH FAILURE QUESTION IS ASKED IS DECIDED BY THE `takeover` ARGUMENT, not by\n * the state the caller happens to be in. The storefront's version keyed it on\n * `previous.kind === 'checking'`, and because its take-over path set `checking`\n * before re-claiming, a take-over that FAILED was read as a first claim and\n * failed OPEN — two people editing, one lock, and the real holder never told.\n * That is the campaign side's `accessAfterTakeoverFailure` bug, in the other\n * editor, reachable from a button. A caller must therefore NOT move to\n * `checking` before a take-over: the state it was pressed from is the state a\n * failure falls back to, and it is already non-writable, so writes stay held.\n *\n * The gate itself is untouched: a 409 naming a holder still blocks, and a held\n * write is never released into a document somebody else has.\n */\n const makeClaimBeat = (deps: ClaimBeatDeps): ((takeover: boolean) => Promise<void>) => {\n // Per claim: a second interval would beat twice as often for as long as the\n // document stayed open.\n let heartbeatStarted = false;\n return async (takeover: boolean): Promise<void> => {\n let previous: EditAccess;\n let next: EditAccess;\n try {\n const lock = await deps.claim(takeover);\n if (deps.cancelled()) return;\n previous = deps.currentAccess();\n next = { kind: 'editing' };\n // The backend owns the interval, so it can never drift out of step with\n // the expiry it is paired with.\n if (!heartbeatStarted && lock.heartbeat_seconds > 0) {\n heartbeatStarted = true;\n deps.startHeartbeat(lock.heartbeat_seconds * 1000);\n }\n } catch (err) {\n if (deps.cancelled()) return;\n previous = deps.currentAccess();\n // Three different questions, and reading one as another is how somebody\n // loses an afternoon — see the note above this function.\n next = takeover\n ? accessAfterTakeoverFailure(err, previous)\n : previous.kind === 'checking'\n ? accessAfterClaimFailure(err)\n : accessAfterHeartbeatFailure(err, previous);\n // The caller has already asked for a fresh token and retried once by the\n // time this throws, so a still-refused session will refuse every future\n // beat for the same reason. Stop, rather than re-send a credential the\n // server has answered on. Deliberately AFTER the access decision and with\n // no bearing on it: the document is not taken away.\n if (isAuthRejection(err)) deps.stopHeartbeat();\n }\n deps.setAccess(next);\n // Follows the ACCESS, not the happy path: a claim that FAILS OPEN grants\n // the document just as much as one that succeeds, and the write held for it\n // is this editor's to send either way.\n if (shouldReleaseHeldWrite(previous, next, deps.heldWrite())) deps.releaseHeldWrite();\n };\n };\n\n return {\n accessAfterClaimFailure,\n accessAfterTakeoverFailure,\n accessAfterHeartbeatFailure,\n isAuthRejection,\n readSaveConflict,\n makeClaimBeat,\n };\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "editor-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Shared editor-chrome primitives for the EFFICIENT editors: EditorRail (a Next-16-safe left icon-rail leaf), the shell token layer, and GoldTextInput — type in place and watch the markup formatting appear as you type.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Lewis Liu",
|
|
@@ -54,6 +54,11 @@
|
|
|
54
54
|
"default": "./dist/shell/index.js"
|
|
55
55
|
},
|
|
56
56
|
"./shell.css": "./dist/shell/shell.css",
|
|
57
|
+
"./edit-lock": {
|
|
58
|
+
"types": "./dist/edit-lock/index.d.ts",
|
|
59
|
+
"import": "./dist/edit-lock/index.js",
|
|
60
|
+
"default": "./dist/edit-lock/index.js"
|
|
61
|
+
},
|
|
57
62
|
"./gold": {
|
|
58
63
|
"types": "./dist/gold/index.d.ts",
|
|
59
64
|
"import": "./dist/gold/index.js",
|