@panphora/hyper-cms 0.8.1 → 0.11.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 +33 -0
- package/dist/hypercms.min.js +442 -36
- package/package.json +7 -4
- package/packed-contract.json +1 -0
- package/src/anchor.js +24 -0
- package/src/apply-loop.js +79 -46
- package/src/enhance.js +79 -6
- package/src/events.js +102 -8
- package/src/hooks.js +100 -0
- package/src/hypercms-clayjs.js +10 -0
- package/src/hypercms.js +109 -379
- package/src/inline-icons.js +15 -0
- package/src/lib/content-dom.js +110 -0
- package/src/lib/region-capabilities.js +71 -0
- package/src/morph.js +1 -0
- package/src/place.js +131 -0
- package/src/platform.js +12 -0
- package/src/refresh.js +31 -14
- package/src/richclay-bridge.js +140 -0
- package/src/row-identity.js +109 -0
- package/src/session.js +303 -0
- package/src/shell.js +27 -6
- package/src/targets.js +149 -0
- package/src/theme/pixel-quiet.overrides.css +303 -9
- package/src/theme.generated.css +303 -9
- package/src/toggle.js +627 -34
- package/src/unresolved.js +87 -18
- package/src/views/inline-ghosts.js +139 -0
- package/src/views/inline-layer.js +663 -0
- package/src/views/inline.js +1092 -0
- package/src/views/sidebar.js +97 -0
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
// The inline view: the editing session rendered onto the page itself rather than
|
|
2
|
+
// into a side panel.
|
|
3
|
+
//
|
|
4
|
+
// Three ways to change one field, chosen per target: text is edited in place
|
|
5
|
+
// through richclay bound to the page element, a native control on the page is
|
|
6
|
+
// left alone to be used, and everything else gets a handle that opens the
|
|
7
|
+
// popover holding the selected item's real form controls.
|
|
8
|
+
//
|
|
9
|
+
// The form is NOT a second representation of the data. It is the same form the
|
|
10
|
+
// sidebar builds, mounted inside the inline host with every field hidden; the
|
|
11
|
+
// popover reveals the selected field and its item siblings in place. Nothing is ever
|
|
12
|
+
// moved in or out of the form tree, so engine.extract(formRoot, formRules) can
|
|
13
|
+
// never be missing a path. That is the failure mode of the obvious alternative,
|
|
14
|
+
// relocating a field node into a popover and back.
|
|
15
|
+
//
|
|
16
|
+
// A text edit is the one path that does not write the page through the engine:
|
|
17
|
+
// richclay has already written it. What the commit does is mirror that value
|
|
18
|
+
// into the form leaf and apply as usual, which the engine's compare-before-write
|
|
19
|
+
// guard turns into a no-op on the element under the caret.
|
|
20
|
+
|
|
21
|
+
import { buildForm } from '../form-builder.js'
|
|
22
|
+
import {
|
|
23
|
+
bindEvents,
|
|
24
|
+
commit,
|
|
25
|
+
cssEscape,
|
|
26
|
+
extractFormData,
|
|
27
|
+
findLeafField,
|
|
28
|
+
onAdd,
|
|
29
|
+
onMove,
|
|
30
|
+
requestRemove,
|
|
31
|
+
suppressUndo,
|
|
32
|
+
writeFieldValue,
|
|
33
|
+
} from '../events.js'
|
|
34
|
+
import { autosizeTextarea, enhanceFields, upgradeInlineTextRules } from '../enhance.js'
|
|
35
|
+
import { applyUnresolvedState } from '../unresolved.js'
|
|
36
|
+
import { refreshForm } from '../refresh.js'
|
|
37
|
+
import { clearSessionOpen, markSessionOpen, reensureStyles } from '../shell.js'
|
|
38
|
+
import { isAnchorable } from '../anchor.js'
|
|
39
|
+
import { resolveTargets } from '../targets.js'
|
|
40
|
+
import { createContentView } from '../lib/content-dom.js'
|
|
41
|
+
import { morph } from 'hyper-morph'
|
|
42
|
+
import { place } from '../place.js'
|
|
43
|
+
import { platform } from '../platform.js'
|
|
44
|
+
import {
|
|
45
|
+
BOUND_ATTR,
|
|
46
|
+
BOUND_ID_ATTR,
|
|
47
|
+
RC_OWNED_ATTR,
|
|
48
|
+
markBound,
|
|
49
|
+
registerBinding,
|
|
50
|
+
releaseBinding,
|
|
51
|
+
resolveRichClay,
|
|
52
|
+
stripOrphanEditorState,
|
|
53
|
+
} from '../richclay-bridge.js'
|
|
54
|
+
import { installSnapshotHook } from '../hooks.js'
|
|
55
|
+
import { createInlineLayer } from './inline-layer.js'
|
|
56
|
+
import { inlineIcon } from '../inline-icons.js'
|
|
57
|
+
|
|
58
|
+
const HOST_TAG = 'hypercms-inline'
|
|
59
|
+
|
|
60
|
+
// Revealed fields and their ancestors, cleared before each activation.
|
|
61
|
+
const ACTIVE_CLASS = 'is-hcms-inline-active'
|
|
62
|
+
const ONPATH_CLASS = 'is-hcms-inline-onpath'
|
|
63
|
+
|
|
64
|
+
const FOCUSABLE =
|
|
65
|
+
'input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
|
66
|
+
|
|
67
|
+
// The same commands the sidebar's rich-text fields carry, so the two views
|
|
68
|
+
// offer the same formatting.
|
|
69
|
+
const TOOLBAR = ['bold', 'italic', 'link', 'undo', 'redo']
|
|
70
|
+
|
|
71
|
+
const HEADING = /^H[1-6]$/
|
|
72
|
+
|
|
73
|
+
let nextBoundId = 0
|
|
74
|
+
|
|
75
|
+
export function createInlineView({ doc, pageRoot, opts = {} }) {
|
|
76
|
+
const richText = opts.richText !== false
|
|
77
|
+
let host = null
|
|
78
|
+
let layer = null
|
|
79
|
+
let unbindPage = null
|
|
80
|
+
// Every richclay instance this view put on a PAGE element, keyed by that
|
|
81
|
+
// element. Bindings are made on the first click and live until the session
|
|
82
|
+
// ends, so a second click on a heading finds its editor rather than building
|
|
83
|
+
// another one.
|
|
84
|
+
const bindings = new Map()
|
|
85
|
+
// Which target the popover is open over, which handle opened it (so focus can
|
|
86
|
+
// go back there), and the placement mode of THIS open. The mode is reset on
|
|
87
|
+
// every activation: place()'s hysteresis is about one popover following its
|
|
88
|
+
// own anchor, and carrying a previous open's mode into a new one would bias
|
|
89
|
+
// the first placement toward wherever the last field happened to sit.
|
|
90
|
+
let activeTarget = null
|
|
91
|
+
let activeHandle = null
|
|
92
|
+
let popMode = null
|
|
93
|
+
|
|
94
|
+
// Build one binding: the editor, the marker, the snapshot hook, and the two
|
|
95
|
+
// listeners that turn typing into commits and blur into one undo primitive.
|
|
96
|
+
// Returns it, or null when the element cannot carry an editor. Shared by the
|
|
97
|
+
// first click and by the repair after a morph, so a rebound element comes back
|
|
98
|
+
// with everything the first bind gave it.
|
|
99
|
+
function bindText(ctx, el, path, prop, carry) {
|
|
100
|
+
const RichClay = resolveRichClay(doc.defaultView)
|
|
101
|
+
if (typeof RichClay !== 'function') return null
|
|
102
|
+
|
|
103
|
+
// A rebind inherits what the element looked like before richclay first
|
|
104
|
+
// touched it. Re-deriving these from the DOM reads the editor's own
|
|
105
|
+
// normalisation back as the author's markup, and the peer's morph as
|
|
106
|
+
// proof there was never an author's editor here.
|
|
107
|
+
const inherited = carry || {}
|
|
108
|
+
// Before richclay touches it. Squire normalises the markup it is handed:
|
|
109
|
+
// measured in Chrome, binding a heading rewrote
|
|
110
|
+
// <h1>A page that edits <em>itself</em></h1>
|
|
111
|
+
// into
|
|
112
|
+
// <h1><div>A page that edits <i>itself</i></div></h1>
|
|
113
|
+
// and that reaches the saved file. Someone who clicks a heading, reads it
|
|
114
|
+
// and presses Escape has changed nothing and must not have changed their
|
|
115
|
+
// document either, so an untouched session puts this back verbatim.
|
|
116
|
+
const originalHTML = inherited.originalHTML ?? createContentView(el, { capability: 'history' }).html()
|
|
117
|
+
const richClayIsOurs = inherited.richClayIsOurs ?? !el.hasAttribute('data-richclay')
|
|
118
|
+
// richclay's constructor returns the existing instance for an element that
|
|
119
|
+
// already has one (richclay.js:85-86), so on a page where the author mounted
|
|
120
|
+
// their own editor this is theirs, not ours. Adopt it for the session and
|
|
121
|
+
// never tear it down: destroying it would take away an editor hypercms did
|
|
122
|
+
// not create and cannot put back. data-richclay-active is richclay's own
|
|
123
|
+
// "there is a live editor here" flag, written by setupEditorAttributes.
|
|
124
|
+
const adopted = inherited.adopted ?? el.getAttribute('data-richclay-active') === 'true'
|
|
125
|
+
// The undo baseline, read before richclay touches the element for the same
|
|
126
|
+
// reason as originalHTML above: read after construct it holds Squire's
|
|
127
|
+
// normalisation, and undoing then writes <div> wrappers and <i> tags into
|
|
128
|
+
// the author's file. richclay stamps no-undo on what it activates, so from
|
|
129
|
+
// the bind until unbind this is the only record the page's undo stack gets.
|
|
130
|
+
// Never inherited: a rebind's baseline is the value on the page now.
|
|
131
|
+
const oldValue = readPageValue(el, prop, 'history')
|
|
132
|
+
const editor = construct(ctx, RichClay, el, prop, richClayIsOurs)
|
|
133
|
+
if (!editor) return null
|
|
134
|
+
|
|
135
|
+
// The rule's projection, not this element's children. A marker is only ever
|
|
136
|
+
// read back to answer "should the rule be rich", and for an array the rule
|
|
137
|
+
// is shared by every row: a plain row under a rich rule would otherwise
|
|
138
|
+
// report plain and downgrade the whole rule the moment the row that proved
|
|
139
|
+
// it rich is deleted. Recording the projection also states the frozen
|
|
140
|
+
// projection rule directly instead of approximating it.
|
|
141
|
+
markBound(el, prop === 'innerHTML', richClayIsOurs)
|
|
142
|
+
const boundId = String(++nextBoundId)
|
|
143
|
+
el.setAttribute(BOUND_ID_ATTR, boundId)
|
|
144
|
+
// The second trigger for the snapshot hook, and the reliable one: by the
|
|
145
|
+
// time anyone clicks a heading the host client is certainly loaded, even
|
|
146
|
+
// on a page that missed the readiness event at open(). Without the hook
|
|
147
|
+
// the editor's contenteditable reaches the saved file.
|
|
148
|
+
installSnapshotHook()
|
|
149
|
+
|
|
150
|
+
const binding = {
|
|
151
|
+
el,
|
|
152
|
+
editor,
|
|
153
|
+
path,
|
|
154
|
+
prop,
|
|
155
|
+
boundId,
|
|
156
|
+
originalHTML,
|
|
157
|
+
oldValue,
|
|
158
|
+
richClayIsOurs,
|
|
159
|
+
adopted,
|
|
160
|
+
// Set by squire's input event, which is the only signal that someone
|
|
161
|
+
// actually edited. Comparing the projected value cannot answer this:
|
|
162
|
+
// the normalisation above changes innerHTML at bind time, before anyone
|
|
163
|
+
// has typed anything.
|
|
164
|
+
dirty: false,
|
|
165
|
+
written: false,
|
|
166
|
+
// Whether the pre-bind markup may go back. Adopted means the editor is the
|
|
167
|
+
// author's and its normalisation is theirs to keep; dirty means they
|
|
168
|
+
// typed; written means a commit changed this path.
|
|
169
|
+
restorable() {
|
|
170
|
+
return !this.adopted && !this.dirty && !this.written
|
|
171
|
+
},
|
|
172
|
+
lastEdited: undefined,
|
|
173
|
+
}
|
|
174
|
+
bindings.set(el, binding)
|
|
175
|
+
registerBinding(boundId, binding)
|
|
176
|
+
|
|
177
|
+
// Toolbar commands mutate the DOM through squire without always firing a
|
|
178
|
+
// native input event, so squire's own signal is the one to commit on.
|
|
179
|
+
const squire = editor.squire
|
|
180
|
+
if (squire && typeof squire.addEventListener === 'function') {
|
|
181
|
+
const onInput = () => {
|
|
182
|
+
binding.dirty = true
|
|
183
|
+
// What this person produced, captured while the DOM still holds it.
|
|
184
|
+
// recordUndo reads this rather than the element, because at the one
|
|
185
|
+
// boundary that matters the element no longer holds their work.
|
|
186
|
+
binding.lastEdited = readPageValue(el, binding.prop, 'history')
|
|
187
|
+
acceptInlineTextChange(ctx, binding)
|
|
188
|
+
}
|
|
189
|
+
squire.addEventListener('input', onInput)
|
|
190
|
+
binding.detachInput = () => squire.removeEventListener?.('input', onInput)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Blur closes an edit session: one undo primitive covering everything
|
|
194
|
+
// typed since the bind (or since the last blur).
|
|
195
|
+
const onBlur = () => recordUndo(binding)
|
|
196
|
+
el.addEventListener('blur', onBlur)
|
|
197
|
+
binding.detachBlur = () => el.removeEventListener('blur', onBlur)
|
|
198
|
+
|
|
199
|
+
return binding
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
name: 'inline',
|
|
204
|
+
richText,
|
|
205
|
+
ctx: null,
|
|
206
|
+
root: null,
|
|
207
|
+
formRoot: null,
|
|
208
|
+
errorEl: null,
|
|
209
|
+
noticeEl: null,
|
|
210
|
+
handoffEl: null,
|
|
211
|
+
handoffCountEl: null,
|
|
212
|
+
|
|
213
|
+
popEl: null,
|
|
214
|
+
|
|
215
|
+
// Page targets own richclay instances. Popover siblings use their native
|
|
216
|
+
// form controls without mounting another richclay instance per field.
|
|
217
|
+
enhanceFormRichText: false,
|
|
218
|
+
|
|
219
|
+
// The wider upgrade, not the sidebar's. Every text projection this view
|
|
220
|
+
// binds rich text to has to round-trip through @innerHTML, list rows
|
|
221
|
+
// included, and only this one looks inside an array.
|
|
222
|
+
prepareRules(sourceRules) {
|
|
223
|
+
return richText ? upgradeInlineTextRules(sourceRules, pageRoot) : sourceRules
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
bindText(el, path, prop, carry) { return bindText(this.ctx, el, path, prop, carry) },
|
|
227
|
+
|
|
228
|
+
mount(initialData) {
|
|
229
|
+
const ctx = this.ctx
|
|
230
|
+
host = mountInlineHost(doc, opts.theme)
|
|
231
|
+
this.root = host.root
|
|
232
|
+
this.formRoot = host.formRoot
|
|
233
|
+
this.errorEl = host.errorEl
|
|
234
|
+
this.noticeEl = host.noticeEl
|
|
235
|
+
this.handoffEl = host.handoffEl
|
|
236
|
+
this.handoffCountEl = host.handoffCountEl
|
|
237
|
+
this.popEl = host.popEl
|
|
238
|
+
// A body class is a page edit unless it is suppressed, the same reason
|
|
239
|
+
// the sidebar wraps its own mount.
|
|
240
|
+
suppressUndo(() => markSessionOpen(doc))
|
|
241
|
+
|
|
242
|
+
const fragment = buildForm({
|
|
243
|
+
pageRules: ctx.pageRules,
|
|
244
|
+
formRules: ctx.formRules,
|
|
245
|
+
data: initialData,
|
|
246
|
+
doc,
|
|
247
|
+
})
|
|
248
|
+
host.formRoot.appendChild(fragment)
|
|
249
|
+
ctx.seeder.seed(host.formRoot)
|
|
250
|
+
enhanceFields(host.formRoot, doc, this.enhanceFormRichText)
|
|
251
|
+
applyUnresolvedState(ctx)
|
|
252
|
+
|
|
253
|
+
bindEvents(ctx)
|
|
254
|
+
|
|
255
|
+
layer = createInlineLayer({
|
|
256
|
+
doc,
|
|
257
|
+
layerEl: host.layerEl,
|
|
258
|
+
// The handle and the page click are two doors to one room.
|
|
259
|
+
onActivate: (target, handle) => this.activate(target, handle),
|
|
260
|
+
onListAction: (op) => this.listAction(op),
|
|
261
|
+
})
|
|
262
|
+
layer.setFollower(() => this.placePopover())
|
|
263
|
+
host.closeEl.addEventListener('click', () => this.deactivate())
|
|
264
|
+
host.toggleEl.addEventListener('click', (event) => {
|
|
265
|
+
event.preventDefault()
|
|
266
|
+
event.stopPropagation()
|
|
267
|
+
this.toggleControls()
|
|
268
|
+
})
|
|
269
|
+
// There is no drawer. What a field with no visual representation gets is
|
|
270
|
+
// this one tap out to the view that edits everything.
|
|
271
|
+
// The switch carries the session's options across (hypercms.js open()), so
|
|
272
|
+
// the sidebar comes up on the same page root with the same callbacks.
|
|
273
|
+
host.handoffEl.querySelector('[data-hcms-open-view]').addEventListener('click', (event) => {
|
|
274
|
+
event.preventDefault()
|
|
275
|
+
event.stopPropagation()
|
|
276
|
+
ctx.onViewRequested?.('sidebar')
|
|
277
|
+
})
|
|
278
|
+
this.bindPage()
|
|
279
|
+
this.syncTargets()
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
// Add, remove and reorder, every one of them acting on the FORM row and
|
|
283
|
+
// letting the commit move the page.
|
|
284
|
+
//
|
|
285
|
+
// Moving the page row here instead would corrupt the rollback:
|
|
286
|
+
// captureChildren runs INSIDE applyWithRollback (apply-loop.js:39), after
|
|
287
|
+
// the caller has already mutated, so a failed apply would restore the
|
|
288
|
+
// post-move DOM and the reorder would never come undone. Moving the form row
|
|
289
|
+
// first means the commit snapshots the page container still in its pre-move
|
|
290
|
+
// state. Routing through requestRemove rather than onRemove is the same kind
|
|
291
|
+
// of inheritance: the consent modal an object array raises is that path's,
|
|
292
|
+
// and reimplementing it here would be a second policy to keep in step.
|
|
293
|
+
listAction({ action, list, index, row }) {
|
|
294
|
+
const ctx = this.ctx
|
|
295
|
+
const arrayPath = list.path.join('.')
|
|
296
|
+
const arrayEl = ctx.formRoot.querySelector(`[data-hcms-path="${cssEscape(arrayPath)}"]`)
|
|
297
|
+
if (!arrayEl) return
|
|
298
|
+
if (action === 'add') {
|
|
299
|
+
onAdd(arrayPath, ctx)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
// The strip was stamped with the index its row held when the layer was
|
|
303
|
+
// last built, and a refresh trails a structural change by an observer
|
|
304
|
+
// batch. Re-resolve from the row element so a second click inside that
|
|
305
|
+
// window acts on the row under the pointer rather than on whatever has
|
|
306
|
+
// since taken its number.
|
|
307
|
+
const rowIndex = row ? liveRowIndex(ctx, arrayPath, row) : index
|
|
308
|
+
// A row that is no longer in the list was removed by the click before this
|
|
309
|
+
// one, and its strip is still on screen because the refresh trails the
|
|
310
|
+
// change. There is nothing to act on, and the number it was stamped with
|
|
311
|
+
// now belongs to the row that moved up into its place, so doing nothing is
|
|
312
|
+
// the only correct outcome.
|
|
313
|
+
if (rowIndex === -1) return
|
|
314
|
+
const formRow = formRowAt(arrayEl, rowIndex)
|
|
315
|
+
if (!formRow) return
|
|
316
|
+
if (action === 'remove') {
|
|
317
|
+
requestRemove(formRow, ctx)
|
|
318
|
+
// The commit has already moved the page and the form. Reconcile now
|
|
319
|
+
// rather than on the debounced refresh: until this runs, every binding
|
|
320
|
+
// below the change still holds the numeric path it had before it, and a
|
|
321
|
+
// keystroke inside that window commits into the wrong row.
|
|
322
|
+
this.syncTargets()
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
onMove(formRow, action === 'move-up' ? -1 : 1, ctx)
|
|
326
|
+
this.syncTargets()
|
|
327
|
+
},
|
|
328
|
+
|
|
329
|
+
toggleControls() {
|
|
330
|
+
if (!layer || !host) return
|
|
331
|
+
const hidden = !layer.controlsHidden
|
|
332
|
+
layer.setControlsHidden(hidden)
|
|
333
|
+
host.toggleEl.setAttribute('aria-pressed', String(hidden))
|
|
334
|
+
const label = host.toggleEl.querySelector('.mirk-button__label')
|
|
335
|
+
if (label) label.textContent = hidden ? 'Show controls' : 'Hide controls'
|
|
336
|
+
},
|
|
337
|
+
|
|
338
|
+
// Discovery and activation, delegated on the page root rather than bound
|
|
339
|
+
// per target: the target set is rebuilt on every refresh, and per-element
|
|
340
|
+
// listeners would have to be torn down and reattached with it.
|
|
341
|
+
bindPage() {
|
|
342
|
+
const root = this.ctx.pageRoot
|
|
343
|
+
const hostRoot = host.root
|
|
344
|
+
|
|
345
|
+
const onPointerOver = (event) => {
|
|
346
|
+
const target = layer && layer.elementToTarget(event.target)
|
|
347
|
+
if (target) layer.showHighlight(target.el)
|
|
348
|
+
else layer?.hideHighlight()
|
|
349
|
+
// One pointer path, two questions. The second: a list row too small to
|
|
350
|
+
// hold its own strip shows it only while the pointer is on that row,
|
|
351
|
+
// and the raw element is what answers that — the strip is drawn over
|
|
352
|
+
// its row and is not a target, so a resolved target cannot say so.
|
|
353
|
+
layer?.setHoveredRow(event.target)
|
|
354
|
+
}
|
|
355
|
+
const onPointerLeave = () => {
|
|
356
|
+
layer?.hideHighlight()
|
|
357
|
+
layer?.setHoveredRow(null)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Bind on the press, not on the click. The browser places the caret as the
|
|
361
|
+
// default action of mousedown, and it can only do that if the element is
|
|
362
|
+
// already editable by then. Binding on click is one step too late, which is
|
|
363
|
+
// why the caret used to land at the start of the text however far into it
|
|
364
|
+
// the person pressed. Nothing here focuses: the browser's own default
|
|
365
|
+
// action does that, and it is the only thing that knows the press
|
|
366
|
+
// coordinates.
|
|
367
|
+
const onPointerDown = (event) => {
|
|
368
|
+
if (hostRoot.contains(event.target)) return
|
|
369
|
+
const target = layer && layer.elementToTarget(event.target)
|
|
370
|
+
if (!target || target.kind !== 'text') return
|
|
371
|
+
if (bindings.has(target.el)) return
|
|
372
|
+
bindText(this.ctx, target.el, target.path.join('.'), projectionOf(target))
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const onClick = (event) => {
|
|
376
|
+
// The editor's own chrome. A handle already activates through its own
|
|
377
|
+
// listener, and a click in the popover must not close it.
|
|
378
|
+
if (hostRoot.contains(event.target)) return
|
|
379
|
+
const target = layer && layer.elementToTarget(event.target)
|
|
380
|
+
if (!target) {
|
|
381
|
+
this.deactivate()
|
|
382
|
+
return
|
|
383
|
+
}
|
|
384
|
+
// A text target is already bound and focused by the pointerdown above,
|
|
385
|
+
// so this only has to leave its default alone. A link inside one is the
|
|
386
|
+
// exception: while the session is open, following it would navigate away
|
|
387
|
+
// mid-edit.
|
|
388
|
+
const inLink = typeof event.target.closest === 'function' && event.target.closest('a[href]')
|
|
389
|
+
if (inLink || target.kind !== 'text') event.preventDefault()
|
|
390
|
+
this.activate(target)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const onKeyDown = (event) => {
|
|
394
|
+
if (event.key === 'Escape') this.deactivate()
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// A commit naming this element's path means something wrote content to it,
|
|
398
|
+
// and the pre-bind markup must never go back over that. richclay's
|
|
399
|
+
// rewriting commits nothing, which is precisely what makes this the right
|
|
400
|
+
// signal and the text comparison it replaces the wrong one: that could not
|
|
401
|
+
// tell an API write that kept the same words from the editor's own work.
|
|
402
|
+
//
|
|
403
|
+
// On the document because the event is dispatched on the host, which does
|
|
404
|
+
// not sit inside the page root. detail.pageRoot is what the payload
|
|
405
|
+
// carries it for.
|
|
406
|
+
const onChanged = (event) => {
|
|
407
|
+
const detail = event.detail
|
|
408
|
+
if (!detail || detail.pageRoot !== this.ctx.pageRoot || !detail.path) return
|
|
409
|
+
for (const binding of bindings.values()) {
|
|
410
|
+
if (binding.path === detail.path) binding.written = true
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
root.addEventListener('pointerover', onPointerOver)
|
|
415
|
+
root.addEventListener('pointerleave', onPointerLeave)
|
|
416
|
+
root.addEventListener('pointerdown', onPointerDown)
|
|
417
|
+
root.addEventListener('click', onClick)
|
|
418
|
+
hostRoot.addEventListener('keydown', onKeyDown)
|
|
419
|
+
doc.addEventListener('hcms:change', onChanged)
|
|
420
|
+
|
|
421
|
+
unbindPage = () => {
|
|
422
|
+
root.removeEventListener('pointerover', onPointerOver)
|
|
423
|
+
root.removeEventListener('pointerleave', onPointerLeave)
|
|
424
|
+
root.removeEventListener('pointerdown', onPointerDown)
|
|
425
|
+
root.removeEventListener('click', onClick)
|
|
426
|
+
hostRoot.removeEventListener('keydown', onKeyDown)
|
|
427
|
+
doc.removeEventListener('hcms:change', onChanged)
|
|
428
|
+
}
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
// Reveal this target's form fields over the target. Nothing moves in
|
|
432
|
+
// or out of the form tree — the leaf is shown where it already lives — so
|
|
433
|
+
// engine.extract(formRoot) can never be missing a path.
|
|
434
|
+
activate(target, handle) {
|
|
435
|
+
if (!target || !host) return
|
|
436
|
+
// A text target is edited where it sits, and only falls through to the
|
|
437
|
+
// popover when it cannot be: no richclay on the page, or a root richclay
|
|
438
|
+
// refuses. Without the fall-through, clicking such a target would do
|
|
439
|
+
// nothing at all.
|
|
440
|
+
if (target.kind === 'text' && this.activateText(target)) return
|
|
441
|
+
|
|
442
|
+
const leaf = revealPath(this, host, target.path.join('.'))
|
|
443
|
+
if (!leaf) {
|
|
444
|
+
this.deactivate()
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
activeTarget = target
|
|
449
|
+
activeHandle = handle || null
|
|
450
|
+
popMode = null
|
|
451
|
+
|
|
452
|
+
// Unhide BEFORE measuring. A hidden popover has a zero rect, which places
|
|
453
|
+
// it at the clamp instead of beside its anchor — the same trap as the 0px
|
|
454
|
+
// textarea below, which was sized while the popover had no height.
|
|
455
|
+
host.popEl.hidden = false
|
|
456
|
+
host.formRoot.querySelectorAll(`.${ACTIVE_CLASS}`).forEach(autosizeIn)
|
|
457
|
+
this.placePopover()
|
|
458
|
+
focusFirst(leaf)
|
|
459
|
+
},
|
|
460
|
+
|
|
461
|
+
// Bind richclay to the page element and edit the text where it sits.
|
|
462
|
+
// Returns false when the target cannot be bound, which is the caller's
|
|
463
|
+
// signal to open the popover over it instead.
|
|
464
|
+
//
|
|
465
|
+
// On demand, never at mount: an instance per text target on a page with
|
|
466
|
+
// twenty of them is twenty Squire editors, twenty toolbars and a hundred
|
|
467
|
+
// document-level listeners for one heading someone might click.
|
|
468
|
+
activateText(target) {
|
|
469
|
+
const el = target.el
|
|
470
|
+
const existing = bindings.get(el)
|
|
471
|
+
if (existing) {
|
|
472
|
+
focusEditor(existing)
|
|
473
|
+
return true
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const binding = bindText(this.ctx, el, target.path.join('.'), projectionOf(target))
|
|
477
|
+
if (!binding) return false
|
|
478
|
+
|
|
479
|
+
focusEditor(binding)
|
|
480
|
+
return true
|
|
481
|
+
},
|
|
482
|
+
|
|
483
|
+
placePopover() {
|
|
484
|
+
if (!host || !activeTarget || host.popEl.hidden) return
|
|
485
|
+
const win = doc.defaultView
|
|
486
|
+
if (!win) return
|
|
487
|
+
const viewport = { width: win.innerWidth, height: win.innerHeight }
|
|
488
|
+
const anchor = activeTarget.el.getBoundingClientRect()
|
|
489
|
+
const bar = host.popEl.getBoundingClientRect()
|
|
490
|
+
const { mode, x, y } = place({ anchor, bar, viewport, current: popMode })
|
|
491
|
+
popMode = mode
|
|
492
|
+
host.popEl.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`
|
|
493
|
+
},
|
|
494
|
+
|
|
495
|
+
deactivate() {
|
|
496
|
+
if (!host || (!activeTarget && host.popEl.hidden)) return
|
|
497
|
+
host.popEl.hidden = true
|
|
498
|
+
clearPathClasses(host.root)
|
|
499
|
+
const handle = activeHandle
|
|
500
|
+
activeTarget = null
|
|
501
|
+
activeHandle = null
|
|
502
|
+
popMode = null
|
|
503
|
+
if (handle && doc.contains(handle) && typeof handle.focus === 'function') {
|
|
504
|
+
try { handle.focus({ preventScroll: true }) } catch (_) { handle.focus() }
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
// Re-resolve every editable thing on the page and hand the current set to
|
|
509
|
+
// the layer. Runs on mount and on every refresh, because a refresh can add
|
|
510
|
+
// or remove rows and can rebind the rules (refresh.js re-runs prepareRules).
|
|
511
|
+
syncTargets() {
|
|
512
|
+
if (!layer) return
|
|
513
|
+
const ctx = this.ctx
|
|
514
|
+
const { targets, lists } = resolveTargets(ctx.pageRoot, ctx.pageRules)
|
|
515
|
+
layer.setTargets(targets, lists)
|
|
516
|
+
const byEl = reconcileBindings(this, bindings, targets, doc)
|
|
517
|
+
// An open popover holds its path the same way a binding does. Re-point it
|
|
518
|
+
// at the target that now stands for the element it is anchored over, or
|
|
519
|
+
// the popover keeps editing the row that inherited its old index.
|
|
520
|
+
if (activeTarget) {
|
|
521
|
+
const candidates = byEl.get(activeTarget.el) || []
|
|
522
|
+
const fresh = candidates.find((target) => sameTargetBinding(target, activeTarget)) ||
|
|
523
|
+
candidates.find((target) => target.path.join('.') === activeTarget.path.join('.')) ||
|
|
524
|
+
(candidates.length === 1 ? candidates[0] : null)
|
|
525
|
+
// Gone from the rules, so the popover would keep writing its old path
|
|
526
|
+
// from an element nothing reads. Close it rather than leave it editing a
|
|
527
|
+
// field that is not there any more.
|
|
528
|
+
if (fresh) activeTarget = fresh
|
|
529
|
+
else this.deactivate()
|
|
530
|
+
}
|
|
531
|
+
sweepOrphanEditors(this.ctx, bindings, doc)
|
|
532
|
+
// Every ruled field with no anchorable element, whatever its kind: a
|
|
533
|
+
// permanently hidden metadata span and a target inside a closed tab are
|
|
534
|
+
// byte-identical to the browser, so this is a live count and not a
|
|
535
|
+
// classification. Read off the same floor the handles clear rather than a
|
|
536
|
+
// second rule that could disagree with it.
|
|
537
|
+
const away = targets.reduce((total, target) => total + (isAnchorable(target.el) ? 0 : 1), 0)
|
|
538
|
+
if (this.handoffEl) {
|
|
539
|
+
this.handoffCountEl.textContent = away === 0
|
|
540
|
+
? ''
|
|
541
|
+
: `${away} ${away === 1 ? "field isn't" : "fields aren't"} visible right now.`
|
|
542
|
+
// At zero the bar says nothing at all, rather than "0 fields".
|
|
543
|
+
this.handoffEl.hidden = away === 0
|
|
544
|
+
}
|
|
545
|
+
},
|
|
546
|
+
|
|
547
|
+
// `changes` is the mutation batch behind an 'observer' refresh. The inline
|
|
548
|
+
// view will need it to recognise its own edits; nothing reads it yet, and a
|
|
549
|
+
// view must treat an absent batch as "refresh everything".
|
|
550
|
+
refresh(reason, changes) {
|
|
551
|
+
if (reason === 'livesync') {
|
|
552
|
+
// The stylesheet lives in <head> and the session class on <body>, both
|
|
553
|
+
// outside this host, so a full-document morph can strip either. The page
|
|
554
|
+
// shift is still the sidebar's alone: this view adds no geometry class.
|
|
555
|
+
reensureStyles(doc)
|
|
556
|
+
markSessionOpen(doc)
|
|
557
|
+
refreshForm(this.ctx, { ignoreActiveValue: true })
|
|
558
|
+
} else if (reason === 'undo') {
|
|
559
|
+
refreshForm(this.ctx, { ignoreActiveValue: false })
|
|
560
|
+
} else {
|
|
561
|
+
refreshForm(this.ctx)
|
|
562
|
+
}
|
|
563
|
+
this.syncTargets()
|
|
564
|
+
// Both reasons put a morph over the page, and a morph takes the editor's
|
|
565
|
+
// attributes off every element this view has bound.
|
|
566
|
+
if (reason === 'livesync' || reason === 'undo') this.rebindText(reason)
|
|
567
|
+
this.restoreActive()
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
// A live-sync morph re-syncs the page against the incoming copy, and this
|
|
571
|
+
// view's own snapshot hook is what makes that copy clean: contenteditable,
|
|
572
|
+
// data-richclay, no-undo and the bound marker all come back off an element
|
|
573
|
+
// a live editor is still pointing at. An undo replaying those same
|
|
574
|
+
// attribute writes in reverse does the same thing. Repair the pairing after
|
|
575
|
+
// the morph, for the reason restoreActive repairs the popover after it.
|
|
576
|
+
rebindText(reason) {
|
|
577
|
+
// On the undo path the value did not change because anyone typed: the undo
|
|
578
|
+
// itself reverted it. Recording that would push a primitive describing the
|
|
579
|
+
// undo's own effect, and a fresh record clears the redo stack, so the
|
|
580
|
+
// person who just pressed undo could not press redo. Measured against the
|
|
581
|
+
// recorder, which fires its handlers outside its own pause.
|
|
582
|
+
const record = reason !== 'undo'
|
|
583
|
+
for (const [el, binding] of [...bindings]) {
|
|
584
|
+
// Replaced outright: no key matched, so the incoming element took this
|
|
585
|
+
// one's place. What they typed before the sync is already committed and
|
|
586
|
+
// still belongs on the undo stack, but the node it was typed into is off
|
|
587
|
+
// the page — nothing is put back onto it, and the incoming markup stands.
|
|
588
|
+
if (!doc.contains(el)) {
|
|
589
|
+
unbind(this.ctx, binding, { restore: false, record })
|
|
590
|
+
bindings.delete(el)
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
if (el.hasAttribute(BOUND_ATTR)) {
|
|
594
|
+
const current = readPageValue(el, binding.prop)
|
|
595
|
+
// An undo or a sync that did not reach this element leaves its value
|
|
596
|
+
// exactly as the person typed it, and their edit is still pending.
|
|
597
|
+
// Only an element the change actually moved needs a fresh baseline,
|
|
598
|
+
// and only then is what they typed gone with it.
|
|
599
|
+
if (current !== binding.lastEdited) {
|
|
600
|
+
// Survived with the marker intact, so the editor is still live and
|
|
601
|
+
// still correct; only the value moved under it. Its baseline is now
|
|
602
|
+
// the value the morph reverted away from, and left stale the next
|
|
603
|
+
// blur records that revert as a fresh edit — which clears the redo
|
|
604
|
+
// stack, so the person who pressed undo cannot press redo.
|
|
605
|
+
binding.oldValue = current
|
|
606
|
+
// The value they had typed is gone with the undo, so it must not
|
|
607
|
+
// survive as a pending edit: left set, the next blur would record it
|
|
608
|
+
// against the new baseline and clear the redo stack, which is the
|
|
609
|
+
// defect the line above exists to prevent, arriving one step later.
|
|
610
|
+
binding.lastEdited = undefined
|
|
611
|
+
}
|
|
612
|
+
continue
|
|
613
|
+
}
|
|
614
|
+
// Survived, but the page no longer treats it as ours. Close the session
|
|
615
|
+
// that was open, one primitive for what they typed, and open a fresh one
|
|
616
|
+
// on the markup as it now stands. Deliberately no restore: the element
|
|
617
|
+
// holds the incoming copy's markup, and putting a pre-morph snapshot
|
|
618
|
+
// back over it would undo somebody else's edit.
|
|
619
|
+
const hadFocus = doc.activeElement === el
|
|
620
|
+
unbind(this.ctx, binding, { restore: false, record })
|
|
621
|
+
bindings.delete(el)
|
|
622
|
+
const rebound = bindText(this.ctx, el, binding.path, binding.prop, {
|
|
623
|
+
richClayIsOurs: binding.richClayIsOurs,
|
|
624
|
+
adopted: binding.adopted,
|
|
625
|
+
})
|
|
626
|
+
// Only the element they were actually in: a sync that arrives while
|
|
627
|
+
// someone is typing must not pull the caret into a different one.
|
|
628
|
+
if (rebound && hadFocus) focusEditor(rebound)
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
|
|
632
|
+
// morphForm re-syncs every field from a freshly built form, and that form
|
|
633
|
+
// carries no reveal classes, so an open popover goes blank while still
|
|
634
|
+
// sitting there open. Measured in a browser: the leaf keeps its identity
|
|
635
|
+
// and loses only its class, which is why nothing here rebuilds anything.
|
|
636
|
+
// The same reason enhanceFields and applyErrorState re-run after the morph
|
|
637
|
+
// in refresh.js.
|
|
638
|
+
restoreActive() {
|
|
639
|
+
if (!host || !activeTarget || host.popEl.hidden) return
|
|
640
|
+
// The refresh can take the page element out from under an open popover:
|
|
641
|
+
// a live-sync that deletes the row being edited, say. Its rect would then
|
|
642
|
+
// measure zero and the popover would place against the viewport clamp.
|
|
643
|
+
if (!doc.contains(activeTarget.el)) {
|
|
644
|
+
this.deactivate()
|
|
645
|
+
return
|
|
646
|
+
}
|
|
647
|
+
const leaf = revealPath(this, host, activeTarget.path.join('.'))
|
|
648
|
+
if (!leaf) {
|
|
649
|
+
this.deactivate()
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
// After the classes, never before: a textarea measured while its wrapper
|
|
653
|
+
// is still display:none reports a zero scrollHeight and sizes to nothing.
|
|
654
|
+
host.formRoot.querySelectorAll(`.${ACTIVE_CLASS}`).forEach(autosizeIn)
|
|
655
|
+
this.placePopover()
|
|
656
|
+
// Deliberately no focusFirst: a refresh can land mid-typing, and moving
|
|
657
|
+
// the caret back to the top of the field on every page mutation would
|
|
658
|
+
// make the field unusable.
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
// Deliberately NOT a page field. Focusing one would scroll the page and
|
|
662
|
+
// begin an edit nobody asked for. Focusing the host itself puts the next Tab
|
|
663
|
+
// into the session bar without moving the page or starting anything.
|
|
664
|
+
focusOnOpen() {
|
|
665
|
+
if (this.root && typeof this.root.focus === 'function') {
|
|
666
|
+
try { this.root.focus({ preventScroll: true }) } catch (_) { this.root.focus() }
|
|
667
|
+
}
|
|
668
|
+
},
|
|
669
|
+
|
|
670
|
+
destroy() {
|
|
671
|
+
// Dropped before the close, not restored to: the handle is about to be
|
|
672
|
+
// removed with the layer, and focusing it on the way out would fight the
|
|
673
|
+
// session's own restore of whatever had focus before it opened.
|
|
674
|
+
activeHandle = null
|
|
675
|
+
this.deactivate()
|
|
676
|
+
for (const binding of bindings.values()) unbind(this.ctx, binding)
|
|
677
|
+
bindings.clear()
|
|
678
|
+
unbindPage?.()
|
|
679
|
+
unbindPage = null
|
|
680
|
+
layer?.destroy()
|
|
681
|
+
layer = null
|
|
682
|
+
host?.destroy()
|
|
683
|
+
host = null
|
|
684
|
+
clearSessionOpen(doc)
|
|
685
|
+
this.popEl = null
|
|
686
|
+
this.handoffEl = null
|
|
687
|
+
this.handoffCountEl = null
|
|
688
|
+
},
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// Build the editor, or answer null when this element cannot carry one.
|
|
693
|
+
//
|
|
694
|
+
// RichClay writes contenteditable, data-richclay and a class onto an authored
|
|
695
|
+
// page element. Undo suppression keeps those runtime attributes off the undo
|
|
696
|
+
// stack. The mutation observer can schedule one refresh, which must converge.
|
|
697
|
+
function construct(ctx, RichClay, el, prop, richClayIsOurs) {
|
|
698
|
+
return suppressUndo(() => {
|
|
699
|
+
let editor = null
|
|
700
|
+
try {
|
|
701
|
+
editor = new RichClay(el, {
|
|
702
|
+
inline: true,
|
|
703
|
+
hyperclay: false,
|
|
704
|
+
// Formatting is offered only where the rule can keep it. A textContent
|
|
705
|
+
// projection commits the element's text, so a bold applied through this
|
|
706
|
+
// toolbar would be flattened by the very next commit — the button would
|
|
707
|
+
// be promising something the rule cannot hold. That is every scalar
|
|
708
|
+
// array row (enhance.js never upgrades a "[]" rule) and everything at
|
|
709
|
+
// all under richText: false. richclay reads false as "no toolbar"
|
|
710
|
+
// (resolveToolbarControls, richclay.js:1007).
|
|
711
|
+
toolbar: prop === 'innerHTML' ? TOOLBAR : false,
|
|
712
|
+
// A heading is one line by definition; without this, Enter in it
|
|
713
|
+
// creates a second block inside the <h1>.
|
|
714
|
+
...(HEADING.test(el.tagName) ? { singleLine: true } : null),
|
|
715
|
+
})
|
|
716
|
+
} catch (err) {
|
|
717
|
+
console.warn('[hypercms] richclay activation failed; the field falls back to the popover', err)
|
|
718
|
+
return null
|
|
719
|
+
}
|
|
720
|
+
// richclay's own verdict, which it delivers by returning an instance that
|
|
721
|
+
// never activates: TABLE and its row parts, SCRIPT/STYLE/TEXTAREA/TITLE/
|
|
722
|
+
// IFRAME/NOSCRIPT/XMP, TEMPLATE, and anything outside the HTML namespace.
|
|
723
|
+
// Without this check the person clicks one of those and nothing happens.
|
|
724
|
+
if (editor.unsupported || !editor.active) {
|
|
725
|
+
// Only tear down an instance this call is responsible for. On an
|
|
726
|
+
// element the author opted in on, richclay owns the mount and the
|
|
727
|
+
// instance is theirs; destroying it takes away an editor hypercms
|
|
728
|
+
// did not create and cannot put back.
|
|
729
|
+
if (richClayIsOurs) { try { editor.destroy() } catch (_) {} }
|
|
730
|
+
return null
|
|
731
|
+
}
|
|
732
|
+
// An editor can be active while its element is inert. A live-sync morph
|
|
733
|
+
// strips contenteditable and the runtime attributes off the element and
|
|
734
|
+
// leaves the instance believing it never stopped, so on the rebind that
|
|
735
|
+
// follows, this call is the only thing that makes the element editable
|
|
736
|
+
// again. reattach answers for itself whether anything needs doing, so a
|
|
737
|
+
// healthy editor costs one comparison. Absent on an older richclay, where
|
|
738
|
+
// the element stays inert and there is nothing hypercms can do about it.
|
|
739
|
+
if (typeof editor.reattach === 'function') editor.reattach()
|
|
740
|
+
return editor
|
|
741
|
+
})
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Mirror the page element into the form leaf, then commit like any other edit.
|
|
745
|
+
//
|
|
746
|
+
// Per change, not on blur. commit IS the notification — it dispatches
|
|
747
|
+
// hcms:change and calls onChange — so a blur-only commit would lose the edit's
|
|
748
|
+
// own event to whatever unrelated commit folded the text in first, and then be
|
|
749
|
+
// skipped on its fingerprint. The form leaf has to be written first or the
|
|
750
|
+
// commit extracts the stale one and puts the old text back on the page.
|
|
751
|
+
function acceptInlineTextChange(ctx, { path, el, prop }) {
|
|
752
|
+
const field = findLeafField(ctx.formRoot, path)
|
|
753
|
+
if (!field) return
|
|
754
|
+
writeFieldValue(field, readPageValue(el, prop), ctx.formRoot, path)
|
|
755
|
+
commit(extractFormData(ctx), { path, structural: false }, ctx)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// What the engine will read back out of this element. The DOM adapter trims a
|
|
759
|
+
// text projection (hyper-html-api dom.js text()), so a raw textContent read puts
|
|
760
|
+
// a value in the form that the page can never extract, the mirror disagrees on
|
|
761
|
+
// every commit, and the engine rewrites the text node the caret is sitting in.
|
|
762
|
+
function readPageValue(el, prop, capability = 'data') {
|
|
763
|
+
const view = createContentView(el, { capability })
|
|
764
|
+
return prop === 'innerHTML' ? view.html() : view.text().trim()
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// One primitive per edit session, at the boundary that closes it. Everything in
|
|
768
|
+
// between is invisible to the page's undo stack: richclay stamps no-undo on
|
|
769
|
+
// what it activates and hyper-undo honours it.
|
|
770
|
+
//
|
|
771
|
+
// Unlike resolveUnobservedProjection this can record an array row, because the
|
|
772
|
+
// element is in hand rather than resolved from a document-level selector.
|
|
773
|
+
function recordUndo(binding) {
|
|
774
|
+
const { el, prop, oldValue, lastEdited } = binding
|
|
775
|
+
// Deliberately not the element. A live-sync morph replaces the content before
|
|
776
|
+
// this boundary is reached, so reading the DOM here records a peer's edit
|
|
777
|
+
// under this person's name and undo reverts THEIR change. No local edit means
|
|
778
|
+
// no primitive at all, which is the same fix from the other side: an untouched
|
|
779
|
+
// binding that a morph wrote through must not put anything on the stack.
|
|
780
|
+
const newValue = lastEdited
|
|
781
|
+
if (newValue === undefined || newValue === oldValue) return
|
|
782
|
+
binding.oldValue = newValue
|
|
783
|
+
const u = platform('undo')
|
|
784
|
+
if (!u || typeof u.recordValue !== 'function') return
|
|
785
|
+
// hyper-undo drops recordValue while its recorder is paused (scope.js:190),
|
|
786
|
+
// and both boundaries that close a binding sit inside a pause somebody else
|
|
787
|
+
// opened: teardownSession suppresses around destroy (session.js:225), and
|
|
788
|
+
// clayjs dispatches clay:sync-applied inside its mutation pause, which pauses
|
|
789
|
+
// undo too (live-sync.js:1357, mutation.js:132). The values are read here and
|
|
790
|
+
// the record is posted after those finally-blocks have run, so the edit keeps
|
|
791
|
+
// its place on the stack instead of vanishing at the moment it ends.
|
|
792
|
+
//
|
|
793
|
+
// A record against an element the morph took off the page would be an entry
|
|
794
|
+
// that replays into nothing and still costs an undo press, so it is skipped.
|
|
795
|
+
const options = {
|
|
796
|
+
prop,
|
|
797
|
+
oldValue,
|
|
798
|
+
newValue,
|
|
799
|
+
read: target => readPageValue(target, prop, 'history'),
|
|
800
|
+
write: (target, value) => replaceContent(target, prop, value),
|
|
801
|
+
}
|
|
802
|
+
if (u.isPaused) queueMicrotask(() => { if (el.isConnected) u.recordValue(el, options) })
|
|
803
|
+
else u.recordValue(el, options)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function replaceContent(target, prop, value) {
|
|
807
|
+
const source = target.cloneNode(false)
|
|
808
|
+
if (prop === 'innerHTML') source.innerHTML = value
|
|
809
|
+
else source.textContent = value
|
|
810
|
+
morph(target, Array.from(source.childNodes), {
|
|
811
|
+
morphStyle: 'innerHTML',
|
|
812
|
+
policy: 'history',
|
|
813
|
+
restoreFocus: false,
|
|
814
|
+
scripts: { handle: false, merge: false },
|
|
815
|
+
})
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Close one binding. The undo record comes first because teardown suppresses
|
|
819
|
+
// undo while it removes editor runtime state.
|
|
820
|
+
//
|
|
821
|
+
// `restore` is what separates ending a session from repairing one. Ending it
|
|
822
|
+
// puts the author's markup back when nobody typed; repairing after a morph must
|
|
823
|
+
// not, because the markup on the element is the incoming copy's and a pre-morph
|
|
824
|
+
// snapshot written back over it would undo somebody else's edit.
|
|
825
|
+
function unbind(ctx, binding, { restore = true, record = true } = {}) {
|
|
826
|
+
if (record) recordUndo(binding)
|
|
827
|
+
binding.detachInput?.()
|
|
828
|
+
binding.detachBlur?.()
|
|
829
|
+
suppressUndo(() => {
|
|
830
|
+
if (!binding.adopted) {
|
|
831
|
+
try { binding.editor.destroy() } catch (err) {
|
|
832
|
+
console.warn('[hypercms] richclay teardown failed; editor state may reach the save', err)
|
|
833
|
+
}
|
|
834
|
+
// destroy() takes data-richclay off only on richclay 0.5.0, which tracks
|
|
835
|
+
// who wrote it. Neither vendored 0.4.0 copy removes that attribute
|
|
836
|
+
// anywhere, so without this the mount selector stays on the live element
|
|
837
|
+
// and the next save carries it into the author's file, exactly what the
|
|
838
|
+
// clone-side removal exists to prevent. Never on an element the author
|
|
839
|
+
// opted in on: there the attribute is theirs.
|
|
840
|
+
if (binding.richClayIsOurs) binding.el.removeAttribute('data-richclay')
|
|
841
|
+
}
|
|
842
|
+
// The bridge only unmarks the snapshot clone. Left on the live element,
|
|
843
|
+
// the marker would tell the next snapshot to strip an element hypercms no
|
|
844
|
+
// longer owns, and the provenance flag beside it would reach the file.
|
|
845
|
+
binding.el.removeAttribute(BOUND_ATTR)
|
|
846
|
+
binding.el.removeAttribute(RC_OWNED_ATTR)
|
|
847
|
+
binding.el.removeAttribute(BOUND_ID_ATTR)
|
|
848
|
+
releaseBinding(binding.boundId)
|
|
849
|
+
// Nobody typed, so the only difference between this element and the one
|
|
850
|
+
// the author wrote is the editor's own normalisation. Put their markup
|
|
851
|
+
// back. Undo suppression keeps the restore from becoming an edit.
|
|
852
|
+
if (restore && binding.restorable()) {
|
|
853
|
+
replaceContent(binding.el, 'innerHTML', binding.originalHTML)
|
|
854
|
+
}
|
|
855
|
+
})
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// The click that opens a text target lands before the element is editable, so
|
|
859
|
+
// the browser has put no caret in it. richclay's focus() goes through squire,
|
|
860
|
+
// which puts the caret in the text rather than selecting the whole region.
|
|
861
|
+
function focusEditor(binding) {
|
|
862
|
+
const { editor, el } = binding
|
|
863
|
+
if (el.ownerDocument.activeElement === el) return
|
|
864
|
+
try {
|
|
865
|
+
if (typeof editor.focus === 'function') editor.focus()
|
|
866
|
+
else el.focus()
|
|
867
|
+
} catch (_) {}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// resolveTargets re-walks the page on every refresh, so it is the only authority
|
|
871
|
+
// on which path reaches which element right now. Both a binding and an open
|
|
872
|
+
// popover cache a path from the moment they were made: a move or a remove
|
|
873
|
+
// renumbers every row after it, and without this, typing into the row you just
|
|
874
|
+
// moved up writes into the row that took its old index.
|
|
875
|
+
//
|
|
876
|
+
// The projection is re-read for the same reason. refreshForm re-runs
|
|
877
|
+
// prepareRules every refresh, so a rule upgrades to @innerHTML the moment its
|
|
878
|
+
// element gains markup. A binding frozen on textContent would flatten every tag
|
|
879
|
+
// in that element on the next keystroke, including the node under the caret.
|
|
880
|
+
function reconcileBindings(view, bindings, targets, doc) {
|
|
881
|
+
const byEl = new Map()
|
|
882
|
+
for (const target of targets) {
|
|
883
|
+
const candidates = byEl.get(target.el)
|
|
884
|
+
if (candidates) candidates.push(target)
|
|
885
|
+
else byEl.set(target.el, [target])
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
for (const [el, binding] of [...bindings]) {
|
|
889
|
+
const candidates = byEl.get(el) || []
|
|
890
|
+
const target = candidates.find((candidate) => projectionOf(candidate) === binding.prop) || candidates[0]
|
|
891
|
+
if (!target) {
|
|
892
|
+
// The rules no longer name this element. Left bound it stays editable with
|
|
893
|
+
// nowhere of its own to write, and every keystroke commits the old path,
|
|
894
|
+
// so the text lands in whatever element the rule now points at. End the
|
|
895
|
+
// session on it instead. restore is on because nobody has taken the
|
|
896
|
+
// element away: if it was never typed into, the author's markup goes back.
|
|
897
|
+
unbind(view.ctx, binding, { restore: true })
|
|
898
|
+
bindings.delete(el)
|
|
899
|
+
continue
|
|
900
|
+
}
|
|
901
|
+
binding.path = target.path.join('.')
|
|
902
|
+
const prop = projectionOf(target)
|
|
903
|
+
if (prop === binding.prop) continue
|
|
904
|
+
// The projection changed under a live editor. Rebuilding it is the only way
|
|
905
|
+
// the toolbar, the commit and the undo baseline end up describing the same
|
|
906
|
+
// half of the element.
|
|
907
|
+
const hadFocus = doc.activeElement === el
|
|
908
|
+
unbind(view.ctx, binding, { restore: false })
|
|
909
|
+
bindings.delete(el)
|
|
910
|
+
const rebound = view.bindText(el, binding.path, prop, {
|
|
911
|
+
richClayIsOurs: binding.richClayIsOurs,
|
|
912
|
+
adopted: binding.adopted,
|
|
913
|
+
originalHTML: binding.originalHTML,
|
|
914
|
+
})
|
|
915
|
+
if (rebound && hadFocus) focusEditor(rebound)
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
return byEl
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function sameTargetBinding(left, right) {
|
|
922
|
+
return left.el === right.el && left.kind === right.kind && left.attr === right.attr
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// A text target is either a bare rule (textContent) or one prepareRules rebound
|
|
926
|
+
// to @innerHTML. Nothing else is ever text.
|
|
927
|
+
function projectionOf(target) {
|
|
928
|
+
return target.attr === 'innerHTML' ? 'innerHTML' : 'textContent'
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// Runs on every sync, because a structural apply is exactly when the engine
|
|
932
|
+
// clones, and a refresh is what follows one.
|
|
933
|
+
function sweepOrphanEditors(ctx, bindings, doc) {
|
|
934
|
+
for (const el of ctx.pageRoot.querySelectorAll(`[${BOUND_ATTR}]`)) {
|
|
935
|
+
if (bindings.has(el)) continue
|
|
936
|
+
suppressUndo(() => stripOrphanEditorState(el, doc.defaultView))
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Keep the complete form mounted for extraction, but reveal only the selected
|
|
941
|
+
// field and its item siblings. Nested lists keep their own editing scope.
|
|
942
|
+
function revealPath(view, host, path) {
|
|
943
|
+
const leaf = view.formRoot &&
|
|
944
|
+
view.formRoot.querySelector(`[data-hcms-path="${cssEscape(path)}"]`)
|
|
945
|
+
clearPathClasses(host.root)
|
|
946
|
+
if (!leaf) return null
|
|
947
|
+
const item = leaf.closest('[data-hcms-card]')
|
|
948
|
+
const fields = item
|
|
949
|
+
? [...item.querySelectorAll('[data-hcms-shape="scalar"][data-hcms-path]')]
|
|
950
|
+
.filter((field) => field.closest('[data-hcms-card]') === item &&
|
|
951
|
+
field.closest('[data-hcms-shape="object-array"], [data-hcms-shape="scalar-array"]') === item.parentElement.closest('[data-hcms-shape="object-array"]'))
|
|
952
|
+
: [leaf]
|
|
953
|
+
if (!fields.includes(leaf)) fields.push(leaf)
|
|
954
|
+
for (const field of fields) {
|
|
955
|
+
field.classList.add(ACTIVE_CLASS)
|
|
956
|
+
field.removeAttribute('draggable')
|
|
957
|
+
for (let el = field.parentElement; el; el = el.parentElement) {
|
|
958
|
+
el.classList.add(ONPATH_CLASS)
|
|
959
|
+
el.removeAttribute('draggable')
|
|
960
|
+
if (el === view.formRoot) break
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
return leaf
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function clearPathClasses(root) {
|
|
967
|
+
if (!root) return
|
|
968
|
+
for (const el of root.querySelectorAll(`.${ACTIVE_CLASS}, .${ONPATH_CLASS}`)) {
|
|
969
|
+
el.classList.remove(ACTIVE_CLASS, ONPATH_CLASS)
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// Every textarea in the revealed leaf was measured while the popover was
|
|
974
|
+
// hidden, so it is sitting at height: 0 until it is sized again.
|
|
975
|
+
function autosizeIn(leaf) {
|
|
976
|
+
if (leaf.tagName === 'TEXTAREA') autosizeTextarea(leaf)
|
|
977
|
+
leaf.querySelectorAll?.('textarea').forEach(autosizeTextarea)
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
function focusFirst(leaf) {
|
|
981
|
+
const el = leaf.matches?.(FOCUSABLE) ? leaf : leaf.querySelector?.(FOCUSABLE)
|
|
982
|
+
if (!el || typeof el.focus !== 'function') return
|
|
983
|
+
try { el.focus({ preventScroll: true }) } catch (_) { el.focus() }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
function mountInlineHost(doc, theme) {
|
|
987
|
+
// reensureStyles is idempotent: it returns immediately when the stylesheet is
|
|
988
|
+
// already in the document and installs it when it is not, so it doubles as the
|
|
989
|
+
// "ensure" the sidebar's private ensureStyles provides.
|
|
990
|
+
reensureStyles(doc)
|
|
991
|
+
|
|
992
|
+
const root = doc.createElement(HOST_TAG)
|
|
993
|
+
|
|
994
|
+
// Same theme contract as the sidebar: pixel-quiet is the baked-in look and an
|
|
995
|
+
// optional theme pins light/dark. hcms-shell is what the tokens and every
|
|
996
|
+
// mirk widget rule are scoped to, so without it the popover's fields render
|
|
997
|
+
// as browser defaults; hcms-inline is what keeps the docked-panel geometry
|
|
998
|
+
// off, since that is now scoped to .hcms-panel.
|
|
999
|
+
const themeClass = theme === 'dark' ? ' dark' : theme === 'light' ? ' light' : ''
|
|
1000
|
+
root.className = 'hcms-shell pixel-quiet hcms-inline' + themeClass
|
|
1001
|
+
|
|
1002
|
+
// The engine skip selector. Every read and write in hypercms passes
|
|
1003
|
+
// { skip: '[data-hcms-shell]' }, so this one attribute is what keeps the
|
|
1004
|
+
// engine from walking into the form and treating the CMS's own fields as page
|
|
1005
|
+
// content. Reused rather than renamed: it is already honored in seven places
|
|
1006
|
+
// across six files, and each of them covers this host for free.
|
|
1007
|
+
root.setAttribute('data-hcms-shell', '')
|
|
1008
|
+
root.setAttribute('editor-ui', '')
|
|
1009
|
+
|
|
1010
|
+
// Both spellings of "never persist this", because the two clients each have a
|
|
1011
|
+
// path that knows only one of them. clayjs strips [no-save] and [save-remove]
|
|
1012
|
+
// alike (region-policy.js:82), but hyperclayjs's AI-edit clone path queries
|
|
1013
|
+
// [save-remove] exclusively (ai-edit.js:151, :249), so the modern spelling on
|
|
1014
|
+
// its own would leave the whole editor sitting in that clone.
|
|
1015
|
+
root.setAttribute('no-save', '')
|
|
1016
|
+
root.setAttribute('save-remove', '')
|
|
1017
|
+
// Out of every snapshot, not just the save: without this, live sync ships the
|
|
1018
|
+
// editor to everyone else's browser.
|
|
1019
|
+
root.setAttribute('snapshot-remove', '')
|
|
1020
|
+
// The CMS's own chrome must never wake autosave, undo or dirty tracking.
|
|
1021
|
+
// no-watch implies all three (region-policy.js:184-185), which is why
|
|
1022
|
+
// save-ignore and no-trigger-autosave are NOT here: they would be redundant,
|
|
1023
|
+
// and a redundant attribute is one a later reader mistakes for load-bearing.
|
|
1024
|
+
root.setAttribute('no-watch', '')
|
|
1025
|
+
|
|
1026
|
+
// Focusable for focusOnOpen, but NOT role="dialog" / aria-modal. The sidebar
|
|
1027
|
+
// is modal — it traps focus and locks the page. This one is the opposite: the
|
|
1028
|
+
// page stays fully usable while it is open, which is the entire point.
|
|
1029
|
+
root.setAttribute('tabindex', '-1')
|
|
1030
|
+
|
|
1031
|
+
root.innerHTML = `
|
|
1032
|
+
<div class="hcms-inline-bar">
|
|
1033
|
+
<div class="hcms-inline-handoff" hidden>
|
|
1034
|
+
<span class="hcms-inline-handoff-count"></span>
|
|
1035
|
+
<button type="button" class="hcms-inline-handoff-open mirk-button mirk-button--small" data-hcms-open-view="sidebar">
|
|
1036
|
+
<span class="mirk-button__label">Edit in the sidebar</span>
|
|
1037
|
+
</button>
|
|
1038
|
+
</div>
|
|
1039
|
+
<button type="button" class="hcms-inline-toggle mirk-button mirk-button--small" data-hcms-controls-toggle aria-pressed="false" hidden>
|
|
1040
|
+
<span class="mirk-button__label">Hide controls</span>
|
|
1041
|
+
</button>
|
|
1042
|
+
<div class="hcms-inline-notice" role="status" hidden></div>
|
|
1043
|
+
<div class="hcms-inline-error" role="alert" hidden></div>
|
|
1044
|
+
</div>
|
|
1045
|
+
<div class="hcms-inline-layer"></div>
|
|
1046
|
+
<div class="hcms-inline-pop" role="dialog" aria-label="Edit content" hidden>
|
|
1047
|
+
<div class="hcms-inline-pop-header">
|
|
1048
|
+
<span>Edit content</span>
|
|
1049
|
+
<button type="button" class="hcms-inline-pop-close mirk-button mirk-button--small" aria-label="Close field editor">
|
|
1050
|
+
<span class="mirk-button__label">${inlineIcon('remove')}</span>
|
|
1051
|
+
</button>
|
|
1052
|
+
</div>
|
|
1053
|
+
<div data-hcms-form-root class="hcms-form"></div>
|
|
1054
|
+
</div>
|
|
1055
|
+
`
|
|
1056
|
+
|
|
1057
|
+
doc.body.appendChild(root)
|
|
1058
|
+
|
|
1059
|
+
return {
|
|
1060
|
+
root,
|
|
1061
|
+
formRoot: root.querySelector('[data-hcms-form-root]'),
|
|
1062
|
+
noticeEl: root.querySelector('.hcms-inline-notice'),
|
|
1063
|
+
errorEl: root.querySelector('.hcms-inline-error'),
|
|
1064
|
+
handoffEl: root.querySelector('.hcms-inline-handoff'),
|
|
1065
|
+
handoffCountEl: root.querySelector('.hcms-inline-handoff-count'),
|
|
1066
|
+
layerEl: root.querySelector('.hcms-inline-layer'),
|
|
1067
|
+
popEl: root.querySelector('.hcms-inline-pop'),
|
|
1068
|
+
closeEl: root.querySelector('.hcms-inline-pop-close'),
|
|
1069
|
+
toggleEl: root.querySelector('[data-hcms-controls-toggle]'),
|
|
1070
|
+
destroy() { root.remove() },
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// The form row a page row stands for. onMove and requestRemove both take a form
|
|
1075
|
+
// item element and find their array back from it, so this resolves exactly what
|
|
1076
|
+
// they expect to be handed: the array's own items slot, indexed. Deliberately
|
|
1077
|
+
// not a path query — the row's data-hcms-path is restamped by every structural
|
|
1078
|
+
// operation, and the slot's child order is the thing those two read.
|
|
1079
|
+
function formRowAt(arrayEl, index) {
|
|
1080
|
+
const slot = arrayEl.querySelector('.hcms-array-items')
|
|
1081
|
+
if (!slot) return null
|
|
1082
|
+
const rows = slot.querySelectorAll(':scope > [data-hcms-card], :scope > [data-hcms-array-item]')
|
|
1083
|
+
return rows[index] || null
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// Where this page row sits in its list right now, or -1 when the row is no
|
|
1087
|
+
// longer in it (a remove that already landed).
|
|
1088
|
+
function liveRowIndex(ctx, arrayPath, row) {
|
|
1089
|
+
const { lists } = resolveTargets(ctx.pageRoot, ctx.pageRules)
|
|
1090
|
+
const list = lists.find((candidate) => candidate.path.join('.') === arrayPath)
|
|
1091
|
+
return list ? list.items.indexOf(row) : -1
|
|
1092
|
+
}
|