diff --git a/src/assets/spawn_message-hover.png b/src/assets/spawn_message-hover.png new file mode 100644 index 0000000000000000000000000000000000000000..ce99dd993b8f655ab0c1552726681dd40537b557 Binary files /dev/null and b/src/assets/spawn_message-hover.png differ diff --git a/src/assets/spawn_message.png b/src/assets/spawn_message.png new file mode 100644 index 0000000000000000000000000000000000000000..8f6ce1e2064e6ec10e00cc64b99d0660e4ed1ee3 Binary files /dev/null and b/src/assets/spawn_message.png differ diff --git a/src/assets/stylesheets/presence-log.scss b/src/assets/stylesheets/presence-log.scss index bed6d5cd50cae9b07dd406009c01917ac48477d8..aefc553c9e9a959081ade436eb7b5383306e521c 100644 --- a/src/assets/stylesheets/presence-log.scss +++ b/src/assets/stylesheets/presence-log.scss @@ -25,7 +25,36 @@ margin: 8px 64px 8px 16px; font-size: 0.8em; padding: 8px 16px; - border-radius: 16px; + border-radius: 20px; + display: flex; + align-items: center; + + :local(.message-body) { + margin-left: 4px; + white-space: pre; + } + + :local(.message-body-multi) { + margin-left: 0px; + } + + :local(.message-body-mono) { + font-family: monospace; + font-size: 14px; + } + + :local(.message-wrap) { + display: flex; + align-items: center; + justify-content: center; + } + + :local(.message-wrap-multi) { + display: flex; + align-items: flex-start; + justify-content: center; + flex-direction: column; + } a { color: $action-color; @@ -35,6 +64,29 @@ max-width: 75%; } + :local(.spawn-message) { + appearance: none; + -moz-appearance: none; + -webkit-appearance: none; + outline-style: none; + width: 24px; + height: 24px; + background-size: 100%; + border: 0; + display: flex; + justify-content: center; + align-items: center; + align-self: flex-start; + cursor: pointer; + background-image: url(../spawn_message.png); + margin-right: 6px; + background-color: transparent; + } + + :local(.spawn-message):hover { + background-image: url(../spawn_message-hover.png); + } + &:local(.media) { display: flex; align-items: center; @@ -62,26 +114,66 @@ transition: visibility 0s 0.5s, opacity 0.5s linear, transform 0.5s; } + :local(.presence-log-entry-with-button) { + padding: 8px 18px 8px 10px; + } } :local(.presence-log-in-room) { - max-height: 200px; - - @media(min-height: 800px) and (min-width: 600px) { - max-height: 400px; - } - position: absolute; bottom: 165px; :local(.presence-log-entry) { background-color: $hud-panel-background; color: $light-text; + min-height: 18px; user-select: none; -moz-user-select: none; -webkit-user-select: none; -ms-user-select: none; + + a { + color: white; + } + } +} + +:local(.presence-log-spawn) { + position: absolute; + top: 0; + z-index: -10; + width: auto; + margin: 0; + + :local(.presence-log-entry) { + background-color: white; + color: black; + min-height: 18px; + padding: 8px 16px; + border-radius: 16px; + line-height: 18px; + margin: 0; + + :local(.message-body) { + margin-left: 0; + } + + a { + color: white; + } + } + + :local(.presence-log-entry-one-line) { + font-weight: bold; + line-height: 19px; + text-align: center; + } + + :local(.presence-log-emoji) { + background-color: transparent; + padding: 0; + margin: 0; } } diff --git a/src/assets/stylesheets/ui-root.scss b/src/assets/stylesheets/ui-root.scss index 11de7f546b713ca297e99e92e090bb355e55e334..b7761c48d938ea6e19504ef940bf84ad3a718c37 100644 --- a/src/assets/stylesheets/ui-root.scss +++ b/src/assets/stylesheets/ui-root.scss @@ -204,10 +204,12 @@ -moz-appearance: none; -webkit-appearance: none; outline-style: none; + overflow: hidden; + resize: none; background-color: transparent; color: black; padding: 8px 1.25em; - line-height: 2em; + line-height: 28px; font-size: 1.1em; width: 100%; border: 0px; @@ -224,9 +226,11 @@ :local(.message-entry-submit) { @extend %action-button; position: absolute; - right: 12px; + right: 10px; height: 32px; min-width: 80px; + bottom: 8px; + border-radius: 10px; } :local(.message-entry-in-room) { @@ -246,11 +250,29 @@ border-radius: 16px; pointer-events: auto; opacity: 0.3; - transition: opacity 0.25s linear; + transition: opacity 0.15s linear; :local(.message-entry-input-in-room) { color: white; padding: 8px 1.25em; + margin-left: 32px; + } + + :local(.message-entry-spawn) { + @extend %action-button; + position: absolute; + left: 12px; + height: 32px; + width: 32px; + bottom: 8px; + min-width: auto; + background-size: 90%; + background-image: url(../spawn_message.png); + background-position-x: 1px; + background-position-y: 1px; + padding: 0; + border-radius: 16px; + visibility: hidden; } :local(.message-entry-submit-in-room) { @@ -259,11 +281,15 @@ } } -:local(.message-entry-in-room):hover { +:local(.message-entry-in-room):focus-within { opacity: 1.0; - transition: opacity 0.25s linear; + transition: opacity 0.15s linear; :local(.message-entry-submit-in-room) { visibility: visible; } + + :local(.message-entry-spawn) { + visibility: visible; + } } diff --git a/src/hub.html b/src/hub.html index 99232684d4d2da80d13a3e953ffad076f3eeb748..fc27508c6827eca9eb4e72b2638cd75e8ec5bb61 100644 --- a/src/hub.html +++ b/src/hub.html @@ -32,6 +32,7 @@ vr-mode-ui="enabled: false" stats-plus="false" action-to-event__mute="path: /actions/muteMic; event: action_mute;" + action-to-event__focus_chat="path: /actions/focusChat; event: action_focus_chat;" > <a-assets> diff --git a/src/hub.js b/src/hub.js index 93221603b2ed89a4ef85e698d4eee5c842766cd1..f16044fa877f9afc61ee9c4b4b5a7aedd321a5fa 100644 --- a/src/hub.js +++ b/src/hub.js @@ -316,6 +316,7 @@ document.addEventListener("DOMContentLoaded", async () => { window.APP.scene = scene; registerNetworkSchemas(); + remountUI({ hubChannel, linkChannel, @@ -325,6 +326,8 @@ document.addEventListener("DOMContentLoaded", async () => { initialIsSubscribed: subscriptions.isSubscribed() }); + scene.addEventListener("action_focus_chat", () => document.querySelector(".chat-focus-target").focus()); + pollForSupportAvailability(isSupportAvailable => remountUI({ isSupportAvailable })); const platformUnsupportedReason = getPlatformUnsupportedReason(); @@ -501,8 +504,9 @@ document.addEventListener("DOMContentLoaded", async () => { hubPhxChannel.on("message", ({ session_id, type, body }) => { const userInfo = hubPhxPresence.state[session_id]; if (!userInfo) return; + const maySpawn = scene.is("entered"); - addToPresenceLog({ name: userInfo.metas[0].profile.displayName, type, body }); + addToPresenceLog({ name: userInfo.metas[0].profile.displayName, type, body, maySpawn }); }); linkChannel.setSocket(socket); diff --git a/src/react-components/chat-message.js b/src/react-components/chat-message.js new file mode 100644 index 0000000000000000000000000000000000000000..992f311f571298b4a2794c803ad56c28a70b42b6 --- /dev/null +++ b/src/react-components/chat-message.js @@ -0,0 +1,113 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import PropTypes from "prop-types"; +import styles from "../assets/stylesheets/presence-log.scss"; +import classNames from "classnames"; +import Linkify from "react-linkify"; +import { toArray as toEmojis } from "react-emoji-render"; +import serializeElement from "../utils/serialize-element"; + +const messageCanvas = document.createElement("canvas"); +const emojiRegex = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud800-\udbff][\udc00-\udfff]|[\u0023-\u0039]\ufe0f?\u20e3|\u3299|\u3297|\u303d|\u3030|\u24c2|\ud83c[\udd70-\udd71]|\ud83c[\udd7e-\udd7f]|\ud83c\udd8e|\ud83c[\udd91-\udd9a]|\ud83c[\udde6-\uddff]|[\ud83c[\ude01-\ude02]|\ud83c\ude1a|\ud83c\ude2f|[\ud83c[\ude32-\ude3a]|[\ud83c[\ude50-\ude51]|\u203c|\u2049|[\u25aa-\u25ab]|\u25b6|\u25c0|[\u25fb-\u25fe]|\u00a9|\u00ae|\u2122|\u2139|\ud83c\udc04|[\u2600-\u26FF]|\u2b05|\u2b06|\u2b07|\u2b1b|\u2b1c|\u2b50|\u2b55|\u231a|\u231b|\u2328|\u23cf|[\u23e9-\u23f3]|[\u23f8-\u23fa]|\ud83c\udccf|\u2934|\u2935|[\u2190-\u21ff])/; +const urlRegex = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)$/; + +const messageBodyDom = body => { + // Support wrapping text in ` to get monospace, and multiline. + const multiLine = body.split("\n").length > 1; + const mono = body.startsWith("`") && body.endsWith("`"); + const messageBodyClasses = { + [styles.messageBody]: true, + [styles.messageBodyMulti]: multiLine, + [styles.messageBodyMono]: mono + }; + + const cleanedBody = (mono ? body.substring(1, body.length - 1) : body).trim(); + + return ( + <div className={classNames(messageBodyClasses)}> + <Linkify properties={{ target: "_blank", rel: "noopener referrer" }}>{toEmojis(cleanedBody)}</Linkify> + </div> + ); +}; + +export function spawnChatMessage(body) { + if (body.length === 0) return; + + if (body.match(urlRegex)) { + document.querySelector("a-scene").emit("add_media", body); + return; + } + + const isOneLine = body.split("\n").length === 1; + const context = messageCanvas.getContext("2d"); + const emoji = toEmojis(body); + const isEmoji = + emoji.length === 1 && emoji[0].props && emoji[0].props.children.match && emoji[0].props.children.match(emojiRegex); + + const el = document.createElement("div"); + el.setAttribute("xmlns", "http://www.w3.org/1999/xhtml"); + el.setAttribute("class", `${styles.presenceLog} ${styles.presenceLogSpawn}`); + + // The element is added to the DOM in order to have layout compute the width & height, + // and then it is removed after being rendered. + document.body.appendChild(el); + + const entryDom = ( + <div + className={classNames({ + [styles.presenceLogEntry]: !isEmoji, + [styles.presenceLogEntryOneLine]: !isEmoji && isOneLine, + [styles.presenceLogEmoji]: isEmoji + })} + > + {messageBodyDom(body)} + </div> + ); + + ReactDOM.render(entryDom, el, () => { + // Scale by 12x + messageCanvas.width = el.offsetWidth * 12.1; + messageCanvas.height = el.offsetHeight * 12.1; + + const xhtml = encodeURIComponent(` + <svg xmlns="http://www.w3.org/2000/svg" width="${messageCanvas.width}" height="${messageCanvas.height}"> + <foreignObject width="8.333%" height="8.333%" style="transform: scale(12.0);"> + ${serializeElement(el)} + </foreignObject> + </svg> + `); + const img = new Image(); + + img.onload = async () => { + context.drawImage(img, 0, 0); + const blob = await new Promise(resolve => messageCanvas.toBlob(resolve)); + document.querySelector("a-scene").emit("add_media", new File([blob], "message.png", { type: "image/png" })); + el.parentNode.removeChild(el); + }; + + img.src = "data:image/svg+xml," + xhtml; + }); +} + +export default function ChatMessage(props) { + const isOneLine = props.body.split("\n").length === 1; + + return ( + <div className={props.className}> + {props.maySpawn && <button className={styles.spawnMessage} onClick={() => spawnChatMessage(props.body)} />} + <div className={isOneLine ? styles.messageWrap : styles.messageWrapMulti}> + <div className={styles.messageSource}> + <b>{props.name}</b>: + </div> + {messageBodyDom(props.body)} + </div> + </div> + ); +} + +ChatMessage.propTypes = { + name: PropTypes.string, + maySpawn: PropTypes.bool, + body: PropTypes.string, + className: PropTypes.string +}; diff --git a/src/react-components/presence-log.js b/src/react-components/presence-log.js index d46550957f1eb7918b9310263ada1a2f64537dfb..491b98aa03d5586af55622ef905418f667773adc 100644 --- a/src/react-components/presence-log.js +++ b/src/react-components/presence-log.js @@ -2,9 +2,8 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; import styles from "../assets/stylesheets/presence-log.scss"; import classNames from "classnames"; -import Linkify from "react-linkify"; -import { toArray as toEmojis } from "react-emoji-render"; import { FormattedMessage } from "react-intl"; +import ChatMessage from "./chat-message"; export default class PresenceLog extends Component { static propTypes = { @@ -19,6 +18,8 @@ export default class PresenceLog extends Component { domForEntry = e => { const entryClasses = { [styles.presenceLogEntry]: true, + [styles.presenceLogEntryWithButton]: e.type === "chat" && e.maySpawn, + [styles.presenceLogChat]: e.type === "chat", [styles.expired]: !!e.expired }; @@ -27,27 +28,30 @@ export default class PresenceLog extends Component { case "entered": return ( <div key={e.key} className={classNames(entryClasses)}> - <b>{e.name}</b> <FormattedMessage id={`presence.${e.type}_${e.presence}`} /> + <b>{e.name}</b> <FormattedMessage id={`presence.${e.type}_${e.presence}`} /> </div> ); case "leave": return ( <div key={e.key} className={classNames(entryClasses)}> - <b>{e.name}</b> <FormattedMessage id={`presence.${e.type}`} /> + <b>{e.name}</b> <FormattedMessage id={`presence.${e.type}`} /> </div> ); case "display_name_changed": return ( <div key={e.key} className={classNames(entryClasses)}> - <b>{e.oldName}</b> <FormattedMessage id="presence.name_change" /> <b>{e.newName}</b>. + <b>{e.oldName}</b> <FormattedMessage id="presence.name_change" /> <b>{e.newName}</b>. </div> ); case "chat": return ( - <div key={e.key} className={classNames(entryClasses)}> - <b>{e.name}</b>:{" "} - <Linkify properties={{ target: "_blank", rel: "noopener referrer" }}>{toEmojis(e.body)}</Linkify> - </div> + <ChatMessage + key={e.key} + name={e.name} + className={classNames(entryClasses)} + body={e.body} + maySpawn={e.maySpawn} + /> ); case "spawn": { const { src } = e.body; @@ -58,13 +62,15 @@ export default class PresenceLog extends Component { </a> <div className={styles.mediaBody}> <span> - <b>{e.name}</b>: + <b>{e.name}</b> </span> <span> {"took a "} - <a href={src} target="_blank" rel="noopener noreferrer"> - photo - </a> + <b> + <a href={src} target="_blank" rel="noopener noreferrer"> + photo + </a> + </b>. </span> </div> </div> diff --git a/src/react-components/ui-root.js b/src/react-components/ui-root.js index fe99b50763f2aa4d7cc9394b8a8ecd19565754bf..7a3987d2bb15f8b8dfa6ec8fafcdbac88d8478c4 100644 --- a/src/react-components/ui-root.js +++ b/src/react-components/ui-root.js @@ -30,6 +30,7 @@ import CreateObjectDialog from "./create-object-dialog.js"; import PresenceLog from "./presence-log.js"; import PresenceList from "./presence-list.js"; import TwoDHUD from "./2d-hud"; +import { spawnChatMessage } from "./chat-message"; import { faUsers } from "@fortawesome/free-solid-svg-icons/faUsers"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -454,7 +455,7 @@ class UIRoot extends Component { if (!this.props.forcedVREntryType || !this.props.forcedVREntryType.endsWith("_now")) { this.goToEntryStep(ENTRY_STEPS.audio_setup); } else { - setTimeout(this.onAudioReadyButton, 3000); // Need to wait otherwise input doesn't work :/ + this.onAudioReadyButton(); } }; @@ -687,6 +688,9 @@ class UIRoot extends Component { }; renderEntryStartPanel = () => { + const textRows = this.state.pendingMessage.split("\n").length; + const pendingMessageTextareaHeight = textRows * 28 + "px"; + const pendingMessageFieldHeight = textRows * 28 + 20 + "px"; const hasPush = navigator.serviceWorker && "PushManager" in window; return ( @@ -700,12 +704,21 @@ class UIRoot extends Component { </div> <form onSubmit={this.sendMessage}> - <div className={styles.messageEntry}> - <input - className={styles.messageEntryInput} + <div className={styles.messageEntry} style={{ height: pendingMessageFieldHeight }}> + <textarea + className={classNames([styles.messageEntryInput, "chat-focus-target"])} value={this.state.pendingMessage} + rows={textRows} + style={{ height: pendingMessageTextareaHeight }} onFocus={e => e.target.select()} onChange={e => this.setState({ pendingMessage: e.target.value })} + onKeyDown={e => { + if (e.keyCode === 13 && !e.shiftKey) { + this.sendMessage(e); + } else if (e.keyCode === 27) { + e.target.blur(); + } + }} placeholder="Send a message..." /> <input className={styles.messageEntrySubmit} type="submit" value="send" /> @@ -999,6 +1012,10 @@ class UIRoot extends Component { const entryFinished = this.state.entryStep === ENTRY_STEPS.finished; const showVREntryButton = entryFinished && this.props.availableVREntryTypes.isInHMD; + const textRows = this.state.pendingMessage.split("\n").length; + const pendingMessageTextareaHeight = textRows * 28 + "px"; + const pendingMessageFieldHeight = textRows * 28 + 20 + "px"; + return ( <IntlProvider locale={lang} messages={messages}> <div className={styles.ui}> @@ -1018,15 +1035,31 @@ class UIRoot extends Component { {entryFinished && <PresenceLog inRoom={true} entries={this.props.presenceLogEntries || []} />} {entryFinished && ( <form onSubmit={this.sendMessage}> - <div className={styles.messageEntryInRoom}> - <input - className={classNames([styles.messageEntryInput, styles.messageEntryInputInRoom])} + <div className={styles.messageEntryInRoom} style={{ height: pendingMessageFieldHeight }}> + <textarea + style={{ height: pendingMessageTextareaHeight }} + className={classNames([ + styles.messageEntryInput, + styles.messageEntryInputInRoom, + "chat-focus-target" + ])} value={this.state.pendingMessage} + rows={textRows} onFocus={e => e.target.select()} onChange={e => { e.stopPropagation(); this.setState({ pendingMessage: e.target.value }); }} + onKeyDown={e => { + if (e.keyCode === 13 && !e.shiftKey) { + this.sendMessage(e); + } else if (e.keyCode === 13 && e.shiftKey && e.ctrlKey) { + spawnChatMessage(e.target.value); + this.setState({ pendingMessage: "" }); + } else if (e.keyCode === 27) { + e.target.blur(); + } + }} placeholder="Send a message..." /> <input @@ -1034,6 +1067,17 @@ class UIRoot extends Component { type="submit" value="send" /> + <button + className={classNames([styles.messageEntrySpawn])} + onClick={() => { + if (this.state.pendingMessage.length > 0) { + spawnChatMessage(this.state.pendingMessage); + this.setState({ pendingMessage: "" }); + } else { + this.showCreateObjectDialog(); + } + }} + /> </div> </form> )} diff --git a/src/scene-entry-manager.js b/src/scene-entry-manager.js index 6974195ca86bd67bd6f60d2c75f67e9593d7f744..f109520adbee6f715339416988eccfa502432db7 100644 --- a/src/scene-entry-manager.js +++ b/src/scene-entry-manager.js @@ -218,7 +218,7 @@ export default class SceneEntryManager { }); document.addEventListener("paste", e => { - if (e.target.nodeName === "INPUT" && document.activeElement === e.target) return; + if (e.matches("input, textarea") && document.activeElement === e.target) return; const url = e.clipboardData.getData("text"); const files = e.clipboardData.files && e.clipboardData.files; diff --git a/src/systems/userinput/bindings/keyboard-mouse-user.js b/src/systems/userinput/bindings/keyboard-mouse-user.js index 12e3b3e4f64b814c06427030a7b5e9be7d6aaf0a..3179ade73b65105bd2410e94505bf6b79653aecb 100644 --- a/src/systems/userinput/bindings/keyboard-mouse-user.js +++ b/src/systems/userinput/bindings/keyboard-mouse-user.js @@ -130,6 +130,15 @@ export const keyboardMouseUserBindings = { }, xform: xforms.rising }, + { + src: { + value: paths.device.keyboard.key("t") + }, + dest: { + value: paths.actions.focusChat + }, + xform: xforms.rising + }, { src: { value: paths.device.keyboard.key("l") diff --git a/src/systems/userinput/paths.js b/src/systems/userinput/paths.js index d9f74a9ac6e437ead10d2e57237275c2de44aa44..4d5b0fd9dbb242be53f9d8fb938353add5e353de 100644 --- a/src/systems/userinput/paths.js +++ b/src/systems/userinput/paths.js @@ -13,6 +13,7 @@ paths.actions.startGazeTeleport = "/actions/startTeleport"; paths.actions.stopGazeTeleport = "/actions/stopTeleport"; paths.actions.spawnPen = "/actions/spawnPen"; paths.actions.muteMic = "/actions/muteMic"; +paths.actions.focusChat = "/actions/focusChat"; paths.actions.cursor = {}; paths.actions.cursor.pose = "/actions/cursorPose"; paths.actions.cursor.grab = "/actions/cursorGrab"; diff --git a/src/utils/serialize-element.js b/src/utils/serialize-element.js new file mode 100644 index 0000000000000000000000000000000000000000..7782d331bc98aef445b143e4a43af6a5c71fd9a4 --- /dev/null +++ b/src/utils/serialize-element.js @@ -0,0 +1,191 @@ +// https://stackoverflow.com/questions/6209161/extract-the-current-dom-and-print-it-as-a-string-with-styles-intact +// +// Mapping between tag names and css default values lookup tables. This allows to exclude default values in the result. +const defaultStylesByTagName = {}; + +// Styles inherited from style sheets will not be rendered for elements with these tag names +const noStyleTags = { + BASE: true, + HEAD: true, + HTML: true, + META: true, + NOFRAME: true, + NOSCRIPT: true, + PARAM: true, + SCRIPT: true, + STYLE: true, + TITLE: true +}; + +// This list determines which css default values lookup tables are precomputed at load time +// Lookup tables for other tag names will be automatically built at runtime if needed +const tagNames = [ + "A", + "ABBR", + "ADDRESS", + "AREA", + "ARTICLE", + "ASIDE", + "AUDIO", + "B", + "BASE", + "BDI", + "BDO", + "BLOCKQUOTE", + "BODY", + "BR", + "BUTTON", + "CANVAS", + "CAPTION", + "CENTER", + "CITE", + "CODE", + "COL", + "COLGROUP", + "COMMAND", + "DATALIST", + "DD", + "DEL", + "DETAILS", + "DFN", + "DIV", + "DL", + "DT", + "EM", + "EMBED", + "FIELDSET", + "FIGCAPTION", + "FIGURE", + "FONT", + "FOOTER", + "FORM", + "H1", + "H2", + "H3", + "H4", + "H5", + "H6", + "HEAD", + "HEADER", + "HGROUP", + "HR", + "HTML", + "I", + "IFRAME", + "IMG", + "INPUT", + "INS", + "KBD", + "KEYGEN", + "LABEL", + "LEGEND", + "LI", + "LINK", + "MAP", + "MARK", + "MATH", + "MENU", + "META", + "METER", + "NAV", + "NOBR", + "NOSCRIPT", + "OBJECT", + "OL", + "OPTION", + "OPTGROUP", + "OUTPUT", + "P", + "PARAM", + "PRE", + "PROGRESS", + "Q", + "RP", + "RT", + "RUBY", + "S", + "SAMP", + "SCRIPT", + "SECTION", + "SELECT", + "SMALL", + "SOURCE", + "SPAN", + "STRONG", + "STYLE", + "SUB", + "SUMMARY", + "SUP", + "SVG", + "TABLE", + "TBODY", + "TD", + "TEXTAREA", + "TFOOT", + "TH", + "THEAD", + "TIME", + "TITLE", + "TR", + "TRACK", + "U", + "UL", + "VAR", + "VIDEO", + "WBR" +]; + +function computeDefaultStyleByTagName(tagName) { + const defaultStyle = {}; + const element = document.body.appendChild(document.createElement(tagName)); + const computedStyle = getComputedStyle(element); + for (let i = 0; i < computedStyle.length; i++) { + defaultStyle[computedStyle[i]] = computedStyle[computedStyle[i]]; + } + document.body.removeChild(element); + return defaultStyle; +} + +function getDefaultStyleByTagName(tagName) { + tagName = tagName.toUpperCase(); + if (!defaultStylesByTagName[tagName]) { + defaultStylesByTagName[tagName] = computeDefaultStyleByTagName(tagName); + } + return defaultStylesByTagName[tagName]; +} + +export default function serializeElement(el) { + if (Object.keys(defaultStylesByTagName).length === 0) { + // Precompute the lookup tables. + for (let i = 0; i < tagNames.length; i++) { + if (!noStyleTags[tagNames[i]]) { + defaultStylesByTagName[tagNames[i]] = computeDefaultStyleByTagName(tagNames[i]); + } + } + } + + if (el.nodeType !== Node.ELEMENT_NODE) { + throw new TypeError(); + } + const cssTexts = []; + const elements = el.querySelectorAll("*"); + for (let i = 0; i < elements.length; i++) { + const e = elements[i]; + if (!noStyleTags[e.tagName]) { + const computedStyle = getComputedStyle(e); + const defaultStyle = getDefaultStyleByTagName(e.tagName); + cssTexts[i] = e.style.cssText; + for (let ii = 0; ii < computedStyle.length; ii++) { + const cssPropName = computedStyle[ii]; + if (computedStyle[cssPropName] !== defaultStyle[cssPropName]) { + e.style[cssPropName] = computedStyle[cssPropName]; + } + } + } + } + const result = el.outerHTML; + for (let i = 0; i < elements.length; i++) { + elements[i].style.cssText = cssTexts[i]; + } + return result; +}