import React, {useMemo, useRef, useState} from 'react'; import {EditorContent, useEditor, useEditorState} from '@tiptap/react'; import StarterKit from '@tiptap/starter-kit'; import {Markdown} from '@tiptap/markdown'; import {csrfToken, patchJson} from '../api'; import AddPhotoModal from './AddPhotoModal'; import LinkSuggestionList from './LinkSuggestionList'; import {createLinkChip, createLinkSearch} from './linkChip'; import photoFigure from './photoFigure'; import useDraftAutosave from './useDraftAutosave'; const BLOCKS = [ {value: 'paragraph', label: 'Paragraph'}, {value: 'h2', label: 'Heading'}, {value: 'h3', label: 'Subheading'}, ]; function ToolButton({label, active, onClick, children, ...rest}) { return ( ); } function Toolbar({editor, onAddPhoto}) { const state = useEditorState({ editor, selector: ({editor: e}) => ({ block: e.isActive('heading', {level: 2}) ? 'h2' : e.isActive('heading', {level: 3}) ? 'h3' : 'paragraph', bold: e.isActive('bold'), italic: e.isActive('italic'), link: e.isActive('link'), bulletList: e.isActive('bulletList'), blockquote: e.isActive('blockquote'), }), }); const setBlock = (value) => { const chain = editor.chain().focus(); if (value === 'paragraph') chain.setParagraph().run(); else chain.toggleHeading({level: Number(value[1])}).run(); }; const setLink = () => { const previous = editor.getAttributes('link').href || ''; const url = window.prompt('Link address', previous); if (url === null) return; if (url === '') editor.chain().focus().extendMarkRange('link').unsetLink().run(); else editor.chain().focus().extendMarkRange('link').setLink({href: url}).run(); }; return (
editor.chain().focus().toggleBold().run()}> B editor.chain().focus().toggleItalic().run()}> I Link editor.chain().focus().toggleBulletList().run()} > • List editor.chain().focus().toggleBlockquote().run()} > ❝ Quote {onAddPhoto && ( + Flickr photo )} {/* Typing @ opens the list of things to link to, so this just types it. */} editor.chain().focus().insertContent('@').run()}> @ Link to…
); } const search = createLinkSearch(); function suggestionState(props) { return {items: props.items, selected: 0, rect: props.clientRect && props.clientRect(), command: props.command}; } // A rich text editor for a post body that stores markdown, so people can // format without knowing it. The Markdown tab shows and edits the raw text. // The markdown goes in a hidden field called `name`, so the surrounding form // submits it as it would a textarea. // // With `autosave` (see useDraftAutosave) the post is saved as a draft as it is // written. With `previewUrl` a Preview tab shows the body as the site will // render it. // // With `canAddPhoto`, the toolbar picks a Flickr photo for the post and places // it in the text. `photos` are the post's photos so far ({id, title, // thumbnail_url, fullsize_url}), `postPath` is where an existing post lives // (a draft not yet saved has none: it is saved first), and `cardPhotoId` is the // photo that stands for the post. export default function PostEditor({ name, value = '', id = 'post_body', autosave = null, previewUrl = null, canAddPhoto = false, photos = [], postPath = null, cardPhotoId = null, }) { const [markdown, setMarkdown] = useState(value); const [tab, setTab] = useState('write'); const [previewHtml, setPreviewHtml] = useState(''); const {status, saveNow} = useDraftAutosave(markdown, autosave); const [notice, setNotice] = useState(''); const [pickerUrl, setPickerUrl] = useState(null); // the add photo dialog's address, while it is open // The photos and card photo the figures in the text look up as they draw. const photoList = useRef(photos); const cardId = useRef(cardPhotoId); const editorRef = useRef(null); // Where the post lives, saving the draft first if it is a new one. Null when // there is nothing to save yet (it needs a title and some text). async function ensurePostPath() { return autosave ? saveNow() : postPath; } async function makeCardPhoto(photoId) { const path = await ensurePostPath(); if (!path) return false; const {ok} = await patchJson(path, {post: {card_photo_id: photoId}}); if (ok) cardId.current = photoId; return ok; } async function openPhotoPicker() { setNotice(''); const path = await ensurePostPath(); if (!path) { setNotice('The draft could not be saved, so a photo cannot be added yet. Try again.'); return; } setPickerUrl(`/photos/new?type=post&id=${path.split('/').pop()}`); } function photoAdded({photo}) { setPickerUrl(null); photoList.current = [...photoList.current, photo]; // After the current line, or after the photo that is selected: inserting // over a selected photo would replace it, and only one could ever be added. const editor = editorRef.current; const {selection} = editor.state; const position = selection.node ? selection.to : selection.from; editor .chain() .focus() .insertContentAt(position, {type: 'photoFigure', attrs: {photoId: photo.id, caption: photo.title, size: 'wide'}}) .run(); } const figure = useMemo(() => photoFigure.configure({ getPhoto: (photoId) => photoList.current.find((p) => p.id === photoId), getCardPhotoId: () => cardId.current, setCardPhoto: (photoId) => makeCardPhoto(photoId), }), []); // eslint-disable-line react-hooks/exhaustive-deps const tabs = [['write', 'Write'], previewUrl && ['preview', 'Preview'], ['markdown', 'Markdown']].filter(Boolean); async function loadPreview() { setPreviewHtml(''); const response = await fetch(previewUrl, { method: 'POST', credentials: 'same-origin', headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken()}, body: JSON.stringify({body: markdown}), }); setPreviewHtml(response.ok ? await response.text() : '

The preview could not be loaded.

'); } // The matches after an @ ({items, selected, rect, command}), or null. const [suggest, setSuggest] = useState(null); const suggestRef = useRef(null); suggestRef.current = suggest; const linkChip = useMemo(() => createLinkChip({ suggestion: { items: ({query}) => search(query), render: () => ({ onStart: (props) => setSuggest(suggestionState(props)), onUpdate: (props) => setSuggest(suggestionState(props)), onKeyDown: ({event}) => { const current = suggestRef.current; if (!current || current.items.length === 0) return false; const count = current.items.length; if (event.key === 'ArrowDown') setSuggest({...current, selected: (current.selected + 1) % count}); else if (event.key === 'ArrowUp') setSuggest({...current, selected: (current.selected + count - 1) % count}); else if (event.key === 'Enter') current.command(current.items[current.selected]); else return false; return true; }, onExit: () => setSuggest(null), }), }, }), []); const editor = useEditor({ extensions: [StarterKit, Markdown, linkChip, figure], content: value, contentType: 'markdown', onUpdate: ({editor: e}) => setMarkdown(e.getMarkdown().trim()), editorProps: {attributes: {class: 'post-editor-content', 'aria-label': 'Post body', role: 'textbox', 'aria-multiline': 'true'}}, }); editorRef.current = editor; const showTab = (next) => { if (next === 'write' && editor) { editor.commands.setContent(markdown, {contentType: 'markdown'}); } if (next === 'preview') loadPreview(); setTab(next); }; return (
{tabs.map(([key, label]) => ( ))} {notice || status}
{pickerUrl && ( setPickerUrl(null)} onAdded={photoAdded} /> )}
{tab === 'write' && editor && ( <> {suggest && ( )} )} {tab === 'preview' && ( // The server renders (and sanitises) the markdown, as it does for a posted post.
)} {tab === 'markdown' && (