Skip to content

Commit b86322f

Browse files
committed
Extract delegate for TrixEditorElement
In preparation for [basecamp#1128][], this commit introduces a module-private `Delegate` class to serve as a representation of what form integration requires for the `<trix-editor>` custom element. The structure of the `Delegate` class mirrors that of the `TrixEditorElement` from which its contents are extracted. First, there are the properties that mimic those of most form controls, including: * `labels` * `form` * `name` * `value` * `defaultValue` * `type` With the exception of `labels`, property access is mostly proxied through the associated `<input type="hidden">` element (accessed through its own `inputElement` property). Next, the `Delegate` defines methods that correspond to the Custom Element lifecycle events, including: * `connectedCallback` * `disconnectedCallback` * `setFormValue` The connected and disconnected callbacks mirror that of the `TrixEditorElement` itself. These callbacks attach and remove event listeners for `click` and `reset` events. The `setFormValue` is named to correspond with [ElementInternals.setFormValue][]. Along with introducing this callback method, this commit renames the `TrixEditorElement.setInputElementValue` method to `TrixEditorElement.setFormValue`. In addition to renaming `setInputElementValue`, this commit also defines `TrixEditorElement.formResetCallback` (along with other empty callbacks), then implements `TrixEditorElement.reset` as an alias. The name mirrors the [ElementInternals.formResetCallback][]. [basecamp#1128]: basecamp#1128 [ElementInternals.setFormValue]: https://developer.mozilla.org/en-US/docs/Web/API/ElementInternals/setFormValue [ElementInternals.formResetCallback]: https://web.dev/articles/more-capable-form-controls#void_formresetcallback
1 parent 3f22606 commit b86322f

File tree

3 files changed

+158
-69
lines changed

3 files changed

+158
-69
lines changed

src/test/system/custom_element_test.js

+34-3
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,12 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
391391
})
392392
})
393393

394+
test("element returns empty string when value is missing", async () => {
395+
const element = getEditorElement()
396+
397+
assert.equal(element.value, "")
398+
})
399+
394400
test("element serializes HTML after attribute changes", async () => {
395401
const element = getEditorElement()
396402
let serializedHTML = element.value
@@ -440,9 +446,17 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
440446
return promise
441447
})
442448

