diff --git a/README.md b/README.md index 7550e90d..c8c4e946 100644 --- a/README.md +++ b/README.md @@ -476,6 +476,8 @@ And then add this node in the place where you want to see Remark42 widget: After that widget will be rendered inside this node. +If you want to set this up on a Single Page App, see [/docs/spa.md](/docs/spa.md). + ##### Themes Right now Remark has two themes: light and dark. diff --git a/docs/README.md b/docs/README.md index d54c0310..99f8059c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,7 @@ - [How to configure remark42 with nginx reverse proxy](nginx-proxy.md) - [How to configure remark42 without a subdomain](subdomain.md) with Nginx or Caddy +- [How to configure remark42 for Single Page Apps (SPA)](spa.md) - [Telegram notifications](telegram.md) - [Setup email authentication and\or email notifications](email.md) - [How to add new translation to remark42](translation.md) diff --git a/docs/spa.md b/docs/spa.md new file mode 100644 index 00000000..d2fc8e4a --- /dev/null +++ b/docs/spa.md @@ -0,0 +1,68 @@ +## How to configure remark42 for Single Page Apps (SPA) + +Originally tested on [Nuxt.js](https://nuxtjs.org/), but it should be applicable to all SPAs. + +- Add the following JavaScript to your `index.html`, which in this case, it is identical to `` + +```js +;(function () { + var host = // Your remark42 host + var components = ['embed'] // Your choice of remark42 components + + ;(function(c) { + for (let i = 0; i < c.length; i++) { + const d = document + const s = d.createElement('script') + s.src = remark_config.host + '/web/' + c[i] + '.js' + s.defer = true + ;(d.head || d.body).appendChild(s) + } + })(components) +}) +``` + +- Created `remark42Instance` when the `div` containing remark42 has appear, usually at `mounted` or `componentDidMount` of the SPA lifecycle. Destroy the previous instance first, if neccessary. + +```ts + initRemark42() { + if (window.REMARK42) { + if (this.remark42Instance) { + this.remark42Instance.destroy() + } + + this.remark42Instance = window.REMARK42.createInstance({ + node: this.$refs.remark42 as HTMLElement, + ...remark42_config // See + }) + } + } + + mounted() { + if (window.REMARK42) { + this.initRemark42() + } else { + window.addEventListener('REMARK42::ready', () => { + this.initRemark42() + }) + } + } +``` + +- Ensure that this is called every time route changes + +```ts + @Watch('$route.path') + onRouteChange() { + this.initRemark42() + } +``` + +- And, destroyed before routeLeave + +```ts + beforeRouteLeave() { + if (this.remark42Instance) { + this.remark42Instance.destroy() + } + } +``` diff --git a/frontend/.size-limit.js b/frontend/.size-limit.js index 1379630b..b9339f9e 100644 --- a/frontend/.size-limit.js +++ b/frontend/.size-limit.js @@ -1,7 +1,7 @@ module.exports = [ { path: 'public/embed.js', - limit: '2.55 KB', + limit: '2.7 KB', }, { limit: '86 KB', diff --git a/frontend/app/@types/global.d.ts b/frontend/app/@types/global.d.ts index 77495f01..0e417ea5 100644 --- a/frontend/app/@types/global.d.ts +++ b/frontend/app/@types/global.d.ts @@ -5,7 +5,16 @@ declare global { interface Window { remark_config: CommentsConfig; REMARK42: { - changeTheme(theme: Theme): void; + changeTheme?: (theme: Theme) => void; + destroy?: () => void; + createInstance: ( + remark_config: CommentsConfig + ) => + | { + changeTheme(theme: Theme): void; + destroy(): void; + } + | undefined; }; } diff --git a/frontend/app/common/config-types.ts b/frontend/app/common/config-types.ts index 37f83280..5a48c825 100644 --- a/frontend/app/common/config-types.ts +++ b/frontend/app/common/config-types.ts @@ -15,7 +15,7 @@ export interface CommentsConfig { max_shown_comments?: number; theme?: Theme; page_title?: string; - node?: string; + node?: string | HTMLElement; locale?: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any __colors__?: any; diff --git a/frontend/app/embed.ts b/frontend/app/embed.ts index 38520290..b6a5e0b0 100644 --- a/frontend/app/embed.ts +++ b/frontend/app/embed.ts @@ -1,6 +1,7 @@ /* eslint-disable no-console */ import { BASE_URL, NODE_ID, COMMENT_NODE_CLASSNAME_PREFIX } from '@app/common/constants.config'; import { UserInfo, Theme } from '@app/common/types'; +import { CommentsConfig } from './common/config-types'; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); @@ -47,41 +48,50 @@ function createFrame({ } function init() { - const node = document.getElementById(window.remark_config.node || NODE_ID); + window.REMARK42 = window.REMARK42 || {}; + window.REMARK42.createInstance = createInstance; + + if (window.remark_config) { + createInstance(window.remark_config); + } + + window.dispatchEvent(new Event('REMARK42::ready')); +} + +function createInstance(config: CommentsConfig) { + let initDataAnimationTimeout: number | null = null; + let titleObserver: MutationObserver | null = null; + + try { + config = config || {}; + } catch (e) { + console.error('Remark42: Config object is undefined.'); + return; + } + + if (!config.site_id) { + console.error('Remark42: Site ID is undefined.'); + return; + } + + config.url = (config.url || window.location.origin + window.location.pathname).split('#')[0]; + + const node = config.node instanceof HTMLElement ? config.node : document.getElementById(config.node || NODE_ID); if (!node) { console.error("Remark42: Can't find root node."); return; } - try { - window.remark_config = window.remark_config || {}; - } catch (e) { - console.error('Remark42: Config object is undefined.'); - return; - } - - if (!window.remark_config.site_id) { - console.error('Remark42: Site ID is undefined.'); - return; - } - - window.remark_config.url = (window.remark_config.url || window.location.origin + window.location.pathname).split( - '#' - )[0]; - - window.REMARK42 = window.REMARK42 || {}; - window.REMARK42.changeTheme = changeTheme; - - const query = Object.keys(window.remark_config) + const query = Object.keys(config) .filter(key => key !== '__colors__') .map(key => { - return `${encodeURIComponent(key)}=${encodeURIComponent( - window.remark_config[key as keyof typeof window.remark_config] - )}`; + return `${encodeURIComponent(key)}=${encodeURIComponent(config[key as keyof typeof config])}`; }) .join('&'); - const iframe = createFrame({ host: BASE_URL, query, __colors__: window.remark_config.__colors__ }); + const iframe = + (document.querySelector('iframe[title=Remark42]') as HTMLIFrameElement) || + createFrame({ host: BASE_URL, query, __colors__: config.__colors__ }); node.appendChild(iframe); @@ -91,7 +101,8 @@ function init() { const titleElement = document.querySelector('title'); if (titleElement) { - new MutationObserver(mutations => postTitleToIframe(mutations[0].target.textContent!)).observe(titleElement, { + titleObserver = new MutationObserver(mutations => postTitleToIframe(mutations[0].target.textContent!)); + titleObserver.observe(titleElement, { subtree: true, characterData: true, childList: true, @@ -126,54 +137,54 @@ function init() { this.style.setAttribute('rel', 'stylesheet'); this.style.setAttribute('type', 'text/css'); this.style.innerHTML = ` - #${remarkRootId}-node { - position: fixed; - top: 0; - right: 0; - bottom: 0; - width: 400px; - transition: transform 0.4s ease-out; - max-width: 100%; - transform: translate(400px, 0); - } - #${remarkRootId}-node[data-animation] { - transform: translate(0, 0); - } - #${remarkRootId}-back { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0,0,0,0.7); - opacity: 0; - transition: opacity 0.4s ease-out; - } - #${remarkRootId}-back[data-animation] { - opacity: 1; - } + #${remarkRootId}-node { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 400px; + transition: transform 0.4s ease-out; + max-width: 100%; + transform: translate(400px, 0); + } + #${remarkRootId}-node[data-animation] { + transform: translate(0, 0); + } + #${remarkRootId}-back { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0,0,0,0.7); + opacity: 0; + transition: opacity 0.4s ease-out; + } + #${remarkRootId}-back[data-animation] { + opacity: 1; + } + #${remarkRootId}-close { + top: 0px; + right: 400px; + position: absolute; + text-align: center; + font-size: 25px; + cursor: pointer; + color: white; + border-color: transparent; + border-width: 0; + padding: 0; + margin-right: 4px; + background-color: transparent; + } + @media all and (max-width: 430px) { #${remarkRootId}-close { - top: 0px; - right: 400px; - position: absolute; - text-align: center; - font-size: 25px; - cursor: pointer; - color: white; - border-color: transparent; - border-width: 0; - padding: 0; - margin-right: 4px; - background-color: transparent; + right: 0px; + font-size: 20px; + color: black; } - @media all and (max-width: 430px) { - #${remarkRootId}-close { - right: 0px; - font-size: 20px; - color: black; - } - } - `; + } + `; } if (!this.node) { this.node = document.createElement('div'); @@ -202,10 +213,10 @@ function init() { document.body.appendChild(this.back); document.body.appendChild(this.node); document.addEventListener('keydown', this.onKeyDown); - setTimeout(() => { + initDataAnimationTimeout = window.setTimeout(() => { this.back!.setAttribute('data-animation', ''); this.node!.setAttribute('data-animation', ''); - this.iframe!.focus(); + iframe.focus(); }, 400); }, close() { @@ -306,4 +317,33 @@ function init() { function changeTheme(theme: Theme) { iframe.contentWindow!.postMessage(JSON.stringify({ theme }), '*'); } + + function destroy() { + if (initDataAnimationTimeout) { + clearTimeout(initDataAnimationTimeout); + } + + window.removeEventListener('message', receiveMessages); + window.removeEventListener('hashchange', postHashToIframe); + document.removeEventListener('click', postClickOutsideToIframe); + + if (titleObserver) { + titleObserver.disconnect(); + } + + iframe.remove(); + } + + // TODO: These do not appear in Chrome DevTools + window.REMARK42.changeTheme = changeTheme; + window.REMARK42.destroy = () => { + destroy(); + delete window.REMARK42.changeTheme; + delete window.REMARK42.destroy; + }; + + return { + changeTheme, + destroy, + }; }