@issuegraph/editor 0.3.0 → 0.10.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 +31 -6
- package/dist/a11y/baseline.d.ts +182 -0
- package/dist/a11y/baseline.d.ts.map +1 -0
- package/dist/a11y/baseline.js +430 -0
- package/dist/a11y/baseline.js.map +1 -0
- package/dist/audit/styles.d.ts +1 -1
- package/dist/audit/styles.d.ts.map +1 -1
- package/dist/audit/styles.js +1 -1
- package/dist/create/draft.d.ts +17 -0
- package/dist/create/draft.d.ts.map +1 -1
- package/dist/create/draft.js +19 -0
- package/dist/create/draft.js.map +1 -1
- package/dist/create/keys.d.ts +70 -5
- package/dist/create/keys.d.ts.map +1 -1
- package/dist/create/keys.js +78 -17
- package/dist/create/keys.js.map +1 -1
- package/dist/firstpass/candidates.d.ts +17 -1
- package/dist/firstpass/candidates.d.ts.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -2
- package/dist/index.js.map +1 -1
- package/dist/overlay/render.d.ts.map +1 -1
- package/dist/overlay/render.js +54 -14
- package/dist/overlay/render.js.map +1 -1
- package/dist/overlay/styles.d.ts +1 -1
- package/dist/overlay/styles.d.ts.map +1 -1
- package/dist/overlay/styles.js +40 -0
- package/dist/overlay/styles.js.map +1 -1
- package/dist/picker/render.d.ts +26 -16
- package/dist/picker/render.d.ts.map +1 -1
- package/dist/picker/render.js +25 -36
- package/dist/picker/render.js.map +1 -1
- package/dist/picker/styles.d.ts +7 -7
- package/dist/picker/styles.d.ts.map +1 -1
- package/dist/picker/styles.js +7 -36
- package/dist/picker/styles.js.map +1 -1
- package/dist/picker/view.d.ts +8 -4
- package/dist/picker/view.d.ts.map +1 -1
- package/dist/picker/view.js +4 -3
- package/dist/picker/view.js.map +1 -1
- package/dist/picker/words.d.ts +12 -10
- package/dist/picker/words.d.ts.map +1 -1
- package/dist/picker/words.js +12 -8
- package/dist/picker/words.js.map +1 -1
- package/dist/scale/ladder.d.ts.map +1 -1
- package/dist/scale/ladder.js +10 -0
- package/dist/scale/ladder.js.map +1 -1
- package/dist/scale/render.d.ts.map +1 -1
- package/dist/scale/render.js +9 -1
- package/dist/scale/render.js.map +1 -1
- package/dist/workspace/chrome.d.ts +1 -1
- package/dist/workspace/chrome.d.ts.map +1 -1
- package/dist/workspace/chrome.js +48 -0
- package/dist/workspace/chrome.js.map +1 -1
- package/dist/workspace/firstpass.d.ts +166 -0
- package/dist/workspace/firstpass.d.ts.map +1 -0
- package/dist/workspace/firstpass.js +151 -0
- package/dist/workspace/firstpass.js.map +1 -0
- package/dist/workspace/host.d.ts +212 -10
- package/dist/workspace/host.d.ts.map +1 -1
- package/dist/workspace/host.js +434 -42
- package/dist/workspace/host.js.map +1 -1
- package/dist/workspace/inspector.d.ts +45 -1
- package/dist/workspace/inspector.d.ts.map +1 -1
- package/dist/workspace/inspector.js +11 -0
- package/dist/workspace/inspector.js.map +1 -1
- package/dist/workspace/mount.d.ts +52 -5
- package/dist/workspace/mount.d.ts.map +1 -1
- package/dist/workspace/mount.js +1391 -56
- package/dist/workspace/mount.js.map +1 -1
- package/dist/workspace/rail.d.ts.map +1 -1
- package/dist/workspace/rail.js +12 -0
- package/dist/workspace/rail.js.map +1 -1
- package/dist/workspace/recovery.d.ts +155 -0
- package/dist/workspace/recovery.d.ts.map +1 -0
- package/dist/workspace/recovery.js +156 -0
- package/dist/workspace/recovery.js.map +1 -0
- package/dist/workspace/render.d.ts +402 -1
- package/dist/workspace/render.d.ts.map +1 -1
- package/dist/workspace/render.js +947 -36
- package/dist/workspace/render.js.map +1 -1
- package/dist/workspace/styles.d.ts +1 -1
- package/dist/workspace/styles.d.ts.map +1 -1
- package/dist/workspace/styles.js +554 -41
- package/dist/workspace/styles.js.map +1 -1
- package/package.json +3 -3
package/dist/workspace/host.js
CHANGED
|
@@ -26,11 +26,13 @@
|
|
|
26
26
|
* of which a host reducer should know about, and each of which the demo now
|
|
27
27
|
* handles beside the mount rather than through it.
|
|
28
28
|
*/
|
|
29
|
-
import {
|
|
29
|
+
import { edgeIdentityEnd, isEdgeField } from '@issuegraph/core';
|
|
30
30
|
import { findEdge } from '@issuegraph/store';
|
|
31
31
|
import { IDLE_CREATE_DRAFT, createReducer } from "../create/draft.js";
|
|
32
|
+
import {} from "../firstpass/queue.js";
|
|
32
33
|
import { pickerView } from "../picker/view.js";
|
|
33
34
|
import { INITIAL_SCALE_STATE, scaleReducer } from "../scale/commands.js";
|
|
35
|
+
import { INITIAL_FIRST_PASS, firstPassReducer, } from "./firstpass.js";
|
|
34
36
|
import { INITIAL_SELECTION, selectedEdgeId, selectedKey, selectionReducer, } from "./selection.js";
|
|
35
37
|
export const INITIAL_HOST_STATE = Object.freeze({
|
|
36
38
|
selection: INITIAL_SELECTION,
|
|
@@ -41,12 +43,188 @@ export const INITIAL_HOST_STATE = Object.freeze({
|
|
|
41
43
|
targetQuery: '',
|
|
42
44
|
drag: null,
|
|
43
45
|
drop: null,
|
|
46
|
+
firstPass: INITIAL_FIRST_PASS,
|
|
47
|
+
diffOpen: null,
|
|
44
48
|
});
|
|
45
49
|
function settled(state) {
|
|
46
|
-
return { state, effects: [] };
|
|
50
|
+
return { state, effects: [], claimed: true };
|
|
51
|
+
}
|
|
52
|
+
/** The `default` arm's result: not this reducer's command. See {@link HostResult.claimed}. */
|
|
53
|
+
function unclaimed(state) {
|
|
54
|
+
return { state, effects: [], claimed: false };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The issue whose own frontmatter declares a relationship.
|
|
58
|
+
*
|
|
59
|
+
* ONE SPELLING OF A FACT TWO DECISIONS DEPEND ON. `from` is the carrier for
|
|
60
|
+
* every field, the symmetric ones included: §4.3 puts a relationship in ONE
|
|
61
|
+
* issue's block, and `from` is the end it was declared from — which is why a
|
|
62
|
+
* flip of a symmetric kind is refused rather than moving it. Both readers
|
|
63
|
+
* below — where the panel goes when the edge it was showing stops being drawn,
|
|
64
|
+
* and which issue a refused edit is stated under — wrote `edge.from` for
|
|
65
|
+
* themselves, which is two answers to one question, agreeing only for as long
|
|
66
|
+
* as nobody touched either.
|
|
67
|
+
*
|
|
68
|
+
* IT CAN ONLY BE READ WHILE THE DOCUMENT STILL HOLDS THE EDGE, and every reader
|
|
69
|
+
* of it is shaped by that. Once the edge is gone its identity is the only record
|
|
70
|
+
* left, and it is a weaker record for the SYMMETRIC fields: `edgeIdentity` sorts
|
|
71
|
+
* their endpoints, so a `serialize-with` declared from `z` to `a` and one
|
|
72
|
+
* declared from `a` to `z` are one string and `from` is not a function of it. A revision of `@issuegraph/core` named the carrying end of
|
|
73
|
+
* every identity anyway; for the symmetric fields that answer was the sort order
|
|
74
|
+
* wearing the carrier's name, and a refusal about such an edge was stated on the
|
|
75
|
+
* far end's panel — the panel the reader was NOT on — whenever the pair sorted
|
|
76
|
+
* the other way. `edgeIdentityEnd` now answers `either` for exactly those and
|
|
77
|
+
* names the carrier for the directed fields, so this function stays the only
|
|
78
|
+
* source of the fact while the edge is there, and nothing downstream has to
|
|
79
|
+
* guess which kind of record it is holding.
|
|
80
|
+
*/
|
|
81
|
+
function carrierOf(edge) {
|
|
82
|
+
return edge.from;
|
|
83
|
+
}
|
|
84
|
+
function endsOf(document, proposal) {
|
|
85
|
+
// A CREATE HAS NO EDGE YET, and its own two references are the whole of what
|
|
86
|
+
// it is about — including the `unknown-issue` case, where one of them is an
|
|
87
|
+
// issue the document does not hold and no lookup could recover it.
|
|
88
|
+
if (proposal.op === 'create')
|
|
89
|
+
return { kind: 'pair', carrier: proposal.from, far: proposal.to };
|
|
90
|
+
const edge = findEdge(document, proposal.edgeId);
|
|
91
|
+
if (edge !== undefined)
|
|
92
|
+
return { kind: 'pair', carrier: carrierOf(edge), far: edge.to };
|
|
93
|
+
// AND THE EDGE CAN BE ABSENT, which is the `unknown-edge` refusal itself: the
|
|
94
|
+
// reader acted on a relationship a landed write had already removed. All that
|
|
95
|
+
// is left is the identity the act named.
|
|
96
|
+
return { kind: 'identity', edgeId: proposal.edgeId };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Which issue an edit is ABOUT — the panel that states it, and the panel it is
|
|
100
|
+
* refused on.
|
|
101
|
+
*
|
|
102
|
+
* THE SUBJECT OF AN EDIT IS DECIDED ONCE, AND THAT MEANS ONCE IN TIME AS WELL
|
|
103
|
+
* AS ONCE IN THE SOURCE. It used to be decided twice and in two vocabularies:
|
|
104
|
+
* the panel derived its own subject from the selection (canonicalized onto a
|
|
105
|
+
* together unit's lead), and the inspector separately asked whether the REFUSED
|
|
106
|
+
* EDGE's identity named that subject. Two derivations of one fact diverge, and
|
|
107
|
+
* they did, three times over. This function replaced the second derivation —
|
|
108
|
+
* and then a fourth divergence appeared inside it, because it was still being
|
|
109
|
+
* CALLED twice: once when the edit went out, and again when the refusal came
|
|
110
|
+
* back, against a document a sibling write had changed in between. So the shell
|
|
111
|
+
* calls it at the moment of emission, records the answer against the write, and
|
|
112
|
+
* reads the record afterwards. See `HostEffect`'s `carrier` and `mount.ts`.
|
|
113
|
+
*
|
|
114
|
+
* THE CARRIER'S END WINS, AND THE FAR END IS THE FALLBACK. The relationship is
|
|
115
|
+
* declared in the carrier's block, so that is the panel a reader made the edit
|
|
116
|
+
* from and the panel that can undo it. A create naming an unknown SOURCE has
|
|
117
|
+
* no such panel, and stating the refusal on the target's — the only issue the
|
|
118
|
+
* document holds — beats stating it nowhere.
|
|
119
|
+
*
|
|
120
|
+
* AND WITH ONLY AN IDENTITY LEFT, THE SAME RANKING APPLIES AS FAR AS THE FORMAT
|
|
121
|
+
* KEPT IT. `edgeIdentity` discards the declaring end of a SYMMETRIC pair and
|
|
122
|
+
* keeps it for a directed one, so `edgeIdentityEnd` names the carrier of a
|
|
123
|
+
* directed identity and answers `either` for a symmetric one — and both arms
|
|
124
|
+
* here rank by one rule instead of two. THIS ARM HAS BEEN WRONG IN BOTH
|
|
125
|
+
* DIRECTIONS: ranking a symmetric identity's segments invented an order the
|
|
126
|
+
* format had thrown away and stated a refusal on the far end's panel, and the
|
|
127
|
+
* correction — ranking nothing at all — discarded the order a DIRECTED identity
|
|
128
|
+
* still carries, so a host proposing on the shared store had its refusal placed
|
|
129
|
+
* under whichever end `document.issues` listed first. `either`, and a directed
|
|
130
|
+
* identity whose carrier this backlog does not hold, both fall through to the
|
|
131
|
+
* fallback this arm always had: "an issue this backlog holds and this edit was
|
|
132
|
+
* between", in the document's own order. The reader's own edits reach none of
|
|
133
|
+
* it, because their carrier was taken while the edge was still there.
|
|
134
|
+
*
|
|
135
|
+
* `null` when the document holds NEITHER end, which is not a refusal being
|
|
136
|
+
* dropped: there is no panel for an issue this backlog does not carry, so
|
|
137
|
+
* there is nowhere the reader could be standing to read it.
|
|
138
|
+
*/
|
|
139
|
+
export function editCarrier(document, proposal) {
|
|
140
|
+
const keys = document.issues.map((issue) => issue.ref);
|
|
141
|
+
const ends = endsOf(document, proposal);
|
|
142
|
+
switch (ends.kind) {
|
|
143
|
+
case 'pair':
|
|
144
|
+
return keys.find((ref) => ref === ends.carrier) ?? keys.find((ref) => ref === ends.far) ?? null;
|
|
145
|
+
case 'identity':
|
|
146
|
+
return (keys.find((ref) => edgeIdentityEnd(ends.edgeId, ref) === 'carrier') ??
|
|
147
|
+
keys.find((ref) => edgeIdentityEnd(ends.edgeId, ref) !== null) ??
|
|
148
|
+
null);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/** The reader's own edits: performed as a plain write, with nothing to take back by name. */
|
|
152
|
+
const TO_THE_STORE = { kind: 'propose' };
|
|
153
|
+
/**
|
|
154
|
+
* The effect a route emits, as an exhaustive switch rather than a pair of
|
|
155
|
+
* branches: a third way to write is a compile error here, not a route that
|
|
156
|
+
* quietly builds its own effect again.
|
|
157
|
+
*/
|
|
158
|
+
function effectFor(route, proposal, carrier) {
|
|
159
|
+
switch (route.kind) {
|
|
160
|
+
case 'propose':
|
|
161
|
+
return { kind: 'propose', proposal, carrier };
|
|
162
|
+
case 'first-pass-apply':
|
|
163
|
+
return { kind: 'first-pass-apply', candidateId: route.candidateId, proposal, carrier };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Emit an edit, and leave the panel on the issue the edit is about.
|
|
168
|
+
*
|
|
169
|
+
* EVERY PROPOSAL THIS REDUCER EMITS GOES THROUGH HERE, which is the point. The
|
|
170
|
+
* type does not prove that on its own — a new arm could still write out a
|
|
171
|
+
* `carrier` by hand — but it makes skipping this deliberate rather than an
|
|
172
|
+
* omission: both of `HostEffect`'s write-bearing arms REQUIRE the field, so a
|
|
173
|
+
* route that goes round the funnel has to invent an answer in plain sight
|
|
174
|
+
* instead of quietly not having one.
|
|
175
|
+
*
|
|
176
|
+
* §17b requires a refusal to be visible at the moment the reader is refused,
|
|
177
|
+
* and a refusal is stated on its carrier's panel — so the panel has
|
|
178
|
+
* to BE the carrier's when the edit goes out, rather than arriving there
|
|
179
|
+
* afterwards by whichever route happens to notice. Each route that had to
|
|
180
|
+
* notice separately cost a review round: a retype's projection hides the edge
|
|
181
|
+
* the panel was filtered to, and until `reconcileHost` was taught about hidden
|
|
182
|
+
* edges the panel resolved to nothing selected and the reason was drawn
|
|
183
|
+
* nowhere; a create completed after the reader had clicked another issue went
|
|
184
|
+
* out from the draft's source while the panel was headed by the issue they
|
|
185
|
+
* clicked, which nothing downstream can see at all — a completed draft leaves
|
|
186
|
+
* no trace of where it began; and a first-pass `apply` built its effect for
|
|
187
|
+
* itself, so a queue opened with nothing selected answered `Y`, was refused,
|
|
188
|
+
* and closed onto a panel that stated nothing.
|
|
189
|
+
*
|
|
190
|
+
* IT WRITES BACK WHAT WAS ALREADY THERE ON THE CREATE PATHS, which is the
|
|
191
|
+
* check that this is not a behaviour change smuggled in beside a fix.
|
|
192
|
+
* Beginning a draft already selects its source and a canvas drop already
|
|
193
|
+
* selects the node it started on, so the undiverted paths land on the same
|
|
194
|
+
* selection. The picker's routes DO move — an edit on a selected edge takes
|
|
195
|
+
* the panel to that edge's carrier now rather than one render later, which is
|
|
196
|
+
* where a retype or a flip already ended up through `reconcileHost` and where
|
|
197
|
+
* a delete of the selected edge used to leave the reader with `none` once the
|
|
198
|
+
* write landed and took the edge away.
|
|
199
|
+
*
|
|
200
|
+
* SO DOES THE FIRST PASS, AND IT MOVES THE PANEL UNDER A SURFACE THAT COVERS
|
|
201
|
+
* IT. That is the intended effect rather than a side one: the overlay is modal,
|
|
202
|
+
* the reader answers `Y`, and where they are put down when it closes is the
|
|
203
|
+
* only chance a refusal of that answer has of being read. Whether the write
|
|
204
|
+
* will be refused is not knowable here, so every `apply` moves the panel and
|
|
205
|
+
* the last one answered is the panel the reader lands on.
|
|
206
|
+
*
|
|
207
|
+
* AN EDIT ABOUT NO ISSUE THIS DOCUMENT HOLDS LEAVES THE SELECTION ALONE. See
|
|
208
|
+
* {@link editCarrier}: there is no panel to move to, and clearing would take
|
|
209
|
+
* the reader off the one they are on for a reason they could not see. The
|
|
210
|
+
* effect still carries the `null`, because "no panel states this" is an answer
|
|
211
|
+
* the shell must not go and compute a different one for.
|
|
212
|
+
*/
|
|
213
|
+
function emitting(state, proposal, document, route) {
|
|
214
|
+
const carrier = editCarrier(document, proposal);
|
|
215
|
+
return {
|
|
216
|
+
state: carrier === null
|
|
217
|
+
? state
|
|
218
|
+
: {
|
|
219
|
+
...state,
|
|
220
|
+
selection: selectionReducer(INITIAL_SELECTION, { kind: 'select-issue', key: carrier }),
|
|
221
|
+
},
|
|
222
|
+
effects: [effectFor(route, proposal, carrier)],
|
|
223
|
+
claimed: true,
|
|
224
|
+
};
|
|
47
225
|
}
|
|
48
226
|
/** A draft step: apply the create reducer, and emit the proposal it completes. */
|
|
49
|
-
function drafted(state, command) {
|
|
227
|
+
function drafted(state, command, document) {
|
|
50
228
|
const result = createReducer(state.draft, command);
|
|
51
229
|
if (result.proposal === null) {
|
|
52
230
|
// BEGINNING A DRAFT SELECTS ITS SOURCE. The inspector draws the picker for
|
|
@@ -63,16 +241,92 @@ function drafted(state, command) {
|
|
|
63
241
|
const chrome = command.kind === 'cancel' ? { targetQuery: '', drop: null } : {};
|
|
64
242
|
return settled({ ...state, draft: result.draft, selection, ...chrome });
|
|
65
243
|
}
|
|
244
|
+
// THE DRAFT ENDS WHERE IT BEGAN. `begin` selects the source three lines
|
|
245
|
+
// above; `emitting` puts the panel back on the edit's carrier, which for a
|
|
246
|
+
// create IS that source. Without it a click on another issue between the two
|
|
247
|
+
// steps left the write going out from one issue and the panel headed by
|
|
248
|
+
// another — and a refusal about the first stated on no panel at all.
|
|
249
|
+
const proposed = emitting(state, result.proposal, document, TO_THE_STORE);
|
|
66
250
|
return {
|
|
67
|
-
|
|
68
|
-
|
|
251
|
+
...proposed,
|
|
252
|
+
state: { ...proposed.state, draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null },
|
|
69
253
|
};
|
|
70
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* Whether a string names an {@link Answer}.
|
|
257
|
+
*
|
|
258
|
+
* Its own guard rather than a cast: the value arrives off a DOM attribute, and
|
|
259
|
+
* `AGENTS.md`'s strict-TypeScript rule forbids asserting it into the union. The
|
|
260
|
+
* three names are `queue.ts`'s, read from a frozen tuple so an answer added
|
|
261
|
+
* there fails a build here rather than being silently unreachable.
|
|
262
|
+
*/
|
|
263
|
+
const ANSWERS = Object.freeze(['apply', 'reject', 'skip']);
|
|
264
|
+
function isAnswer(value) {
|
|
265
|
+
return ANSWERS.some((answer) => answer === value);
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Apply one first-pass command, and turn what it produced into effects.
|
|
269
|
+
*
|
|
270
|
+
* The two emissions are exactly the two things `QueueResult` reports and the
|
|
271
|
+
* queue itself cannot perform: the proposal an `apply` stands for, and the
|
|
272
|
+
* answer an `undo` took back. Nothing else here reaches the store, which is what
|
|
273
|
+
* keeps §17e's consent rule a property of the shape — drawing a candidate,
|
|
274
|
+
* opening the surface, closing it and skipping all emit nothing because there is
|
|
275
|
+
* no arm on which they could.
|
|
276
|
+
*
|
|
277
|
+
* THE APPLY GOES THROUGH {@link emitting}, AND FOR A ROUND IT DID NOT. This
|
|
278
|
+
* function built the `first-pass-apply` effect itself, which made the claim
|
|
279
|
+
* "every proposal this reducer emits goes through one funnel" false at the one
|
|
280
|
+
* emission that could not be reached any other way — the overlay is modal, so
|
|
281
|
+
* the reader's hands are nowhere near the panel. A queue opened with nothing
|
|
282
|
+
* selected, or with an unrelated issue selected, answered `Y`, had the create
|
|
283
|
+
* refused, closed, and put the reader back exactly where they had been: a panel
|
|
284
|
+
* that states no refusal because the edit was about some other issue. It is the
|
|
285
|
+
* `document` argument that this route was missing, so that is what it now takes.
|
|
286
|
+
*/
|
|
287
|
+
function firstPassed(state, command, document) {
|
|
288
|
+
const outcome = firstPassReducer(state.firstPass, command);
|
|
289
|
+
// THE DRAFT IS NOT TOUCHED HERE, and an earlier revision's attempt to is worth
|
|
290
|
+
// recording: opening the surface does have to cancel a live draft — the queue
|
|
291
|
+
// covers the target search and the kind chooser, and a draft left standing
|
|
292
|
+
// behind it is re-entered on close with the reader's context gone — but this
|
|
293
|
+
// reducer cannot tell an open that will SCAN from one the shell is about to
|
|
294
|
+
// refuse for want of a source. Clearing on every open destroyed the reader's
|
|
295
|
+
// draft for a queue that then never appeared. So the cancel belongs to the
|
|
296
|
+
// shell, which knows, and it is dispatched there — see `mount.ts`.
|
|
297
|
+
let next = { ...state, firstPass: outcome.state };
|
|
298
|
+
const effects = [];
|
|
299
|
+
if (outcome.scanning !== null)
|
|
300
|
+
effects.push({ kind: 'find-candidates', scan: outcome.scanning });
|
|
301
|
+
const result = outcome.result;
|
|
302
|
+
if (result !== null) {
|
|
303
|
+
// THE ANSWER'S OWN CANDIDATE, read off the queue's record of it rather than
|
|
304
|
+
// off the screen: by the time this runs the cursor has already advanced.
|
|
305
|
+
const given = result.state.answers[result.state.answers.length - 1];
|
|
306
|
+
if (result.proposal !== null && given !== undefined) {
|
|
307
|
+
const applied = emitting(next, result.proposal, document, {
|
|
308
|
+
kind: 'first-pass-apply',
|
|
309
|
+
candidateId: given.candidate.id,
|
|
310
|
+
});
|
|
311
|
+
// THE PANEL MOVE IS KEPT, not discarded beside the effect. It is the whole
|
|
312
|
+
// of what this route was missing: the overlay closes onto the issue the
|
|
313
|
+
// answered create was about, which is the panel its refusal is stated on.
|
|
314
|
+
next = applied.state;
|
|
315
|
+
effects.push(...applied.effects);
|
|
316
|
+
}
|
|
317
|
+
// ONLY A WITHDRAWN `apply` REACHES THE STORE. A withdrawn `reject` or `skip`
|
|
318
|
+
// dispatched nothing, so there is nothing out there to take back.
|
|
319
|
+
if (result.withdrawn !== null && result.withdrawn.answer === 'apply') {
|
|
320
|
+
effects.push({ kind: 'first-pass-withdraw', candidateId: result.withdrawn.candidate.id });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return { state: next, effects, claimed: true };
|
|
324
|
+
}
|
|
71
325
|
/** A pointer on an issue: a target while one is being chosen, a selection otherwise. */
|
|
72
|
-
function pointed(state, key) {
|
|
326
|
+
function pointed(state, key, document) {
|
|
73
327
|
const choosingTarget = state.draft.kind !== null && state.draft.target === null;
|
|
74
328
|
if (choosingTarget && key !== state.draft.source) {
|
|
75
|
-
return drafted(state, { kind: 'target', ref: key });
|
|
329
|
+
return drafted(state, { kind: 'target', ref: key }, document);
|
|
76
330
|
}
|
|
77
331
|
return settled({
|
|
78
332
|
...state,
|
|
@@ -106,7 +360,7 @@ function controlled(state, name, target, value, document) {
|
|
|
106
360
|
// open blocker, the claimed peer — as this control, so a reader can reach
|
|
107
361
|
// the issue holding the one they are looking at. It is a pointer on that
|
|
108
362
|
// issue, with a pointer's rules: a selection, or the target of a draft.
|
|
109
|
-
return target === undefined ? settled(state) : pointed(state, target);
|
|
363
|
+
return target === undefined ? settled(state) : pointed(state, target, document);
|
|
110
364
|
case 'clear':
|
|
111
365
|
return settled({
|
|
112
366
|
...state,
|
|
@@ -134,52 +388,125 @@ function controlled(state, name, target, value, document) {
|
|
|
134
388
|
if (edgeId === null || value === undefined || !isEdgeField(value))
|
|
135
389
|
return settled(state);
|
|
136
390
|
const proposal = pickerProposal(document, edgeId, { kind: 'retype', field: value });
|
|
137
|
-
return proposal === null ? settled(state) :
|
|
391
|
+
return proposal === null ? settled(state) : emitting(state, proposal, document, TO_THE_STORE);
|
|
138
392
|
}
|
|
139
393
|
case 'flip': {
|
|
140
394
|
if (edgeId === null)
|
|
141
395
|
return settled(state);
|
|
142
396
|
const proposal = pickerProposal(document, edgeId, { kind: 'flip' });
|
|
143
|
-
return proposal === null ? settled(state) :
|
|
397
|
+
return proposal === null ? settled(state) : emitting(state, proposal, document, TO_THE_STORE);
|
|
144
398
|
}
|
|
145
399
|
case 'dismiss-change':
|
|
146
|
-
return { state, effects: [{ kind: 'dismiss-change' }] };
|
|
147
|
-
// --- the mount's chrome ---
|
|
148
|
-
case 'audit-filter':
|
|
149
|
-
return settled({ ...state, auditFiltered: !state.auditFiltered });
|
|
400
|
+
return { state, effects: [{ kind: 'dismiss-change' }], claimed: true };
|
|
150
401
|
case 'add': {
|
|
151
|
-
|
|
152
|
-
|
|
402
|
+
// THE CONTROL'S OWN SUBJECT FIRST, AND THE SELECTION AS THE FALLBACK —
|
|
403
|
+
// the same one rule the `delete` arm below states, for the same reason.
|
|
404
|
+
//
|
|
405
|
+
// `inspectorView` CANONICALIZES a selection naming a together-unit member
|
|
406
|
+
// onto its slot's LEAD, and `renderWorkspace` words the whole panel from
|
|
407
|
+
// that: the heading, the title, every row. `selectedKey` answers the RAW
|
|
408
|
+
// key. So with a partner selected the panel was titled with the lead and
|
|
409
|
+
// its `+ add` began a relationship from the partner — one control writing
|
|
410
|
+
// about a different issue from the one every other line of its own panel
|
|
411
|
+
// named. The panel publishes the canonical key on the control, which is
|
|
412
|
+
// the fact only it holds.
|
|
413
|
+
const source = target ?? selectedKey(state.selection);
|
|
414
|
+
return source === null ? settled(state) : drafted(state, { kind: 'begin', source }, document);
|
|
153
415
|
}
|
|
154
416
|
case 'kind':
|
|
155
417
|
return value === undefined || !isEdgeField(value)
|
|
156
418
|
? settled(state)
|
|
157
|
-
: drafted(state, { kind: 'type', edgeKind: value });
|
|
158
|
-
case 'target-query':
|
|
159
|
-
return settled({ ...state, targetQuery: value ?? '' });
|
|
160
|
-
case 'target':
|
|
161
|
-
return target === undefined ? settled(state) : drafted(state, { kind: 'target', ref: target });
|
|
419
|
+
: drafted(state, { kind: 'type', edgeKind: value }, document);
|
|
162
420
|
case 'cancel':
|
|
163
421
|
return settled({ ...state, draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null });
|
|
164
|
-
case 'delete':
|
|
165
|
-
|
|
422
|
+
case 'delete': {
|
|
423
|
+
// THE CONTROL'S OWN EDGE FIRST, AND THE SELECTION AS THE FALLBACK.
|
|
424
|
+
//
|
|
425
|
+
// This arm read the selection and ignored `target` entirely, which was
|
|
426
|
+
// sound for exactly as long as the only delete control lived inside
|
|
427
|
+
// `if (edgeId !== null)` — one button, about the one selected edge, and
|
|
428
|
+
// nothing else could publish the command. §17a's inspector puts a remove
|
|
429
|
+
// control on EVERY relationship row, and against the old arm each of them
|
|
430
|
+
// was wrong in one of two ways: with an issue selected `edgeId` is `null`
|
|
431
|
+
// and every row's remove was a silent no-op, and with row B's edge
|
|
432
|
+
// selected row A's remove deleted B. A control that deletes a different
|
|
433
|
+
// relationship from the one it sits on is worse than one that does
|
|
434
|
+
// nothing.
|
|
435
|
+
//
|
|
436
|
+
// THE FALLBACK IS NOT A CONVENIENCE — it is the keyboard. `create/keys.ts`
|
|
437
|
+
// binds `⌫` to the SELECTED edge, because a selection is the only edge a
|
|
438
|
+
// keyboard has named, and that intent arrives through `intended` rather
|
|
439
|
+
// than here; what still needs the fallback is the mount's own labelled
|
|
440
|
+
// delete button, which is drawn only for a selected edge and carries no
|
|
441
|
+
// target. One rule — "the edge the act names" — with the selection as the
|
|
442
|
+
// subject when nothing else names one.
|
|
443
|
+
const subject = target ?? edgeId;
|
|
444
|
+
return subject === null
|
|
166
445
|
? settled(state)
|
|
167
|
-
:
|
|
446
|
+
: emitting(state, { op: 'delete', edgeId: subject }, document, TO_THE_STORE);
|
|
447
|
+
}
|
|
448
|
+
// --- the mount's chrome: what still needs a DOM, or a host's own surface ---
|
|
449
|
+
// `add`, `kind`, `cancel` and `delete` used to be listed here, and they are
|
|
450
|
+
// not the mount's any more: `renderWorkspace` draws every one of them in its
|
|
451
|
+
// own markup, so a host rendering the package without mounting it publishes
|
|
452
|
+
// them too. What is left below genuinely is the shell's — a live input over
|
|
453
|
+
// the reader's query and the matches it offers, the audit toggle the header
|
|
454
|
+
// publishes, and the two write-recovery controls the mount draws beside a
|
|
455
|
+
// failed record.
|
|
456
|
+
case 'audit-filter':
|
|
457
|
+
return settled({ ...state, auditFiltered: !state.auditFiltered });
|
|
458
|
+
case 'target-query':
|
|
459
|
+
return settled({ ...state, targetQuery: value ?? '' });
|
|
460
|
+
case 'target':
|
|
461
|
+
return target === undefined ? settled(state) : drafted(state, { kind: 'target', ref: target }, document);
|
|
168
462
|
case 'retry':
|
|
169
|
-
return target === undefined
|
|
463
|
+
return target === undefined
|
|
464
|
+
? settled(state)
|
|
465
|
+
: { state, effects: [{ kind: 'retry', mutationId: target }], claimed: true };
|
|
170
466
|
case 'discard':
|
|
171
467
|
return target === undefined
|
|
172
468
|
? settled(state)
|
|
173
|
-
: { state, effects: [{ kind: 'discard', mutationId: target }] };
|
|
469
|
+
: { state, effects: [{ kind: 'discard', mutationId: target }], claimed: true };
|
|
470
|
+
// A TOGGLE, AND NOTHING LEAVES THE CLIENT. Showing a held document is a
|
|
471
|
+
// reading act: it dispatches nothing, adopts nothing, and cannot be the
|
|
472
|
+
// step that resolves a conflict. So it emits NO effect — which is also the
|
|
473
|
+
// strongest thing that can be said about it, and what its test asserts.
|
|
474
|
+
//
|
|
475
|
+
// IT IS A `control` LIKE ITS TWO SIBLINGS, not a command arm of its own.
|
|
476
|
+
// The shell turns every `data-ig-command` into `{ kind: 'control', name,
|
|
477
|
+
// target }`, so an arm outside that shape has no route from a button and
|
|
478
|
+
// would be unreachable code behind a control that did nothing.
|
|
479
|
+
case 'view-diff':
|
|
480
|
+
return target === undefined
|
|
481
|
+
? settled(state)
|
|
482
|
+
: settled({ ...state, diffOpen: state.diffOpen === target ? null : target });
|
|
483
|
+
// --- the first pass ---
|
|
484
|
+
case 'first-pass':
|
|
485
|
+
return firstPassed(state, { kind: 'open' }, document);
|
|
486
|
+
case 'first-pass-close':
|
|
487
|
+
return firstPassed(state, { kind: 'close' }, document);
|
|
488
|
+
case 'first-pass-answer':
|
|
489
|
+
return value === undefined || !isAnswer(value)
|
|
490
|
+
? settled(state)
|
|
491
|
+
: firstPassed(state, { kind: 'queue', command: { kind: 'answer', answer: value } }, document);
|
|
492
|
+
case 'undo':
|
|
493
|
+
// GUARDED BY THE PHASE, not by the control's existence: `undo` is a name a
|
|
494
|
+
// host's own chrome could publish too, and `firstPassReducer` answers a
|
|
495
|
+
// queue command with no queue by changing nothing.
|
|
496
|
+
return firstPassed(state, { kind: 'queue', command: { kind: 'undo' } }, document);
|
|
174
497
|
default:
|
|
175
498
|
// A COMMAND THIS REDUCER DOES NOT KNOW CHANGES NOTHING. A host's own
|
|
176
499
|
// chrome may publish commands on the same attribute — the demo's theme
|
|
177
500
|
// switch does — and those are the host's to read from its own listener,
|
|
178
501
|
// never a reason for this reducer to guess.
|
|
179
|
-
|
|
502
|
+
//
|
|
503
|
+
// AND IT SAYS SO, rather than being indistinguishable from an arm that
|
|
504
|
+
// settled. See {@link HostResult.claimed}: a keyboard shell that cancels
|
|
505
|
+
// this press takes the host's control away from the keyboard entirely.
|
|
506
|
+
return unclaimed(state);
|
|
180
507
|
}
|
|
181
508
|
}
|
|
182
|
-
function intended(state, intent) {
|
|
509
|
+
function intended(state, intent, document) {
|
|
183
510
|
switch (intent.kind) {
|
|
184
511
|
case 'none':
|
|
185
512
|
// `T` opens the picker; the picker is already drawn whenever an edge is
|
|
@@ -187,16 +514,16 @@ function intended(state, intent) {
|
|
|
187
514
|
case 'retype':
|
|
188
515
|
return settled(state);
|
|
189
516
|
case 'create':
|
|
190
|
-
return drafted(state, intent.command);
|
|
517
|
+
return drafted(state, intent.command, document);
|
|
191
518
|
case 'propose':
|
|
192
|
-
return
|
|
519
|
+
return emitting(state, intent.proposal, document, TO_THE_STORE);
|
|
193
520
|
}
|
|
194
521
|
}
|
|
195
522
|
/** The one reducer. `document` is the landed document, for edge lookups. */
|
|
196
523
|
export function reduceHost(state, command, document) {
|
|
197
524
|
switch (command.kind) {
|
|
198
525
|
case 'point':
|
|
199
|
-
return pointed(state, command.key);
|
|
526
|
+
return pointed(state, command.key, document);
|
|
200
527
|
case 'group': {
|
|
201
528
|
// A mark names either an edge (its store identity) or a slot (its lead).
|
|
202
529
|
// A MARK NAMING NEITHER CHANGES NOTHING. The canvas draws an edge the
|
|
@@ -207,13 +534,15 @@ export function reduceHost(state, command, document) {
|
|
|
207
534
|
if (findEdge(document, command.id) !== undefined)
|
|
208
535
|
return selectEdge(state, command.id);
|
|
209
536
|
if (document.issues.some((issue) => issue.ref === command.id))
|
|
210
|
-
return pointed(state, command.id);
|
|
537
|
+
return pointed(state, command.id, document);
|
|
211
538
|
return settled(state);
|
|
212
539
|
}
|
|
213
540
|
case 'control':
|
|
214
541
|
return controlled(state, command.name, command.target, command.value, document);
|
|
542
|
+
case 'first-pass':
|
|
543
|
+
return firstPassed(state, command.command, document);
|
|
215
544
|
case 'intent':
|
|
216
|
-
return intended(state, command.intent);
|
|
545
|
+
return intended(state, command.intent, document);
|
|
217
546
|
case 'scroll':
|
|
218
547
|
return settled({ ...state, railStart: Math.max(0, Math.floor(command.start)) });
|
|
219
548
|
case 'drag-start':
|
|
@@ -237,7 +566,7 @@ export function reduceHost(state, command, document) {
|
|
|
237
566
|
}
|
|
238
567
|
}
|
|
239
568
|
/**
|
|
240
|
-
* Bring the state back into agreement with
|
|
569
|
+
* Bring the state back into agreement with what the reader can SEE.
|
|
241
570
|
*
|
|
242
571
|
* The store re-renders on every landed write, and a write can remove what the
|
|
243
572
|
* state names: a retype or a flip gives the edge a NEW identity, a delete
|
|
@@ -246,20 +575,71 @@ export function reduceHost(state, command, document) {
|
|
|
246
575
|
* the inspector showing a picker for nothing — the viewer already refuses a
|
|
247
576
|
* stale selection the same way, so the host does too, from the document
|
|
248
577
|
* rather than from memory of what it just proposed.
|
|
578
|
+
*
|
|
579
|
+
* ## `hidden` is the OTHER half of "no longer carries", and the landed
|
|
580
|
+
* document cannot state it
|
|
581
|
+
*
|
|
582
|
+
* `document` is what LANDED, and an unsettled retype or flip lands nothing —
|
|
583
|
+
* so the edge the reader was inspecting is still in it, and the check above
|
|
584
|
+
* passes, while the store's projection has already hidden that edge and the
|
|
585
|
+
* workspace has already stopped drawing it. Two documents were answering
|
|
586
|
+
* "does this selection resolve", and they disagreed for the whole life of the
|
|
587
|
+
* write: the panel resolved the selection to `none` and drew the empty
|
|
588
|
+
* sentence, and — because a refusal is only drawn for the subject it names —
|
|
589
|
+
* the reason the edit was refused was drawn nowhere at all. A reader who
|
|
590
|
+
* retyped an edge into a relationship that already exists saw the picker
|
|
591
|
+
* close and nothing else. `hidden` is the store's own set of landed edges its
|
|
592
|
+
* projection is not showing, so both halves of the question are asked here
|
|
593
|
+
* and the answer is one.
|
|
594
|
+
*
|
|
595
|
+
* A HIDDEN EDGE RETURNS THE PANEL TO ITS CARRIER, not to nothing. Through
|
|
596
|
+
* {@link carrierOf}, which is the same rule the refusal's own carrier was taken
|
|
597
|
+
* by when the edit went out — so the panel this lands on and the panel the
|
|
598
|
+
* refusal is stated under are one answer rather than two that agree. Clearing to `none`
|
|
599
|
+
* instead would be the same silence the check above already produced.
|
|
600
|
+
*
|
|
601
|
+
* IT IS A BACKSTOP NOW, NOT THE ONLY GUARD. `emitting` already puts the panel
|
|
602
|
+
* on the carrier the moment the reader's own edit goes out, so the route this
|
|
603
|
+
* paragraph was written for cannot reach here any more. What still can is a
|
|
604
|
+
* SIBLING write: another edit's retype or flip hiding the edge this selection
|
|
605
|
+
* names, which no act of the reader's announced.
|
|
606
|
+
*
|
|
607
|
+
* RECONCILING ONTO THE PROJECTED REPLACEMENT was the other candidate — follow
|
|
608
|
+
* the selection to the identity the edit produced — and it cannot be done from
|
|
609
|
+
* here or anywhere else. It works only when that identity happens to be
|
|
610
|
+
* landed, which is exactly one of the two refusals this route reaches:
|
|
611
|
+
* `duplicate-edge` produces an edge that already exists, but `cardinality`
|
|
612
|
+
* produces a PHANTOM, and a selection naming an edge the landed document does
|
|
613
|
+
* not carry is dropped by the first check in this very function on the next
|
|
614
|
+
* render. Measured: `reconcileHost` returns `{ kind: 'none' }` for it. That is
|
|
615
|
+
* the same vanishing one frame later, and it is the same decision the phantom
|
|
616
|
+
* capsule already records by publishing no `select-edge` — there is nothing
|
|
617
|
+
* there to select.
|
|
249
618
|
*/
|
|
250
|
-
export function reconcileHost(state, document) {
|
|
619
|
+
export function reconcileHost(state, document, hidden) {
|
|
251
620
|
const edgeId = selectedEdgeId(state.selection);
|
|
252
|
-
const
|
|
253
|
-
const
|
|
621
|
+
const edge = edgeId === null ? undefined : findEdge(document, edgeId);
|
|
622
|
+
const resolved = edgeId === null
|
|
623
|
+
? state.selection
|
|
624
|
+
: edge === undefined
|
|
625
|
+
? INITIAL_SELECTION
|
|
626
|
+
: hidden.has(edge.id)
|
|
627
|
+
? selectionReducer(INITIAL_SELECTION, { kind: 'select-issue', key: carrierOf(edge) })
|
|
628
|
+
: state.selection;
|
|
629
|
+
const issueKey = selectedKey(resolved);
|
|
254
630
|
const known = new Set(document.issues.map((issue) => issue.ref));
|
|
631
|
+
// ASKED OF `resolved`, NOT OF `state.selection`. The carrier above is read
|
|
632
|
+
// off an edge, and an edge can name an issue the document does not list;
|
|
633
|
+
// asking the question of the selection that came IN would let that one
|
|
634
|
+
// through unchecked, which is the stale name this function exists to refuse.
|
|
635
|
+
const selection = issueKey !== null && !known.has(issueKey) ? INITIAL_SELECTION : resolved;
|
|
255
636
|
const draftStands = (state.draft.source === null || known.has(state.draft.source)) &&
|
|
256
637
|
(state.draft.target === null || known.has(state.draft.target));
|
|
257
|
-
if (selection === state.selection && draftStands
|
|
638
|
+
if (selection === state.selection && draftStands)
|
|
258
639
|
return state;
|
|
259
|
-
}
|
|
260
640
|
return {
|
|
261
641
|
...state,
|
|
262
|
-
selection
|
|
642
|
+
selection,
|
|
263
643
|
...(draftStands ? {} : { draft: IDLE_CREATE_DRAFT, targetQuery: '', drop: null }),
|
|
264
644
|
};
|
|
265
645
|
}
|
|
@@ -273,8 +653,6 @@ export function targetMatches(issues, query, source, limit = 8) {
|
|
|
273
653
|
.filter((issue) => issue.ref.includes(needle) || issue.title.toLowerCase().includes(needle))
|
|
274
654
|
.slice(0, limit);
|
|
275
655
|
}
|
|
276
|
-
/** The edge kinds, in the format's order — the keyboard path's `1`–`5` is this list. */
|
|
277
|
-
export const KINDS = EDGE_FIELDS;
|
|
278
656
|
/** How far the reader may scroll into the rail window before it is re-cut around them, in rows. */
|
|
279
657
|
export const RAIL_SLACK = 20;
|
|
280
658
|
/**
|
|
@@ -287,6 +665,20 @@ export const RAIL_SLACK = 20;
|
|
|
287
665
|
export function railSlackFor(count) {
|
|
288
666
|
return Math.min(RAIL_SLACK, Math.max(1, Math.floor(count / 4)));
|
|
289
667
|
}
|
|
668
|
+
/**
|
|
669
|
+
* Which order row a rail scroll offset points at.
|
|
670
|
+
*
|
|
671
|
+
* THE ROWS DO NOT START AT THE TOP OF THE RAIL. The legend sits above them, and
|
|
672
|
+
* so do the host header and the NOW list when the host supplies them — and the
|
|
673
|
+
* NOW list is one row per running job, unbounded. Dividing the raw offset by
|
|
674
|
+
* the row pitch read that chrome as rows: with enough jobs the window advanced
|
|
675
|
+
* past ranks the reader had not reached, and the rows chased the scroll
|
|
676
|
+
* position. The chrome's height is measured and subtracted first, so the
|
|
677
|
+
* offset that reaches the division is the offset INTO the rows.
|
|
678
|
+
*/
|
|
679
|
+
export function railRowAt(scrollTop, chromeHeight, pitch) {
|
|
680
|
+
return Math.floor(Math.max(0, scrollTop - chromeHeight) / pitch);
|
|
681
|
+
}
|
|
290
682
|
/**
|
|
291
683
|
* Where the rail window should be re-cut for a scroll position, or `null` when
|
|
292
684
|
* it should stay where it is.
|