// Theirs import url from 'url' import React from 'react' import domtoimage from 'dom-to-image' import dynamic from 'next/dynamic' import Dropzone from 'dropperx' // Ours import Dropdown from './Dropdown' import Settings from './Settings' import Toolbar from './Toolbar' import Overlay from './Overlay' import Carbon from './Carbon' import ExportMenu from './ExportMenu' import Themes from './Themes' import TweetButton from './TweetButton' import SpinnerWrapper from './SpinnerWrapper' import { LANGUAGES, LANGUAGE_MIME_HASH, LANGUAGE_MODE_HASH, LANGUAGE_NAME_HASH, DEFAULT_EXPORT_SIZE, COLORS, EXPORT_SIZES_HASH, DEFAULT_CODE, DEFAULT_SETTINGS, DEFAULT_LANGUAGE, DEFAULT_PRESET_ID } from '../lib/constants' import { serializeState, getQueryStringState } from '../lib/routing' import { getSettings, escapeHtml, unescapeHtml, formatCode, omit } from '../lib/util' import LanguageIcon from './svg/Language' const languageIcon = const BackgroundSelect = dynamic(() => import('./BackgroundSelect'), { loading: () => null }) class Editor extends React.Component { constructor(props) { super(props) this.state = { ...DEFAULT_SETTINGS, preset: DEFAULT_PRESET_ID } this.export = this.export.bind(this) this.upload = this.upload.bind(this) this.updateSetting = this.updateSetting.bind(this) this.updateTheme = this.updateTheme.bind(this) this.updateLanguage = this.updateLanguage.bind(this) this.updateBackground = this.updateBackground.bind(this) this.resetDefaultSettings = this.resetDefaultSettings.bind(this) this.getCarbonImage = this.getCarbonImage.bind(this) this.onDrop = this.onDrop.bind(this) this.innerRef = node => (this.carbonNode = node) } async componentDidMount() { const { asPath = '' } = this.props.router const { query, pathname } = url.parse(asPath, true) const path = escapeHtml(pathname.split('/').pop()) const queryParams = getQueryStringState(query) const initialState = Object.keys(queryParams).length ? queryParams : {} try { // TODO fix this hack if (this.props.api.getGist && path.length >= 19 && path.indexOf('.') === -1) { const { content, language } = await this.props.api.getGist(path) if (language) { initialState.language = language.toLowerCase() } initialState.code = content } } catch (e) { // eslint-disable-next-line console.log(e) } const newState = { // Load from localStorage ...getSettings(localStorage), // and then URL params ...initialState } // Makes sure the slash in 'application/X' is decoded if (newState.language) { newState.language = unescapeHtml(newState.language) } this.updateState(newState) this.isSafari = window.navigator && window.navigator.userAgent.indexOf('Safari') !== -1 && window.navigator.userAgent.indexOf('Chrome') === -1 } updateState = updates => this.setState(updates, () => this.props.onUpdate(this.state)) updateCode = code => this.updateState({ code }) updateAspectRatio = aspectRatio => this.updateState({ aspectRatio }) updateTitleBar = titleBar => this.updateState({ titleBar }) async getCarbonImage( { format, type, squared = this.state.squaredImage, exportSize = (EXPORT_SIZES_HASH[this.state.exportSize] || DEFAULT_EXPORT_SIZE).value } = { format: 'png' } ) { // if safari, get image from api const isPNG = format !== 'svg' if (this.props.api.image && this.isSafari && isPNG) { const encodedState = serializeState(this.state) return this.props.api.image(encodedState) } const node = this.carbonNode const map = new Map() const undoMap = value => { map.forEach((value, node) => (node.innerText = value)) return value } if (isPNG) { node.querySelectorAll('span[role="presentation"]').forEach(node => { if (node.innerText && node.innerText.match(/%[A-Za-z0-9]{2}/)) { map.set(node, node.innerText) node.innerText.match(/%[A-Za-z0-9]{2}/g).forEach(t => { node.innerText = node.innerText.replace(t, encodeURIComponent(t)) }) } }) } const width = node.offsetWidth * exportSize const height = squared ? node.offsetWidth * exportSize : node.offsetHeight * exportSize const config = { style: { transform: `scale(${exportSize})`, 'transform-origin': 'center', background: squared ? this.state.backgroundColor : 'none' }, filter: n => { if (n.className) { return String(n.className).indexOf('eliminateOnRender') < 0 } return true }, width, height } try { if (type === 'blob') { if (format === 'svg') { return ( domtoimage .toSvg(node, config) .then(dataUrl => dataUrl .replace(/ /g, ' ') // https://github.com/tsayen/dom-to-image/blob/fae625bce0970b3a039671ea7f338d05ecb3d0e8/src/dom-to-image.js#L551 .replace(/%23/g, '#') .replace(/%0A/g, '\n') ) // https://stackoverflow.com/questions/7604436/xmlparseentityref-no-name-warnings-while-loading-xml-into-a-php-file .then(dataUrl => dataUrl.replace(/&(?!#?[a-z0-9]+;)/g, '&')) .then(uri => uri.slice(uri.indexOf(',') + 1)) .then(data => new Blob([data], { type: 'image/svg+xml' })) .then(data => window.URL.createObjectURL(data)) ) } return await domtoimage.toBlob(node, config).then(blob => window.URL.createObjectURL(blob)) } // Twitter needs regular dataurls return await domtoimage.toPng(node, config) } finally { undoMap() } } updateSetting(key, value) { this.updateState({ [key]: value }) if (Object.prototype.hasOwnProperty.call(DEFAULT_SETTINGS, key)) { this.updateState({ preset: null }) } } export(format = 'png') { const link = document.createElement('a') const prefix = this.state.filename || 'carbon' return this.getCarbonImage({ format, type: 'blob' }).then(url => { if (format !== 'open') { link.download = `${prefix}.${format}` } link.href = url document.body.appendChild(link) link.click() link.remove() }) } resetDefaultSettings() { this.updateState({ ...DEFAULT_SETTINGS, preset: DEFAULT_PRESET_ID }) this.props.onReset() } upload() { this.getCarbonImage({ format: 'png' }).then( this.props.api.tweet.bind(null, this.state.code || DEFAULT_CODE) ) } onDrop([file]) { if (isImage(file)) { this.updateState({ backgroundImage: file.content, backgroundImageSelection: null, backgroundMode: 'image', preset: null }) } else { this.updateState({ code: file.content, language: 'auto' }) } } updateTheme(theme) { this.updateSetting('theme', theme) } updateLanguage(language) { this.updateSetting('language', language.mime || language.mode) } updateBackground({ photographer, ...changes } = {}) { if (this.isSafari) { this.disablePNG = !verifyPayloadSize(changes.backgroundImage) } if (photographer) { this.updateState(({ code = DEFAULT_CODE }) => ({ ...changes, code: code + `\n\n// Photo by ${photographer.name} on Unsplash`, preset: null })) } else { this.updateState({ ...changes, preset: null }) } } format = () => formatCode(this.state.code) .then(this.updateCode) .catch(() => { // create toast here in the future }) applyPreset = ({ id: preset, ...settings }) => this.updateState({ preset, ...settings }) render() { const { theme, language, backgroundColor, backgroundImage, backgroundMode, aspectRatio, titleBar, code, exportSize } = this.state const config = omit(this.state, ['code', 'aspectRatio', 'titleBar']) return ( {this.props.api.tweet && } {({ canDrop }) => ( {/*key ensures Carbon's internal language state is updated when it's changed by Dropdown*/} {code != null ? code : DEFAULT_CODE} )} ) } } const MAX_PAYLOAD_SIZE = 5e6 // bytes function verifyPayloadSize(str) { if (typeof str !== 'string') return true return new Blob([str]).size < MAX_PAYLOAD_SIZE } function isImage(file) { return file.type.split('/')[0] === 'image' } Editor.defaultProps = { api: {}, onUpdate: () => {}, onReset: () => {} } export default Editor