web-client: recognize procedure-valued on-*: event attributes
Wire the on-*: DOM-event convention into the renderer (slate-core.md §9 gap 1). An sxml attribute whose name is a keyword of the form on-<event>: and whose value is a procedure is registered as a DOM event listener via the bridge instead of being ignored:
- A node-keyed closure registry (keyed by the integer DOM handle, stable across patches since the diff reuses nodes) plus a lazily-installed dispatcher that the bridge routes every fired event to. - web-client-apply-attrs gains two additive passes: detach listeners gone from the new attrs, then (re)register the ones present. Plain string/symbol attributes take the exact same path as before (keyword attrs were already skipped by web-client-dom-attribute?). - Listeners are unregistered on node removal/replace (patch-node clears an incompatible old subtree; patch-children clears dropped top-level records). This is cleanup only; the reconciliation strategy is unchanged.
on-key: maps to "keydown"; every other on-<x>: maps to "<x>". The closure receives a small general props dict (type, key, is-composing, value, caret); returning #t requests preventDefault.
src/sigil/web/client.sgl | 164 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 163 insertions(+), 1 deletion(-)src/sigil/web/client.sglmodified
value) (else (list value)))) ;; ---- on-*: DOM event listeners ----------------------------------------- ;; An attribute whose name is a keyword of the form on-<event>: and whose ;; value is a procedure is wired as a DOM event listener through the bridge, ;; keyed by node identity, instead of being treated as a string attribute. ;; When the event fires the bridge calls web-client-dispatch-dom-event, which ;; finds the closure registered for that node + event type and applies it ;; with a small event props dict. Plain string/symbol attributes are ;; untouched by this path. ;; Registry: an alist of (node-id . type-alist), where type-alist maps an ;; event-type string to the closure currently bound for it. node-id is the ;; integer DOM handle the bridge assigns; it is stable across patches because ;; the diff reuses nodes, so it is a durable identity key. (define web-client-listeners '()) (define web-client-dispatcher-installed? #f) ;; Install the single dispatch handler the bridge routes every fired event ;; to, once, lazily on the first listener registration. (define (web-client-ensure-dispatcher!) (when (not web-client-dispatcher-installed?) (wasm-dom-set-event-handler web-client-dispatch-dom-event) (set! web-client-dispatcher-installed? #t))) ;; Map an on-<event>: attribute keyword to a DOM event type string. on-key: ;; is a convenience alias for "keydown"; every other on-<x>: maps to "<x>", ;; so the convention stays general (on-change:, on-blur:, ... all work). (define (web-client-event-type-name attr-key) (let ((name (keyword->string attr-key))) (if (and (>= (string-length name) 3) (string=? (substring name 0 3) "on-")) (let ((evt (substring name 3 (string-length name)))) (if (string=? evt "key") "keydown" evt)) #f))) ;; An on-*: listener attribute: a keyword of the on-<event>: form whose value ;; is a procedure. Returns the event-type string, or #f if not a listener. (define (web-client-event-attr-type attr) (and (pair? attr) (keyword? (car attr)) (procedure? (web-client-attribute-value attr)) (web-client-event-type-name (car attr)))) (define (web-client-node-listeners node) (let loop ((rest web-client-listeners)) (cond ((null? rest) #f) ((eqv? (car (car rest)) node) (car rest)) (else (loop (cdr rest)))))) (define (web-client-alist-delete key alist) (cond ((null? alist) '()) ((equal? (car (car alist)) key) (cdr alist)) (else (cons (car alist) (web-client-alist-delete key (cdr alist)))))) ;; Register (or refresh) the closure for node + event type. The bridge-level ;; addEventListener happens only the first time a given node + type is seen; ;; later renders just swap the stored closure (the JS listener is stable and ;; always looks up the current closure), so a re-created lambda each render ;; costs nothing at the DOM layer. (define (web-client-listener-set! node type closure) (web-client-ensure-dispatcher!) (let ((entry (web-client-node-listeners node))) (if entry (let ((existing (assoc type (cdr entry)))) (if existing (set-cdr! existing closure) (begin (wasm-dom-add-event-listener node type) (set-cdr! entry (cons (cons type closure) (cdr entry)))))) (begin (wasm-dom-add-event-listener node type) (set! web-client-listeners (cons (cons node (list (cons type closure))) web-client-listeners)))))) (define (web-client-listener-remove! node type) (let ((entry (web-client-node-listeners node))) (when entry (when (assoc type (cdr entry)) (wasm-dom-remove-event-listener node type) (let ((rest (web-client-alist-delete type (cdr entry)))) ;; Prune the node's entry entirely once its last listener is gone, ;; so the registry never accumulates empty (node . ()) entries. (if (null? rest) (set! web-client-listeners (web-client-alist-delete node web-client-listeners)) (set-cdr! entry rest))))))) ;; Detach every listener on a node and drop its registry entry. Used when a ;; node is removed or replaced so listeners never outlive their node. (define (web-client-listeners-clear-node! node) (let ((entry (web-client-node-listeners node))) (when entry (for-each (lambda (pair) (wasm-dom-remove-event-listener node (car pair))) (cdr entry)) (set! web-client-listeners (web-client-alist-delete node web-client-listeners))))) ;; The dispatch handler the bridge invokes for every fired event: route to ;; the closure registered for this node + event type, applied with the props ;; dict. A #t result propagates back so the bridge can preventDefault. (define (web-client-dispatch-dom-event node props) (let ((entry (web-client-node-listeners node))) (if entry (let ((existing (assoc (dict-ref props type: "") (cdr entry)))) (if existing ((cdr existing) props) #f)) #f))) ;; Recursively drop listeners for an element record and its subtree. (define (web-client-unregister-record-tree! record) (when (and record (pair? record) (eq? (web-client-record-kind record) 'element)) (web-client-listeners-clear-node! (web-client-record-node record)) (for-each web-client-unregister-record-tree! (web-client-record-children record)))) (define (web-client-element-nodes records) (let loop ((rest records) (acc '())) (cond ((null? rest) acc) ((and (car rest) (pair? (car rest)) (eq? (web-client-record-kind (car rest)) 'element)) (loop (cdr rest) (cons (web-client-record-node (car rest)) acc))) (else (loop (cdr rest) acc))))) ;; Old top-level records not carried into the new set are being removed: ;; unregister their listener subtrees. Additive cleanup only -- it does not ;; change which nodes the diff keeps. (define (web-client-unregister-dropped! old-records new-records) (let ((kept (web-client-element-nodes new-records))) (for-each (lambda (old) (when (and old (pair? old) (eq? (web-client-record-kind old) 'element) (not (memv (web-client-record-node old) kept))) (web-client-unregister-record-tree! old))) old-records))) (define (web-client-apply-attrs node old-attrs new-attrs) (for-each (lambda (attr) (wasm-dom-remove-attribute node name)) (else (wasm-dom-set-attribute node name (web-client-value->string value)))))))) new-attrs) ;; on-*: listeners present in the old attrs but gone (or no longer a ;; procedure) in the new attrs: detach them. (for-each (lambda (attr) (let ((type (web-client-event-attr-type attr))) (when type (let ((new-attr (web-client-find-attr new-attrs (car attr)))) (when (not (and new-attr (web-client-event-attr-type new-attr))) (web-client-listener-remove! node type)))))) old-attrs) ;; on-*: listeners in the new attrs: register the node + type once and keep ;; the closure current. (for-each (lambda (attr) (let ((type (web-client-event-attr-type attr))) (when type (web-client-listener-set! node type (web-client-attribute-value attr))))) new-attrs)) (define (web-client-text-value value) (web-client-apply-attrs node old-attrs new-attrs) (list 'element node tag new-attrs children))) (else (web-client-create-node value))) (web-client-create-node value))) (begin ;; Incompatible: the old node (and its subtree) is being replaced -- ;; drop its listeners before the new node supplants it. (web-client-unregister-record-tree! old-record) (web-client-create-node value)))) (define (web-client-patch-children parent old-records new-values) (let loop ((rest (web-client-normalize-children new-values)) (records '())) (if (null? rest) (let ((ordered (reverse records))) (web-client-unregister-dropped! old-records ordered) (wasm-dom-replace-children parent) (for-each (lambda (record)