449+
test("editor resets to its original value on element reset", async () => {
450+
const element = getEditorElement()
451+
452+
await typeCharacters("hello")
453+
element.reset()
454+
expectDocument("\n")
455+
})
456+
443457
test("editor resets to its original value on form reset", async () => {
444458
const element = getEditorElement()
445-
const { form } = element.inputElement
459+
const { form } = element
446460

447461
await typeCharacters("hello")
448462
form.reset()
@@ -451,7 +465,7 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
451465

452466
test("editor resets to last-set value on form reset", async () => {
453467
const element = getEditorElement()
454-
const { form } = element.inputElement
468+
const { form } = element
455469

456470
element.value = "hi"
457471
await typeCharacters("hello")
@@ -461,7 +475,7 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
461475

462476
test("editor respects preventDefault on form reset", async () => {
463477
const element = getEditorElement()
464-
const { form } = element.inputElement
478+
const { form } = element
465479
const preventDefault = (event) => event.preventDefault()
466480

467481
await typeCharacters("hello")
@@ -473,6 +487,17 @@ testGroup("Custom element API", { template: "editor_empty" }, () => {
473487
})
474488
})
475489

490+
testGroup("HTML sanitization", { template: "editor_html" }, () => {
491+
test("ignores text nodes in script elements", () => {
492+
const element = getEditorElement()
493+
element.value = "<div>safe</div><script>alert(\"unsafe\")</script>"
494+
495+
expectDocument("safe\n")
496+
assert.equal(element.innerHTML, "<div><!--block-->safe</div>")
497+
assert.equal(element.value, "<div>safe</div>")
498+
})
499+
})
500+
476501
testGroup("<label> support", { template: "editor_with_labels" }, () => {
477502
test("associates all label elements", () => {
478503
const labels = [ document.getElementById("label-1"), document.getElementById("label-3") ]
@@ -514,4 +539,10 @@ testGroup("form property references its <form>", { template: "editors_with_forms
514539
const editor = document.getElementById("editor-with-no-form")
515540
assert.equal(editor.form, null)
516541
})
542+
543+
test("editor returns its type", async() => {
544+
const element = getEditorElement()
545+
546+
assert.equal("trix-editor", element.type)
547+
})
517548
})

src/trix/controllers/editor_controller.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@ export default class EditorController extends Controller {
503503
updateInputElement() {
504504
const element = this.compositionController.getSerializableElement()
505505
const value = serializeToContentType(element, "text/html")
506-
return this.editorElement.setInputElementValue(value)
506+
return this.editorElement.setFormValue(value)
507507
}
508508

509509
notifyEditorElement(message, data) {

src/trix/elements/trix_editor_element.js

+123-65
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,110 @@ installDefaultCSSForTagName("trix-editor", `\
160160
margin-right: -1px !important;
161161
}`)
162162

163+
class Delegate {
164+
#element
165+
166+
constructor(element) {
167+
this.#element = element
168+
}
169+
170+
// Properties
171+
172+
get labels() {
173+
const labels = []
174+
if (this.#element.id && this.#element.ownerDocument) {
175+
labels.push(...Array.from(this.#element.ownerDocument.querySelectorAll(`label[for='${this.#element.id}']`) || []))
176+
}
177+
178+
const label = findClosestElementFromNode(this.#element, { matchingSelector: "label" })
179+
if (label) {
180+
if ([ this.#element, null ].includes(label.control)) {
181+
labels.push(label)
182+
}
183+
}
184+
185+
return labels
186+
}
187+
188+
get form() {
189+
return this.inputElement?.form
190+
}
191+
192+
get inputElement() {
193+
if (this.#element.hasAttribute("input")) {
194+
return this.#element.ownerDocument?.getElementById(this.#element.getAttribute("input"))
195+
} else if (this.#element.parentNode) {
196+
const inputId = `trix-input-${this.#element.trixId}`
197+
this.#element.setAttribute("input", inputId)
198+
const element = makeElement("input", { type: "hidden", id: inputId })
199+
this.#element.parentNode.insertBefore(element, this.#element.nextElementSibling)
200+
return element
201+
} else {
202+
return undefined
203+
}
204+
}
205+
206+
get name() {
207+
return this.inputElement?.name
208+
}
209+
210+
get value() {
211+
return this.inputElement?.value
212+
}
213+
214+
get defaultValue() {
215+
return this.value
216+
}
217+
218+
// Element lifecycle
219+
220+
connectedCallback() {
221+
window.addEventListener("reset", this.#resetBubbled, false)
222+
window.addEventListener("click", this.#clickBubbled, false)
223+
}
224+
225+
disconnectedCallback() {
226+
window.removeEventListener("reset", this.#resetBubbled, false)
227+
window.removeEventListener("click", this.#clickBubbled, false)
228+
}
229+
230+
setFormValue(value) {
231+
if (this.inputElement) {
232+
this.inputElement.value = value
233+
}
234+
}
235+
236+
// Form support
237+
238+
#resetBubbled = (event) => {
239+
if (event.defaultPrevented) return
240+
if (event.target !== this.form) return
241+
return this.#element.formResetCallback()
242+
}
243+
244+
#clickBubbled = (event) => {
245+
if (event.defaultPrevented) return
246+
if (this.#element.contains(event.target)) return
247+
248+
const label = findClosestElementFromNode(event.target, { matchingSelector: "label" })
249+
if (!label) return
250+
251+
if (!Array.from(this.labels).includes(label)) return
252+
253+
return this.#element.focus()
254+
}
255+
}
256+
163257
export default class TrixEditorElement extends HTMLElement {
258+
static delegateClass = Delegate
259+
static formAssociated = false
260+
261+
#delegate
262+
263+
constructor() {
264+
super()
265+
this.#delegate = new this.constructor.delegateClass(this)
266+
}
164267

165268
// Properties
166269

@@ -174,19 +277,7 @@ export default class TrixEditorElement extends HTMLElement {
174277
}
175278

176279
get labels() {
177-
const labels = []
178-
if (this.id && this.ownerDocument) {
179-
labels.push(...Array.from(this.ownerDocument.querySelectorAll(`label[for='${this.id}']`) || []))
180-
}
181-
182-
const label = findClosestElementFromNode(this, { matchingSelector: "label" })
183-
if (label) {
184-
if ([ this, null ].includes(label.control)) {
185-
labels.push(label)
186-
}
187-
}
188-
189-
return labels
280+
return this.#delegate.labels
190281
}
191282

192283
get toolbarElement() {
@@ -204,33 +295,27 @@ export default class TrixEditorElement extends HTMLElement {
204295
}
205296

206297
get form() {
207-
return this.inputElement?.form
298+
return this.#delegate.form
208299
}
209300

210301
get inputElement() {
211-
if (this.hasAttribute("input")) {
212-
return this.ownerDocument?.getElementById(this.getAttribute("input"))
213-
} else if (this.parentNode) {
214-
const inputId = `trix-input-${this.trixId}`
215-
this.setAttribute("input", inputId)
216-
const element = makeElement("input", { type: "hidden", id: inputId })
217-
this.parentNode.insertBefore(element, this.nextElementSibling)
218-
return element
219-
} else {
220-
return undefined
221-
}
302+
return this.#delegate.inputElement
222303
}
223304

224305
get editor() {
225306
return this.editorController?.editor
226307
}
227308

228309
get name() {
229-
return this.inputElement?.name
310+
return this.#delegate.name
230311
}
231312

232313
get value() {
233-
return this.inputElement?.value
314+
return this.#delegate.value
315+
}
316+
317+
get type() {
318+
return this.localName
234319
}
235320

236321
set value(defaultValue) {
@@ -246,10 +331,8 @@ export default class TrixEditorElement extends HTMLElement {
246331
}
247332
}
248333

249-
setInputElementValue(value) {
250-
if (this.inputElement) {
251-
this.inputElement.value = value
252-
}
334+
setFormValue(value) {
335+
this.#delegate.setFormValue(value)
253336
}
254337

255338
// Element lifecycle
@@ -264,62 +347,37 @@ export default class TrixEditorElement extends HTMLElement {
264347
triggerEvent("trix-before-initialize", { onElement: this })
265348
this.editorController = new EditorController({
266349
editorElement: this,
267-
html: this.defaultValue = this.value,
350+
html: this.defaultValue = this.#delegate.defaultValue,
268351
})
269352
requestAnimationFrame(() => triggerEvent("trix-initialize", { onElement: this }))
270353
}
271354
this.editorController.registerSelectionManager()
272-
this.registerResetListener()
273-
this.registerClickListener()
355+
this.#delegate.connectedCallback()
274356
autofocus(this)
275357
}
276358
}
277359

278360
disconnectedCallback() {
279361
this.editorController?.unregisterSelectionManager()
280-
this.unregisterResetListener()
281-
return this.unregisterClickListener()
362+
this.#delegate.disconnectedCallback()
282363
}
283364

284365
// Form support
285366

286-
registerResetListener() {
287-
this.resetListener = this.resetBubbled.bind(this)
288-
return window.addEventListener("reset", this.resetListener, false)
289-
}
290-
291-
unregisterResetListener() {
292-
return window.removeEventListener("reset", this.resetListener, false)
367+
formAssociatedCallback(form) {
293368
}
294369

295-
registerClickListener() {
296-
this.clickListener = this.clickBubbled.bind(this)
297-
return window.addEventListener("click", this.clickListener, false)
370+
formDisabledCallback(disabled) {
298371
}
299372

300-
unregisterClickListener() {
301-
return window.removeEventListener("click", this.clickListener, false)
373+
formStateRestoreCallback(state, mode) {
302374
}
303375

304-
resetBubbled(event) {
305-
if (event.defaultPrevented) return
306-
if (event.target !== this.form) return
307-
return this.reset()
308-
}
309-
310-
clickBubbled(event) {
311-
if (event.defaultPrevented) return
312-
if (this.contains(event.target)) return
313-
314-
const label = findClosestElementFromNode(event.target, { matchingSelector: "label" })
315-
if (!label) return
316-
317-
if (!Array.from(this.labels).includes(label)) return
318-
319-
return this.focus()
376+
formResetCallback() {
377+
this.value = this.defaultValue
320378
}
321379

322380
reset() {
323-
this.value = this.defaultValue
381+
this.formResetCallback()
324382
}
325383
}

0 commit comments

Comments
 (0)