├── .npmrc ├── src ├── toolbar │ ├── index.css │ ├── components │ │ ├── Icon │ │ │ ├── index.js │ │ │ └── Icon.js │ │ ├── Loader │ │ │ ├── index.js │ │ │ ├── Loader.js │ │ │ └── loader.css │ │ ├── NavTabs │ │ │ ├── index.js │ │ │ ├── NavTabs.css │ │ │ └── NavTabs.js │ │ ├── Toolbar │ │ │ ├── index.js │ │ │ ├── Toolbar.css │ │ │ └── Toolbar.js │ │ ├── EditButton │ │ │ ├── index.js │ │ │ ├── EditButton.css │ │ │ └── EditButton.js │ │ ├── ScrollingName │ │ │ ├── index.js │ │ │ └── ScrollingName.js │ │ ├── views.js │ │ ├── DevMode │ │ │ ├── index.js │ │ │ ├── collapsible-arrow.svg │ │ │ ├── DevMode.css │ │ │ └── DevMode.js │ │ ├── Menu │ │ │ ├── index.js │ │ │ ├── Menu.js │ │ │ ├── Menu.css │ │ │ ├── pencil.svg │ │ │ └── x.svg │ │ ├── Panel │ │ │ ├── BasePanel.js │ │ │ ├── index.js │ │ │ ├── x.svg │ │ │ ├── Panel.js │ │ │ ├── PreviewPanel.js │ │ │ ├── SharePanel.js │ │ │ ├── DocumentPanel.js │ │ │ ├── link.svg │ │ │ ├── Panel.css │ │ │ ├── prismic-white.svg │ │ │ └── prismic.svg │ │ ├── PreviewMenu │ │ │ ├── index.js │ │ │ ├── PreviewMenu.js │ │ │ ├── PreviewMenu.css │ │ │ ├── x.svg │ │ │ └── link.svg │ │ ├── JsonView │ │ │ ├── index.js │ │ │ ├── minus.svg │ │ │ ├── plus.svg │ │ │ ├── JsonView.css │ │ │ └── JsonView.js │ │ ├── index.js │ │ ├── animation.js │ │ └── animation.css │ ├── utils.js │ ├── checkBrowser.js │ ├── analytics.js │ ├── experiment │ │ ├── cookie.js │ │ └── index.js │ ├── toolbar.js │ ├── preview │ │ ├── screenshot.js │ │ ├── index.js │ │ └── cookie.js │ ├── prediction.js │ └── index.js ├── iframe │ ├── index.js │ ├── index.html │ ├── devMode.js │ ├── analytics.js │ ├── prediction.js │ └── preview.js ├── common │ ├── promise-utils.js │ ├── events.js │ ├── index.js │ ├── dom.js │ ├── cookie.js │ ├── hooks.js │ ├── sorter.js │ └── general.js └── toolbar-service │ ├── messages.js │ ├── client.js │ ├── iframe.js │ └── index.js ├── ngrok.yml ├── ngrok ├── browserslist ├── .editorconfig ├── jsconfig.json ├── .babelrc.js ├── .eslintrc.js ├── README.md ├── .gitignore ├── package.json └── webpack.config.js /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /src/toolbar/index.css: -------------------------------------------------------------------------------- 1 | @import "./components/**/*.css" 2 | -------------------------------------------------------------------------------- /ngrok.yml: -------------------------------------------------------------------------------- 1 | authtoken: 3iA6YrE6nu12VsJzoVky4_4J7UHe3itYnJNP3SidfFZ 2 | -------------------------------------------------------------------------------- /ngrok: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prismicio/prismic-toolbar/master/ngrok -------------------------------------------------------------------------------- /src/toolbar/components/Icon/index.js: -------------------------------------------------------------------------------- 1 | export { Icon } from './Icon'; 2 | -------------------------------------------------------------------------------- /src/toolbar/components/Loader/index.js: -------------------------------------------------------------------------------- 1 | export { Loader } from './Loader'; 2 | -------------------------------------------------------------------------------- /src/toolbar/components/NavTabs/index.js: -------------------------------------------------------------------------------- 1 | export { NavTabs } from './NavTabs'; 2 | -------------------------------------------------------------------------------- /src/toolbar/components/Toolbar/index.js: -------------------------------------------------------------------------------- 1 | export { Toolbar } from './Toolbar'; 2 | -------------------------------------------------------------------------------- /src/toolbar/components/EditButton/index.js: -------------------------------------------------------------------------------- 1 | export { EditButton } from './EditButton'; 2 | -------------------------------------------------------------------------------- /src/toolbar/components/ScrollingName/index.js: -------------------------------------------------------------------------------- 1 | export { ScrollingName } from './ScrollingName'; 2 | -------------------------------------------------------------------------------- /src/iframe/index.js: -------------------------------------------------------------------------------- 1 | import { ToolbarService } from '@toolbar-service'; 2 | 3 | ToolbarService.setupIframe(); 4 | -------------------------------------------------------------------------------- /browserslist: -------------------------------------------------------------------------------- 1 | last 2 Chrome versions 2 | last 2 Firefox versions 3 | last 2 Safari versions 4 | last 2 Edge versions 5 | IE 11 -------------------------------------------------------------------------------- /src/toolbar/components/views.js: -------------------------------------------------------------------------------- 1 | export const views = { 2 | NONE: 0, 3 | DOCS: 1, 4 | DRAFTS: 2, 5 | SHARE: 4, 6 | }; 7 | -------------------------------------------------------------------------------- /src/iframe/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Prismic Toolbar iFrame 4 | 5 | -------------------------------------------------------------------------------- /src/toolbar/components/DevMode/index.js: -------------------------------------------------------------------------------- 1 | export { DevMode } from './DevMode'; 2 | export { default as collapsibleArrow } from './collapsible-arrow.svg'; 3 | -------------------------------------------------------------------------------- /src/toolbar/components/Menu/index.js: -------------------------------------------------------------------------------- 1 | export { default as pencilSvg } from './pencil.svg'; 2 | export { default as xSvg } from './x.svg'; 3 | export { Menu } from './Menu'; 4 | -------------------------------------------------------------------------------- /src/toolbar/components/Panel/BasePanel.js: -------------------------------------------------------------------------------- 1 | export const BasePanel = ({ children, className = '' }) => ( 2 |
{children}
3 | ); 4 | -------------------------------------------------------------------------------- /src/toolbar/components/Icon/Icon.js: -------------------------------------------------------------------------------- 1 | export const Icon = ({ className = '', src, ...other }) => ( 2 | 3 | ); 4 | -------------------------------------------------------------------------------- /src/toolbar/components/PreviewMenu/index.js: -------------------------------------------------------------------------------- 1 | export { PreviewMenu } from './PreviewMenu'; 2 | export { default as xSvg } from './x.svg'; 3 | export { default as linkSvg } from './link.svg'; 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.js] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | trim_trailing_whitespace = true 7 | charset = utf-8 8 | indent_style = space 9 | indent_size = 2 -------------------------------------------------------------------------------- /src/toolbar/components/JsonView/index.js: -------------------------------------------------------------------------------- 1 | export { JsonView } from './JsonView'; 2 | export { default as minusSquare } from './minus.svg'; 3 | export { default as plusSquare } from './plus.svg'; 4 | -------------------------------------------------------------------------------- /src/toolbar/utils.js: -------------------------------------------------------------------------------- 1 | export const reloadOrigin = () => window.location.reload(); 2 | 3 | let a; 4 | export const getAbsoluteURL = url => { 5 | if (!a) a = document.createElement('a'); 6 | a.href = url; 7 | return a.href; 8 | }; 9 | -------------------------------------------------------------------------------- /src/iframe/devMode.js: -------------------------------------------------------------------------------- 1 | export function getQueriesResults(tracker) { 2 | return fetch(`/toolbar/devMode?tracker=${tracker}`).then(r => { 3 | if (r.status === 200) { 4 | return r.json(); 5 | } 6 | return []; 7 | }); 8 | } 9 | -------------------------------------------------------------------------------- /src/toolbar/components/DevMode/collapsible-arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "resolveJsonModule": true, 4 | "baseUrl": ".", 5 | "paths": { 6 | "@common": ["./src/common"], 7 | "@common/*": ["./src/common/*"], 8 | } 9 | }, 10 | "exclude": ["node_modules", "build"] 11 | } 12 | -------------------------------------------------------------------------------- /src/toolbar/checkBrowser.js: -------------------------------------------------------------------------------- 1 | const ltIE11 = window.navigator.userAgent.indexOf('MSIE ') > 0; 2 | const isIE11 = window.navigator.userAgent.indexOf('Trident/') > 0; 3 | const isIE = ltIE11 || isIE11; 4 | 5 | if (isIE) { 6 | throw new Error('Prismic does not support Internet Explorer.'); 7 | } 8 | -------------------------------------------------------------------------------- /src/toolbar/components/Loader/Loader.js: -------------------------------------------------------------------------------- 1 | export const Loader = () => ( 2 |
3 | 4 | 5 | 6 |
7 | ); 8 | -------------------------------------------------------------------------------- /src/toolbar/analytics.js: -------------------------------------------------------------------------------- 1 | export class Analytics { 2 | constructor(client) { 3 | this.client = client; 4 | } 5 | 6 | // Track edit button document clicks 7 | trackDocumentClick = arg => this.client.trackDocumentClick(arg); 8 | 9 | // Track initial setup of toolbar 10 | trackToolbarSetup = () => this.client.trackToolbarSetup(); 11 | } 12 | -------------------------------------------------------------------------------- /src/common/promise-utils.js: -------------------------------------------------------------------------------- 1 | export function eventToPromise( 2 | /* HTMLElement */element, 3 | /* string */eventName, 4 | /* (event) => T */resolver 5 | ) /* Promise */ { 6 | return new Promise(resolve => { 7 | element.addEventListener(eventName, event => { 8 | const toResolve = resolver(event); 9 | resolve(toResolve); 10 | }, { once: true }); 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /src/toolbar/components/Panel/index.js: -------------------------------------------------------------------------------- 1 | export { default as xSvg } from './x.svg'; 2 | export { default as prismicSvg } from './prismic.svg'; 3 | export { default as prismicWhiteSvg } from './prismic-white.svg'; 4 | export { BasePanel } from './BasePanel'; 5 | export { DocumentPanel } from './DocumentPanel'; 6 | export { PreviewPanel } from './PreviewPanel'; 7 | export { SharePanel } from './SharePanel'; 8 | export { Panel } from './Panel'; 9 | -------------------------------------------------------------------------------- /src/toolbar/components/Menu/Menu.js: -------------------------------------------------------------------------------- 1 | import { pencilSvg, xSvg } from '.'; 2 | import { Icon, views, Animation } from '..'; 3 | 4 | const { DOCS, NONE } = views; 5 | 6 | export const Menu = ({ setPage, page, in: inProp }) => ( 7 | 8 |
setPage(page === DOCS ? NONE : DOCS)}> 9 | 10 |
11 |
12 | ); 13 | -------------------------------------------------------------------------------- /src/toolbar/experiment/cookie.js: -------------------------------------------------------------------------------- 1 | import { getCookie, setCookie, deleteCookie } from '@common'; 2 | 3 | const EXPERIMENT_COOKIE_NAME = 'io.prismic.experiment'; 4 | 5 | export class ExperimentCookie { 6 | get() { 7 | return getCookie(EXPERIMENT_COOKIE_NAME); 8 | } 9 | 10 | set(expId, variation) { 11 | const value = [expId, variation].join(' '); 12 | setCookie(EXPERIMENT_COOKIE_NAME, value); 13 | } 14 | 15 | delete() { 16 | deleteCookie(EXPERIMENT_COOKIE_NAME); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/iframe/analytics.js: -------------------------------------------------------------------------------- 1 | import { localStorage, getCookie } from '@common'; 2 | 3 | export async function trackDocumentClick(isMain) { 4 | await fetch(`/toolbar/trackDocumentClick?isMain=${Boolean(isMain)}`); 5 | return null; 6 | } 7 | 8 | export async function trackToolbarSetup () { 9 | const didTrack = localStorage('toolbarSetupTracked'); 10 | if (!getCookie('is-logged-in') || didTrack.get()) return; 11 | await fetch('/toolbar/trackToolbarSetup'); 12 | didTrack.set(true); 13 | return null; 14 | } 15 | -------------------------------------------------------------------------------- /src/toolbar/components/Menu/Menu.css: -------------------------------------------------------------------------------- 1 | .Menu { 2 | color: white; 3 | display: flex; 4 | align-items: center; 5 | justify-content: center; 6 | border-radius: 100px; 7 | width: 70px; 8 | height: 70px; 9 | user-select: none; 10 | cursor: pointer; 11 | margin-right: 30px; 12 | background: #5163BA; 13 | will-change: transform; 14 | 15 | & .Icon { 16 | transition: all .2s; 17 | will-change: transform; 18 | 19 | &.x { 20 | transform: rotate(-90deg); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/toolbar-service/messages.js: -------------------------------------------------------------------------------- 1 | export const ToolbarServiceProtocol = { 2 | SetupPort: 'setup_port', 3 | Ready: 'ready', 4 | }; 5 | 6 | export const Messages = { 7 | PreviewState: 'preview_state', 8 | PredictionDocs: 'prediction_docs', 9 | DevModeQueriesResults: 'dev_mode_queries_results', 10 | UpdatePreview: 'update_preview', 11 | ClosePreviewSession: 'close_preview_session', 12 | SharePreview: 'share_preview', 13 | TrackDocumentClick: 'track_document_click', 14 | TrackToolbarSetup: 'track_toolbar_setup' 15 | }; 16 | -------------------------------------------------------------------------------- /src/toolbar/components/index.js: -------------------------------------------------------------------------------- 1 | // Animations 2 | import * as Animation from './animation'; 3 | 4 | export { Animation }; 5 | 6 | // Constants 7 | export { views } from './views'; 8 | 9 | // Components 10 | export { Icon } from './Icon'; 11 | export { Toolbar } from './Toolbar'; 12 | export { Panel } from './Panel'; 13 | export { Menu } from './Menu'; 14 | export { PreviewMenu } from './PreviewMenu'; 15 | export { ScrollingName } from './ScrollingName'; 16 | export { NavTabs } from './NavTabs'; 17 | export { DevMode } from './DevMode'; 18 | export { JsonView } from './JsonView'; 19 | export { EditButton } from './EditButton'; 20 | -------------------------------------------------------------------------------- /src/common/events.js: -------------------------------------------------------------------------------- 1 | export const toolbarEvents = { 2 | prismic: 'prismic', 3 | previewUpdate: 'prismicPreviewUpdate', 4 | previewEnd: 'prismicPreviewEnd' 5 | }; 6 | 7 | /** 8 | * Dispatches an event with given data 9 | * 10 | * @param {string} event - Event name 11 | * @param {?any} data - Data to attach to the event 12 | * 13 | * @return {boolean} - `true` if event `event.preventDefault()` 14 | * was not called (event was not cancelled) 15 | */ 16 | export const dispatchToolbarEvent = (event, data = null) => 17 | window.dispatchEvent(new CustomEvent(event, { 18 | detail: data, 19 | cancelable: true 20 | })); 21 | -------------------------------------------------------------------------------- /src/toolbar/components/animation.js: -------------------------------------------------------------------------------- 1 | import { CSSTransition } from 'react-transition-group'; 2 | 3 | export const GrowIn = ({ children, in: inProp = true, ...other }) => ( 4 | 12 | {children} 13 | 14 | ); 15 | 16 | export const SlideIn = ({ children, in: inProp = true, ...other }) => ( 17 | 25 | {children} 26 | 27 | ); 28 | -------------------------------------------------------------------------------- /src/toolbar/components/Toolbar/Toolbar.css: -------------------------------------------------------------------------------- 1 | .Toolbar a { 2 | text-decoration: none; 3 | color: inherit; 4 | display: block; 5 | } 6 | 7 | .Toolbar { 8 | position: fixed; 9 | bottom: 30px; 10 | left: 30px; 11 | z-index: 1000; 12 | font-family: 'SF Pro Text', 'Helvetica', 'sans-serif'; 13 | font-size: 13px; 14 | color: #667587; 15 | display: flex; 16 | } 17 | 18 | .Toolbar h1 { 19 | font-weight: 400; 20 | font-size: 24px; 21 | color: #1D2230; 22 | margin: 0; 23 | } 24 | 25 | .Toolbar h2 { 26 | font-weight: 400; 27 | font-size: 13px; 28 | color: #9fb1c5; 29 | margin: 0; 30 | } 31 | 32 | .Toolbar h3 { 33 | font-weight: 500; 34 | color: #1D2230; 35 | margin: 0; 36 | } 37 | -------------------------------------------------------------------------------- /src/common/index.js: -------------------------------------------------------------------------------- 1 | // General helpers 2 | export { getCookie, setCookie, deleteCookie, demolishCookie } from './cookie'; 3 | export { Hooks } from './hooks'; 4 | export { Sorter } from './sorter'; 5 | export { 6 | toolbarEvents, 7 | dispatchToolbarEvent, 8 | } from './events'; 9 | export { 10 | warn, 11 | err, 12 | isObject, 13 | switchy, 14 | fetchy, 15 | ellipsis, 16 | readyDOM, 17 | wait, 18 | delay, 19 | stringCheck, 20 | disabledCookies, 21 | random, 22 | query, 23 | parseQuery, 24 | copyText, 25 | throttle, 26 | memoize, 27 | once, 28 | localStorage, 29 | getLocation, 30 | shadow, 31 | deleteNodes, 32 | appendCSS, 33 | script, 34 | } from './general'; 35 | -------------------------------------------------------------------------------- /src/toolbar/components/animation.css: -------------------------------------------------------------------------------- 1 | @keyframes growIn { 2 | from { transform: scale(.8) } 3 | 50% { transform: scale(1.03) } 4 | 75% { transform: scale(.98) } 5 | } 6 | 7 | .growIn-enter-active, .growIn-appear-active { 8 | animation: 300ms ease growIn both; 9 | } 10 | 11 | .growIn-exit, .growIn-active-exit { 12 | animation: 300ms ease reverse growIn both; 13 | } 14 | 15 | @keyframes slideIn { 16 | from { opacity: 0; transform: translateY(18px); } 17 | 40% { opacity: 1; } 18 | } 19 | 20 | .slideIn-enter-active, .slideIn-appear-active { 21 | animation: 200ms ease-out slideIn both; 22 | } 23 | 24 | .slideIn-exit, .slideIn-active-exit { 25 | animation: 200ms ease-out reverse slideIn both; 26 | } 27 | -------------------------------------------------------------------------------- /.babelrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | node: 'current', 8 | }, 9 | }, 10 | ], 11 | '@babel/preset-react' 12 | ], 13 | plugins: [ 14 | // Preact 15 | ['@babel/plugin-transform-react-jsx', { pragma: 'h' }], 16 | 17 | // Stage 2 18 | '@babel/plugin-proposal-export-namespace-from', 19 | '@babel/plugin-proposal-numeric-separator', 20 | '@babel/plugin-proposal-throw-expressions', 21 | 22 | // Stage 3 23 | '@babel/plugin-syntax-dynamic-import', 24 | ['@babel/plugin-proposal-class-properties', { loose: false }], 25 | '@babel/plugin-proposal-json-strings', 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /src/toolbar/components/NavTabs/NavTabs.css: -------------------------------------------------------------------------------- 1 | .nav-tab-list { 2 | list-style: none; 3 | display:flex; 4 | flex-wrap: nowrap; 5 | width: 100%; 6 | height: 60px; 7 | margin: 0; 8 | padding: 0; 9 | 10 | & li.react-tabs__tab { 11 | /* Shape Of Tab */ 12 | flex-basis: 50%; 13 | font-weight: bold; 14 | text-align: center; 15 | height: 100%; 16 | background: #FFFFFF; 17 | display: flex; 18 | justify-content: center; 19 | align-items: center; 20 | cursor: pointer; 21 | box-shadow: 0 4px 4px -4px rgba(0,0,0,0.15); 22 | 23 | /* Style of text */ 24 | font-size: 16px; 25 | color: #293258; 26 | letter-spacing: 0.23px; 27 | 28 | &.react-tabs__tab--selected { 29 | box-shadow: inset 0px -6px 0px -2px #5163BA; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/toolbar/components/Panel/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/toolbar/experiment/index.js: -------------------------------------------------------------------------------- 1 | import { script, disabledCookies } from '@common'; 2 | import { ExperimentCookie } from './cookie'; 3 | import { reloadOrigin } from '../utils'; 4 | 5 | export class Experiment { 6 | constructor(expId) { 7 | this.cookie = new ExperimentCookie(); 8 | this.expId = expId; 9 | this.setup(); 10 | } 11 | 12 | async setup() { 13 | if (disabledCookies()) return; 14 | await script(`//www.google-analytics.com/cx/api.js?experiment=${this.expId}`); 15 | this.variation = window.cxApi.chooseVariation(); 16 | if (this.variation === window.cxApi.NOT_PARTICIPATING) this.end(); 17 | else this.start(); 18 | } 19 | 20 | async start() { 21 | const old = this.cookie.get(); 22 | this.cookie.set(this.expId, this.variation); 23 | if (this.cookie.get() !== old) reloadOrigin(); 24 | } 25 | 26 | end() { 27 | const old = this.cookie.get(); 28 | this.cookie.delete(); 29 | if (this.cookie.get() !== old) reloadOrigin(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/toolbar/components/NavTabs/NavTabs.js: -------------------------------------------------------------------------------- 1 | import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; 2 | import './NavTabs.css'; 3 | import { Component } from 'react'; 4 | 5 | /* ----- BEGINNING OF CLASS ----- */ 6 | export class NavTabs extends Component { 7 | constructor (props) { 8 | super(props); 9 | this.state = { activeTab: 0, tabsName: props.tabsName, tabsContent: props.tabsContent }; 10 | } 11 | 12 | /* ----- RENDER FUNCTION ----- */ 13 | render() { 14 | const { tabsName, tabsContent } = this.state; 15 | 16 | return ( 17 | this.setState({ activeTab: tabIndex })} 20 | > 21 | 22 | {tabsName.map(name => 23 | {name} 24 | )} 25 | 26 | 27 | { 28 | tabsContent.map(content => {content} ) 29 | } 30 | 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/common/dom.js: -------------------------------------------------------------------------------- 1 | // Div 2 | export const div = (id, options = {}) => { 3 | const { style, ...otherOpts } = options; 4 | let el = document.getElementById(id); 5 | if (!el) { 6 | el = document.createElement('div'); 7 | document.body.appendChild(el); 8 | } 9 | Object.assign(el.style, style); 10 | Object.assign(el, otherOpts, { id }); 11 | return el; 12 | }; 13 | 14 | // Shadow DOM 15 | export const shadow = (id, options) => { 16 | let _shadow = div(id, options); 17 | if (document.head.attachShadow) _shadow = _shadow.attachShadow({ mode: 'open' }); 18 | return _shadow; 19 | }; 20 | 21 | // Delete DOM nodes matching CSS query 22 | export const deleteNodes = cssQuery => { 23 | document.querySelectorAll(cssQuery).forEach(el => el.remove()); 24 | }; 25 | 26 | // Append Stylesheet to DOM node 27 | export const appendCSS = (el, css) => { 28 | const style = document.createElement('style'); 29 | style.type = 'text/css'; 30 | style.appendChild(document.createTextNode(css)); 31 | el.appendChild(style); 32 | }; 33 | -------------------------------------------------------------------------------- /src/toolbar/components/Menu/pencil.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/toolbar/components/Panel/Panel.js: -------------------------------------------------------------------------------- 1 | import { TransitionGroup } from 'react-transition-group'; 2 | import { switchy } from '@common'; 3 | import { views, Animation } from '..'; 4 | import { DocumentPanel, PreviewPanel, SharePanel } from '.'; 5 | 6 | const { DOCS, DRAFTS, SHARE } = views; 7 | 8 | export const Panel = ({ 9 | closePanel, 10 | documents, 11 | queries, 12 | documentsLoading, 13 | preview, 14 | page, 15 | onDocumentClick 16 | }) => ( 17 | { 18 | !page || ( 19 | { 20 | switchy(page)({ 21 | [DOCS]: , 27 | [DRAFTS]: , 33 | [SHARE]: , 34 | }) 35 | } 36 | ) 37 | } 38 | ); 39 | -------------------------------------------------------------------------------- /src/toolbar/components/ScrollingName/ScrollingName.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'preact'; 2 | 3 | export class ScrollingName extends Component { 4 | componentDidMount() { 5 | this.mounted = true; 6 | const [inner] = this.base.children; 7 | this.inner = inner; 8 | this.base.addEventListener('mouseover', this.animate.bind(this)); 9 | this.base.addEventListener('mouseout', this.reset.bind(this)); 10 | } 11 | 12 | animate() { 13 | if (!this.mounted) return; 14 | 15 | const hiddenWidth = this.inner.scrollWidth - this.inner.offsetWidth; 16 | const scrollTime = hiddenWidth / 100; 17 | 18 | if (hiddenWidth <= 0) return; 19 | this.inner.style.transition = `transform ${scrollTime}s linear`; 20 | this.inner.style.transform = `translateX(-${hiddenWidth}px)`; 21 | } 22 | 23 | reset() { 24 | if (!this.mounted) return; 25 | 26 | this.inner.style.transition = ''; 27 | this.inner.style.transform = ''; 28 | } 29 | 30 | componentWillUnmount() { 31 | this.mounted = false; 32 | } 33 | 34 | render() { 35 | const { children, ...other } = this.props; 36 | return ( 37 |
38 |
{children}
39 |
40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/toolbar/components/DevMode/DevMode.css: -------------------------------------------------------------------------------- 1 | .Collapsible { 2 | border-bottom: 1px solid #E0E2EE; 3 | } 4 | 5 | .Collapsible__trigger { 6 | padding-left:25px; 7 | height: 70px; 8 | display: flex; 9 | align-items: center; 10 | 11 | &.is-open { 12 | border-bottom: 1px solid #E0E2EE; 13 | } 14 | } 15 | 16 | .Collapsible__contentOuter { 17 | background: #F5F6F9; 18 | } 19 | 20 | .wrapper-trigger { 21 | cursor:pointer; 22 | height: 40px; /* height equal to line height x2 (2 lines of text) to make sure it is centered vertically */ 23 | line-height: 20px; 24 | width: stretch; 25 | 26 | & .trigger-title { 27 | font-size: 16px; 28 | color: #293258; 29 | letter-spacing: 0.23px; 30 | font-weight: bold; 31 | } 32 | 33 | & .trigger-subtitle { 34 | font-size: 13px; 35 | color: #435169; 36 | letter-spacing: 0.19px; 37 | } 38 | 39 | & .trigger-triangle { 40 | position: relative; 41 | float: right; 42 | right: 25px; 43 | bottom: 33px; 44 | color: #3F4A56; 45 | transition: all 0.2s ease; 46 | 47 | &.active { 48 | transform: rotate(90deg); 49 | } 50 | } 51 | } -------------------------------------------------------------------------------- /src/toolbar/components/Loader/loader.css: -------------------------------------------------------------------------------- 1 | @keyframes rotate { 2 | 100% { 3 | transform: rotate(360deg); 4 | } 5 | } 6 | @keyframes dash { 7 | 0% { 8 | stroke-dasharray: 1, 200; 9 | stroke-dashoffset: 0; 10 | } 11 | 50% { 12 | stroke-dasharray: 89, 200; 13 | stroke-dashoffset: -35px; 14 | } 15 | 100% { 16 | stroke-dasharray: 89, 200; 17 | stroke-dashoffset: -124px; 18 | } 19 | } 20 | 21 | .spinning-loader { 22 | position: absolute; 23 | z-index: 100; 24 | margin: 0 auto; 25 | width: 40px; 26 | left: calc(50% - 20px); 27 | top: 20%; 28 | transform: translateY(-50%); 29 | stroke: #5163BA; 30 | 31 | 32 | &:before { 33 | content: ''; 34 | display: block; 35 | padding-top: 100%; 36 | } 37 | 38 | & .circular { 39 | animation: rotate 2s linear infinite; 40 | height: 100%; 41 | transform-origin: center center; 42 | width: 100%; 43 | position: absolute; 44 | top: 0; 45 | bottom: 0; 46 | left: 0; 47 | right: 0; 48 | margin: auto; 49 | } 50 | 51 | & .path { 52 | stroke-dasharray: 1, 200; 53 | stroke-dashoffset: 0; 54 | animation: dash 1.5s ease-in-out infinite, color 6s ease-in-out infinite; 55 | stroke-linecap: round; 56 | stroke: $indigo; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/toolbar/components/PreviewMenu/PreviewMenu.js: -------------------------------------------------------------------------------- 1 | import { Icon, views, ScrollingName, Animation } from '..'; 2 | import { xSvg, linkSvg } from '.'; 3 | 4 | const { DRAFTS, SHARE } = views; 5 | 6 | export const PreviewMenu = ({ setPage, auth, preview, in: inProp, closePreview }) => { 7 | const len = preview.documents.length; 8 | 9 | const close = () => { 10 | // unmount preview component 11 | closePreview(); 12 | // kill the preview session 13 | preview.end(); 14 | }; 15 | 16 | return ( 17 | 18 |
19 |
20 | {preview.title} 21 | {Boolean(len) &&
setPage(DRAFTS)}> 22 | ({len} doc{len !== 1 ? 's' : ''}) 23 |
} 24 |
25 | 26 | {auth 27 | ?
setPage(SHARE)}> 28 | Get a shareable link 29 | 30 |
31 | : Powered by Prismic 32 | } 33 | 34 | 35 |
36 |
37 | ); 38 | }; 39 | -------------------------------------------------------------------------------- /src/toolbar/toolbar.js: -------------------------------------------------------------------------------- 1 | import { render } from 'preact'; 2 | import { appendCSS, shadow, readyDOM } from '@common'; 3 | import { Toolbar as ToolbarComponent } from './components'; 4 | import shadowStyles from './index.css'; 5 | 6 | class Toolbar { 7 | constructor({ displayPreview, auth, preview, prediction, analytics }) { 8 | this.preview = preview; 9 | this.auth = auth; 10 | this.prediction = prediction; 11 | this.analytics = analytics; 12 | this.displayPreview = displayPreview; 13 | this.setup(); 14 | } 15 | 16 | async setup() { 17 | // Because we need the DOM now 18 | await readyDOM(); 19 | 20 | // Create toolbar in a shadow DOM 21 | const toolbar = shadow({ 22 | id: 'prismic-toolbar-v2', 23 | style: { position: 'fixed', zIndex: 2147483647 }, 24 | }); 25 | 26 | // Put above Intercom 27 | appendCSS(document.body, '#intercom-container { z-index: 2147483646 !important }'); 28 | 29 | // Styles 30 | appendCSS(toolbar, shadowStyles); 31 | 32 | // Render the React app 33 | render( 34 | , 41 | toolbar 42 | ); 43 | } 44 | } 45 | 46 | window.prismic.Toolbar = Toolbar; 47 | -------------------------------------------------------------------------------- /src/toolbar/preview/screenshot.js: -------------------------------------------------------------------------------- 1 | let html2canvasPromise; 2 | 3 | const screenshot = async () => { 4 | document.getElementById('prismic-toolbar-v2').setAttribute('data-html2canvas-ignore', true); 5 | if (!html2canvasPromise) html2canvasPromise = script('https://html2canvas.hertzen.com/dist/html2canvas.min.js'); 6 | await html2canvasPromise; 7 | try { 8 | const canvas = await window.html2canvas(document.body, { 9 | logging: false, 10 | width: '100%', 11 | height: window.innerHeight, 12 | }); 13 | return new Promise(resolve => canvas.toBlob(resolve, 'image/jpeg', 0.6)); 14 | } catch (e) { 15 | console.warn('Caught html2canvas error', e); 16 | // sometimes html2canvas errors. This breaks creating a link. 17 | // While a preview image is nice it's not essential. 18 | // If html2canvas errors we create a dummy canvas and convert that to a blob. 19 | // The preview image is a black square 20 | // but at least we can share the image. 21 | const tempCanvas = document.createElement('canvas'); 22 | return new Promise(resolve => tempCanvas.toBlob(resolve, 'image/jpeg', 0.6)); 23 | } 24 | }; 25 | 26 | function script(src) { 27 | return new Promise(resolve => { 28 | const el = document.createElement('script'); 29 | el.src = src; 30 | document.head.appendChild(el); 31 | el.addEventListener('load', () => resolve(el)); 32 | }); 33 | } 34 | 35 | export default screenshot; 36 | -------------------------------------------------------------------------------- /src/toolbar/components/PreviewMenu/PreviewMenu.css: -------------------------------------------------------------------------------- 1 | .PreviewMenu { 2 | position: relative; 3 | color: white; 4 | border-radius: 6px; 5 | width: 395px; 6 | padding: 15px 22px; 7 | background: #5163BA; 8 | will-change: transform; 9 | 10 | & .top { 11 | height: 20px; 12 | margin-bottom: 3px; 13 | display: flex; 14 | align-items: center; 15 | 16 | & .preview-title { 17 | color: white; 18 | font-size: 16px; 19 | font-weight: 500; 20 | white-space: nowrap; 21 | overflow: hidden; 22 | cursor: default; 23 | will-change: transform; 24 | } 25 | 26 | & .docs { 27 | text-decoration: underline; 28 | cursor: pointer; 29 | display: inline; 30 | font-size: 12px; 31 | margin-left: 5px; 32 | white-space: nowrap; 33 | margin-right: 35px; 34 | } 35 | } 36 | 37 | & a.homepage-prismic { 38 | color: #F5F6F9; 39 | font-size: 12px; 40 | } 41 | 42 | & .share { 43 | color: #B6C1FD; 44 | text-decoration: underline; 45 | cursor: pointer; 46 | font-size: 12px; 47 | display: flex; 48 | align-items: center; 49 | } 50 | 51 | & .x { 52 | position: absolute; 53 | right: 22px; 54 | top: calc(50% - 9px); 55 | cursor: pointer; 56 | user-select: none; 57 | transition: all .2s; 58 | 59 | &:hover { 60 | transform: scale(.9); 61 | } 62 | } 63 | 64 | & .link { 65 | margin-left: 5px; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/common/cookie.js: -------------------------------------------------------------------------------- 1 | import Cookies from 'js-cookie'; 2 | 3 | export function getCookie(name) { 4 | return Cookies.get(name); // or undefined 5 | } 6 | 7 | export function setCookie(name, value, expires = Infinity /* days */) { 8 | const path = '/'; 9 | return Cookies.set(name, value, { path, expires, sameSite: 'lax' }); 10 | } 11 | 12 | export function deleteCookie(name) { 13 | const path = '/'; 14 | Cookies.remove(name, { path }); 15 | } 16 | 17 | // TODO remove after we force no /preview route (url prediction) 18 | export function demolishCookie(name, options) { 19 | const subdomains = window.location.hostname.split('.'); // ['www','gosport','com'] 20 | const subpaths = window.location.pathname.slice(1).split('/'); // ['my','path'] 21 | 22 | const DOMAINS = [] 23 | .concat(subdomains.map((sub, idx) => `${subdomains.slice(idx).join('.')}`)) // www.gosport.com 24 | .concat(subdomains.map((sub, idx) => `.${subdomains.slice(idx).join('.')}`)) // .gosport.com 25 | .concat(null); // no domain specified 26 | 27 | const PATHS = [] 28 | .concat(subpaths.map((path, idx) => `/${subpaths.slice(0, idx + 1).join('/')}`)) // /a/b/foo 29 | .concat(subpaths.map((path, idx) => `/${subpaths.slice(0, idx + 1).join('/')}/`)) // /a/b/foo/ 30 | .concat('/') // root path 31 | .concat(null); // no path specified 32 | 33 | 34 | DOMAINS.forEach(domain => 35 | PATHS.forEach(path => Cookies.remove(name, { ...options, domain, path })) 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/toolbar/components/Menu/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/toolbar/components/PreviewMenu/x.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/toolbar/components/Panel/PreviewPanel.js: -------------------------------------------------------------------------------- 1 | import { stringCheck } from '@common'; 2 | import { BasePanel, xSvg } from '.'; 3 | import { Icon } from '..'; 4 | 5 | 6 | export const PreviewPanel = ({ maxSummarySize, maxTitleSize, onClose, preview }) => ( 7 | 8 | 9 | 13 | 18 | 19 | ); 20 | 21 | const PreviewHeader = ({ title, numberOfDocs }) => ( 22 |
23 |

{title}

24 |

{numberOfDocs} document{numberOfDocs === 1 ? '' : 's'} to preview

25 |
26 | ); 27 | 28 | const PreviewDocuments = ({ documents, maxSummarySize, maxTitleSize }) => ( 29 |
30 | {documents.map(doc => 31 | 36 | )} 37 |
38 | ); 39 | 40 | const PreviewDocument = ({ doc, maxSummarySize, maxTitleSize }) => ( 41 | 42 |

{stringCheck(doc.title, maxTitleSize)}

43 |
{stringCheck(doc.summary, maxSummarySize)}
44 |
45 | ); 46 | -------------------------------------------------------------------------------- /src/iframe/prediction.js: -------------------------------------------------------------------------------- 1 | import { query, Sorter, fetchy } from '@common'; 2 | 3 | export async function getDocuments({ url, ref, tracker, location }) { 4 | const documents = await fetchy({ 5 | url: `/toolbar/predict?${query({ url, ref, tracker })}` 6 | }).then(res => res.documents.map(normalizeDocument)); 7 | 8 | const documentsSorted = ( 9 | // from less important to most important 10 | new Sorter(documents) 11 | // .fuzzy(a => `${a.title} ${a.summary}`, text) // Sometimes wrong 12 | // .min(a => a.urls.length) 13 | .max(a => a.updated) 14 | .min(a => a.queryTotal) 15 | .is(a => a.uid && location.hash.match(a.uid)) 16 | .is(a => a.uid && location.search.match(a.uid)) 17 | .is(a => a.uid && location.pathname.match(a.uid)) 18 | .min(a => a.urls.length) 19 | .min(a => a.weight) 20 | .is(a => a.singleton) 21 | .is(a => a.uid && location.hash.match(a.uid) && !a.singleton) 22 | .is(a => a.uid && location.search.match(a.uid) && !a.singleton) 23 | .is(a => a.uid && location.pathname.match(a.uid) && !a.singleton) 24 | .compute() 25 | ); 26 | 27 | return documentsSorted; 28 | } 29 | 30 | 31 | function normalizeDocument(doc) { 32 | const status = (() => { 33 | if (doc.editorUrl.includes('c=unclassified')) return 'draft'; 34 | if (doc.editorUrl.includes('c=release')) return 'release'; 35 | if (doc.editorUrl.includes('c=variation')) return 'experiment'; 36 | if (doc.editorUrl.includes('c=published')) return 'live'; 37 | return null; 38 | })(); 39 | 40 | return { 41 | ...doc, 42 | editorUrl: window.location.origin + doc.editorUrl, 43 | status 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/toolbar/components/JsonView/minus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 12 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | parser: 'babel-eslint', 5 | extends: ['standard', 'standard-preact', 'airbnb-base'], 6 | env: { 7 | browser: true, 8 | jest: true, 9 | }, 10 | rules: { 11 | 'no-unused-expressions': 0, 12 | 'prefer-template': 0, 13 | 'no-confusing-arrow': 0, 14 | 'import/no-cycle': 0, // maybe revert this one (config-experiment, panel-children) 15 | 'no-await-in-loop': 0, 16 | 'promise/param-names': 0, 17 | 'class-methods-use-this': 0, 18 | 'func-names': 0, 19 | "global-require": 0, 20 | 'no-new': 0, 21 | 'no-multi-assign': 0, 22 | 'space-before-function-paren': 0, 23 | 'function-paren-newline': 0, 24 | 'no-param-reassign': 0, 25 | 'no-return-assign': [2, 'except-parens'], 26 | 'no-use-before-define': 0, 27 | 'consistent-return': 0, 28 | 'no-prototype-builtins': 0, 29 | 'arrow-parens': [2, 'as-needed'], 30 | 'no-console': [2, { allow: ['warn', 'error'] }], 31 | 'prefer-rest-params': 0, 32 | 'nonblock-statement-body-position': 0, 33 | 'no-underscore-dangle': 0, 34 | 'react/react-in-jsx-scope': 0, 35 | 'comma-dangle': [ 36 | 'error', 37 | { 38 | arrays: 'only-multiline', 39 | objects: 'only-multiline', 40 | imports: 'only-multiline', 41 | exports: 'only-multiline', 42 | functions: 'ignore', 43 | }, 44 | ], 45 | 'import/prefer-default-export': 0, 46 | 'import/no-extraneous-dependencies': 0, 47 | 'object-curly-newline': 0, 48 | 'implicit-arrow-linebreak': 0, 49 | curly: 0, 50 | }, 51 | "settings": { 52 | "import/resolver": { 53 | webpack: { 54 | config: 'webpack.config.js' 55 | } 56 | } 57 | } 58 | }; 59 | -------------------------------------------------------------------------------- /src/toolbar/components/Panel/SharePanel.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'preact'; 2 | import { copyText, wait } from '@common'; 3 | import { BasePanel, xSvg } from '.'; 4 | import { Icon } from '..'; 5 | 6 | export class SharePanel extends Component { 7 | constructor() { 8 | super(...arguments); 9 | this.state = { loading: true, url: '' }; 10 | this.props.preview.share().then(url => this.setState({ url, loading: false })); 11 | } 12 | 13 | render() { 14 | const { onClose, preview } = this.props; 15 | const { url, loading } = this.state; 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | } 25 | 26 | const ShareHeader = ({ title }) => ( 27 |
28 |

{title}

29 |

Get a shareable link

30 |
31 | ); 32 | 33 | class Share extends Component { 34 | state = { copied: false }; 35 | 36 | async copy() { 37 | copyText(this.props.url); 38 | this.setState({ copied: true }); 39 | await wait(1); 40 | this.setState({ copied: false }); 41 | } 42 | 43 | render() { 44 | const { url, loading } = this.props; 45 | const { copied } = this.state; 46 | return ( 47 |
48 |

Share this preview via public share link

49 |
{loading ? 'Loading...' : url}
50 | {url && ( 51 |
this.copy()}> 52 | {copied ? 'Copied!' : 'Copy the link'} 53 |
54 | )} 55 |
56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/toolbar/components/PreviewMenu/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Created with Sketch. 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Prismic toolbar 2 | The prismic toolbar enables content writers to: 3 | - Identify Prismic content on the page 4 | - Preview unpublished changes (drafts and releases) 5 | - Perform A/B tests (experiments) 6 | 7 | 8 | 9 | ## How to use it? 10 | Include the following script on every page of your site (including the `404` page). 11 | 12 | Remember to replace `YOUR_REPO_NAME` with the name of your Prismic repository. 13 | 14 | ``` 15 | 16 | ``` 17 | 18 | ## How to develop 19 | 20 | - Start your toolbar locally: 21 | ```script 22 | npm start 23 | ``` 24 | 25 | - Serve toolbar assets: 26 | ```script 27 | npm run serve 28 | ``` 29 | 30 | It will serve assets at `http://localhost:8081/prismic-toolbar/[version]`. Where 31 | version is current `package.json` version. 32 | 33 | - Change the path of the script to point to `http://localhost:8081/prismic-toolbar/[version]/prismic.js` from your public folder 34 | 35 | By default the toolbar will communicate with `prismic.io` so the local 36 | `[version]` must match the version served by prismic. 37 | 38 | ### With a proxy 39 | 40 | If you are using a proxy in front of the development server, you must set the 41 | `CDN_HOST` environment variable, so the script will be loaded through the proxy. 42 | 43 | Example: 44 | ```script 45 | CDN_HOST=http://wroom.test npm start 46 | ``` 47 | 48 | Then from your project, load the prismic script like this: 49 | 50 | ``` 51 | 52 | ``` 53 | 54 | Note that the repo name should be qualified with your proxy domain for the 55 | communication to work. 56 | 57 | ## How to deploy 58 | 59 | - Deploy on prod: 60 | ``` 61 | npm run build:prod 62 | ``` 63 | -------------------------------------------------------------------------------- /src/toolbar/components/Panel/DocumentPanel.js: -------------------------------------------------------------------------------- 1 | import { BasePanel, prismicWhiteSvg } from '.'; 2 | import { Icon } from '../Icon'; 3 | import { Loader } from '../Loader'; 4 | import { NavTabs, EditButton, DevMode } from '..'; 5 | 6 | export const DocumentPanel = ({ loading, documents, queries, onDocumentClick }) => { 7 | if (!documents || (documents && documents.length <= 0)) return null; 8 | 9 | return ( 10 | 11 | 12 | { loading 13 | ? 14 | : panelContent(documents, queries, onDocumentClick) 15 | } 16 | 17 | ); 18 | }; 19 | 20 | const panelContent = (documents, queries, onDocumentClick) => { 21 | // If there is no queries then, we don't display the json. 22 | if (queries && queries.length > 0) { 23 | return ( 24 | , 33 | 37 | ]} 38 | /> 39 | ); 40 | } 41 | return ( 42 | 48 | ); 49 | }; 50 | 51 | const ToolbarHeader = () => ( 52 |
53 |
54 |
55 | 56 |
57 |
58 | 59 |
60 |

Prismic Toolbar

61 |

Document on this page

62 |
63 |
64 | ); 65 | -------------------------------------------------------------------------------- /src/toolbar/components/JsonView/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 11 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/toolbar/components/EditButton/EditButton.css: -------------------------------------------------------------------------------- 1 | /* DocumentsSummaryTab */ 2 | 3 | .documents-summary-tab { 4 | border-top: 1px solid #E0E2EE; 5 | background: #FFFFFF; 6 | 7 | & .small-title { 8 | font-size: 13px; 9 | color: #8091A5; 10 | letter-spacing: 0; 11 | line-height: 35px; 12 | background: #F5F6F9; 13 | margin: 0; 14 | padding-left: 25px; 15 | border-bottom: 1px solid #E0E2EE; 16 | } 17 | 18 | & .document-summary { 19 | padding: 0px 25px; 20 | border-bottom: 1px solid #E0E2EE; 21 | 22 | & .wrapper-title-status { 23 | padding-top: 25px; 24 | display: inline-block; 25 | width: 100%; 26 | 27 | /* status */ 28 | & span { 29 | display: inline-block; 30 | padding: 2px 5px; 31 | font-size: 12px; 32 | color: #53452E; 33 | letter-spacing: 0; 34 | border: 0 solid #FFFFFF; 35 | border-radius: 1.54px; 36 | 37 | &::first-letter { 38 | text-transform: uppercase; 39 | } 40 | 41 | &.live { 42 | background: #B0E2C9; 43 | } 44 | 45 | &.draft { 46 | background: #FFDBA2; 47 | } 48 | 49 | &.release { 50 | background: #ead9ff; 51 | } 52 | 53 | &.experiment { 54 | background: #DCE0E5; 55 | } 56 | } 57 | 58 | /* title */ 59 | & h2 { 60 | padding-left: 10px; 61 | font-size: 16px; 62 | color: #293258; 63 | letter-spacing: 0.23px; 64 | line-height: 20px; 65 | font-weight: bold; 66 | display: inline; 67 | } 68 | } 69 | 70 | /* summary */ 71 | & p { 72 | font-size: 14px; 73 | color: #435169; 74 | letter-spacing: 0; 75 | line-height: 26px; 76 | } 77 | } 78 | } -------------------------------------------------------------------------------- /src/toolbar/components/Panel/link.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /src/toolbar/components/Toolbar/Toolbar.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | import { Panel, Menu, PreviewMenu, views } from '..'; 3 | 4 | const { NONE } = views; 5 | 6 | export class Toolbar extends Component { 7 | constructor({ prediction }) { 8 | super(...arguments); 9 | 10 | if (prediction) { 11 | prediction.onDocuments((documents, queries) => { 12 | this.setState({ documents, queries, documentsLoading: false }); 13 | }); 14 | 15 | prediction.onDocumentsLoading(() => { 16 | this.setState({ documentsLoading: true }); 17 | }); 18 | 19 | prediction.setup(); 20 | } 21 | 22 | this.state = { 23 | page: NONE, 24 | documents: [], 25 | queries: [], 26 | renderedPreview: this.props.preview.active, 27 | documentsLoading: false 28 | }; 29 | } 30 | 31 | setPage = page => this.setState({ page }); 32 | 33 | closePreview = () => { 34 | this.setState({ renderedPreview: false }); 35 | } 36 | 37 | render() { 38 | const { preview, analytics, auth } = this.props; 39 | const { page, documents, queries } = this.state; 40 | const hasDocs = Boolean(documents && documents.length); 41 | 42 | return ( 43 |
44 | this.setPage(NONE)} 47 | documentsLoading={this.state.documentsLoading} 48 | documents={documents} 49 | queries={queries} 50 | preview={preview} 51 | page={page} 52 | /> 53 | 54 | { this.props.displayPreview && this.state.renderedPreview 55 | ? 61 | : null 62 | } 63 |
64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/toolbar-service/client.js: -------------------------------------------------------------------------------- 1 | import { Messages } from './messages'; 2 | 3 | export default class Client { 4 | constructor(portToIframe, hostname) { 5 | this.port = portToIframe; 6 | this.hostname = hostname; 7 | this.events = document.createElement('span'); // EventTarget unsupported in IE 8 | 9 | // Send a custom event when we receive a result after we post a message to the iframe 10 | portToIframe.onmessage = msg => { 11 | const { type, data } = msg.data; 12 | const event = new CustomEvent(type, { detail: data }); 13 | this.events.dispatchEvent(event); 14 | }; 15 | } 16 | 17 | _messageToPromise(/* string */messageName, /* Object | null */data) /* Promise */ { 18 | return new Promise(resolve => { 19 | this.events.addEventListener(messageName, e => resolve(e.detail), { once: true }); 20 | this.port.postMessage({ type: messageName, data }); 21 | }); 22 | } 23 | 24 | getPreviewState() /* Promise<{ Object }> */ { 25 | return this._messageToPromise(Messages.PreviewState); 26 | } 27 | 28 | getPredictionDocs(data) /* Promise */ { 29 | return this._messageToPromise(Messages.PredictionDocs, data); 30 | } 31 | 32 | getDevModeQueriesResults(data) /* Promise */ { 33 | return this._messageToPromise(Messages.DevModeQueriesResults, data); 34 | } 35 | 36 | updatePreview() /* Promise<{ reload: boolean, ref: string }> */ { 37 | return this._messageToPromise(Messages.UpdatePreview); 38 | } 39 | 40 | closePreviewSession() /* Promise */ { 41 | return this._messageToPromise(Messages.ClosePreviewSession); 42 | } 43 | 44 | sharePreview(location, blob) /* Promise */ { 45 | return this._messageToPromise(Messages.SharePreview, { location, blob }); 46 | } 47 | 48 | trackDocumentClick(data) /* Promise */ { 49 | return this._messageToPromise(Messages.TrackDocumentClick, data); 50 | } 51 | 52 | trackToolbarSetup() /* Promise */ { 53 | return this._messageToPromise(Messages.TrackToolbarSetup); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/common/hooks.js: -------------------------------------------------------------------------------- 1 | // Export Hooks 2 | export class Hooks { 3 | constructor() { 4 | this.hooks = []; 5 | } 6 | 7 | _removeHook(type, callback) { 8 | window.removeEventListener(type, callback); 9 | this.hooks = this.hooks.filter(hook => !(hook.type === type && hook.callback === callback)); 10 | } 11 | 12 | _removeType(type) { 13 | this.hooks 14 | .filter(hook => hook.type === type) 15 | .forEach(hook => window.removeEventListener(type, hook.callback)); 16 | 17 | this.hooks = this.hooks.filter(hook => hook.type !== type); 18 | } 19 | 20 | _removeAll() { 21 | this.hooks.forEach(hook => window.removeEventListener(hook.type, hook.callback)); 22 | this.hooks = []; 23 | } 24 | 25 | on = (type, callback) => { 26 | window.addEventListener(type, callback); 27 | this.hooks.push({ type, callback }); 28 | return callback; 29 | }; 30 | 31 | off = (type, callback) => { 32 | if (type && callback) this._removeHook(type, callback); 33 | else if (type) this._removeType(type); 34 | else this._removeAll(); 35 | }; 36 | } 37 | 38 | // Window event 39 | function event(type, detail = null) { 40 | const e = new CustomEvent(type, { detail }); 41 | window.dispatchEvent(e); 42 | } 43 | 44 | // Fetch hook 45 | const oldFetch = window.fetch; 46 | window.fetch = async (...args) => { 47 | if (args[1] && args[1].emitEvents === false) return oldFetch(...args); 48 | event('beforeRequest', args); 49 | const response = await oldFetch(...args); 50 | event('afterRequest', args); 51 | return response; 52 | }; 53 | 54 | // History hook 55 | const wrapHistory = function(type) { 56 | const orig = window.history[type]; 57 | return function() { 58 | const rv = orig.apply(this, arguments); 59 | const e = new CustomEvent('historyChange', { detail: arguments }); 60 | window.dispatchEvent(e); 61 | return rv; 62 | }; 63 | }; 64 | window.history.pushState = wrapHistory('pushState'); 65 | window.history.replaceState = wrapHistory('replaceState'); 66 | 67 | // Active Tab Hook 68 | window.addEventListener('focus', () => { 69 | event('activeTab', true); 70 | }); 71 | 72 | window.addEventListener('blur', () => { 73 | event('activeTab', false); 74 | }); 75 | -------------------------------------------------------------------------------- /src/toolbar-service/iframe.js: -------------------------------------------------------------------------------- 1 | import * as Prediction from '@iframe/prediction'; 2 | import * as DevMode from '@iframe/devMode'; 3 | import Preview from '@iframe/preview'; 4 | import * as Analytics from '@iframe/analytics'; 5 | 6 | import { Messages } from './messages'; 7 | 8 | export function setup(portToMainWindow) { 9 | portToMainWindow.onmessage = async msg => { 10 | const { type, data } = msg.data; 11 | const result /* Promise */ = await (() => { 12 | switch (type) { 13 | case Messages.PreviewState: return getPreviewState(); 14 | case Messages.PredictionDocs: return getPredictionDocs(data); 15 | case Messages.DevModeQueriesResults: return getDevModeQueriesResults(data); 16 | case Messages.UpdatePreview: return updatePreview(); 17 | case Messages.ClosePreviewSession: return closePreviewSession(); 18 | case Messages.SharePreview: return sharePreview(data); 19 | case Messages.TrackDocumentClick: return trackDocumentClick(data); 20 | case Messages.TrackToolbarSetup: return trackToolbarSetup(); 21 | default: return new Promise(null); 22 | } 23 | })(); 24 | portToMainWindow.postMessage({ type, data: result }); 25 | }; 26 | } 27 | 28 | async function getPreviewState() /* Promise<{ Object }> */ { 29 | return Preview.getState(); 30 | } 31 | 32 | async function getPredictionDocs(data) /* Promise */ { 33 | return Prediction.getDocuments(data); 34 | } 35 | 36 | async function getDevModeQueriesResults({ tracker }) /* Promise */ { 37 | return DevMode.getQueriesResults(tracker); 38 | } 39 | 40 | async function updatePreview() /* Promise<{ reload: boolean, ref: string }> */ { 41 | return Preview.getCurrentRef(); 42 | } 43 | 44 | async function closePreviewSession() /* Promise */ { 45 | return Preview.close(); 46 | } 47 | 48 | async function sharePreview({ location, blob }) /* Promise */ { 49 | return Preview.share(location, blob); 50 | } 51 | 52 | async function trackDocumentClick({ isMain }) /* Promise */ { 53 | return Analytics.trackDocumentClick(isMain); 54 | } 55 | 56 | async function trackToolbarSetup() /* Promise */ { 57 | return Analytics.trackToolbarSetup(); 58 | } 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Project 2 | node_modules/ 3 | build/ 4 | 5 | # Build 6 | s3.sh 7 | 8 | # Config 9 | .prettierrc.js 10 | 11 | # Auto-generated 12 | yarn.lock 13 | yarn-error.log 14 | npm-debug.log 15 | .DS_Store 16 | 17 | .vscode/* 18 | 19 | # Created by https://www.gitignore.io/api/intellij 20 | # Edit at https://www.gitignore.io/?templates=intellij 21 | 22 | ### Intellij ### 23 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 24 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 25 | 26 | .idea/ 27 | # User-specific stuff 28 | .idea/**/workspace.xml 29 | .idea/**/tasks.xml 30 | .idea/**/usage.statistics.xml 31 | .idea/**/dictionaries 32 | .idea/**/shelf 33 | 34 | # Generated files 35 | .idea/**/contentModel.xml 36 | 37 | # Sensitive or high-churn files 38 | .idea/**/dataSources/ 39 | .idea/**/dataSources.ids 40 | .idea/**/dataSources.local.xml 41 | .idea/**/sqlDataSources.xml 42 | .idea/**/dynamic.xml 43 | .idea/**/uiDesigner.xml 44 | .idea/**/dbnavigator.xml 45 | 46 | # Gradle 47 | .idea/**/gradle.xml 48 | .idea/**/libraries 49 | 50 | # Gradle and Maven with auto-import 51 | # When using Gradle or Maven with auto-import, you should exclude module files, 52 | # since they will be recreated, and may cause churn. Uncomment if using 53 | # auto-import. 54 | # .idea/modules.xml 55 | # .idea/*.iml 56 | # .idea/modules 57 | 58 | # CMake 59 | cmake-build-*/ 60 | 61 | # Mongo Explorer plugin 62 | .idea/**/mongoSettings.xml 63 | 64 | # File-based project format 65 | *.iws 66 | 67 | # IntelliJ 68 | out/ 69 | 70 | # mpeltonen/sbt-idea plugin 71 | .idea_modules/ 72 | 73 | # JIRA plugin 74 | atlassian-ide-plugin.xml 75 | 76 | # Cursive Clojure plugin 77 | .idea/replstate.xml 78 | 79 | # Crashlytics plugin (for Android Studio and IntelliJ) 80 | com_crashlytics_export_strings.xml 81 | crashlytics.properties 82 | crashlytics-build.properties 83 | fabric.properties 84 | 85 | # Editor-based Rest Client 86 | .idea/httpRequests 87 | 88 | # Android studio 3.1+ serialized cache file 89 | .idea/caches/build_file_checksums.ser 90 | 91 | # JetBrains templates 92 | **___jb_tmp___ 93 | 94 | ### Intellij Patch ### 95 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 96 | 97 | *.iml 98 | # modules.xml 99 | # .idea/misc.xml 100 | # *.ipr 101 | 102 | # Sonarlint plugin 103 | .idea/sonarlint 104 | 105 | # End of https://www.gitignore.io/api/intellij -------------------------------------------------------------------------------- /src/toolbar/prediction.js: -------------------------------------------------------------------------------- 1 | import { Hooks, getLocation, wait } from '@common'; 2 | 3 | export class Prediction { 4 | constructor(client, previewCookie) { 5 | this.client = client; 6 | this.cookie = previewCookie; 7 | this.hooks = new Hooks(); 8 | this.documentHooks = []; 9 | this.documentLoadingHooks = []; 10 | this.count = 0; 11 | this.retry = 0; 12 | this.apiEndPoint = this.buildApiEndpoint(); 13 | } 14 | 15 | buildApiEndpoint = () /* String */ => { 16 | const protocol = this.client.hostname.includes('.test') ? 'http' : 'https'; 17 | return protocol + '://' + this.client.hostname; 18 | } 19 | 20 | // Set event listener 21 | setup = async () => { 22 | const currentTracker = this.cookie.getTracker(); 23 | this.hooks.on('historyChange', () => this.start()); 24 | await this.start(currentTracker); 25 | } 26 | 27 | // Start predictions for this URL 28 | start = async maybeTracker => { 29 | // wait for all requests to be played first (client side) 30 | this.dispatchLoading(); 31 | await wait(2); 32 | // load prediction 33 | const tracker = maybeTracker || this.cookie.getTracker(); 34 | await this.predict(tracker); 35 | this.cookie.refreshTracker(); 36 | } 37 | 38 | // Fetch predicted documents 39 | predict = tracker => ( 40 | new Promise(async resolve => { 41 | const documentsSorted = await this.client.getPredictionDocs({ 42 | ref: this.cookie.getRefForDomain(), 43 | url: window.location.pathname, 44 | tracker, 45 | location: getLocation() 46 | }); 47 | const queriesResults = await this.client.getDevModeQueriesResults({ tracker }); 48 | this.dispatch(documentsSorted, queriesResults); 49 | resolve(); 50 | }) 51 | ) 52 | 53 | retryPrediction = () => { 54 | const nextRetryMs = this.retry * 1000; // 1s / 2s / 3s 55 | setTimeout(this.predict, nextRetryMs); 56 | } 57 | 58 | // Dispatch documents to hooks 59 | dispatch = (documents, queries) => { 60 | Object.values(this.documentHooks).forEach(hook => hook(documents, queries)); // Run the hooks 61 | } 62 | 63 | dispatchLoading = () => { 64 | Object.values(this.documentLoadingHooks).forEach(hook => hook()); 65 | } 66 | 67 | onDocumentsLoading = func => { 68 | const c = this.count += 1; // Create the hook key 69 | this.documentLoadingHooks[c] = func; // Create the hook 70 | return () => delete this.documentLoadingHooks[c]; // Alternative to removeEventListener 71 | } 72 | 73 | // Documents hook 74 | onDocuments = func => { 75 | const c = this.count += 1; // Create the hook key 76 | this.documentHooks[c] = func; // Create the hook 77 | return () => delete this.documentHooks[c]; // Alternative to removeEventListener 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/toolbar/components/Panel/Panel.css: -------------------------------------------------------------------------------- 1 | /* Base Panel */ 2 | 3 | .BasePanel { 4 | background: white; 5 | box-shadow: 0 0 2px 0 rgba(0,0,0,0.07), 0 20px 30px 0 rgba(0,0,0,0.10); 6 | border-radius: 10px; 7 | width: 400px; 8 | height: calc(100vh - 160px); 9 | position: absolute; 10 | bottom: 100px; 11 | left: 0; 12 | overflow-y: auto; 13 | min-height: 250px; 14 | max-height: 650px; 15 | will-change: transform; 16 | 17 | & .top { 18 | padding: 30px; 19 | background: white; 20 | 21 | & h2 { 22 | margin-bottom: 5px; 23 | } 24 | } 25 | 26 | & .bottom { 27 | padding: 30px; 28 | flex-grow: 1; 29 | } 30 | 31 | & .x { 32 | position: absolute; 33 | top: 20px; 34 | right: 20px; 35 | cursor: pointer; 36 | } 37 | } 38 | 39 | /* DocumentPanel Header */ 40 | 41 | .DocumentPanel .toolbar-header { 42 | position: relative; 43 | height: 100px; 44 | background-color: #5163BA; 45 | display: flex; 46 | flex-wrap: nowrap; 47 | 48 | & .background-icon { 49 | position: absolute; 50 | top: 25px; 51 | left: 25px; 52 | background: #1E2D77; 53 | width: 50px; 54 | height: 50px; 55 | border-radius: 50%; 56 | } 57 | 58 | & .Icon { 59 | position: absolute; 60 | top: 13px; 61 | left: 13px; 62 | } 63 | 64 | & .wrapper-icon { 65 | flex-basis: 93px; 66 | } 67 | 68 | & .wrapper-title { 69 | display: flex; 70 | flex-direction: column; 71 | justify-content: center; 72 | } 73 | 74 | & h2 { 75 | font-size: 12px; 76 | color: #E3E8EE; 77 | letter-spacing: 0.19px; 78 | line-height: 20px; 79 | } 80 | 81 | & h1 { 82 | font-size: 20px; 83 | color: #FFFFFF; 84 | line-height: 20px; 85 | font-weight: bold; 86 | } 87 | } 88 | 89 | /* PreviewPanel */ 90 | 91 | .PreviewPanel .Draft { 92 | margin-bottom: 20px; 93 | 94 | & h3 { 95 | margin-bottom: 4px; 96 | } 97 | } 98 | 99 | /* SharePanel */ 100 | 101 | .SharePanel .bottom { 102 | /* Label */ 103 | & h2 { 104 | color: #A5A5AE; 105 | margin-bottom: 8px; 106 | } 107 | 108 | /* URL */ 109 | & .url { 110 | background: #EDEDED; 111 | padding: 20px 15px; 112 | border: 1px solid #E3E3E3; 113 | margin-bottom: 30px; 114 | user-select: all; 115 | 116 | &::before { 117 | content: url('link.svg'); 118 | margin-right: 10px; 119 | } 120 | } 121 | 122 | /* Copy button */ 123 | & .copy { 124 | border-radius: 4px; 125 | width: 91%; 126 | text-align: center; 127 | padding: 16px; 128 | color: white; 129 | background: #5163BA; 130 | user-select: none; 131 | cursor: pointer; 132 | 133 | &:hover { 134 | background: #4050a0; 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "prismic-toolbar", 3 | "version": "4.1.2", 4 | "description": "Prismic Toolbar", 5 | "license": "Apache-2.0", 6 | "main": "build/prismic-toolbar.js", 7 | "scripts": { 8 | "start": "webpack --mode development --watch", 9 | "build:prod": "webpack --mode production", 10 | "serve": "http-server -p 8081 -g -c-1 build/", 11 | "lint": "eslint src" 12 | }, 13 | "engines": { 14 | "node": ">=4.8.1", 15 | "npm": ">=2.15.11" 16 | }, 17 | "devDependencies": { 18 | "@babel/core": "^7.11.0", 19 | "@babel/plugin-proposal-class-properties": "^7.10.4", 20 | "@babel/plugin-proposal-export-namespace-from": "^7.10.4", 21 | "@babel/plugin-proposal-json-strings": "^7.10.4", 22 | "@babel/plugin-proposal-numeric-separator": "^7.10.4", 23 | "@babel/plugin-proposal-throw-expressions": "^7.10.4", 24 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 25 | "@babel/plugin-transform-react-jsx": "^7.10.4", 26 | "@babel/preset-env": "^7.11.0", 27 | "@babel/preset-react": "^7.10.4", 28 | "@babel/preset-stage-2": "^7.8.3", 29 | "babel-eslint": "^10.0.1", 30 | "babel-loader": "^8.2.5", 31 | "clean-webpack-plugin": "3.0.0", 32 | "css-loader": "^1.0.1", 33 | "cssnano": "^4.1.7", 34 | "eslint": "^5.9.0", 35 | "eslint-config-airbnb": "^17.1.0", 36 | "eslint-config-airbnb-base": "^13.1.0", 37 | "eslint-config-standard": "^12.0.0", 38 | "eslint-config-standard-preact": "^1.1.6", 39 | "eslint-import-resolver-webpack": "^0.10.1", 40 | "eslint-loader": "^2.1.2", 41 | "eslint-plugin-import": "^2.14.0", 42 | "eslint-plugin-jsx-a11y": "^6.1.2", 43 | "eslint-plugin-node": "11.1.0", 44 | "eslint-plugin-promise": "4.2.1", 45 | "eslint-plugin-react": "^7.11.1", 46 | "eslint-plugin-standard": "^4.0.0", 47 | "event-target": "^1.2.3", 48 | "events-polyfill": "^2.1.0", 49 | "exports-loader": "^0.7.0", 50 | "fuse.js": "^3.3.0", 51 | "html2canvas": "^1.0.0-alpha.12", 52 | "http-server": "0.12.3", 53 | "imports-loader": "^0.8.0", 54 | "postcss-easy-import": "^3.0.0", 55 | "postcss-loader": "^3.0.0", 56 | "postcss-preset-env": "^6.4.0", 57 | "postcss-url": "^8.0.0", 58 | "puppeteer": "^1.10.0", 59 | "raw-loader": "^0.5.1", 60 | "regenerator-runtime": "^0.12.1", 61 | "rimraf": "^2.6.2", 62 | "style-loader": "^0.23.1", 63 | "suppress-chunks-webpack-plugin": "0.0.5", 64 | "url-loader": "^1.1.2", 65 | "web-webpack-plugin": "4.2.1", 66 | "webpack": "^4.25.1", 67 | "webpack-bundle-analyzer": "^3.8.0", 68 | "webpack-cli": "^3.3.11" 69 | }, 70 | "dependencies": { 71 | "js-cookie": "^2.2.0", 72 | "preact": "8.5.3", 73 | "preact-compat": "3.19.0", 74 | "react-collapsible": "^2.6.0", 75 | "react-tabs": "^3.0.0", 76 | "react-transition-group": "^2.5.0", 77 | "react-treebeard": "^2.1.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/toolbar/components/JsonView/JsonView.css: -------------------------------------------------------------------------------- 1 | /* ----- WRAPPER CSS ----- */ 2 | .wrapper-json-view { 3 | font-family: 'Source Code Pro', monospace; 4 | font-size: 12px; 5 | 6 | 7 | /* ---- REMOVE LEFT INDENT FOR THE FIRST CHILDREN OF AN OBJECT ---- */ 8 | & > ul > li > div > ul > li { 9 | left: 0 !important; 10 | } 11 | 12 | & > ul > li { 13 | left: 0 !important; 14 | } 15 | } 16 | 17 | /* ----- BANNER FOR UID AND METADATA ----- */ 18 | .banner-uid { 19 | padding: 0px 0px 0px 25px; 20 | position: relative; 21 | color: #8091A5; 22 | letter-spacing: 0; 23 | line-height: 35px; 24 | background: #EFF1F9; 25 | border-bottom: 1px solid #E0E2EE; 26 | } 27 | 28 | .banner-metadata { 29 | color: #7F8FAA; 30 | letter-spacing: 0.17px; 31 | padding: 0px 0px 0px 25px; 32 | line-height: 20px; 33 | } 34 | 35 | /* ----- DECORATOR CSS ----- */ 36 | .json-view-container { 37 | line-height: 23px; 38 | letter-spacing: -0.2px; 39 | background: #F5F6F9; 40 | 41 | & .icon-toggle { 42 | width: 13px; 43 | height: 13px; 44 | padding-right: 8px; 45 | vertical-align: middle; 46 | cursor: pointer; 47 | } 48 | 49 | & .copy-button { 50 | font-family: 'Source Code Pro', monospace; 51 | font-size: 9px; 52 | cursor: pointer; 53 | color: #48525E; 54 | background: #FFFFFF; 55 | position: absolute; 56 | right: 3px; 57 | top: 3px; 58 | padding: 0px 3px; 59 | 60 | letter-spacing: 0; 61 | text-align: center; 62 | line-height: 17px; 63 | display: none; 64 | } 65 | 66 | & .key-object { 67 | color: #5163BA; 68 | padding: 0; 69 | vertical-align: middle; 70 | cursor: pointer; 71 | } 72 | 73 | & .key-string { 74 | color: #1E2D77; 75 | padding: 0; 76 | vertical-align: middle; 77 | } 78 | 79 | & .value-string { 80 | color: #435169; 81 | vertical-align: middle; 82 | } 83 | } 84 | 85 | .json-view-container:hover { 86 | background: rgba(81,99,186,0.10); 87 | border-radius: 3px; 88 | 89 | & .copy-button { 90 | display: inline-block; 91 | } 92 | } 93 | 94 | .border { 95 | display: inline-block; 96 | width: 15px; 97 | height: 1px; 98 | background: #BCC2CB; 99 | vertical-align: middle; 100 | 101 | &:after { 102 | content: ''; 103 | position: absolute; 104 | top: -5px; 105 | left: 0; 106 | border-left: 1px solid #BCC2CB; 107 | } 108 | 109 | &.horizontal:after { 110 | height: 100%; 111 | } 112 | 113 | &.last-nested:after { 114 | height: 17px; 115 | } 116 | 117 | &.with-right-margin { 118 | margin-right: 4px; 119 | } 120 | } -------------------------------------------------------------------------------- /src/toolbar/preview/index.js: -------------------------------------------------------------------------------- 1 | import { toolbarEvents, dispatchToolbarEvent, getLocation } from '@common'; 2 | import { reloadOrigin } from '../utils'; 3 | import screenshot from './screenshot'; 4 | 5 | export class Preview { 6 | constructor(client, previewCookie, previewState) { 7 | this.cookie = previewCookie; 8 | this.client = client; 9 | this.state = previewState; 10 | 11 | this.end = this.end.bind(this); 12 | this.share = this.share.bind(this); 13 | } 14 | 15 | // Run once on page load to start or end preview 16 | setup = async () => { 17 | const preview = this.state.preview || {}; 18 | this.active = Boolean(preview.ref); 19 | this.ref = preview.ref; 20 | this.title = preview.title; 21 | this.updated = preview.updated; 22 | this.documents = preview.documents || []; 23 | 24 | const refUpToDate = preview.ref === this.cookie.getRefForDomain(); 25 | const displayPreview = this.active && refUpToDate; 26 | // We don't display the preview by default unless the start function says so 27 | if (displayPreview) this.watchPreviewUpdates(); 28 | 29 | return { 30 | isActive: this.active, 31 | initialRef: preview.ref, 32 | upToDate: refUpToDate 33 | }; 34 | }; 35 | 36 | watchPreviewUpdates() { 37 | if (this.active) { 38 | this.interval = setInterval(() => { 39 | if (document.visibilityState === 'visible') { 40 | if (this.cookie.getRefForDomain()) { 41 | this.updatePreview(); 42 | } else { 43 | this.end(); 44 | } 45 | } 46 | }, 3000); 47 | } 48 | } 49 | 50 | cancelPreviewUpdates() { 51 | if (this.interval) clearInterval(this.interval); 52 | } 53 | 54 | async updatePreview() { 55 | const { reload, ref } = await this.client.updatePreview(); 56 | this.start(ref); 57 | if (reload) { 58 | // Dispatch the update event and hard reload if not cancelled by handlers 59 | if (dispatchToolbarEvent(toolbarEvents.previewUpdate, { ref })) { 60 | this.cancelPreviewUpdates(); 61 | reloadOrigin(); 62 | } 63 | } 64 | } 65 | 66 | // Start preview 67 | async start(ref) { 68 | if (!ref) { 69 | await this.end(); 70 | return { displayPreview: false, shouldReload: false }; 71 | } 72 | if (ref === this.cookie.getRefForDomain()) { 73 | return { displayPreview: true, shouldReload: false }; 74 | } 75 | this.cookie.upsertPreviewForDomain(ref); 76 | // Force to display the preview 77 | return { displayPreview: false, shouldReload: true }; 78 | } 79 | 80 | // End preview 81 | async end() { 82 | this.cancelPreviewUpdates(); 83 | await this.client.closePreviewSession(); 84 | this.cookie.deletePreviewForDomain(); 85 | 86 | // Dispatch the end event and hard reload if not cancelled by handlers 87 | if (dispatchToolbarEvent(toolbarEvents.previewEnd)) { 88 | reloadOrigin(); 89 | } 90 | } 91 | 92 | async share() { 93 | const screenBlob = await screenshot(); 94 | return this.client.sharePreview(getLocation(), screenBlob); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/toolbar/components/EditButton/EditButton.js: -------------------------------------------------------------------------------- 1 | import './EditButton.css'; 2 | import { Component } from 'react'; 3 | import { stringCheck } from '@common'; 4 | 5 | /* ----- BEGINNING OF CLASS ----- */ 6 | export class EditButton extends Component { 7 | constructor (props) { 8 | super(props); 9 | this.maxTitleSize = props.maxTitleSize; 10 | this.maxSummarySize = props.maxSummarySize; 11 | this.state = { documents: props.documents, onClick: props.onClick }; 12 | } 13 | 14 | splitDocuments(documents) { 15 | if (documents.length === 0) return [[], [], []]; 16 | if (documents.length === 1) return [documents[0], [], []]; 17 | 18 | return documents.slice(1).reduce(([main, others, links], doc) => { 19 | if (doc.isDocumentLink) return [main, others, links.concat([doc])]; 20 | return [main, others.concat([doc]), links]; 21 | }, [documents[0], [], []]); 22 | } 23 | 24 | /* ----- RENDER FUNCTION ----- */ 25 | render() { 26 | const { documents, onClick } = this.state; 27 | const [mainDocument, otherDocuments, documentLinks] = this.splitDocuments(documents); 28 | 29 | return ( 30 |
31 |

Main Document

32 | 39 | {BannerOtherDocs(otherDocuments)} 40 | { 41 | otherDocuments.map(document => 42 | 48 | ) 49 | } 50 | 51 | {BannerDocumentLinks(documentLinks)} 52 | { 53 | documentLinks.map(document => 54 | 60 | ) 61 | } 62 |
63 | ); 64 | } 65 | } 66 | 67 | const DocumentSummary = ({ document, isMain, maxTitleSize, maxSummarySize, onClick }) => ( 68 | onClick({ isMain })}> 69 |
70 | {document.status} 71 |

{stringCheck(document.title, maxTitleSize)}

72 |
73 |

{stringCheck(document.summary, maxSummarySize) || 'Our goal at Prismic is to build the future of the CMS. All our improvements and features are based on the great'}

74 |
75 | ); 76 | 77 | const BannerOtherDocs = otherDocs => { 78 | if (otherDocs && otherDocs.length) { // check if array exist and has elements 79 | return ( 80 |

Other Documents

81 | ); 82 | } 83 | }; 84 | 85 | const BannerDocumentLinks = documentLinks => { 86 | if (documentLinks && documentLinks.length) { // check if array exist and has elements 87 | return ( 88 |

Linked Documents

89 | ); 90 | } 91 | }; 92 | -------------------------------------------------------------------------------- /src/iframe/preview.js: -------------------------------------------------------------------------------- 1 | import { fetchy, query, getCookie, demolishCookie, throttle, memoize } from '@common'; 2 | 3 | const SESSION_ID = getCookie('io.prismic.previewSession'); 4 | 5 | // Close preview session 6 | function closePreviewSession () /* void */{ 7 | demolishCookie('io.prismic.previewSession', { sameSite: 'None', secure: true }); 8 | } 9 | 10 | const PreviewRef = { 11 | getCurrent: throttle(async () => { 12 | const s = await State.get(); 13 | const ref = encodeURIComponent(s.preview.ref); 14 | const current = await fetchy({ url: `/previews/${SESSION_ID}/ping?ref=${ref}` }); 15 | 16 | if (typeof s.preview === 'object') { 17 | s.preview.ref = current.ref; 18 | State.set(s); 19 | } 20 | 21 | return current; 22 | }, 2000) 23 | }; 24 | 25 | const Share = { 26 | run: memoize(async (location, blob) => { 27 | const imageId = location.pathname.slice(1) + location.hash + SESSION_ID + '.jpg'; 28 | const imageName = imageId; 29 | const session = await Share.getSession({ location, imageName }); 30 | if (!session.hasPreviewImage) Share.uploadScreenshot(imageName, blob); 31 | return session.url; 32 | }, ({ href }) => href), 33 | 34 | async getSession({ location, imageName }) { 35 | const s = await State.get(); 36 | const qs = query({ 37 | sessionId: SESSION_ID, 38 | pageURL: location.href, 39 | title: s.preview.title, 40 | imageName, 41 | _: s.csrf, 42 | }); 43 | 44 | return fetchy({ 45 | url: `/previews/s?${qs}`, 46 | method: 'POST', 47 | }); 48 | }, 49 | 50 | async uploadScreenshot(imageName, blob) { 51 | const acl = await fetchy({ 52 | url: `/previews/${SESSION_ID}/acl`, 53 | }); 54 | 55 | // Form 56 | const body = new FormData(); 57 | body.append('key', `${acl.directory}/${imageName}`); 58 | body.append('AWSAccessKeyId', acl.key); 59 | body.append('acl', 'public-read'); 60 | body.append('policy', acl.policy); 61 | body.append('signature', acl.signature); 62 | body.append('Content-Type', 'image/png'); 63 | body.append('Cache-Control', 'max-age=315360000'); 64 | body.append('Content-Disposition', `inline; filename=${imageName}`); 65 | body.append('file', blob); 66 | 67 | // Upload 68 | return fetch(acl.url, { method: 'POST', body }); 69 | } 70 | }; 71 | 72 | const State = { 73 | liveStateNeeded: Boolean(getCookie('is-logged-in')) || Boolean(getCookie('io.prismic.previewSession')), 74 | 75 | state: null, 76 | 77 | get: async () => { 78 | if (!State.state) { 79 | await State.insert(); 80 | } 81 | return State.state; 82 | }, 83 | 84 | set: (newState = {}) => { 85 | State.state = newState; 86 | }, 87 | 88 | setNormalized: (newState = {}) => { 89 | State.set(State.normalize(newState)); 90 | }, 91 | 92 | insert: async () => { 93 | if (!State.liveStateNeeded) { 94 | State.setNormalized(); 95 | } else { 96 | State.setNormalized(await fetchy({ url: '/toolbar/state' })); 97 | } 98 | }, 99 | 100 | normalize: (_state = {}) => ( 101 | Object.assign({}, { 102 | csrf: _state.csrf || null, 103 | auth: Boolean(_state.isAuthenticated), 104 | preview: _state.previewState || null 105 | }, _state.previewState ? { 106 | preview: { 107 | ref: _state.previewState.ref, 108 | title: _state.previewState.title, 109 | updated: _state.previewState.lastUpdate, 110 | documents: [] 111 | .concat(_state.previewState.draftPreview) 112 | .concat(_state.previewState.releasePreview) 113 | .filter(Boolean) 114 | } 115 | } : {}) 116 | ) 117 | }; 118 | 119 | export default { 120 | getState: State.get, 121 | share: Share.run, 122 | close: closePreviewSession, 123 | getCurrentRef: PreviewRef.getCurrent 124 | }; 125 | -------------------------------------------------------------------------------- /src/toolbar/preview/cookie.js: -------------------------------------------------------------------------------- 1 | import { getCookie, setCookie, demolishCookie, isObject } from '@common'; 2 | import { random } from '../../common/general'; 3 | 4 | const PREVIEW_COOKIE_NAME = 'io.prismic.preview'; 5 | 6 | // Preview cookie manager for a specific repository (safe to have multiple instances) 7 | export class PreviewCookie { 8 | constructor(isAuthenticated, domain) { 9 | this.isAuthenticated = isAuthenticated; 10 | this.domain = domain; 11 | } 12 | 13 | init(ref) { 14 | const hasConvertCookie = this.makeConvertLegacy(); 15 | if (hasConvertCookie) return { convertedLegacy: true }; 16 | 17 | const tracker = (() => { 18 | const c = this.get(); 19 | return c && c._tracker; 20 | })(); 21 | const value = this.build({ tracker, preview: ref }); 22 | this.set(value); 23 | return { convertedLegacy: false }; 24 | } 25 | 26 | makeConvertLegacy() { 27 | const cookieOpt = getCookie(PREVIEW_COOKIE_NAME); 28 | if (cookieOpt) { 29 | const parsedCookie = (() => { 30 | try { 31 | return JSON.parse(cookieOpt); 32 | } catch (e) { 33 | return null; 34 | } 35 | })(); 36 | if (parsedCookie) return false; 37 | this.convertLegacyCookie(cookieOpt); 38 | return true; 39 | } 40 | } 41 | 42 | get() /* Object | string */ { 43 | const cookieOpt = getCookie(PREVIEW_COOKIE_NAME); 44 | if (cookieOpt) { 45 | const parsedCookie = (() => { 46 | try { 47 | return JSON.parse(cookieOpt); 48 | } catch (e) { 49 | return null; 50 | } 51 | })(); 52 | if (parsedCookie) return parsedCookie; 53 | const converted = this.convertLegacyCookie(cookieOpt); 54 | return converted; 55 | } 56 | } 57 | 58 | set(value) { 59 | if (value) setCookie(PREVIEW_COOKIE_NAME, value); 60 | else demolishCookie(PREVIEW_COOKIE_NAME); 61 | } 62 | 63 | build({ 64 | preview, 65 | tracker 66 | } = { 67 | preview: null, 68 | tracker: null 69 | }) { 70 | const previewBlock = (() => { 71 | // copy previews and delete the current one before rebuilding it 72 | if (!preview) return; 73 | if (isObject(preview)) return preview; 74 | return { [this.domain]: { preview } }; 75 | })(); 76 | 77 | const trackerBlock = (() => { 78 | if (!this.isAuthenticated) return; 79 | if (!tracker) return; 80 | return { _tracker: tracker || this.generateTracker() }; 81 | })(); 82 | 83 | if (previewBlock || trackerBlock) 84 | return Object.assign({}, trackerBlock || {}, previewBlock || {}); 85 | } 86 | 87 | convertLegacyCookie(legacyCookieValue) { 88 | const cleanedCookie = this.build({ 89 | tracker: this.generateTracker(), 90 | preview: legacyCookieValue 91 | }); 92 | this.set(cleanedCookie); 93 | return cleanedCookie; 94 | } 95 | 96 | generateTracker() { 97 | return random(8); 98 | } 99 | 100 | upsertPreviewForDomain(previewRef) { 101 | const tracker = (() => { 102 | const c = this.get(); 103 | return c && c._tracker; 104 | })(); 105 | const updatedCookieValue = this.build({ tracker, preview: previewRef }); 106 | this.set(updatedCookieValue); 107 | } 108 | 109 | deletePreviewForDomain() { 110 | const updatedCookieValue = this.build(); 111 | this.set(updatedCookieValue); 112 | } 113 | 114 | getRefForDomain() { 115 | const cookie = this.get(); 116 | if (!cookie) return; 117 | return cookie[this.domain] && cookie[this.domain].preview; 118 | } 119 | 120 | getTracker() { 121 | const cookie = this.get(); 122 | if (!cookie) return; 123 | return cookie._tracker; 124 | } 125 | 126 | refreshTracker() { 127 | const ref = this.getRefForDomain(); 128 | const updatedCookie = this.build({ preview: ref, tracker: this.generateTracker() }); 129 | this.set(updatedCookie); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/common/sorter.js: -------------------------------------------------------------------------------- 1 | // import Fuse from 'fuse.js'; // uncomment to use fuzzy find 2 | /* global Fuse */ 3 | 4 | const transpose = matrix => matrix[0].map((col, i) => matrix.map(row => row[i])); 5 | 6 | // Sorter: Filters executed in order and winner comes first 7 | export class Sorter { 8 | constructor(data) { 9 | this.data = data; 10 | this.filters = []; 11 | } 12 | 13 | addFilter(computeValues, compare) { 14 | this.filters.push({ computeValues, compare }); 15 | return this; 16 | } 17 | 18 | compareData() { 19 | const computedValues = this.filters.map(f => f.computeValues(this.data)); 20 | return transpose(computedValues).map((nodes, index) => ({ nodes, index })); 21 | } 22 | 23 | compareFunction() { 24 | const comparers = this.filters.map(f => f.compare); 25 | return function(a, b) { 26 | let result = 0; 27 | for (let i = 0; i < comparers.length; i += 1) { 28 | const { didFirstWin, tie } = comparers[i](a.nodes[i], b.nodes[i]); 29 | if (!tie) result = didFirstWin ? -1 : 1; 30 | } 31 | return result; 32 | }; 33 | } 34 | 35 | is(val) { 36 | return this.addFilter( 37 | data => data.map(x => Boolean(val(x))), 38 | (a, b) => ({ 39 | didFirstWin: a, 40 | tie: a === b, 41 | }) 42 | ); 43 | } 44 | 45 | isNot(val) { 46 | return this.addFilter( 47 | data => data.map(x => Boolean(val(x))), 48 | (a, b) => ({ 49 | didFirstWin: !a, 50 | tie: a === b, 51 | }) 52 | ); 53 | } 54 | 55 | min(val) { 56 | return this.addFilter( 57 | data => data.map(x => val(x)), 58 | (a, b) => ({ 59 | didFirstWin: a < b, 60 | tie: a === b, 61 | }) 62 | ); 63 | } 64 | 65 | max(val) { 66 | return this.addFilter( 67 | data => data.map(x => val(x)), 68 | (a, b) => ({ 69 | didFirstWin: a > b, 70 | tie: a === b, 71 | }) 72 | ); 73 | } 74 | 75 | missing(val, regex) { 76 | return this.addFilter( 77 | data => data.map(x => Boolean(val(x).match(regex))), 78 | (a, b) => ({ 79 | didFirstWin: a, 80 | tie: a === b, 81 | }) 82 | ); 83 | } 84 | 85 | in(val, str) { 86 | return this.addFilter( 87 | data => data.map(x => Boolean(str.match(val(x)))), 88 | (a, b) => ({ 89 | didFirstWin: a, 90 | tie: a === b, 91 | }) 92 | ); 93 | } 94 | 95 | // val: x => 'food', query: 'foo', options: { caseSensitive, threshold, location, distance } 96 | fuzzy(val, text, options) { 97 | return this.addFilter( 98 | data => { 99 | const values = data.map(x => val(x)); 100 | 101 | const defaults = { 102 | caseSensitive: false, 103 | maxPatternLength: 300, 104 | minMatchCharLength: 4, 105 | }; 106 | 107 | const overrides = { 108 | id: undefined, 109 | keys: undefined, 110 | shouldSort: false, 111 | tokenize: true, 112 | matchAllTokens: true, 113 | includeScore: true, 114 | findAllMatches: true, 115 | includeMatches: false, 116 | }; 117 | 118 | const fuse = new Fuse([text], Object.assign(defaults, options, overrides)); 119 | 120 | // Return search results 121 | return values 122 | .map(value => fuse.search(value.slice(0, 300).trim())[0] || { score: 1 }) 123 | .map(x => x.score); 124 | }, 125 | (a, b) => ({ 126 | didFirstWin: a < b, 127 | tie: a === b, 128 | }) 129 | ); 130 | } 131 | 132 | compute() { 133 | const result = stableSort(this.compareData(), this.compareFunction()); 134 | return result.map(r => this.data[r.index]); 135 | } 136 | } 137 | 138 | const stableSort = (arr, compare) => 139 | arr 140 | .map((item, index) => ({ item, index })) 141 | .sort((a, b) => compare(a.item, b.item) || a.index - b.index) 142 | .map(({ item }) => item); 143 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const cssnano = require('cssnano'); 3 | const postcssUrl = require('postcss-url'); 4 | const postcssEasyImport = require('postcss-easy-import'); 5 | const postcssPresetEnv = require('postcss-preset-env'); 6 | const { WebPlugin } = require('web-webpack-plugin'); 7 | const SuppressChunksPlugin = require('suppress-chunks-webpack-plugin').default; 8 | const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 9 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 10 | const packagejson = require('./package.json'); 11 | 12 | // Make relative path 13 | const relative = path => require('path').resolve(__dirname, path); 14 | 15 | const targetPath = `prismic-toolbar/${packagejson.version}` 16 | 17 | module.exports = (_, options) => { 18 | const dev = !options || options.mode === 'development'; 19 | 20 | return { 21 | // Minimal console output 22 | stats: 'minimal', 23 | 24 | // Source maps 25 | devtool: dev ? 'inline-cheap-module-source-map' : false, 26 | 27 | // Webpack scope hoisting is broken 28 | optimization: { concatenateModules: false }, 29 | 30 | // Don't watch node_modules 31 | watchOptions: { ignored: '/node_modules/' }, 32 | 33 | // Toolbar & iFrame 34 | entry: { 35 | iframe: relative('src/iframe'), 36 | prismic: relative('src/toolbar'), 37 | toolbar: relative('src/toolbar/toolbar'), 38 | }, 39 | 40 | // Output to prismic app 41 | output: { 42 | path: relative('build'), 43 | filename: `${targetPath}/[name].js`, 44 | }, 45 | 46 | // Helper Functions 47 | resolve: { 48 | alias: { 49 | '~': relative('src'), 50 | '@common': relative('src/common'), 51 | '@toolbar': relative('src/toolbar'), 52 | '@iframe': relative('src/iframe'), 53 | '@toolbar-service': relative('src/toolbar-service'), 54 | react: 'preact-compat', 55 | 'react-dom': 'preact-compat', 56 | } 57 | }, 58 | plugins: [ 59 | new webpack.DefinePlugin({ 60 | CDN_HOST: process.env.CDN_HOST ? 61 | JSON.stringify(process.env.CDN_HOST) : 62 | dev ? 63 | JSON.stringify('http://localhost:8081') : 64 | JSON.stringify('https://prismic.io') 65 | }), 66 | // Ensure working regenerator-runtime 67 | new webpack.ProvidePlugin({ 68 | regeneratorRuntime: 'regenerator-runtime', 69 | h: ['preact', 'h'], 70 | }), 71 | // Expose environment variables 72 | new webpack.EnvironmentPlugin(['npm_package_version']), 73 | // Output HTML for iFrame 74 | new WebPlugin({ 75 | filename: `${targetPath}/iframe.html`, 76 | template: relative('src/iframe/index.html'), 77 | }), 78 | new SuppressChunksPlugin(['iframe']), 79 | new CleanWebpackPlugin(), 80 | new BundleAnalyzerPlugin({ 81 | openAnalyzer: false, 82 | analyzerMode: 'static', 83 | }), 84 | ], 85 | 86 | module: { 87 | rules: [ 88 | { 89 | test: /\.css$/, 90 | use: [ 91 | // `import foo.css` gets the raw text to inject anywhere 92 | 'raw-loader', 93 | { 94 | // PostCSS 95 | loader: 'postcss-loader', 96 | options: { 97 | sourceMap: 'inline', 98 | plugins: () => [ 99 | postcssEasyImport(), 100 | postcssUrl({ url: 'inline' }), 101 | postcssPresetEnv({ 102 | features: { 103 | 'nesting-rules': true, 104 | 'color-mod-function': true, 105 | }, 106 | }), 107 | cssnano(), 108 | ], 109 | }, 110 | }, 111 | ], 112 | }, 113 | 114 | // Babel 115 | { 116 | test: /\.js$/, 117 | exclude: /node_modules/, 118 | use: 'babel-loader', 119 | }, 120 | 121 | // ESLint 122 | { 123 | test: /\.js$/, 124 | exclude: /node_modules/, 125 | use: ['eslint-loader'] 126 | }, 127 | 128 | // DataURI Image Loader 129 | { 130 | test: /\.(svg|jpg)$/, 131 | use: 'url-loader', 132 | }, 133 | ], 134 | }, 135 | }; 136 | }; 137 | -------------------------------------------------------------------------------- /src/toolbar-service/index.js: -------------------------------------------------------------------------------- 1 | import { eventToPromise } from '@common/promise-utils'; 2 | import ToolbarServiceClient from '@toolbar-service/client'; 3 | import { setup as setupIframe } from './iframe'; 4 | import { ToolbarServiceProtocol } from './messages'; 5 | 6 | const Client = { 7 | async get(/* string */iframeSourceUrl) /* Promise */ { 8 | const body = await Client.documentBodyReady(); 9 | const iframe = Client.buildIframe(iframeSourceUrl); 10 | const { hostname } = new URL(iframeSourceUrl); 11 | body.appendChild(iframe); 12 | 13 | const loadedIframe = await eventToPromise(iframe, 'load', () => iframe); 14 | const portToIframe = await Client.establishConnection(loadedIframe); 15 | return new ToolbarServiceClient(portToIframe, hostname); 16 | }, 17 | 18 | buildIframe(/* string */src) /* HTMLIFrameElement */ { 19 | const ifr = document.createElement('iframe'); 20 | ifr.src = src; 21 | ifr.style.cssText = 'display:none!important'; 22 | ifr.tabIndex = -1; 23 | ifr.ariaHidden = 'true'; 24 | return ifr; 25 | }, 26 | 27 | documentBodyReady() /* Promise */ { 28 | return new Promise(async resolve => { 29 | if (document.body) resolve(document.body); 30 | else document.addEventListener('DOMContentLoaded', () => resolve(document.body)); 31 | }); 32 | }, 33 | 34 | establishConnection(/* HTMLIFrameElement */iframe) /* Promise */ { 35 | return new Promise(resolve => { 36 | const { port1: portToIframe, port2: portToMainWindow } = new MessageChannel(); 37 | 38 | portToIframe.onmessage = (/* MessageEvent */message => { 39 | if (message.data === ToolbarServiceProtocol.Ready) resolve(portToIframe); 40 | else throw new Error(`Unexpected message received before iframe ready: ${message.data}`); 41 | }); 42 | 43 | if (iframe.contentWindow) iframe.contentWindow.postMessage(ToolbarServiceProtocol.SetupPort, '*', [portToMainWindow]); 44 | else throw Error('Unable to post a message the the toolbar iframe.'); 45 | }); 46 | } 47 | }; 48 | 49 | const Iframe = { 50 | // If initialization has been delayed 51 | initializationDelayed: false, 52 | // Warning messages to be shown upon delayed initialization 53 | initializationDelayedTimeouts: [], 54 | // If iframe has been initialized 55 | initialized: false, 56 | 57 | async setup() /* void */ { 58 | // Reset initialization state (iframe should only be setup once though) 59 | Iframe.initializationDelayed = false; 60 | Iframe.initializationDelayedTimeouts = []; 61 | Iframe.initialized = false; 62 | 63 | window.addEventListener('message', msg => Iframe.initialisationMessageHandler(msg)); 64 | }, 65 | 66 | initialisationMessageHandler(/* MessageEvent */message) /* void */ { 67 | if (message.data === ToolbarServiceProtocol.SetupPort) { 68 | // Checking for `initialized` has a quicker effect than removing the listener 69 | // because we can check for it after 70 | Iframe.initialized = true; 71 | 72 | window.removeEventListener('message', msg => Iframe.initialisationMessageHandler(msg)); 73 | const portToMainWindow = message.ports[0]; 74 | setupIframe(portToMainWindow); 75 | portToMainWindow.postMessage(ToolbarServiceProtocol.Ready); 76 | 77 | // Clear all ongoing timeouts 78 | Iframe.initializationDelayedTimeouts.forEach(timeout => clearTimeout(timeout)); 79 | 80 | // If iframe has been delayed, let the user know that the toolbar managed to get ready 81 | if (Iframe.initializationDelayed) { 82 | // eslint-disable-next-line no-console 83 | console.info('%cPrismic toolbar initialized successfully! This message only appears when unexpected messages were received by the iframe during Prismic toolbar setup.\n', 'color: #52b256;'); 84 | } 85 | } else if (!Iframe.initialized) { 86 | // Setting a timeout allows to buffer first few messages the iframe might receive 87 | // when waiting for its own init message 88 | Iframe.initializationDelayedTimeouts.push(setTimeout(() => { 89 | // If timeout is reached, then iframe had been delayed and may not be initialized 90 | Iframe.initializationDelayed = true; 91 | console.warn(`Unexpected message received by the iframe during Prismic toolbar setup.\n\nExpected: ${ToolbarServiceProtocol.SetupPort}\nReceived: ${typeof message.data === 'string' ? message.data : JSON.stringify(message.data)}\n\nThis can happen due to an extension tampering with iframes (Dashlane, MetaMask, etc.)\n\nAn explicit message following this one will let you know if the toolbar was successfully initialized.`); 92 | }, 500)); 93 | } 94 | } 95 | }; 96 | 97 | export const ToolbarService = { getClient: Client.get, setupIframe: Iframe.setup }; 98 | -------------------------------------------------------------------------------- /src/toolbar/index.js: -------------------------------------------------------------------------------- 1 | import './checkBrowser'; 2 | import { ToolbarService } from '@toolbar-service'; 3 | import { toolbarEvents, dispatchToolbarEvent, script } from '@common'; 4 | import { reloadOrigin, getAbsoluteURL } from './utils'; 5 | import { Preview } from './preview'; 6 | import { Prediction } from './prediction'; 7 | import { Analytics } from './analytics'; 8 | import { PreviewCookie } from './preview/cookie'; 9 | 10 | const version = process.env.npm_package_version; 11 | const IS_EMBEDDED = window.self !== window.top; 12 | 13 | if (!IS_EMBEDDED) { 14 | const warn = (...message) => require('@common').warn` 15 | ${String.raw(...message)} 16 | 17 | Please remove your current Prismic Toolbar installation and replace it with 18 | 19 | 20 | 21 | For complete documentation on setting up the Prismic Toolbar, please refer to 22 | https://prismic.io/docs/javascript/beyond-the-api/in-website-preview`; 23 | 24 | // Prismic Toolbar Interface 25 | window.prismic = window.PrismicToolbar = { 26 | endpoint: null, 27 | ...window.prismic/* Legacy */, 28 | version, 29 | setup: (...args) => { 30 | warn`window.prismic.setup is deprecated.`; 31 | args.forEach(setup); 32 | }, 33 | startExperiment/* TODO automate */: expId => { 34 | const { Experiment } = require('./experiment'); 35 | new Experiment(expId); 36 | }, 37 | setupEditButton/* Legacy */: () => { 38 | warn`window.prismic.setupEditButton is deprecated.`; 39 | }, 40 | }; 41 | 42 | let repos = new Set(); 43 | 44 | // Prismic variable is available 45 | dispatchToolbarEvent(toolbarEvents.prismic); 46 | 47 | // Auto-querystring setup 48 | const scriptURL = new URL(getAbsoluteURL(document.currentScript.getAttribute('src'))); 49 | const repoParam = scriptURL.searchParams.get('repo'); 50 | if (repoParam !== null) repos = new Set([...repos, ...repoParam.split(',')]); 51 | 52 | // Auto-legacy setup 53 | const legacyEndpoint = getLegacyEndpoint(); 54 | if (legacyEndpoint) { 55 | warn`window.prismic.endpoint is deprecated.`; 56 | repos.add(legacyEndpoint); 57 | } 58 | 59 | if (!repos.size) warn`Your are not connected to a repository.`; 60 | 61 | // Setup the Prismic Toolbar for one repository TODO support multi-repo 62 | let setupDomain = null; 63 | 64 | repos.forEach(setup); 65 | 66 | // eslint-disable-next-line no-inner-declarations 67 | async function setup (rawInput) { 68 | // Validate repository 69 | const domain = parseEndpoint(rawInput); 70 | 71 | if (!domain) return warn` 72 | Failed to setup. Expected a repository identifier (example | example.prismic.io) but got ${rawInput || 'nothing'}`; 73 | 74 | // Only allow setup to be called once 75 | if (setupDomain) return warn` 76 | Already connected to a repository (${setupDomain}).`; 77 | 78 | setupDomain = domain; 79 | 80 | const protocol = domain.match('.test$') ? window.location.protocol : 'https:'; 81 | const toolbarClient = await ToolbarService.getClient(`${protocol}//${domain}/prismic-toolbar/${version}/iframe.html`); 82 | const previewState = await toolbarClient.getPreviewState(); 83 | const previewCookieHelper = new PreviewCookie(previewState.auth, toolbarClient.hostname); 84 | // convert from legacy or clean the cookie if not authenticated 85 | const preview = new Preview(toolbarClient, previewCookieHelper, previewState); 86 | 87 | const prediction = previewState.auth && new Prediction(toolbarClient, previewCookieHelper); 88 | const analytics = previewState.auth && new Analytics(toolbarClient); 89 | 90 | // Start concurrently preview (always) and prediction (if authenticated) 91 | const { initialRef, upToDate, isActive } = await preview.setup(); 92 | const { convertedLegacy } = previewCookieHelper.init(initialRef); 93 | 94 | if (convertedLegacy || !upToDate) { 95 | reloadOrigin(); 96 | return; 97 | } 98 | 99 | if (isActive || previewState.auth) { 100 | // eslint-disable-next-line no-undef 101 | await script(`${CDN_HOST}/prismic-toolbar/${version}/toolbar.js`); 102 | new window.prismic.Toolbar({ 103 | displayPreview: isActive, 104 | auth: previewState.auth, 105 | preview, 106 | prediction, 107 | analytics 108 | }); 109 | 110 | // Track initial setup of toolbar 111 | if (analytics) analytics.trackToolbarSetup(); 112 | } 113 | 114 | if (!isActive) { 115 | if (previewCookieHelper.getRefForDomain()) 116 | previewCookieHelper.deletePreviewForDomain(); 117 | 118 | await toolbarClient.closePreviewSession(); 119 | } 120 | } 121 | } 122 | 123 | function parseEndpoint(repo) { 124 | if (!repo) return null; 125 | /* eslint-disable no-useless-escape */ 126 | if (!/^(https?:\/\/)?[-a-zA-Z0-9.\/]+/.test(repo)) return null; 127 | // eslint-disable-next-line no-undef 128 | if (!repo.includes('.')) repo = `${repo}.prismic.io`; 129 | return repo; 130 | } 131 | 132 | function getLegacyEndpoint() { 133 | try { 134 | return new URL(window.prismic.endpoint).hostname.replace('.cdn', ''); 135 | } catch (e) { 136 | return window.prismic.endpoint; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/toolbar/components/Panel/prismic-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Combined Shape 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /src/toolbar/components/Panel/prismic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Created with Sketch. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/toolbar/components/DevMode/DevMode.js: -------------------------------------------------------------------------------- 1 | import './DevMode.css'; 2 | import { Component } from 'preact'; 3 | import Collapsible from 'react-collapsible'; 4 | import { stringCheck } from '@common'; 5 | import { collapsibleArrow } from '.'; 6 | import { JsonView } from '..'; 7 | 8 | /* ----- BEGINNING OF CLASS ----- */ 9 | export class DevMode extends Component { 10 | constructor (props) { 11 | super(props); 12 | this.maxStringSize = props.maxStringSize; 13 | this.state = { queries: props.queries }; 14 | } 15 | 16 | getGraphqlItemInfo(/* Object */query) /* : String */ { 17 | const entries = Object.entries(query); 18 | const title = entries 19 | .map(e => e[0]) 20 | .reduce((acc, val) => acc + ' & ' + val); 21 | 22 | // return only the title for a graphql query 23 | return { title }; 24 | } 25 | 26 | /* ----- RETURN TRIGGER INFOS ----- */ 27 | getApiItemInfo = /* Object */query => /* Object */ { 28 | /* 29 | expected format of itemInfos 30 | { 31 | type1 : ..., 32 | type2 : ..., 33 | ... 34 | } 35 | */ 36 | const itemInfos = query 37 | .map(doc => doc.type) 38 | .reduce((acc, val) => { 39 | if (acc[val]) { 40 | acc[val] += 1; 41 | } else { 42 | acc[val] = 1; 43 | } 44 | return acc; 45 | }, {}); 46 | 47 | const title = this.constructTitleOfItem(itemInfos); 48 | 49 | // expected format of title : (X) type 1 & (Y) type 2 ... 50 | const nbLinkedDoc = query 51 | .map(doc => this.countLinkedDocInDocument(doc.data)) 52 | .reduce((acc, val) => acc + val); 53 | 54 | // expected to return title and number of linked docs 55 | return { title, nbLinkedDoc }; 56 | } 57 | 58 | 59 | /* ----- CONSTRUCT TITLE BASED ON TYPES AND OCCURRENCES -----*/ 60 | constructTitleOfItem(/* Object */itemInfos) /* : String */ { 61 | const copyInfo = Object.assign({}, itemInfos); 62 | const keys = Object.keys(copyInfo); 63 | 64 | const title = keys 65 | .map(key => key + ' (' + copyInfo[key] + ')') 66 | .reduce((acc, val) => acc + ' & ' + val); 67 | 68 | return title; 69 | } 70 | 71 | 72 | /* ----- RETURN NUMBER OF LINKED DOCUMENT FOR AN API QUERY ----- */ 73 | countLinkedDocInDocument(/* Object */data) /* : Int */ { 74 | if (!data) { return 0; } // First case data is empty or null 75 | if (data.link_type === 'Document' && data.id) { return 1; } // Second case there is a document, return 1 to increment the count 76 | 77 | // Last case it is an object but not a document, so we check every object inside. 78 | const count = Object.keys(data) 79 | .reduce((/* Int */acc, /* Object || String */key) => { 80 | if (typeof data[key] === 'object') { 81 | const newCount = this.countLinkedDocInDocument(data[key]); 82 | return acc + newCount; 83 | } 84 | return acc; 85 | }, 0); 86 | 87 | return count; 88 | } 89 | 90 | 91 | /* ----- Split the queries into api and graphql queries ----- */ 92 | splitQueries(allQueries) { 93 | return allQueries.reduce((acc, val) => { 94 | // apiQuery : [Object] || graphqlQuery : {data: Object} 95 | if (Object.keys(val).includes('data')) { 96 | // it's a graphql query 97 | acc.graphqlApiQueries.push(val); 98 | } else { 99 | // it's an api query 100 | acc.apiQueries.push(val); 101 | } 102 | return acc; 103 | }, { apiQueries: [], graphqlApiQueries: [] }); 104 | } 105 | 106 | /* ----- RENDER FUNCTION ----- */ 107 | render() { 108 | const { queries } = this.state; 109 | const { apiQueries, graphqlApiQueries } = this.splitQueries(queries); 110 | 111 | return ( 112 |
113 | { 114 | apiQueries.map(query => { // apiQuery : [Object] 115 | if (Object.keys(query).length < 1) { return null; } 116 | const itemInfos = this.getApiItemInfo(query); 117 | 118 | return ( 119 | } 124 | triggerWhenOpen={} 129 | transitionTime={100}> 130 | {query.map(doc => ( 131 | 135 | ))} 136 | 137 | ); 138 | }) 139 | } 140 | 141 | { 142 | graphqlApiQueries.map(query => { // graphqlQuery : {data: Object} 143 | if (!query.data || Object.keys(query.data).length < 1) { return null; } 144 | const { title } = this.getGraphqlItemInfo(query.data); 145 | 146 | return ( 147 | } 153 | triggerWhenOpen={} 159 | transitionTime={100}> 160 | { 161 | Object.entries(query.data).map(e => // e : [ key, value ] 162 | 168 | ) 169 | } 170 | 171 | ); 172 | }) 173 | } 174 |
175 | ); 176 | } 177 | } 178 | 179 | /* ----- HEADER(TRIGGER) FOR THE COLLAPSIBLE ----- */ 180 | const DevModeItem = ({ isOpen, isGraphql, maxStringSize, nbLinkedDoc, title }) => ( 181 |
182 |

{stringCheck(title, maxStringSize)}

183 |

{ isGraphql ? '[Graphql Query]' : nbLinkedDoc + ' linked documents'}

184 | 185 |
186 | ); 187 | -------------------------------------------------------------------------------- /src/common/general.js: -------------------------------------------------------------------------------- 1 | const oneLine = (...str) => String.raw(...str).split('\n').map(line => line.trim()).join('\n') 2 | .trim(); 3 | 4 | // Console warn one-liner 5 | export const warn = (...str) => console.warn('Prismic Toolbar\n\n' + oneLine(...str)); 6 | export const err = (...str) => { throw new Error('Prismic Toolbar\n\n' + oneLine(...str)); }; 7 | 8 | // Is pure Object 9 | export const isObject = val => Boolean(val && typeof val === 'object' && val.constructor === Object); 10 | 11 | // Switchy 12 | export const switchy = (val = '') => (obj = {}) => { 13 | if (typeof obj[val] === 'function') return obj[val](); 14 | return obj[val] || obj._ || null; 15 | }; 16 | 17 | // Fetch Wrapper 18 | export const fetchy = ({ url, ...other }) => fetch(url, other).then(r => r.json()); 19 | 20 | // Cutoff text ellipsis 21 | export const ellipsis = (text, cutoff) => 22 | text.length > cutoff ? text.substring(0, cutoff - 1) + '…' : text; 23 | 24 | // ReadyDOM - DOM Listener is useless (await X is already asynchronous) 25 | export const readyDOM = async () => { 26 | if (document.readyState !== 'complete') await wait(0); 27 | return true; 28 | }; 29 | 30 | // Wait in seconds 31 | export const wait = seconds => new Promise(rs => setTimeout(rs, seconds * 1000)); 32 | 33 | // Wait in milliseconds 34 | export const delay = t => new Promise(rs => setTimeout(rs, t)); 35 | 36 | /* ----- ADD ELLIPSIS IF NECESSARY TO VALUE ----- */ 37 | export const stringCheck = (string, maxStringSize) => /* String */ { 38 | if (string.length >= maxStringSize) { 39 | return (string.substring(0, maxStringSize) + '...'); 40 | } 41 | return string; 42 | }; 43 | 44 | // Cookies disabled 45 | export const disabledCookies = () => !navigator.cookieEnabled; 46 | 47 | // Random id 48 | export const random = num => { 49 | const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 50 | return [...Array(num)].map(() => chars[Math.floor(Math.random() * chars.length)]).join(''); 51 | }; 52 | 53 | // Build querystring 54 | export const query = obj => { 55 | if (!obj) return ''; 56 | return Object.entries(obj) 57 | .filter(v => v[1]) 58 | .map(pair => pair.map(encodeURIComponent).join('=')) 59 | .join('&'); 60 | }; 61 | 62 | // Parse querystring 63 | export const parseQuery = _uri => { 64 | if (!_uri) return {}; 65 | const qs = _uri.split('?')[1]; 66 | if (!qs) return {}; 67 | return qs 68 | .split('&') 69 | .filter(v => v) 70 | .map(v => v.split('=')) 71 | .reduce( 72 | (acc, curr) => 73 | Object.assign(acc, { 74 | [decodeURIComponent(curr[0])]: curr[1] && decodeURIComponent(curr[1]), 75 | }), 76 | {} 77 | ); 78 | }; 79 | 80 | // Copy text to clipboard 81 | export const copyText = text => 82 | navigator.clipboard ? navigator.clipboard.writeText(text) : fallbackCopyText(text); 83 | 84 | const fallbackCopyText = text => { 85 | const textArea = document.createElement('textarea'); 86 | textArea.value = text; 87 | textArea.style.position = 'fixed'; 88 | document.body.appendChild(textArea); 89 | textArea.focus(); 90 | textArea.select(); 91 | if (document.queryCommandEnabled('copy')) document.execCommand('copy'); 92 | document.body.removeChild(textArea); 93 | return Promise.resolve(true); 94 | }; 95 | 96 | // Throttle (https://codeburst.io/throttling-and-debouncing-in-javascript-b01cad5c8edf) 97 | export const throttle = (func, timeout) => { 98 | let queue; 99 | let lastReturn; 100 | let lastRan = -Infinity; 101 | return function() { 102 | const since = Date.now() - lastRan; 103 | const due = since >= timeout; 104 | const run = () => { 105 | lastRan = Date.now(); 106 | lastReturn = func.apply(this, arguments); 107 | }; 108 | clearTimeout(queue); 109 | if (due) run(); 110 | else queue = setTimeout(run, timeout - since); 111 | return lastReturn; 112 | }; 113 | }; 114 | 115 | // Memoize (can have a custom memoizer) 116 | export const memoize = (func, memoizer) => { 117 | const memory = new Map(); 118 | return function(...args) { 119 | const key = memoizer ? memoizer(...args) : JSON.stringify(args); 120 | if (!memory.has(key)) memory.set(key, func(...args)); 121 | return memory.get(key); 122 | }; 123 | }; 124 | 125 | // Once 126 | export const once = func => { 127 | let result; 128 | let done; 129 | return function(...args) { 130 | if (!done) { 131 | result = func(...args); 132 | done = true; 133 | } 134 | return result; 135 | }; 136 | }; 137 | 138 | // Localstorage 139 | export const localStorage = (key, defaultValue = null) => ({ 140 | get() { 141 | const value = window.localStorage.getItem(key); 142 | return value ? JSON.parse(value) : defaultValue; 143 | }, 144 | 145 | set(value) { 146 | window.localStorage.setItem(key, JSON.stringify(value)); 147 | }, 148 | 149 | remove() { 150 | window.localStorage.removeItem(key); 151 | }, 152 | }); 153 | 154 | // Simple location object 155 | export const getLocation = () => { 156 | const { href, origin, protocol, host, hostname, port, pathname, search, hash } = window.location; 157 | return { 158 | href, 159 | origin, 160 | protocol, 161 | host, 162 | hostname, 163 | port, 164 | pathname, 165 | search, 166 | hash, 167 | }; 168 | }; 169 | 170 | // Generate a shadow DOM 171 | 172 | export const shadow = attr => { 173 | const div = document.createElement('div'); 174 | Object.entries(attr).forEach(([key, value]) => { 175 | if (key === 'style') { 176 | return Object.assign(div.style, value); 177 | } 178 | return div.setAttribute(key, value); 179 | }); 180 | const shadowRoot = document.head.attachShadow && div.attachShadow({ mode: 'open' }); 181 | document.body.appendChild(div); 182 | return shadowRoot || div; 183 | }; 184 | 185 | // Delete DOM nodes with CSS query 186 | export const deleteNodes = cssQuery => { 187 | document.querySelectorAll(cssQuery).forEach(el => el.remove()); 188 | }; 189 | 190 | // Append Stylesheet to DOM node 191 | export const appendCSS = (el, css) => { 192 | const style = document.createElement('style'); 193 | style.type = 'text/css'; 194 | style.appendChild(document.createTextNode(css)); 195 | el.appendChild(style); 196 | }; 197 | 198 | // Load script 199 | export function script(src) { 200 | return new Promise(resolve => { 201 | let el = document.getElementById(src); 202 | if (!el) { 203 | el = document.createElement('script'); 204 | el.id = src; 205 | el.src = src; 206 | document.head.appendChild(el); 207 | } 208 | el.addEventListener('load', () => resolve(el)); 209 | }); 210 | } 211 | -------------------------------------------------------------------------------- /src/toolbar/components/JsonView/JsonView.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'preact'; 2 | import { Treebeard } from 'react-treebeard'; 3 | import { copyText, stringCheck } from '@common'; 4 | import './JsonView.css'; 5 | import { minusSquare, plusSquare } from '.'; 6 | 7 | /* ----- BEGINNING OF CLASS ----- */ 8 | export class JsonView extends Component { 9 | constructor (props) { 10 | super(props); 11 | const data = (() => { 12 | if (props.isGraphql) { 13 | return this.getData(props.json, props.isGraphql, props.graphqlUid); 14 | } 15 | return this.getData(props.json); 16 | })(); 17 | const metadata = props.isGraphql ? null : this.getMetadata(props.json); 18 | this.maxStringSize = props.maxStringSize; 19 | this.bannerUid = props.graphqlUid || this.constructBannerUid(props.json); 20 | this.state = { 21 | data, 22 | metadata, 23 | nodeCopied: [], 24 | timeOut: -1 25 | }; 26 | } 27 | 28 | /* ----- TOGGLE FUNCTION ----- */ 29 | onToggle = (/* Object */node, /* Boolean */toggled) => { 30 | node.toggled = toggled; 31 | this.setState(oldState => oldState); 32 | } 33 | 34 | 35 | /* ----- COPY JSON PATH TO CLIPBOARD AND REMOVE OLD COPY ----- */ 36 | copyToClipboard = /* Object */node => { 37 | const { data, metadata, nodeCopied, timeOut } = this.state; 38 | 39 | if (nodeCopied !== node) { 40 | nodeCopied.path ? this.removeOldCopied(data, metadata, nodeCopied) : null; 41 | 42 | const newJsonToModify = this.getWhichJsonToSet(data, metadata, node.path); 43 | this.setIsCopied(newJsonToModify, node.path, true); 44 | 45 | const jsonPath = node.path.reduce((acc, val) => { 46 | if (val[0] === '[' && val[val.length - 1] === ']') { 47 | return acc + val; 48 | } 49 | return acc + '.' + val; 50 | }); 51 | copyText(jsonPath); 52 | 53 | if (timeOut > -1) { 54 | clearTimeout(timeOut); 55 | } 56 | 57 | const newTimeOut = setTimeout(() => { 58 | this.removeOldCopied(data, metadata, node); 59 | this.setState({ 60 | data, 61 | metadata, 62 | nodeCopied: [], 63 | timeOut: -1 64 | }); 65 | }, 1500); 66 | 67 | this.setState({ 68 | data, 69 | metadata, 70 | nodeCopied: node, 71 | timeOut: newTimeOut 72 | }); 73 | } 74 | } 75 | 76 | 77 | /* ----- FUNCTION TO RETURN WHICH DATA TO SET ----- */ 78 | getWhichJsonToSet( 79 | /* Object */data, 80 | /* Object */metadata, 81 | /* List[String] */keyNames 82 | ) /* Object */ { 83 | if (!keyNames) { return; } 84 | if (keyNames[0] === 'data') { 85 | return data; 86 | } 87 | return metadata; 88 | } 89 | 90 | /* ----- FUNCTION TO MODIFY THE ISCOPIED PROPERTY OF A NODE ----- */ 91 | setIsCopied( 92 | /* Object */json, 93 | /* List[String] */keyNames, 94 | /* Boolean */value 95 | ) { 96 | // return if there is previous nodeCopied. 97 | if (!keyNames) { return; } 98 | const { length } = keyNames; 99 | 100 | // reducer to find the data we need to modify in the json 101 | const reducer = ( 102 | /* Object */acc, 103 | /* String */key, 104 | /* Int */index 105 | ) => /* Object */ { 106 | const nodeFound = acc.find(node => node.name === key); 107 | if (index === length - 1) { // return the node itself if it is the last key in the path. 108 | return nodeFound; 109 | } // return the children if it is not the last key in the path 110 | return nodeFound.children; 111 | }; 112 | 113 | const nodeToModify = keyNames 114 | .map(key => (key[0] === '[' && key[key.length - 1] === ']') ? key.substring(1, key.length - 1) : key) 115 | .reduce(reducer, json); 116 | nodeToModify.isCopied = value; 117 | } 118 | 119 | 120 | /* ----- FUNCTION TO REMOVE THE OLD COPY ----- */ 121 | removeOldCopied = ( 122 | /* Object */data, 123 | /* Object */metadata, 124 | /* Object */nodeCopied 125 | ) => { 126 | const oldJsonToModify = this.getWhichJsonToSet(data, metadata, nodeCopied.path); 127 | this.setIsCopied(oldJsonToModify, nodeCopied.path, false); 128 | } 129 | 130 | 131 | /* ----- DECORATORS THAT DEFINE HOW THE JSON IS RENDERED ----- */ 132 | decorators = { 133 | Icon: props => { 134 | // if node has children property then it's an object 135 | if (props.node.children && props.node.children.length > 0) { 136 | return ; 137 | } 138 | }, 139 | 140 | Key: props => 141 | // different style are applied to objects and key-value 142 | ( 143 | 147 | { props.node.name }:{props.node.children && props.node.children.length === 0 ? ' []' : '' }  148 | 149 | ), 150 | 151 | Value: props => { 152 | // if node has children then it's an object, an object has no value field 153 | if (!props.node.children) { 154 | return ( 155 | 158 | {`"${props.stringCheck(props.node.value, this.maxStringSize)}"`} 159 | 160 | ); 161 | } 162 | }, 163 | 164 | NestedBorder: props => { 165 | if (props.node.path.length > 1) { // no border on the root of the json 166 | // if the node is the last child then it has only a half vertical border 167 | if (props.node.isLastChild) { 168 | // right margin if key-value 169 | return ( 170 |
0 ? 'border last-nested' : 'border last-nested with-right-margin'} /> 172 | ); 173 | } 174 | return ( 175 |
0 ? 'border horizontal' : 'border horizontal with-right-margin'} 177 | /> 178 | ); 179 | } 180 | }, 181 | 182 | Copy: props => ( 183 | this.copyToClipboard(props.node)} 186 | > 187 | {props.node.isCopied ? 'Copied' : 'Copy'} 188 | 189 | ), 190 | 191 | Container: props => { 192 | // The container decorator will be applied to each node of the json (root and nested) 193 | const { NestedBorder, Icon, Key, Value, Copy } = this.decorators; 194 | return ( 195 |
196 | 197 | 198 | 199 | 204 | 205 |
206 | ); 207 | } 208 | } 209 | 210 | 211 | /* ----- STYLE APPLIED TO THE TREEBEARD COMPONENT ----- */ 212 | style = { 213 | tree: { 214 | base: { 215 | listStyle: 'none', 216 | backgroundColor: '#F5F6F9', 217 | margin: 0, 218 | padding: '15px 25px 15px 25px' 219 | }, 220 | node: { 221 | base: { 222 | position: 'relative', 223 | left: '15px', 224 | maxWidth: 'calc(100% - 15px)' 225 | }, 226 | subtree: { 227 | listStyle: 'none', 228 | marginLeft: '5px', 229 | paddingLeft: '0px' 230 | } 231 | } 232 | } 233 | } 234 | 235 | 236 | /* ----- TRANSFORM RAW JSON TO JSON FOR TREEBEARD COMPONENT ----- */ 237 | turnJsonToTreeBeardJson = (/* Object */json, /* List[String] */path) => /* Object */ { 238 | if (!json) { return; } 239 | const copyOfJson = Object.assign({}, json); 240 | const keys = Object.keys(json); 241 | const { length } = keys; 242 | 243 | const res = keys.map((key, index) => { 244 | if (typeof copyOfJson[key] === 'object' && copyOfJson[key] != null) { // is an object 245 | const newPath = Array.isArray(json) ? path.concat('[' + key + ']') : path.concat(key); 246 | return { 247 | name: key, 248 | toggled: false, 249 | isCopied: false, 250 | children: this.turnJsonToTreeBeardJson(copyOfJson[key], newPath), 251 | path: newPath, 252 | isLastChild: this.isLastChild(index, length) 253 | }; 254 | } // is a key : string 255 | return { 256 | name: key, 257 | value: copyOfJson[key] || 'null', 258 | path: path.concat(key), 259 | isCopied: false, 260 | isLastChild: this.isLastChild(index, length) 261 | }; 262 | }); 263 | return res; 264 | } 265 | 266 | 267 | /* ----- CHECK IF THE NODE IS THE LAST CHILD FOR THE VERTICAL BORDER ----- */ 268 | isLastChild = (/* Int */index, /* Int */length) => /* Boolean */ { 269 | if (index === length - 1) { 270 | return true; 271 | } 272 | return false; 273 | } 274 | 275 | 276 | /* ----- RETURN THE DATA & METADATA FOR THE TREEBEARD----- */ 277 | getMetadata = /* Object */json => /* Object */ { 278 | const copyOfJson = Object.assign({}, json); 279 | delete copyOfJson.data; 280 | const metadata = this.turnJsonToTreeBeardJson(copyOfJson, []); 281 | return metadata; 282 | } 283 | 284 | getData = (/* Object */json, /* Boolean */isGraphql, graphqlUid) => /* Object */ { 285 | const copyOfData = (() => { 286 | if (isGraphql) { 287 | return Object.assign({}, { [graphqlUid]: json }); 288 | } 289 | return Object.assign({}, json.data); 290 | })(); 291 | const rawData = { data: copyOfData }; 292 | const data = this.turnJsonToTreeBeardJson(rawData, []); 293 | data[0].toggled = true; // to initially open data 294 | return data; 295 | } 296 | 297 | constructBannerUid = /* Object */json => /* String */ { 298 | const { type } = json; 299 | const uid = json.uid ? ' · ' + stringCheck(json.uid, this.maxStringSize) : ''; 300 | return type + uid; 301 | } 302 | 303 | 304 | /* ----- RENDER FUNCTION ----- */ 305 | render() { 306 | const { data, metadata } = this.state; 307 | 308 | if (metadata && Object.keys(metadata).length >= 1) { 309 | return ( 310 |
311 |
{this.bannerUid}
312 | 313 | 319 | 320 | 321 |
Metadata
322 | 323 | 329 |
330 | ); 331 | } 332 | 333 | return ( 334 |
335 |
{this.bannerUid}
336 | 337 | 343 |
344 | ); 345 | } 346 | } 347 | --------------------------------------------------------------------------------