diff --git a/database_migrations/000016_split_oauth_tokens_claims.down.sql b/database_migrations/000016_split_oauth_tokens_claims.down.sql new file mode 100644 index 0000000..7ea745c --- /dev/null +++ b/database_migrations/000016_split_oauth_tokens_claims.down.sql @@ -0,0 +1,13 @@ +BEGIN; + + +ALTER TABLE oauth_tokens + DROP COLUMN id_token_custom_claims_name, + DROP COLUMN id_token_custom_claims_picture, + DROP COLUMN id_token_custom_claims_nickname, + DROP COLUMN id_token_custom_claims_given_name, + DROP COLUMN id_token_custom_claims_updated_at, + DROP COLUMN id_token_custom_claims_family_name; + + +COMMIT; diff --git a/database_migrations/000016_split_oauth_tokens_claims.up.sql b/database_migrations/000016_split_oauth_tokens_claims.up.sql new file mode 100644 index 0000000..7fdf690 --- /dev/null +++ b/database_migrations/000016_split_oauth_tokens_claims.up.sql @@ -0,0 +1,29 @@ +BEGIN; + + +ALTER TABLE oauth_tokens + ADD COLUMN id_token_custom_claims_name TEXT, + ADD COLUMN id_token_custom_claims_picture TEXT, + ADD COLUMN id_token_custom_claims_nickname TEXT, + ADD COLUMN id_token_custom_claims_given_name TEXT, + ADD COLUMN id_token_custom_claims_updated_at TEXT, + ADD COLUMN id_token_custom_claims_family_name TEXT; + +UPDATE oauth_tokens + SET id_token_custom_claims_name = claims ->> 'name', + id_token_custom_claims_picture = claims ->> 'picture', + id_token_custom_claims_nickname = claims ->> 'nickname', + id_token_custom_claims_given_name = claims ->> 'given_name', + id_token_custom_claims_updated_at = (claims ->> 'updated_at')::TIMESTAMPTZ, + id_token_custom_claims_family_name = claims ->> 'family_name'; + +ALTER TABLE oauth_tokens + ALTER COLUMN id_token_custom_claims_name SET NOT NULL, + ALTER COLUMN id_token_custom_claims_picture SET NOT NULL, + ALTER COLUMN id_token_custom_claims_nickname SET NOT NULL, + ALTER COLUMN id_token_custom_claims_given_name SET NOT NULL, + ALTER COLUMN id_token_custom_claims_updated_at SET NOT NULL, + ALTER COLUMN id_token_custom_claims_family_name SET NOT NULL; + + +COMMIT; diff --git a/diagrams/database_schema_public.svg b/diagrams/database_schema_public.svg index edee36a..7f5ee97 100644 --- a/diagrams/database_schema_public.svg +++ b/diagrams/database_schema_public.svg @@ -1 +1 @@ -accountsaccount_id:serial [PK]email:textverified:booleanuser_id:text [FK]etsy_access_tokensuser_id:integer [PK][FK]access_token:textrefresh_token:textaccess_token_expiration:timestamp with time zonerefresh_token_expiration:timestamp with time zoneetsy_oauth_requestsstate:bytea [PK]code_verifier:byteaexpiration:timestamp with time zoneaccount_id:integer [FK]etsy_store_eventsstore_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]platform:text [FK]etsy_usersuser_id:integer [PK]account_id:integer [FK]shop_id:integeroauth_login_statesstate:bytea [PK]expiration:timestamp with time zonetarget_uri:textoauth_tokensaccess_token:text [PK]token_type:textrefresh_token:textexpiry:timestamp with time zoneid_token_issuer:textid_token_audience:text[]id_token_subject:text [FK]id_token_expiry:timestamp with time zoneid_token_issued_at:timestamp with time zoneid_token_nonce:textid_token_access_token_hash:textclaims:jsonboauth_usersuser_id:text [PK]raw_store_eventsplatform:text [PK]store_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]raw_payload:jsonbschema_migrationsversion:bigint [PK]dirty:booleansync_group_listing_draftsaccount_id:integer [PK][FK]order_index:integer [PK]platform:platformshop_id:textlisting_id:textsync_group_listingssync_group_id:integer [PK][FK]order_index:integer [PK]platform:platformshop_id:textlisting_id:textsync_groupssync_group_id:serial [PK]account_id:integer [FK]tiktok_store_eventsstore_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]platform:text [FK]wix_store_eventsstore_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]platform:text [FK] \ No newline at end of file +accountsaccount_id:serial [PK]email:textverified:booleanuser_id:text [FK]etsy_access_tokensuser_id:integer [PK][FK]access_token:textrefresh_token:textaccess_token_expiration:timestamp with time zonerefresh_token_expiration:timestamp with time zoneetsy_oauth_requestsstate:bytea [PK]code_verifier:byteaexpiration:timestamp with time zoneaccount_id:integer [FK]etsy_store_eventsstore_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]platform:text [FK]etsy_usersuser_id:integer [PK]account_id:integer [FK]shop_id:integeroauth_login_statesstate:bytea [PK]expiration:timestamp with time zonetarget_uri:textoauth_tokensaccess_token:text [PK]token_type:textrefresh_token:textexpiry:timestamp with time zoneid_token_issuer:textid_token_audience:text[]id_token_subject:text [FK]id_token_expiry:timestamp with time zoneid_token_issued_at:timestamp with time zoneid_token_nonce:textid_token_access_token_hash:textid_token_custom_claims_name:textid_token_custom_claims_picture:textid_token_custom_claims_nickname:textid_token_custom_claims_given_name:textid_token_custom_claims_family_name:textid_token_custom_claims_updated_at:timestamp with time zoneoauth_usersuser_id:text [PK]raw_store_eventsplatform:text [PK]store_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]raw_payload:jsonbschema_migrationsversion:bigint [PK]dirty:booleansync_group_listing_draftsaccount_id:integer [PK][FK]order_index:integer [PK]platform:platformshop_id:textlisting_id:textsync_group_listingssync_group_id:integer [PK][FK]order_index:integer [PK]platform:platformshop_id:textlisting_id:textsync_groupssync_group_id:serial [PK]account_id:integer [FK]tiktok_store_eventsstore_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]platform:text [FK]wix_store_eventsstore_id:text [PK]event_timestamp:timestamp with time zone [PK]event_id:text [PK]platform:text [FK] \ No newline at end of file diff --git a/diagrams/database_schema_public.uml b/diagrams/database_schema_public.uml index 12a35fc..0f7d65b 100644 --- a/diagrams/database_schema_public.uml +++ b/diagrams/database_schema_public.uml @@ -63,7 +63,12 @@ entity "**oauth_tokens**" { *""id_token_issued_at"": //timestamp with time zone // *""id_token_nonce"": //text // *""id_token_access_token_hash"": //text // - *""claims"": //jsonb // + *""id_token_custom_claims_name"": //text // + *""id_token_custom_claims_picture"": //text // + *""id_token_custom_claims_nickname"": //text // + *""id_token_custom_claims_given_name"": //text // + *""id_token_custom_claims_family_name"": //text // + *""id_token_custom_claims_updated_at"": //timestamp with time zone // } entity "**oauth_users**" { diff --git a/internal/domains/authentication/auth.go b/internal/domains/authentication/auth.go index 7b69009..5284f38 100644 --- a/internal/domains/authentication/auth.go +++ b/internal/domains/authentication/auth.go @@ -2,7 +2,6 @@ package authentication import ( "context" - "encoding/json" "errors" "fmt" "net/url" @@ -131,8 +130,6 @@ func (a *Authenticator) Exchange(ctx context.Context, state, code string) (acces return "", "", time.Time{}, err } - claimsJSON, _ := json.Marshal(claims) - // store the token, and the potentially new user if _, err := a.db.Exec( @@ -171,7 +168,12 @@ func (a *Authenticator) Exchange(ctx context.Context, state, code string) (acces id_token_nonce, id_token_access_token_hash, - claims -- might want to open this up + id_token_custom_claims_family_name, + id_token_custom_claims_given_name, + id_token_custom_claims_name, + id_token_custom_claims_nickname, + id_token_custom_claims_picture, + id_token_custom_claims_updated_at ) SELECT @access_token, @@ -187,7 +189,12 @@ func (a *Authenticator) Exchange(ctx context.Context, state, code string) (acces @id_token_nonce, @id_token_access_token_hash, - @claims + @id_token_custom_claims_family_name, + @id_token_custom_claims_given_name, + @id_token_custom_claims_name, + @id_token_custom_claims_nickname, + @id_token_custom_claims_picture, + @id_token_custom_claims_updated_at FROM the_user `, @@ -205,7 +212,12 @@ func (a *Authenticator) Exchange(ctx context.Context, state, code string) (acces "id_token_nonce": idToken.Nonce, "id_token_access_token_hash": idToken.AccessTokenHash, - "claims": json.RawMessage(claimsJSON), + "id_token_custom_claims_family_name": claims.FamilyName, + "id_token_custom_claims_given_name": claims.GivenName, + "id_token_custom_claims_name": claims.Name, + "id_token_custom_claims_nickname": claims.Nickname, + "id_token_custom_claims_picture": claims.Picture, + "id_token_custom_claims_updated_at": claims.UpdatedAt, }, ); err != nil { return "", "", time.Time{}, fmt.Errorf("failed to perform query to save tokens: %w", err) @@ -291,8 +303,6 @@ func (a *Authenticator) RefreshAccessToken( return "", time.Time{}, err } - claimsJSON, _ := json.Marshal(claims) - if _, err = a.db.Exec( ctx, ` @@ -318,7 +328,12 @@ func (a *Authenticator) RefreshAccessToken( id_token_nonce, id_token_access_token_hash, - claims -- might want to open this up + id_token_custom_claims_family_name, + id_token_custom_claims_given_name, + id_token_custom_claims_name, + id_token_custom_claims_nickname, + id_token_custom_claims_picture, + id_token_custom_claims_updated_at ) VALUES ( @new_access_token, @@ -334,7 +349,12 @@ func (a *Authenticator) RefreshAccessToken( @id_token_nonce, @id_token_access_token_hash, - @claims + @id_token_custom_claims_family_name, + @id_token_custom_claims_given_name, + @id_token_custom_claims_name, + @id_token_custom_claims_nickname, + @id_token_custom_claims_picture, + @id_token_custom_claims_updated_at ) RETURNING access_token AS new_access_token @@ -363,7 +383,12 @@ func (a *Authenticator) RefreshAccessToken( "id_token_nonce": idToken.Nonce, "id_token_access_token_hash": idToken.AccessTokenHash, - "claims": json.RawMessage(claimsJSON), + "id_token_custom_claims_family_name": claims.FamilyName, + "id_token_custom_claims_given_name": claims.GivenName, + "id_token_custom_claims_name": claims.Name, + "id_token_custom_claims_nickname": claims.Nickname, + "id_token_custom_claims_picture": claims.Picture, + "id_token_custom_claims_updated_at": claims.UpdatedAt, }, ); err != nil { return "", time.Time{}, fmt.Errorf("failed to save new access token and delete old access token: %w", err) @@ -372,12 +397,14 @@ func (a *Authenticator) RefreshAccessToken( return tkn.AccessToken, tkn.Expiry.UTC(), nil } -func (a *Authenticator) verifyIDTokenAndClaimsFromToken(ctx context.Context, tkn *oauth2.Token) (idToken *oidc.IDToken, claims map[string]any, err error) { +func (a *Authenticator) verifyIDTokenAndClaimsFromToken(ctx context.Context, tkn *oauth2.Token) (idToken *oidc.IDToken, claims *AccessTokenClaims, err error) { if idToken, err = a.VerifyIDToken(ctx, tkn); err != nil { return nil, nil, fmt.Errorf("failed to verify id Token: %w", err) } - if err := idToken.Claims(&claims); err != nil { + claims = new(AccessTokenClaims) + + if err := idToken.Claims(claims); err != nil { return nil, nil, fmt.Errorf("failed to obtain id token claims: %w", err) } diff --git a/internal/domains/authentication/store.go b/internal/domains/authentication/store.go index 8a497f0..1bed492 100644 --- a/internal/domains/authentication/store.go +++ b/internal/domains/authentication/store.go @@ -3,7 +3,6 @@ package authentication import ( "context" "crypto/rand" - "encoding/json" "errors" "fmt" "ruben/inventory2/internal/consts" @@ -102,7 +101,14 @@ func (a *Authenticator) GetAccessTokenClaimsAndExpiration(ctx context.Context, a ` SELECT expiry, - claims + + id_token_custom_claims_name, + id_token_custom_claims_picture, + id_token_custom_claims_nickname, + id_token_custom_claims_given_name, + id_token_custom_claims_family_name, + id_token_custom_claims_updated_at + FROM oauth_tokens WHERE @@ -117,8 +123,13 @@ func (a *Authenticator) GetAccessTokenClaimsAndExpiration(ctx context.Context, a } type Row struct { - Expiry time.Time - Claims json.RawMessage + Expiry time.Time + Id_token_custom_claims_name string + Id_token_custom_claims_picture string + Id_token_custom_claims_nickname string + Id_token_custom_claims_given_name string + Id_token_custom_claims_family_name string + Id_token_custom_claims_updated_at time.Time } r, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Row]) @@ -129,9 +140,12 @@ func (a *Authenticator) GetAccessTokenClaimsAndExpiration(ctx context.Context, a return AccessTokenClaims{}, time.Time{}, fmt.Errorf("failed to scan row: %w", err) } - if err := json.Unmarshal(r.Claims, &claims); err != nil { - return AccessTokenClaims{}, time.Time{}, fmt.Errorf("failed to scan claims json: %w", err) - } + claims.Name = r.Id_token_custom_claims_name + claims.Picture = r.Id_token_custom_claims_picture + claims.Nickname = r.Id_token_custom_claims_nickname + claims.GivenName = r.Id_token_custom_claims_given_name + claims.FamilyName = r.Id_token_custom_claims_family_name + claims.UpdatedAt = r.Id_token_custom_claims_updated_at return claims, r.Expiry, nil } diff --git a/internal/server/api/sse/router.go b/internal/server/api/sse/router.go index 7d08ed6..08202d8 100644 --- a/internal/server/api/sse/router.go +++ b/internal/server/api/sse/router.go @@ -11,12 +11,16 @@ import ( "github.com/gin-gonic/gin" ) +const ( + maxNumOpenConnectionsPerUser = 3 +) + type ( sseRouter struct { log *logging.Logger sse *sse.Queue - users map[string]context.CancelFunc + users map[string][maxNumOpenConnectionsPerUser]context.CancelFunc lock sync.Mutex } ) @@ -30,7 +34,7 @@ func Routes( s := &sseRouter{ log: logger, sse: sq, - users: make(map[string]context.CancelFunc), + users: make(map[string][maxNumOpenConnectionsPerUser]context.CancelFunc), } r.GET("/", auth.AuthenticateAndAddIdentityGin(), response.Handler(s.serveEvents)) @@ -46,10 +50,11 @@ func (r *sseRouter) serveEvents(c *gin.Context) (response.Response, error) { "accountID", acctID, "userID", userID, "email", email, + "userAgent", c.Request.UserAgent(), ) log.Info("user connected to sse queue") - ctx := r.closeExistingConnectionsForUserAndStoreCancelFuncForUser(c, userID) + ctx := r.closeOutstandingConnectionsForUserAndStoreCancelFuncForUser(c, userID) w := c.Writer @@ -80,18 +85,34 @@ func (r *sseRouter) serveEvents(c *gin.Context) (response.Response, error) { return response.Status(200), nil } -func (r *sseRouter) closeExistingConnectionsForUserAndStoreCancelFuncForUser(ctx context.Context, userID string) context.Context { +func (r *sseRouter) closeOutstandingConnectionsForUserAndStoreCancelFuncForUser(ctx context.Context, userID string) context.Context { r.lock.Lock() defer r.lock.Unlock() + ctx, cancel := context.WithCancel(ctx) + + closeConnFuncs := r.users[userID] + defer func() { + r.users[userID] = closeConnFuncs + }() + // close existing connection - if closeConn, ok := r.users[userID]; ok { - closeConn() - delete(r.users, userID) + + for i := range maxNumOpenConnectionsPerUser { + if fn := closeConnFuncs[i]; fn == nil { + // not hit limit on connections. + // save the cancellation func and done + closeConnFuncs[i] = cancel + return ctx + } } - // store reference to cancel func - ctx, r.users[userID] = context.WithCancel(ctx) + // close the oldest connection, shift all cancellation funcs down, and push the new one in + closeConnFuncs[0]() + for i := range maxNumOpenConnectionsPerUser - 1 { + closeConnFuncs[i] = closeConnFuncs[i+1] + } + closeConnFuncs[maxNumOpenConnectionsPerUser-1] = cancel return ctx } diff --git a/scripts/idiomorph-ext.js b/scripts/idiomorph-ext.js new file mode 100644 index 0000000..f2243a0 --- /dev/null +++ b/scripts/idiomorph-ext.js @@ -0,0 +1,1407 @@ +/** + * @typedef {object} ConfigHead + * + * @property {'merge' | 'append' | 'morph' | 'none'} [style] + * @property {boolean} [block] + * @property {boolean} [ignore] + * @property {function(Element): boolean} [shouldPreserve] + * @property {function(Element): boolean} [shouldReAppend] + * @property {function(Element): boolean} [shouldRemove] + * @property {function(Element, {added: Node[], kept: Element[], removed: Element[]}): void} [afterHeadMorphed] + */ + +/** + * @typedef {object} ConfigCallbacks + * + * @property {function(Node): boolean} [beforeNodeAdded] + * @property {function(Node): void} [afterNodeAdded] + * @property {function(Element, Node): boolean} [beforeNodeMorphed] + * @property {function(Element, Node): void} [afterNodeMorphed] + * @property {function(Element): boolean} [beforeNodeRemoved] + * @property {function(Element): void} [afterNodeRemoved] + * @property {function(string, Element, "update" | "remove"): boolean} [beforeAttributeUpdated] + */ + +/** + * @typedef {object} Config + * + * @property {'outerHTML' | 'innerHTML'} [morphStyle] + * @property {boolean} [ignoreActive] + * @property {boolean} [ignoreActiveValue] + * @property {boolean} [restoreFocus] + * @property {ConfigCallbacks} [callbacks] + * @property {ConfigHead} [head] + */ + +/** + * @typedef {function} NoOp + * + * @returns {void} + */ + +/** + * @typedef {object} ConfigHeadInternal + * + * @property {'merge' | 'append' | 'morph' | 'none'} style + * @property {boolean} [block] + * @property {boolean} [ignore] + * @property {(function(Element): boolean) | NoOp} shouldPreserve + * @property {(function(Element): boolean) | NoOp} shouldReAppend + * @property {(function(Element): boolean) | NoOp} shouldRemove + * @property {(function(Element, {added: Node[], kept: Element[], removed: Element[]}): void) | NoOp} afterHeadMorphed + */ + +/** + * @typedef {object} ConfigCallbacksInternal + * + * @property {(function(Node): boolean) | NoOp} beforeNodeAdded + * @property {(function(Node): void) | NoOp} afterNodeAdded + * @property {(function(Node, Node): boolean) | NoOp} beforeNodeMorphed + * @property {(function(Node, Node): void) | NoOp} afterNodeMorphed + * @property {(function(Node): boolean) | NoOp} beforeNodeRemoved + * @property {(function(Node): void) | NoOp} afterNodeRemoved + * @property {(function(string, Element, "update" | "remove"): boolean) | NoOp} beforeAttributeUpdated + */ + +/** + * @typedef {object} ConfigInternal + * + * @property {'outerHTML' | 'innerHTML'} morphStyle + * @property {boolean} [ignoreActive] + * @property {boolean} [ignoreActiveValue] + * @property {boolean} [restoreFocus] + * @property {ConfigCallbacksInternal} callbacks + * @property {ConfigHeadInternal} head + */ + +/** + * @typedef {Object} IdSets + * @property {Set} persistentIds + * @property {Map>} idMap + */ + +/** + * @typedef {Function} Morph + * + * @param {Element | Document} oldNode + * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent + * @param {Config} [config] + * @returns {undefined | Node[]} + */ + +// base IIFE to define idiomorph +/** + * + * @type {{defaults: ConfigInternal, morph: Morph}} + */ +var Idiomorph = (function () { + "use strict"; + + /** + * @typedef {object} MorphContext + * + * @property {Element} target + * @property {Element} newContent + * @property {ConfigInternal} config + * @property {ConfigInternal['morphStyle']} morphStyle + * @property {ConfigInternal['ignoreActive']} ignoreActive + * @property {ConfigInternal['ignoreActiveValue']} ignoreActiveValue + * @property {ConfigInternal['restoreFocus']} restoreFocus + * @property {Map>} idMap + * @property {Set} persistentIds + * @property {ConfigInternal['callbacks']} callbacks + * @property {ConfigInternal['head']} head + * @property {HTMLDivElement} pantry + * @property {Element[]} activeElementAndParents + */ + + //============================================================================= + // AND NOW IT BEGINS... + //============================================================================= + + const noOp = () => {}; + /** + * Default configuration values, updatable by users now + * @type {ConfigInternal} + */ + const defaults = { + morphStyle: "outerHTML", + callbacks: { + beforeNodeAdded: noOp, + afterNodeAdded: noOp, + beforeNodeMorphed: noOp, + afterNodeMorphed: noOp, + beforeNodeRemoved: noOp, + afterNodeRemoved: noOp, + beforeAttributeUpdated: noOp, + }, + head: { + style: "merge", + shouldPreserve: (elt) => elt.getAttribute("im-preserve") === "true", + shouldReAppend: (elt) => elt.getAttribute("im-re-append") === "true", + shouldRemove: noOp, + afterHeadMorphed: noOp, + }, + restoreFocus: true, + }; + + /** + * Core idiomorph function for morphing one DOM tree to another + * + * @param {Element | Document} oldNode + * @param {Element | Node | HTMLCollection | Node[] | string | null} newContent + * @param {Config} [config] + * @returns {Promise | Node[]} + */ + function morph(oldNode, newContent, config = {}) { + oldNode = normalizeElement(oldNode); + const newNode = normalizeParent(newContent); + const ctx = createMorphContext(oldNode, newNode, config); + + const morphedNodes = saveAndRestoreFocus(ctx, () => { + return withHeadBlocking( + ctx, + oldNode, + newNode, + /** @param {MorphContext} ctx */ (ctx) => { + if (ctx.morphStyle === "innerHTML") { + morphChildren(ctx, oldNode, newNode); + return Array.from(oldNode.childNodes); + } else { + return morphOuterHTML(ctx, oldNode, newNode); + } + }, + ); + }); + + ctx.pantry.remove(); + return morphedNodes; + } + + /** + * Morph just the outerHTML of the oldNode to the newContent + * We have to be careful because the oldNode could have siblings which need to be untouched + * @param {MorphContext} ctx + * @param {Element} oldNode + * @param {Element} newNode + * @returns {Node[]} + */ + function morphOuterHTML(ctx, oldNode, newNode) { + const oldParent = normalizeParent(oldNode); + morphChildren( + ctx, + oldParent, + newNode, + // these two optional params are the secret sauce + oldNode, // start point for iteration + oldNode.nextSibling, // end point for iteration + ); + // this is safe even with siblings, because normalizeParent returns a SlicedParentNode if needed. + return Array.from(oldParent.childNodes); + } + + /** + * @param {MorphContext} ctx + * @param {Function} fn + * @returns {Promise | Node[]} + */ + function saveAndRestoreFocus(ctx, fn) { + if (!ctx.config.restoreFocus) return fn(); + let activeElement = + /** @type {HTMLInputElement|HTMLTextAreaElement|null} */ ( + document.activeElement + ); + + // don't bother if the active element is not an input or textarea + if ( + !( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement + ) + ) { + return fn(); + } + + const { id: activeElementId, selectionStart, selectionEnd } = activeElement; + + const results = fn(); + + if ( + activeElementId && + activeElementId !== document.activeElement?.getAttribute("id") + ) { + activeElement = ctx.target.querySelector(`[id="${activeElementId}"]`); + activeElement?.focus(); + } + if (activeElement && !activeElement.selectionEnd && selectionEnd) { + activeElement.setSelectionRange(selectionStart, selectionEnd); + } + + return results; + } + + const morphChildren = (function () { + /** + * This is the core algorithm for matching up children. The idea is to use id sets to try to match up + * nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but + * by using id sets, we are able to better match up with content deeper in the DOM. + * + * Basic algorithm: + * - for each node in the new content: + * - search self and siblings for an id set match, falling back to a soft match + * - if match found + * - remove any nodes up to the match: + * - pantry persistent nodes + * - delete the rest + * - morph the match + * - elsif no match found, and node is persistent + * - find its match by querying the old root (future) and pantry (past) + * - move it and its children here + * - morph it + * - else + * - create a new node from scratch as a last result + * + * @param {MorphContext} ctx the merge context + * @param {Element} oldParent the old content that we are merging the new content into + * @param {Element} newParent the parent element of the new content + * @param {Node|null} [insertionPoint] the point in the DOM we start morphing at (defaults to first child) + * @param {Node|null} [endPoint] the point in the DOM we stop morphing at (defaults to after last child) + */ + function morphChildren( + ctx, + oldParent, + newParent, + insertionPoint = null, + endPoint = null, + ) { + // normalize + if ( + oldParent instanceof HTMLTemplateElement && + newParent instanceof HTMLTemplateElement + ) { + // @ts-ignore we can pretend the DocumentFragment is an Element + oldParent = oldParent.content; + // @ts-ignore ditto + newParent = newParent.content; + } + insertionPoint ||= oldParent.firstChild; + + // run through all the new content + for (const newChild of newParent.childNodes) { + // once we reach the end of the old parent content skip to the end and insert the rest + if (insertionPoint && insertionPoint != endPoint) { + const bestMatch = findBestMatch( + ctx, + newChild, + insertionPoint, + endPoint, + ); + if (bestMatch) { + // if the node to morph is not at the insertion point then remove/move up to it + if (bestMatch !== insertionPoint) { + removeNodesBetween(ctx, insertionPoint, bestMatch); + } + morphNode(bestMatch, newChild, ctx); + insertionPoint = bestMatch.nextSibling; + continue; + } + } + + // if the matching node is elsewhere in the original content + if (newChild instanceof Element) { + // we can pretend the id is non-null because the next `.has` line will reject it if not + const newChildId = /** @type {String} */ ( + newChild.getAttribute("id") + ); + if (ctx.persistentIds.has(newChildId)) { + // move it and all its children here and morph + const movedChild = moveBeforeById( + oldParent, + newChildId, + insertionPoint, + ctx, + ); + morphNode(movedChild, newChild, ctx); + insertionPoint = movedChild.nextSibling; + continue; + } + } + + // last resort: insert the new node from scratch + const insertedNode = createNode( + oldParent, + newChild, + insertionPoint, + ctx, + ); + // could be null if beforeNodeAdded prevented insertion + if (insertedNode) { + insertionPoint = insertedNode.nextSibling; + } + } + + // remove any remaining old nodes that didn't match up with new content + while (insertionPoint && insertionPoint != endPoint) { + const tempNode = insertionPoint; + insertionPoint = insertionPoint.nextSibling; + removeNode(ctx, tempNode); + } + } + + /** + * This performs the action of inserting a new node while handling situations where the node contains + * elements with persistent ids and possible state info we can still preserve by moving in and then morphing + * + * @param {Element} oldParent + * @param {Node} newChild + * @param {Node|null} insertionPoint + * @param {MorphContext} ctx + * @returns {Node|null} + */ + function createNode(oldParent, newChild, insertionPoint, ctx) { + if (ctx.callbacks.beforeNodeAdded(newChild) === false) return null; + if (ctx.idMap.has(newChild)) { + // node has children with ids with possible state so create a dummy elt of same type and apply full morph algorithm + const newEmptyChild = document.createElement( + /** @type {Element} */ (newChild).tagName, + ); + oldParent.insertBefore(newEmptyChild, insertionPoint); + morphNode(newEmptyChild, newChild, ctx); + ctx.callbacks.afterNodeAdded(newEmptyChild); + return newEmptyChild; + } else { + // optimisation: no id state to preserve so we can just insert a clone of the newChild and its descendants + const newClonedChild = document.importNode(newChild, true); // importNode to not mutate newParent + oldParent.insertBefore(newClonedChild, insertionPoint); + ctx.callbacks.afterNodeAdded(newClonedChild); + return newClonedChild; + } + } + + //============================================================================= + // Matching Functions + //============================================================================= + const findBestMatch = (function () { + /** + * Scans forward from the startPoint to the endPoint looking for a match + * for the node. It looks for an id set match first, then a soft match. + * We abort softmatching if we find two future soft matches, to reduce churn. + * @param {Node} node + * @param {MorphContext} ctx + * @param {Node | null} startPoint + * @param {Node | null} endPoint + * @returns {Node | null} + */ + function findBestMatch(ctx, node, startPoint, endPoint) { + let softMatch = null; + let nextSibling = node.nextSibling; + let siblingSoftMatchCount = 0; + + let cursor = startPoint; + while (cursor && cursor != endPoint) { + // soft matching is a prerequisite for id set matching + if (isSoftMatch(cursor, node)) { + if (isIdSetMatch(ctx, cursor, node)) { + return cursor; // found an id set match, we're done! + } + + // we haven't yet saved a soft match fallback + if (softMatch === null) { + // the current soft match will hard match something else in the future, leave it + if (!ctx.idMap.has(cursor)) { + // save this as the fallback if we get through the loop without finding a hard match + softMatch = cursor; + } + } + } + if ( + softMatch === null && + nextSibling && + isSoftMatch(cursor, nextSibling) + ) { + // The next new node has a soft match with this node, so + // increment the count of future soft matches + siblingSoftMatchCount++; + nextSibling = nextSibling.nextSibling; + + // If there are two future soft matches, block soft matching for this node to allow + // future siblings to soft match. This is to reduce churn in the DOM when an element + // is prepended. + if (siblingSoftMatchCount >= 2) { + softMatch = undefined; + } + } + + // if the current node contains active element, stop looking for better future matches, + // because if one is found, this node will be moved to the pantry, reparenting it and thus losing focus + // @ts-ignore pretend cursor is Element rather than Node, we're just testing for array inclusion + if (ctx.activeElementAndParents.includes(cursor)) break; + + cursor = cursor.nextSibling; + } + + return softMatch || null; + } + + /** + * + * @param {MorphContext} ctx + * @param {Node} oldNode + * @param {Node} newNode + * @returns {boolean} + */ + function isIdSetMatch(ctx, oldNode, newNode) { + let oldSet = ctx.idMap.get(oldNode); + let newSet = ctx.idMap.get(newNode); + + if (!newSet || !oldSet) return false; + + for (const id of oldSet) { + // a potential match is an id in the new and old nodes that + // has not already been merged into the DOM + // But the newNode content we call this on has not been + // merged yet and we don't allow duplicate IDs so it is simple + if (newSet.has(id)) { + return true; + } + } + return false; + } + + /** + * + * @param {Node} oldNode + * @param {Node} newNode + * @returns {boolean} + */ + function isSoftMatch(oldNode, newNode) { + // ok to cast: if one is not element, `id` and `tagName` will be undefined and we'll just compare that. + const oldElt = /** @type {Element} */ (oldNode); + const newElt = /** @type {Element} */ (newNode); + + return ( + oldElt.nodeType === newElt.nodeType && + oldElt.tagName === newElt.tagName && + // If oldElt has an `id` with possible state and it doesn't match newElt.id then avoid morphing. + // We'll still match an anonymous node with an IDed newElt, though, because if it got this far, + // its not persistent, and new nodes can't have any hidden state. + // We can't use .id because of form input shadowing, and we can't count on .getAttribute's presence because it could be a document-fragment + (!oldElt.getAttribute?.("id") || + oldElt.getAttribute?.("id") === newElt.getAttribute?.("id")) + ); + } + + return findBestMatch; + })(); + + //============================================================================= + // DOM Manipulation Functions + //============================================================================= + + /** + * Gets rid of an unwanted DOM node; strategy depends on nature of its reuse: + * - Persistent nodes will be moved to the pantry for later reuse + * - Other nodes will have their hooks called, and then are removed + * @param {MorphContext} ctx + * @param {Node} node + */ + function removeNode(ctx, node) { + // are we going to id set match this later? + if (ctx.idMap.has(node)) { + // skip callbacks and move to pantry + moveBefore(ctx.pantry, node, null); + } else { + // remove for realsies + if (ctx.callbacks.beforeNodeRemoved(node) === false) return; + node.parentNode?.removeChild(node); + ctx.callbacks.afterNodeRemoved(node); + } + } + + /** + * Remove nodes between the start and end nodes + * @param {MorphContext} ctx + * @param {Node} startInclusive + * @param {Node} endExclusive + * @returns {Node|null} + */ + function removeNodesBetween(ctx, startInclusive, endExclusive) { + /** @type {Node | null} */ + let cursor = startInclusive; + // remove nodes until the endExclusive node + while (cursor && cursor !== endExclusive) { + let tempNode = /** @type {Node} */ (cursor); + cursor = cursor.nextSibling; + removeNode(ctx, tempNode); + } + return cursor; + } + + /** + * Search for an element by id within the document and pantry, and move it using moveBefore. + * + * @param {Element} parentNode - The parent node to which the element will be moved. + * @param {string} id - The ID of the element to be moved. + * @param {Node | null} after - The reference node to insert the element before. + * If `null`, the element is appended as the last child. + * @param {MorphContext} ctx + * @returns {Element} The found element + */ + function moveBeforeById(parentNode, id, after, ctx) { + const target = + /** @type {Element} - will always be found */ + ( + // ctx.target.id unsafe because of form input shadowing + // ctx.target could be a document fragment which doesn't have `getAttribute` + (ctx.target.getAttribute?.("id") === id && ctx.target) || + ctx.target.querySelector(`[id="${id}"]`) || + ctx.pantry.querySelector(`[id="${id}"]`) + ); + removeElementFromAncestorsIdMaps(target, ctx); + moveBefore(parentNode, target, after); + return target; + } + + /** + * Removes an element from its ancestors' id maps. This is needed when an element is moved from the + * "future" via `moveBeforeId`. Otherwise, its erstwhile ancestors could be mistakenly moved to the + * pantry rather than being deleted, preventing their removal hooks from being called. + * + * @param {Element} element - element to remove from its ancestors' id maps + * @param {MorphContext} ctx + */ + function removeElementFromAncestorsIdMaps(element, ctx) { + // we know id is non-null String, because this function is only called on elements with ids + const id = /** @type {String} */ (element.getAttribute("id")); + /** @ts-ignore - safe to loop in this way **/ + while ((element = element.parentNode)) { + let idSet = ctx.idMap.get(element); + if (idSet) { + idSet.delete(id); + if (!idSet.size) { + ctx.idMap.delete(element); + } + } + } + } + + /** + * Moves an element before another element within the same parent. + * Uses the proposed `moveBefore` API if available (and working), otherwise falls back to `insertBefore`. + * This is essentialy a forward-compat wrapper. + * + * @param {Element} parentNode - The parent node containing the after element. + * @param {Node} element - The element to be moved. + * @param {Node | null} after - The reference node to insert `element` before. + * If `null`, `element` is appended as the last child. + */ + function moveBefore(parentNode, element, after) { + // @ts-ignore - use proposed moveBefore feature + if (parentNode.moveBefore) { + try { + // @ts-ignore - use proposed moveBefore feature + parentNode.moveBefore(element, after); + } catch (e) { + // fall back to insertBefore as some browsers may fail on moveBefore when trying to move Dom disconnected nodes to pantry + parentNode.insertBefore(element, after); + } + } else { + parentNode.insertBefore(element, after); + } + } + + return morphChildren; + })(); + + //============================================================================= + // Single Node Morphing Code + //============================================================================= + const morphNode = (function () { + /** + * @param {Node} oldNode root node to merge content into + * @param {Node} newContent new content to merge + * @param {MorphContext} ctx the merge context + * @returns {Node | null} the element that ended up in the DOM + */ + function morphNode(oldNode, newContent, ctx) { + if (ctx.ignoreActive && oldNode === document.activeElement) { + // don't morph focused element + return null; + } + + if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) { + return oldNode; + } + + if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) { + // ignore the head element + } else if ( + oldNode instanceof HTMLHeadElement && + ctx.head.style !== "morph" + ) { + // ok to cast: if newContent wasn't also a , it would've got caught in the `!isSoftMatch` branch above + handleHeadElement( + oldNode, + /** @type {HTMLHeadElement} */ (newContent), + ctx, + ); + } else { + morphAttributes(oldNode, newContent, ctx); + if (!ignoreValueOfActiveElement(oldNode, ctx)) { + // @ts-ignore newContent can be a node here because .firstChild will be null + morphChildren(ctx, oldNode, newContent); + } + } + ctx.callbacks.afterNodeMorphed(oldNode, newContent); + return oldNode; + } + + /** + * syncs the oldNode to the newNode, copying over all attributes and + * inner element state from the newNode to the oldNode + * + * @param {Node} oldNode the node to copy attributes & state to + * @param {Node} newNode the node to copy attributes & state from + * @param {MorphContext} ctx the merge context + */ + function morphAttributes(oldNode, newNode, ctx) { + let type = newNode.nodeType; + + // if is an element type, sync the attributes from the + // new node into the new node + if (type === 1 /* element type */) { + const oldElt = /** @type {Element} */ (oldNode); + const newElt = /** @type {Element} */ (newNode); + + const oldAttributes = oldElt.attributes; + const newAttributes = newElt.attributes; + for (const newAttribute of newAttributes) { + if (ignoreAttribute(newAttribute.name, oldElt, "update", ctx)) { + continue; + } + if (oldElt.getAttribute(newAttribute.name) !== newAttribute.value) { + oldElt.setAttribute(newAttribute.name, newAttribute.value); + } + } + // iterate backwards to avoid skipping over items when a delete occurs + for (let i = oldAttributes.length - 1; 0 <= i; i--) { + const oldAttribute = oldAttributes[i]; + + // toAttributes is a live NamedNodeMap, so iteration+mutation is unsafe + // e.g. custom element attribute callbacks can remove other attributes + if (!oldAttribute) continue; + + if (!newElt.hasAttribute(oldAttribute.name)) { + if (ignoreAttribute(oldAttribute.name, oldElt, "remove", ctx)) { + continue; + } + oldElt.removeAttribute(oldAttribute.name); + } + } + + if (!ignoreValueOfActiveElement(oldElt, ctx)) { + syncInputValue(oldElt, newElt, ctx); + } + } + + // sync text nodes + if (type === 8 /* comment */ || type === 3 /* text */) { + if (oldNode.nodeValue !== newNode.nodeValue) { + oldNode.nodeValue = newNode.nodeValue; + } + } + } + + /** + * NB: many bothans died to bring us information: + * + * https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js + * https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113 + * + * @param {Element} oldElement the element to sync the input value to + * @param {Element} newElement the element to sync the input value from + * @param {MorphContext} ctx the merge context + */ + function syncInputValue(oldElement, newElement, ctx) { + if ( + oldElement instanceof HTMLInputElement && + newElement instanceof HTMLInputElement && + newElement.type !== "file" + ) { + let newValue = newElement.value; + let oldValue = oldElement.value; + + // sync boolean attributes + syncBooleanAttribute(oldElement, newElement, "checked", ctx); + syncBooleanAttribute(oldElement, newElement, "disabled", ctx); + + if (!newElement.hasAttribute("value")) { + if (!ignoreAttribute("value", oldElement, "remove", ctx)) { + oldElement.value = ""; + oldElement.removeAttribute("value"); + } + } else if (oldValue !== newValue) { + if (!ignoreAttribute("value", oldElement, "update", ctx)) { + oldElement.setAttribute("value", newValue); + oldElement.value = newValue; + } + } + // TODO: QUESTION(1cg): this used to only check `newElement` unlike the other branches -- why? + // did I break something? + } else if ( + oldElement instanceof HTMLOptionElement && + newElement instanceof HTMLOptionElement + ) { + syncBooleanAttribute(oldElement, newElement, "selected", ctx); + } else if ( + oldElement instanceof HTMLTextAreaElement && + newElement instanceof HTMLTextAreaElement + ) { + let newValue = newElement.value; + let oldValue = oldElement.value; + if (ignoreAttribute("value", oldElement, "update", ctx)) { + return; + } + if (newValue !== oldValue) { + oldElement.value = newValue; + } + if ( + oldElement.firstChild && + oldElement.firstChild.nodeValue !== newValue + ) { + oldElement.firstChild.nodeValue = newValue; + } + } + } + + /** + * @param {Element} oldElement element to write the value to + * @param {Element} newElement element to read the value from + * @param {string} attributeName the attribute name + * @param {MorphContext} ctx the merge context + */ + function syncBooleanAttribute(oldElement, newElement, attributeName, ctx) { + // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties + const newLiveValue = newElement[attributeName], + // @ts-ignore ditto + oldLiveValue = oldElement[attributeName]; + if (newLiveValue !== oldLiveValue) { + const ignoreUpdate = ignoreAttribute( + attributeName, + oldElement, + "update", + ctx, + ); + if (!ignoreUpdate) { + // update attribute's associated DOM property + // @ts-ignore this function is only used on boolean attrs that are reflected as dom properties + oldElement[attributeName] = newElement[attributeName]; + } + if (newLiveValue) { + if (!ignoreUpdate) { + // https://developer.mozilla.org/en-US/docs/Glossary/Boolean/HTML + // this is the correct way to set a boolean attribute to "true" + oldElement.setAttribute(attributeName, ""); + } + } else { + if (!ignoreAttribute(attributeName, oldElement, "remove", ctx)) { + oldElement.removeAttribute(attributeName); + } + } + } + } + + /** + * @param {string} attr the attribute to be mutated + * @param {Element} element the element that is going to be updated + * @param {"update" | "remove"} updateType + * @param {MorphContext} ctx the merge context + * @returns {boolean} true if the attribute should be ignored, false otherwise + */ + function ignoreAttribute(attr, element, updateType, ctx) { + if ( + attr === "value" && + ctx.ignoreActiveValue && + element === document.activeElement + ) { + return true; + } + return ( + ctx.callbacks.beforeAttributeUpdated(attr, element, updateType) === + false + ); + } + + /** + * @param {Node} possibleActiveElement + * @param {MorphContext} ctx + * @returns {boolean} + */ + function ignoreValueOfActiveElement(possibleActiveElement, ctx) { + return ( + !!ctx.ignoreActiveValue && + possibleActiveElement === document.activeElement && + possibleActiveElement !== document.body + ); + } + + return morphNode; + })(); + + //============================================================================= + // Head Management Functions + //============================================================================= + /** + * @param {MorphContext} ctx + * @param {Element} oldNode + * @param {Element} newNode + * @param {function} callback + * @returns {Node[] | Promise} + */ + function withHeadBlocking(ctx, oldNode, newNode, callback) { + if (ctx.head.block) { + const oldHead = oldNode.querySelector("head"); + const newHead = newNode.querySelector("head"); + if (oldHead && newHead) { + const promises = handleHeadElement(oldHead, newHead, ctx); + // when head promises resolve, proceed ignoring the head tag + return Promise.all(promises).then(() => { + const newCtx = Object.assign(ctx, { + head: { + block: false, + ignore: true, + }, + }); + return callback(newCtx); + }); + } + } + // just proceed if we not head blocking + return callback(ctx); + } + + /** + * The HEAD tag can be handled specially, either w/ a 'merge' or 'append' style + * + * @param {Element} oldHead + * @param {Element} newHead + * @param {MorphContext} ctx + * @returns {Promise[]} + */ + function handleHeadElement(oldHead, newHead, ctx) { + let added = []; + let removed = []; + let preserved = []; + let nodesToAppend = []; + + // put all new head elements into a Map, by their outerHTML + let srcToNewHeadNodes = new Map(); + for (const newHeadChild of newHead.children) { + srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild); + } + + // for each elt in the current head + for (const currentHeadElt of oldHead.children) { + // If the current head element is in the map + let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML); + let isReAppended = ctx.head.shouldReAppend(currentHeadElt); + let isPreserved = ctx.head.shouldPreserve(currentHeadElt); + if (inNewContent || isPreserved) { + if (isReAppended) { + // remove the current version and let the new version replace it and re-execute + removed.push(currentHeadElt); + } else { + // this element already exists and should not be re-appended, so remove it from + // the new content map, preserving it in the DOM + srcToNewHeadNodes.delete(currentHeadElt.outerHTML); + preserved.push(currentHeadElt); + } + } else { + if (ctx.head.style === "append") { + // we are appending and this existing element is not new content + // so if and only if it is marked for re-append do we do anything + if (isReAppended) { + removed.push(currentHeadElt); + nodesToAppend.push(currentHeadElt); + } + } else { + // if this is a merge, we remove this content since it is not in the new head + if (ctx.head.shouldRemove(currentHeadElt) !== false) { + removed.push(currentHeadElt); + } + } + } + } + + // Push the remaining new head elements in the Map into the + // nodes to append to the head tag + nodesToAppend.push(...srcToNewHeadNodes.values()); + + let promises = []; + for (const newNode of nodesToAppend) { + // TODO: This could theoretically be null, based on type + let newElt = /** @type {ChildNode} */ ( + document.createRange().createContextualFragment(newNode.outerHTML) + .firstChild + ); + if (ctx.callbacks.beforeNodeAdded(newElt) !== false) { + if ( + ("href" in newElt && newElt.href) || + ("src" in newElt && newElt.src) + ) { + /** @type {(result?: any) => void} */ let resolve; + let promise = new Promise(function (_resolve) { + resolve = _resolve; + }); + newElt.addEventListener("load", function () { + resolve(); + }); + promises.push(promise); + } + oldHead.appendChild(newElt); + ctx.callbacks.afterNodeAdded(newElt); + added.push(newElt); + } + } + + // remove all removed elements, after we have appended the new elements to avoid + // additional network requests for things like style sheets + for (const removedElement of removed) { + if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) { + oldHead.removeChild(removedElement); + ctx.callbacks.afterNodeRemoved(removedElement); + } + } + + ctx.head.afterHeadMorphed(oldHead, { + added: added, + kept: preserved, + removed: removed, + }); + return promises; + } + + //============================================================================= + // Create Morph Context Functions + //============================================================================= + const createMorphContext = (function () { + /** + * + * @param {Element} oldNode + * @param {Element} newContent + * @param {Config} config + * @returns {MorphContext} + */ + function createMorphContext(oldNode, newContent, config) { + const { persistentIds, idMap } = createIdMaps(oldNode, newContent); + + const mergedConfig = mergeDefaults(config); + const morphStyle = mergedConfig.morphStyle || "outerHTML"; + if (!["innerHTML", "outerHTML"].includes(morphStyle)) { + throw `Do not understand how to morph style ${morphStyle}`; + } + + return { + target: oldNode, + newContent: newContent, + config: mergedConfig, + morphStyle: morphStyle, + ignoreActive: mergedConfig.ignoreActive, + ignoreActiveValue: mergedConfig.ignoreActiveValue, + restoreFocus: mergedConfig.restoreFocus, + idMap: idMap, + persistentIds: persistentIds, + pantry: createPantry(), + activeElementAndParents: createActiveElementAndParents(oldNode), + callbacks: mergedConfig.callbacks, + head: mergedConfig.head, + }; + } + + /** + * Deep merges the config object and the Idiomorph.defaults object to + * produce a final configuration object + * @param {Config} config + * @returns {ConfigInternal} + */ + function mergeDefaults(config) { + let finalConfig = Object.assign({}, defaults); + + // copy top level stuff into final config + Object.assign(finalConfig, config); + + // copy callbacks into final config (do this to deep merge the callbacks) + finalConfig.callbacks = Object.assign( + {}, + defaults.callbacks, + config.callbacks, + ); + + // copy head config into final config (do this to deep merge the head) + finalConfig.head = Object.assign({}, defaults.head, config.head); + + return finalConfig; + } + + /** + * @returns {HTMLDivElement} + */ + function createPantry() { + const pantry = document.createElement("div"); + pantry.hidden = true; + document.body.insertAdjacentElement("afterend", pantry); + return pantry; + } + + /** + * @param {Element} oldNode + * @returns {Element[]} + */ + function createActiveElementAndParents(oldNode) { + /** @type {Element[]} */ + let activeElementAndParents = []; + let elt = document.activeElement; + if (elt?.tagName !== "BODY" && oldNode.contains(elt)) { + while (elt) { + activeElementAndParents.push(elt); + if (elt === oldNode) break; + elt = elt.parentElement; + } + } + return activeElementAndParents; + } + + /** + * Returns all elements with an ID contained within the root element and its descendants + * + * @param {Element} root + * @returns {Element[]} + */ + function findIdElements(root) { + let elements = Array.from(root.querySelectorAll("[id]")); + // root could be a document fragment which doesn't have `getAttribute` + if (root.getAttribute?.("id")) { + elements.push(root); + } + return elements; + } + + /** + * A bottom-up algorithm that populates a map of Element -> IdSet. + * The idSet for a given element is the set of all IDs contained within its subtree. + * As an optimzation, we filter these IDs through the given list of persistent IDs, + * because we don't need to bother considering IDed elements that won't be in the new content. + * + * @param {Map>} idMap + * @param {Set} persistentIds + * @param {Element} root + * @param {Element[]} elements + */ + function populateIdMapWithTree(idMap, persistentIds, root, elements) { + for (const elt of elements) { + // we can pretend id is non-null String, because the .has line will reject it immediately if not + const id = /** @type {String} */ (elt.getAttribute("id")); + if (persistentIds.has(id)) { + /** @type {Element|null} */ + let current = elt; + // walk up the parent hierarchy of that element, adding the id + // of element to the parent's id set + while (current) { + let idSet = idMap.get(current); + // if the id set doesn't exist, create it and insert it in the map + if (idSet == null) { + idSet = new Set(); + idMap.set(current, idSet); + } + idSet.add(id); + + if (current === root) break; + current = current.parentElement; + } + } + } + } + + /** + * This function computes a map of nodes to all ids contained within that node (inclusive of the + * node). This map can be used to ask if two nodes have intersecting sets of ids, which allows + * for a looser definition of "matching" than tradition id matching, and allows child nodes + * to contribute to a parent nodes matching. + * + * @param {Element} oldContent the old content that will be morphed + * @param {Element} newContent the new content to morph to + * @returns {IdSets} + */ + function createIdMaps(oldContent, newContent) { + const oldIdElements = findIdElements(oldContent); + const newIdElements = findIdElements(newContent); + + const persistentIds = createPersistentIds(oldIdElements, newIdElements); + + /** @type {Map>} */ + let idMap = new Map(); + populateIdMapWithTree(idMap, persistentIds, oldContent, oldIdElements); + + /** @ts-ignore - if newContent is a duck-typed parent, pass its single child node as the root to halt upwards iteration */ + const newRoot = newContent.__idiomorphRoot || newContent; + populateIdMapWithTree(idMap, persistentIds, newRoot, newIdElements); + + return { persistentIds, idMap }; + } + + /** + * This function computes the set of ids that persist between the two contents excluding duplicates + * + * @param {Element[]} oldIdElements + * @param {Element[]} newIdElements + * @returns {Set} + */ + function createPersistentIds(oldIdElements, newIdElements) { + let duplicateIds = new Set(); + + /** @type {Map} */ + let oldIdTagNameMap = new Map(); + for (const { id, tagName } of oldIdElements) { + if (oldIdTagNameMap.has(id)) { + duplicateIds.add(id); + } else { + oldIdTagNameMap.set(id, tagName); + } + } + + let persistentIds = new Set(); + for (const { id, tagName } of newIdElements) { + if (persistentIds.has(id)) { + duplicateIds.add(id); + } else if (oldIdTagNameMap.get(id) === tagName) { + persistentIds.add(id); + } + // skip if tag types mismatch because its not possible to morph one tag into another + } + + for (const id of duplicateIds) { + persistentIds.delete(id); + } + return persistentIds; + } + + return createMorphContext; + })(); + + //============================================================================= + // HTML Normalization Functions + //============================================================================= + const { normalizeElement, normalizeParent } = (function () { + /** @type {WeakSet} */ + const generatedByIdiomorph = new WeakSet(); + + /** + * + * @param {Element | Document} content + * @returns {Element} + */ + function normalizeElement(content) { + if (content instanceof Document) { + return content.documentElement; + } else { + return content; + } + } + + /** + * + * @param {null | string | Node | HTMLCollection | Node[] | Document & {generatedByIdiomorph:boolean}} newContent + * @returns {Element} + */ + function normalizeParent(newContent) { + if (newContent == null) { + return document.createElement("div"); // dummy parent element + } else if (typeof newContent === "string") { + return normalizeParent(parseContent(newContent)); + } else if ( + generatedByIdiomorph.has(/** @type {Element} */ (newContent)) + ) { + // the template tag created by idiomorph parsing can serve as a dummy parent + return /** @type {Element} */ (newContent); + } else if (newContent instanceof Node) { + if (newContent.parentNode) { + // we can't use the parent directly because newContent may have siblings + // that we don't want in the morph, and reparenting might be expensive (TODO is it?), + // so instead we create a fake parent node that only sees a slice of its children. + /** @type {Element} */ + return /** @type {any} */ (new SlicedParentNode(newContent)); + } else { + // a single node is added as a child to a dummy parent + const dummyParent = document.createElement("div"); + dummyParent.append(newContent); + return dummyParent; + } + } else { + // all nodes in the array or HTMLElement collection are consolidated under + // a single dummy parent element + const dummyParent = document.createElement("div"); + for (const elt of [...newContent]) { + dummyParent.append(elt); + } + return dummyParent; + } + } + + /** + * A fake duck-typed parent element to wrap a single node, without actually reparenting it. + * This is useful because the node may have siblings that we don't want in the morph, and it may also be moved + * or replaced with one or more elements during the morph. This class effectively allows us a window into + * a slice of a node's children. + * "If it walks like a duck, and quacks like a duck, then it must be a duck!" -- James Whitcomb Riley (1849–1916) + */ + class SlicedParentNode { + /** @param {Node} node */ + constructor(node) { + this.originalNode = node; + this.realParentNode = /** @type {Element} */ (node.parentNode); + this.previousSibling = node.previousSibling; + this.nextSibling = node.nextSibling; + } + + /** @returns {Node[]} */ + get childNodes() { + // return slice of realParent's current childNodes, based on previousSibling and nextSibling + const nodes = []; + let cursor = this.previousSibling + ? this.previousSibling.nextSibling + : this.realParentNode.firstChild; + while (cursor && cursor != this.nextSibling) { + nodes.push(cursor); + cursor = cursor.nextSibling; + } + return nodes; + } + + /** + * @param {string} selector + * @returns {Element[]} + */ + querySelectorAll(selector) { + return this.childNodes.reduce((results, node) => { + if (node instanceof Element) { + if (node.matches(selector)) results.push(node); + const nodeList = node.querySelectorAll(selector); + for (let i = 0; i < nodeList.length; i++) { + results.push(nodeList[i]); + } + } + return results; + }, /** @type {Element[]} */ ([])); + } + + /** + * @param {Node} node + * @param {Node} referenceNode + * @returns {Node} + */ + insertBefore(node, referenceNode) { + return this.realParentNode.insertBefore(node, referenceNode); + } + + /** + * @param {Node} node + * @param {Node} referenceNode + * @returns {Node} + */ + moveBefore(node, referenceNode) { + // @ts-ignore - use new moveBefore feature + return this.realParentNode.moveBefore(node, referenceNode); + } + + /** + * for later use with populateIdMapWithTree to halt upwards iteration + * @returns {Node} + */ + get __idiomorphRoot() { + return this.originalNode; + } + } + + /** + * + * @param {string} newContent + * @returns {Node | null | DocumentFragment} + */ + function parseContent(newContent) { + let parser = new DOMParser(); + + // remove svgs to avoid false-positive matches on head, etc. + let contentWithSvgsRemoved = newContent.replace( + /]*>|>)([\s\S]*?)<\/svg>/gim, + "", + ); + + // if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping + if ( + contentWithSvgsRemoved.match(/<\/html>/) || + contentWithSvgsRemoved.match(/<\/head>/) || + contentWithSvgsRemoved.match(/<\/body>/) + ) { + let content = parser.parseFromString(newContent, "text/html"); + // if it is a full HTML document, return the document itself as the parent container + if (contentWithSvgsRemoved.match(/<\/html>/)) { + generatedByIdiomorph.add(content); + return content; + } else { + // otherwise return the html element as the parent container + let htmlElement = content.firstChild; + if (htmlElement) { + generatedByIdiomorph.add(htmlElement); + } + return htmlElement; + } + } else { + // if it is partial HTML, wrap it in a template tag to provide a parent element and also to help + // deal with touchy tags like tr, tbody, etc. + let responseDoc = parser.parseFromString( + "", + "text/html", + ); + let content = /** @type {HTMLTemplateElement} */ ( + responseDoc.body.querySelector("template") + ).content; + generatedByIdiomorph.add(content); + return content; + } + } + + return { normalizeElement, normalizeParent }; + })(); + + //============================================================================= + // This is what ends up becoming the Idiomorph global object + //============================================================================= + return { + morph, + defaults, + }; +})(); +(function () { + function createMorphConfig(swapStyle) { + if (swapStyle === "morph" || swapStyle === "morph:outerHTML") { + return { morphStyle: "outerHTML" }; + } else if (swapStyle === "morph:innerHTML") { + return { morphStyle: "innerHTML" }; + } else if (swapStyle.startsWith("morph:")) { + return Function("return (" + swapStyle.slice(6) + ")")(); + } + } + + htmx.defineExtension("morph", { + isInlineSwap: function (swapStyle) { + let config = createMorphConfig(swapStyle); + return config?.morphStyle === "outerHTML" || config?.morphStyle == null; + }, + handleSwap: function (swapStyle, target, fragment) { + let config = createMorphConfig(swapStyle); + if (config) { + return Idiomorph.morph(target, fragment.children, config); + } + }, + }); +})(); diff --git a/styles/index.css b/styles/index.css index 90c57a6..e974ec6 100644 --- a/styles/index.css +++ b/styles/index.css @@ -16,8 +16,7 @@ --text-xl: 1.25rem; --text-xl--line-height: calc(1.75 / 1.25); --text-3xl: 1.875rem; - --text-5xl: 3rem; - --text-8xl: 6rem; + --text-6xl: 3.75rem; --font-weight-semibold: 600; --font-weight-bold: 700; --radius-lg: var(--radius); @@ -187,6 +186,30 @@ .static { position: static; } + .col-2 { + grid-column: 2; + } + .col-3 { + grid-column: 3; + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } .m-\[1em\] { margin: 1em; } @@ -220,12 +243,18 @@ .flex { display: flex; } + .grid { + display: grid; + } .hidden { display: none; } .table { display: table; } + .max-h-full { + max-height: 100%; + } .min-h-full { min-height: 100%; } @@ -244,6 +273,12 @@ .grow { flex-grow: 1; } + .grow-1 { + flex-grow: 1; + } + .basis-\[fit-content\] { + flex-basis: fit-content; + } .basis-full { flex-basis: 100%; } @@ -253,6 +288,9 @@ .resize { resize: both; } + .grid-cols-\[6rem_10fr_6rem\] { + grid-template-columns: 6rem 10fr 6rem; + } .flex-col { flex-direction: column; } @@ -271,8 +309,8 @@ .overflow-x-auto { overflow-x: auto; } - .overflow-x-scroll { - overflow-x: scroll; + .overflow-x-hidden { + overflow-x: hidden; } .overflow-y-hidden { overflow-y: hidden; @@ -302,9 +340,6 @@ .p-\[1em\] { padding: 1em; } - .p-\[2em\] { - padding: 2em; - } .pt-\[1em\] { padding-top: 1em; } @@ -314,6 +349,9 @@ .text-center { text-align: center; } + .font-display { + font-family: var(--display-family); + } .text-lg { font-size: var(--text-lg); line-height: var(--tw-leading, var(--text-lg--line-height)); @@ -380,6 +418,16 @@ text-decoration-line: none; } } + .min-\[400px\]\:px-\[calc\(10vw-0\.5em\)\] { + @media (width >= 400px) { + padding-inline: calc(10vw - 0.5em); + } + } + .min-\[400px\]\:py-\[calc\(8vw-0\.5em\)\] { + @media (width >= 400px) { + padding-block: calc(8vw - 0.5em); + } + } } @layer base { select { @@ -560,23 +608,23 @@ font-weight: var(--display-weight); } h1 { - font-size: var(--text-8xl); + font-size: var(--text-6xl); } h2 { - font-size: var(--text-5xl); - } - h3 { font-size: var(--text-3xl); } - h4 { + h3 { font-size: var(--text-xl); } - h5 { + h4 { font-size: var(--text-lg); } - h6 { + h5 { font-size: var(--text-md); } + h6 { + font-size: var(--text-sm); + } ul, ol { list-style: none; } diff --git a/styles/typography.css b/styles/typography.css index 35095e5..2581676 100644 --- a/styles/typography.css +++ b/styles/typography.css @@ -272,23 +272,23 @@ } h1 { - font-size: var(--text-8xl); + font-size: var(--text-6xl); } h2 { - font-size: var(--text-5xl); - } - h3 { font-size: var(--text-3xl); } - h4 { + h3 { font-size: var(--text-xl); } - h5 { + h4 { font-size: var(--text-lg); } - h6 { + h5 { font-size: var(--text-md); } + h6 { + font-size: var(--text-sm); + } ul, ol { list-style: none; diff --git a/templates/components/accounts/{acctID.int64}/inventory/sync-groups/draft/table/add-listing-button.html.tmpl b/templates/components/accounts/{acctID.int64}/inventory/sync-groups/draft/table/add-listing-button.html.tmpl index f9b43dc..42a0b03 100644 --- a/templates/components/accounts/{acctID.int64}/inventory/sync-groups/draft/table/add-listing-button.html.tmpl +++ b/templates/components/accounts/{acctID.int64}/inventory/sync-groups/draft/table/add-listing-button.html.tmpl @@ -5,6 +5,7 @@ "HXPost" (printf "/api/accounts/%d/inventory/sync-groups/draft/listings" $acctID) "HXTarget" "#accounts-acct-id-inventory-sync-groups-draft-table" "HXSwap" "outerHTML" + "HXSelect" "#accounts-acct-id-inventory-sync-groups-draft-table" "ID" "sync-groups-draft-table-add-listing-button" "Class" "mt-[1em] mb-[1em]" "Text" "Add Listing" diff --git a/templates/components/accounts/{acctID.int64}/inventory/sync-groups/draft/table/index.html.tmpl b/templates/components/accounts/{acctID.int64}/inventory/sync-groups/draft/table/index.html.tmpl index f9c1524..6e2fbbe 100644 --- a/templates/components/accounts/{acctID.int64}/inventory/sync-groups/draft/table/index.html.tmpl +++ b/templates/components/accounts/{acctID.int64}/inventory/sync-groups/draft/table/index.html.tmpl @@ -8,7 +8,9 @@ id="accounts-acct-id-inventory-sync-groups-draft-table" class="max-w-full overflow-x-auto" hx-get="{{ printf "/ui/accounts/%d/inventory/sync-groups/draft/table" $acctID }}" - hx-trigger="sse:{{ printf "accounts_%d_inventory_sync-groups_draft" $acctID }}" + hx-trigger="sse:{{ printf "accounts_%d_inventory_sync-groups_draft" $acctID }},sse:{{ printf "accounts_%d_inventory_sync-groups_draft_listings" $acctID }}" + hx-select="#accounts-acct-id-inventory-sync-groups-draft-table" + hx-swap="outerHTML" > @@ -30,6 +32,7 @@ + + + + + + {{/* This can be set by the template in ./pages being rendered */}} {{- block "head" . }}{{ end }}
-