import { h, Component } from 'preact'; import PropTypes from 'prop-types'; import linkState from 'linkstate'; import postscribe from 'postscribe'; import { KeyboardShortcuts } from '../shared/components/useKeyboardShortcuts'; import { embedGists } from '../utilities/gist'; import { submitArticle, previewArticle } from './actions'; import { EditorActions, Form, Header, Help, Preview } from './components'; import { Button, Modal } from '@crayons'; import { noDefaultAltTextRule, noEmptyAltTextRule, noLevelOneHeadingsRule, headingIncrement, } from '@utilities/markdown/markdownLintCustomRules'; /* global activateRunkitTags */ /* Although the state fields: id, description, canonicalUrl, series, allSeries and editing are not used in this file, they are important to the editor. */ /** * Settings for the markdownlint library we use to identify potential accessibility failings in posts */ const LINT_OPTIONS = { customRules: [ noDefaultAltTextRule, noLevelOneHeadingsRule, headingIncrement, noEmptyAltTextRule, ], config: { default: false, // disable all default rules [noDefaultAltTextRule.names[0]]: true, [noLevelOneHeadingsRule.names[0]]: true, [headingIncrement.names[0]]: true, [noEmptyAltTextRule.names[0]]: true, }, }; export class ArticleForm extends Component { static handleRunkitPreview() { activateRunkitTags(); } // Scripts inserted via innerHTML won't execute, so we use this handler to // make the Asciinema player work in previews. static handleAsciinemaPreview() { const els = document.getElementsByClassName('ltag_asciinema'); for (let i = 0; i < els.length; i += 1) { const el = els[i]; const script = el.removeChild(el.firstElementChild); postscribe(el, script.outerHTML); } } static propTypes = { version: PropTypes.string.isRequired, article: PropTypes.string.isRequired, organizations: PropTypes.string, siteLogo: PropTypes.string.isRequired, }; static defaultProps = { organizations: '', }; constructor(props) { super(props); const { article, version, siteLogo } = this.props; let { organizations } = this.props; this.article = JSON.parse(article); organizations = organizations ? JSON.parse(organizations) : null; this.url = window.location.href; const previousContent = JSON.parse( localStorage.getItem(`editor-${version}-${window.location.href}`), ) || {}; const isLocalstorageNewer = new Date(previousContent.updatedAt) > new Date(this.article.updated_at); const previousContentState = previousContent && isLocalstorageNewer ? { title: previousContent.title || '', tagList: previousContent.tagList || '', mainImage: previousContent.mainImage || null, bodyMarkdown: previousContent.bodyMarkdown || '', edited: true, } : {}; this.state = { formKey: new Date().toISOString(), id: this.article.id || null, // eslint-disable-line react/no-unused-state title: this.article.title || '', tagList: this.article.cached_tag_list || '', description: '', // eslint-disable-line react/no-unused-state canonicalUrl: this.article.canonical_url || '', // eslint-disable-line react/no-unused-state series: this.article.series || '', // eslint-disable-line react/no-unused-state allSeries: this.article.all_series || [], // eslint-disable-line react/no-unused-state bodyMarkdown: this.article.body_markdown || '', published: this.article.published || false, previewShowing: false, previewResponse: '', submitting: false, editing: this.article.id !== null, // eslint-disable-line react/no-unused-state mainImage: this.article.main_image || null, organizations, organizationId: this.article.organization_id, errors: null, edited: false, updatedAt: this.article.updated_at, version, siteLogo, helpFor: null, helpPosition: null, isModalOpen: false, markdownLintErrors: [], ...previousContentState, }; } componentDidMount() { window.addEventListener('beforeunload', this.localStoreContent); } componentWillUnmount() { window.removeEventListener('beforeunload', this.localStoreContent); } componentDidUpdate() { const { previewResponse } = this.state; if (previewResponse) { embedGists(this.formElement); this.constructor.handleRunkitPreview(); this.constructor.handleAsciinemaPreview(); } } localStoreContent = () => { const { version, title, tagList, mainImage, bodyMarkdown } = this.state; const updatedAt = new Date(); localStorage.setItem( `editor-${version}-${this.url}`, JSON.stringify({ title, tagList, mainImage, bodyMarkdown, updatedAt, }), ); }; setCommonProps = ({ previewShowing = false, helpFor = null, helpPosition = null, }) => { return { previewShowing, helpFor, helpPosition, }; }; fetchPreview = (e) => { const { previewShowing, bodyMarkdown } = this.state; e.preventDefault(); if (previewShowing) { this.setState({ ...this.setCommonProps({}), }); } else { previewArticle(bodyMarkdown, this.showPreview, this.failedPreview); } }; lintMarkdown = () => { const options = { ...LINT_OPTIONS, strings: { content: this.state.bodyMarkdown, }, }; const { content: markdownLintErrors } = window.markdownlint.sync(options); this.setState({ markdownLintErrors }); }; fetchMarkdownLint = async () => { if (!window.markdownlint) { const pathDataElement = document.getElementById('markdown-lint-js-path'); if (!pathDataElement) { return; } // Retrieve the correct fingerprinted URL for the scripts const { markdownItJsPath, markdownLintJsPath } = pathDataElement.dataset; const markdownItScript = document.createElement('script'); markdownItScript.setAttribute('src', markdownItJsPath); document.body.appendChild(markdownItScript); // The markdownlint script needs the first script to have finished loading first markdownItScript.addEventListener('load', () => { const markdownLintScript = document.createElement('script'); markdownLintScript.setAttribute('src', markdownLintJsPath); document.body.appendChild(markdownLintScript); markdownLintScript.addEventListener('load', this.lintMarkdown); }); } else { this.lintMarkdown(); } }; showPreview = (response) => { this.fetchMarkdownLint(); this.setState({ ...this.setCommonProps({ previewShowing: true }), previewResponse: response, errors: null, }); }; handleOrgIdChange = (e) => { const organizationId = e.target.selectedOptions[0].value; this.setState({ organizationId }); }; failedPreview = (response) => { this.setState({ errors: response, submitting: false, }); }; handleConfigChange = (e) => { e.preventDefault(); const newState = {}; newState[e.target.name] = e.target.value; this.setState(newState); }; handleMainImageUrlChange = (payload) => { this.setState({ mainImage: payload.links[0], }); }; removeLocalStorage = () => { const { version } = this.state; localStorage.removeItem(`editor-${version}-${this.url}`); window.removeEventListener('beforeunload', this.localStoreContent); }; onPublish = (e) => { e.preventDefault(); this.setState({ submitting: true }); const payload = { ...this.state, published: true, }; submitArticle({ payload, onSuccess: () => { this.removeLocalStorage(); this.setState({ published: true, submitting: false }); }, onError: this.handleArticleError, }); }; onSaveDraft = (e) => { e.preventDefault(); this.setState({ submitting: true }); const payload = { ...this.state, published: false, }; submitArticle({ payload, onSuccess: () => { this.removeLocalStorage(); this.setState({ published: false, submitting: false }); }, onError: this.handleArticleError, }); }; onClearChanges = (e) => { e.preventDefault(); // eslint-disable-next-line no-alert const revert = window.confirm( 'Are you sure you want to revert to the previous save?', ); if (!revert && navigator.userAgent !== 'DEV-Native-ios') return; this.setState({ // When the formKey prop changes, it causes the
component to recreate the DOM nodes that it manages. // This permits us to reset the defaultValue for the MentionAutcompleteTextArea component without having to change // MentionAutcompleteTextArea component's implementation. formKey: new Date().toISOString(), title: this.article.title || '', tagList: this.article.cached_tag_list || '', description: '', // eslint-disable-line react/no-unused-state canonicalUrl: this.article.canonical_url || '', // eslint-disable-line react/no-unused-state series: this.article.series || '', // eslint-disable-line react/no-unused-state allSeries: this.article.all_series || [], // eslint-disable-line react/no-unused-state bodyMarkdown: this.article.body_markdown || '', published: this.article.published || false, previewShowing: false, previewResponse: '', submitting: false, editing: this.article.id !== null, // eslint-disable-line react/no-unused-state mainImage: this.article.main_image || null, errors: null, edited: false, helpFor: null, helpPosition: 0, isModalOpen: false, }); }; handleArticleError = (response) => { window.scrollTo(0, 0); this.setState({ errors: response, submitting: false, }); }; toggleEdit = () => { this.localStoreContent(); const { edited } = this.state; if (edited) return; this.setState({ edited: true, }); }; showModal = (isModalOpen) => { if (this.state.edited) { this.setState({ isModalOpen, }); } else { // If the user has not edited the body we send them home window.location.href = '/'; } }; switchHelpContext = ({ target }) => { this.setState({ ...this.setCommonProps({ helpFor: target.id, helpPosition: target.getBoundingClientRect().y, }), }); }; render() { const { title, tagList, bodyMarkdown, published, previewShowing, previewResponse, submitting, organizations, organizationId, mainImage, errors, edited, version, helpFor, helpPosition, siteLogo, markdownLintErrors, formKey, } = this.state; return ( { this.formElement = element; }} id="article-form" className="crayons-article-form" onSubmit={this.onSubmit} onInput={this.toggleEdit} aria-label="Edit post" >
this.showModal(true)} /> {previewShowing ? ( ) : ( )} {this.state.isModalOpen && ( this.showModal(false)} >

You've made changes to your post. Do you want to navigate to leave this page?

)} ); } }