diff --git a/app/assets/stylesheets/_posts.scss b/app/assets/stylesheets/_posts.scss index f533ecb01..b3b745314 100644 --- a/app/assets/stylesheets/_posts.scss +++ b/app/assets/stylesheets/_posts.scss @@ -67,3 +67,300 @@ font-family: monospace; } } + +// The photo that leads a post's page. +.post-card-photo { + img { + display: block; + width: 100%; + max-height: 260px; + object-fit: cover; + } +} + +// "More from this author" in the post sidebar. +.more-from-author { + .post-compact { + text-decoration: none; + } + + .post-compact-photo { + width: 56px; + height: 56px; + flex: none; + object-fit: cover; + border-radius: 2px; + } +} + +// The choices in the card photo dialog. +.card-photo-choices { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 16px; + padding: 16px; + background: #f4f2ef; + border: 1px solid rgba(65, 63, 59, .15); + border-radius: 4px; +} + +.card-photo-choice { + position: relative; + cursor: pointer; + + input { + position: absolute; + opacity: 0; + inset: 0; + width: 100%; + height: 100%; + margin: 0; + cursor: pointer; + } + + .card-photo-polaroid { + display: block; + padding: 6px 6px 0; + background: #fff; + font-size: 12px; + box-shadow: 0 1px 3px rgba(65, 63, 59, .25); + outline: 3px solid transparent; + outline-offset: 2px; + + img { + display: block; + width: 100%; + height: 90px; + object-fit: cover; + } + + span { + display: block; + padding: 6px 2px 8px; + } + } + + input:checked + .card-photo-polaroid { + outline-color: #212121; + } + + .card-photo-star { + color: #57803c; + } + + .card-photo-tick { + display: none; + position: absolute; + top: 14px; + left: 14px; + padding: 2px 8px; + border-radius: 999px; + background: #212121; + color: #fff; + font-size: 11px; + font-weight: 700; + letter-spacing: .04em; + } + + input:checked ~ .card-photo-tick { + display: inline-flex; + } + + input:focus-visible + .card-photo-polaroid { + outline-color: #2f4365; + } +} + +.card-photo-preview { + display: flex; + gap: 16px; + align-items: flex-start; + + img { + width: 200px; + height: 130px; + flex: none; + object-fit: cover; + border-radius: 2px; + } +} + +.post-card-photo-change { + display: flex; + justify-content: flex-end; + padding: 8px 16px 0; +} + +.post-editor { + .post-editor-preview { + min-height: 320px; + padding: 16px 20px; + } +} + +// Saving and publishing, always in reach at the foot of the editor page. +.post-save-bar { + position: sticky; + bottom: 0; + z-index: 10; + margin-top: 24px; + padding: 12px 16px; + background: #fff; + border-top: 1px solid rgba(65, 63, 59, .35); + box-shadow: 0 -2px 6px rgba(0, 0, 0, .08); +} + +// The editor page: the form, its sidebar and the save bar share one width. +.post-edit-page { + max-width: 1140px; + margin: 0 auto; +} + +// Links to crops, members and plantings in the editor, and the list for choosing them. +.post-editor-content .link-chip { + padding: 0 8px; + border: 1px solid rgba(65, 63, 59, .15); + border-radius: 999px; + background: #e8f5e9; + color: #413f3b; + font-weight: 600; + cursor: default; + + &.ProseMirror-selectednode { + outline: 2px solid #2f4365; + } +} + +.link-suggestions { + z-index: 1080; + width: 340px; + max-height: 340px; + overflow-y: auto; + background: #fff; + border: 1px solid rgba(65, 63, 59, .15); + border-radius: 4px; + + .link-suggestions-group { + padding: 6px 14px; + background: #f4f2ef; + border-bottom: 1px solid rgba(65, 63, 59, .15); + font-size: 11px; + font-weight: 700; + letter-spacing: .06em; + text-transform: uppercase; + color: #413f3b; + } + + .link-suggestions-item { + padding: 8px 14px; + cursor: pointer; + + &.active, + &:hover { + background: #f4f2ef; + } + } + + .link-suggestions-hint { + padding: 8px 14px; + border-top: 1px solid rgba(65, 63, 59, .15); + font-size: 12px; + } +} + +// Photos placed in a post's text, on the post page. +.post-content .post-figure { + margin: 22px 0; + padding: 8px 8px 0; + background: #fff; + box-shadow: 0 1px 3px rgba(65, 63, 59, .25); + max-width: 720px; + + img { + display: block; + width: 100%; + } + + .post-figure-caption { + margin: 0; + padding: 8px 4px 10px; + font-size: 13px; + color: #413f3b; + } + + &.post-figure-half { + max-width: 350px; + } + + // Two pair photos in a row sit side by side. + &.post-figure-pair { + display: inline-block; + vertical-align: top; + width: calc(50% - 10px); + max-width: 350px; + margin-right: 16px; + } +} + +// A photo in the editor: the polaroid, with its controls beside it. +.photo-figure { + display: flex; + flex-wrap: wrap; + gap: 20px; + align-items: flex-start; + margin: 20px 0 8px; + + &.photo-figure-selected .photo-figure-picture { + outline: 2px solid #2f4365; + outline-offset: 4px; + } + + .photo-figure-picture { + flex: 1 1 320px; + max-width: 460px; + } + + .photo-figure-half, + .photo-figure-pair { + max-width: 230px; + } + + .photo-figure-polaroid { + padding: 8px 8px 0; + background: #fff; + box-shadow: 0 1px 3px rgba(65, 63, 59, .25); + + img { + display: block; + width: 100%; + } + } + + .photo-figure-missing { + padding: 40px 0; + text-align: center; + background: #f4f2ef; + } + + .photo-figure-caption { + padding: 8px 4px 10px; + font-size: 13px; + color: #413f3b; + } + + .photo-figure-size { + display: flex; + flex: 1; + flex-direction: column; + align-items: center; + gap: 4px; + } + + .photo-figure-controls { + flex: 1 1 220px; + padding: 12px 14px; + background: #f4f2ef; + border: 1px solid rgba(65, 63, 59, .15); + border-radius: 2px; + } +} diff --git a/app/controllers/photos_controller.rb b/app/controllers/photos_controller.rb index 2da4aaa7a..6d9c724a4 100644 --- a/app/controllers/photos_controller.rb +++ b/app/controllers/photos_controller.rb @@ -101,7 +101,9 @@ class PhotosController < ApplicationController def render_added_json garden = @item.is_a?(Garden) ? @item : (@item.garden if @item.respond_to?(:garden)) card = GardenCardSerializer.collection([garden], ability: current_ability, show_owner: false).first if garden - render json: { photo: { id: @photo.id, url: photo_path(@photo) }, garden: card }, status: :created + photo = { id: @photo.id, url: photo_path(@photo), title: @photo.title, + thumbnail_url: @photo.thumbnail_url, fullsize_url: @photo.fullsize_url } + render json: { photo:, garden: card }, status: :created end def photo_params @@ -125,7 +127,10 @@ class PhotosController < ApplicationController item_class = params[:type].capitalize raise "Photos not supported" unless Photo::PHOTO_CAPABLE.include? item_class - item_class.constantize.find(params[:id]) + klass = item_class.constantize + # A draft post is still its author's to add photos to. + klass = klass.with_drafts if klass.respond_to?(:with_drafts) + klass.find(params[:id]) end # diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 85c947b9f..96a26cf00 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -2,7 +2,8 @@ class PostsController < ApplicationController before_action :authenticate_member!, except: %i(index show) - load_and_authorize_resource + before_action :find_post, only: %i(show edit update destroy) + load_and_authorize_resource except: %i(preview link_suggestions) responders :flash respond_to :html, :json respond_to :rss, only: %i(index show) @@ -14,7 +15,6 @@ class PostsController < ApplicationController end def show - @post = Post.includes(:author).find(params[:id]) respond_with(@post) end @@ -30,15 +30,32 @@ class PostsController < ApplicationController def edit; end + # What the editor's "link to" list offers besides crops (which it gets from + # /crops/search.json): members, and the plantings of the member writing. + def link_suggestions + authorize! :create, Post + term = params[:term].to_s.strip + render json: { members: member_suggestions(term), plantings: planting_suggestions(term) } + end + + # The body as it will look when posted, for the editor's Preview tab. + def preview + authorize! :create, Post + render partial: 'posts/body', formats: [:html], locals: { body: params[:body].to_s } + end + def create params[:post][:author_id] = current_member.id @post = Post.new(post_params) - flash[:notice] = t('posts.created') if @post.save + flash[:notice] = t(@post.draft? ? 'posts.draft_saved' : 'posts.created') if @post.save respond_with(@post) end def update - flash[:notice] = t('posts.updated') if @post.update(post_params) + was_draft = @post.draft? + if @post.update(post_params) + flash[:notice] = t(was_draft && @post.published? ? 'posts.created' : 'posts.updated') + end respond_with(@post) end @@ -49,13 +66,47 @@ class PostsController < ApplicationController private + SUGGESTIONS = 5 + + def member_suggestions(term) + return [] if term.blank? + + Member.kept.confirmed.where('login_name ILIKE ?', "#{Member.sanitize_sql_like(term)}%") + .order(:login_name).limit(SUGGESTIONS).map { |member| { name: member.login_name } } + end + + def planting_suggestions(term) + return [] if term.blank? + + like = "#{Planting.sanitize_sql_like(term)}%" + current_member.plantings.joins(:crop, :garden).includes(:crop, :garden) + .where('crops.name ILIKE :like OR gardens.name ILIKE :like', like:) + .order(created_at: :desc).limit(SUGGESTIONS) + .map { |planting| { slug: planting.slug, name: planting.to_s, crop: planting.crop.name } } + end + + # A draft is only its author's: to anyone else it does not exist. + def find_post + @post = Post.with_drafts.includes(:author).find(params[:id]) + raise ActiveRecord::RecordNotFound if @post.draft? && @post.author != current_member + end + def post_params - params.require(:post).permit(:body, :subject, :author_id, :forum_id) + params.require(:post).permit(:body, :subject, :author_id, :forum_id, :card_photo_id, :draft) + end + + # All of their own posts with drafts among them, or only the drafts. + def own_posts + return @author.posts unless @author == current_member + + mine = Post.with_drafts.where(author: @author) + params[:drafts].present? ? mine.where(published_at: nil) : mine end def posts if @author - @author.posts + # Members see their own drafts on their own page. + own_posts else Post end.order(created_at: :desc) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 14a909efd..529578c2c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -92,10 +92,16 @@ module ApplicationHelper # A helper to replace the complex template compilation mess # of HAML, Tilt, and dynamic compilation with interpolated ruby. - def markdownify(text) + # + # Photos placed in a post are only drawn with `figures: true`, which is for + # the post itself, and must be given text that has already had its tags + # stripped, as the figures are HTML. + def markdownify(text, figures: false) translator = Haml::Filters::GrowstuffMarkdown.new text = text.to_s.dup + translator.expand_photos!(text, figures:) translator.expand_crops!(text) + translator.expand_plantings!(text) translator.expand_members!(text) text end diff --git a/app/helpers/photos_helper.rb b/app/helpers/photos_helper.rb index 9b24f1716..cf45415b0 100644 --- a/app/helpers/photos_helper.rb +++ b/app/helpers/photos_helper.rb @@ -57,6 +57,26 @@ module PhotosHelper post.default_photo&.thumbnail_url || post.crops.first&.default_photo&.thumbnail_url || placeholder_image end + # What the CardPhotoDialog island needs: where to save the choice, the post's + # photos, and enough of the post to draw how its card will look. + def card_photo_dialog_props(post) + { + url: post_path(post), + subject: post.subject, + excerpt: truncate(strip_tags(markdownify(post.body)), length: 90, separator: ' '), + byline: "by #{post.author} · #{l(post.created_at.to_date, format: :short)}", + selectedId: post.card_photo_id, + addPhotoUrl: new_photo_path_for(post), + addPhotoLabel: photo_label(post), + photos: post.photos.map { |p| { id: p.id, title: p.title, thumbnail_url: p.thumbnail_url, fullsize_url: p.fullsize_url } } + } + end + + # A post's photos, as the editor's islands draw them. + def post_photo_props(post) + post.photos.map { |p| { id: p.id, title: p.title, thumbnail_url: p.thumbnail_url, fullsize_url: p.fullsize_url } } + end + private def photo_or_placeholder(item) diff --git a/app/javascript/components/CardPhotoDialog.jsx b/app/javascript/components/CardPhotoDialog.jsx new file mode 100644 index 000000000..4951e374b --- /dev/null +++ b/app/javascript/components/CardPhotoDialog.jsx @@ -0,0 +1,109 @@ +import React, {useState} from 'react'; + +import {patchJson} from '../api'; +import Modal from './Modal'; + +// A button on a post's page that opens a dialog for choosing the card photo: +// the photo that stands for the post on the posts list, an author's page and +// shared links. The photos are the post's own. Saving reloads, because the +// page is server rendered. +export default function CardPhotoDialog({ + url, photos, selectedId = null, subject, excerpt = '', byline = '', addPhotoUrl = null, addPhotoLabel = '', +}) { + const [open, setOpen] = useState(false); + const [chosenId, setChosenId] = useState(selectedId || (photos[0] && photos[0].id)); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + const chosen = photos.find((p) => p.id === chosenId); + + async function save() { + setSaving(true); + setError(null); + const {ok} = await patchJson(url, {post: {card_photo_id: chosenId}}); + if (ok) { + setTimeout(() => window.location.reload(), 0); + return; + } + setSaving(false); + setError('The photo could not be saved. Try again.'); + } + + return ( + <> + + {open && ( + setOpen(false)}> +
+

+ Photos come from your Flickr account. This photo stands for your post wherever it appears as a card: the + posts list, your page and links shared elsewhere. +

+ {photos.length === 0 ? ( +

Pick a photo from Flickr for this post first, then you can choose it here.

+ ) : ( +
+ Photos in this post +
+ {photos.map((photo) => ( + + ))} +
+
+ )} + {addPhotoUrl && ( +

+ {/* The page's photo dialog opens from any [data-photo-dialog] link. */} + setOpen(false)} + > + Pick a different photo from Flickr + + Or leave it empty and the first photo is used. +

+ )} + {chosen && ( + <> +

How it will look

+
+ Preview of the card photo +
+ {subject} +

{excerpt}

+ {byline} +
+
+ + )} + {error &&
{error}
} +
+
+ + +
+
+ )} + + ); +} diff --git a/app/javascript/components/CardPhotoDialog.test.jsx b/app/javascript/components/CardPhotoDialog.test.jsx new file mode 100644 index 000000000..cdce84126 --- /dev/null +++ b/app/javascript/components/CardPhotoDialog.test.jsx @@ -0,0 +1,100 @@ +import {render, screen, waitFor} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {afterEach, describe, expect, it, vi} from 'vitest'; + +import * as api from '../api'; +import CardPhotoDialog from './CardPhotoDialog'; + +const photos = [ + {id: 1, title: 'Fermenting jar', thumbnail_url: '/jar-thumb.jpg', fullsize_url: '/jar.jpg'}, + {id: 2, title: 'Drying on a plate', thumbnail_url: '/plate-thumb.jpg', fullsize_url: '/plate.jpg'}, +]; + +const props = {url: '/posts/7', photos, subject: 'Saving tomato seed', excerpt: 'The tomatoes', byline: 'by brenda'}; + +async function openDialog(extra = {}) { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', {name: 'Change'})); + return user; +} + +describe('CardPhotoDialog', () => { + afterEach(() => vi.restoreAllMocks()); + + it('is closed until Change is clicked', () => { + render(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('lists the photos in the post', async () => { + await openDialog(); + expect(screen.getAllByRole('radio')).toHaveLength(2); + }); + + it('selects the current card photo', async () => { + await openDialog({selectedId: 2}); + expect(screen.getByRole('radio', {name: /Drying on a plate/})).toBeChecked(); + }); + + it('selects the first photo when none is chosen', async () => { + await openDialog(); + expect(screen.getByRole('radio', {name: /Fermenting jar/})).toBeChecked(); + }); + + it('previews the chosen photo', async () => { + const user = await openDialog(); + await user.click(screen.getByRole('radio', {name: /Drying on a plate/})); + expect(screen.getByAltText('Preview of the card photo')).toHaveAttribute('src', '/plate.jpg'); + }); + + it('saves the chosen photo on the post', async () => { + const patch = vi.spyOn(api, 'patchJson').mockResolvedValue({ok: false, status: 422, data: null}); + const user = await openDialog(); + await user.click(screen.getByRole('radio', {name: /Drying on a plate/})); + await user.click(screen.getByRole('button', {name: 'Use this photo'})); + await waitFor(() => expect(patch).toHaveBeenCalledWith('/posts/7', {post: {card_photo_id: 2}})); + }); + + it('shows an error when saving fails', async () => { + vi.spyOn(api, 'patchJson').mockResolvedValue({ok: false, status: 422, data: null}); + const user = await openDialog(); + await user.click(screen.getByRole('button', {name: 'Use this photo'})); + expect(await screen.findByRole('alert')).toHaveTextContent('could not be saved'); + }); + + it('asks for a photo first when the post has none', async () => { + await openDialog({photos: []}); + expect(screen.getByText(/Pick a photo from Flickr for this post first/)).toBeInTheDocument(); + }); + + it('closes on Cancel', async () => { + const user = await openDialog(); + await user.click(screen.getByRole('button', {name: 'Cancel'})); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + it('says where the photos come from', async () => { + await openDialog(); + expect(screen.getByText(/Photos come from your Flickr account/)).toBeInTheDocument(); + }); + + it('links to the page photo dialog for another photo', async () => { + await openDialog({addPhotoUrl: '/photos/new?id=7&type=post'}); + expect(screen.getByRole('link', {name: 'Pick a different photo from Flickr'})).toHaveAttribute('data-photo-dialog'); + }); + + it('offers no other photo link without an address', async () => { + await openDialog(); + expect(screen.queryByRole('link', {name: /Flickr/})).not.toBeInTheDocument(); + }); + + it('tags the chosen photo as selected', async () => { + await openDialog({selectedId: 2}); + expect(screen.getAllByText('✓ Selected')).toHaveLength(2); // shown by CSS only for the checked one + }); + + it('stars the current card photo', async () => { + await openDialog({selectedId: 2}); + expect(screen.getByRole('radio', {name: /★\s*Drying on a plate/})).toBeInTheDocument(); + }); +}); diff --git a/app/javascript/components/LinkSuggestionList.jsx b/app/javascript/components/LinkSuggestionList.jsx new file mode 100644 index 000000000..835463e7d --- /dev/null +++ b/app/javascript/components/LinkSuggestionList.jsx @@ -0,0 +1,42 @@ +import React from 'react'; + +// The matches for what has been typed after an @ in the post editor, grouped +// (Crops, Members, Yours) and floating under the cursor. The editor owns which +// is highlighted and handles the keys; this only draws the list. +export default function LinkSuggestionList({items, selected, rect, onChoose}) { + if (!rect || items.length === 0) return null; + + let lastGroup = null; + return ( +
+
    + {items.map((item, index) => { + const heading = item.group !== lastGroup; + lastGroup = item.group; + return ( + + {heading &&
  • {item.group}
  • } +
  • { + event.preventDefault(); + onChoose(item); + }} + > + {item.label} + {item.detail && {item.detail}} +
  • +
    + ); + })} +
+
↑↓ to choose · Enter to link · Esc to keep it as text
+
+ ); +} diff --git a/app/javascript/components/LinkSuggestionList.test.jsx b/app/javascript/components/LinkSuggestionList.test.jsx new file mode 100644 index 000000000..c921544c2 --- /dev/null +++ b/app/javascript/components/LinkSuggestionList.test.jsx @@ -0,0 +1,42 @@ +import {render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {describe, expect, it, vi} from 'vitest'; + +import LinkSuggestionList from './LinkSuggestionList'; + +const items = [ + {group: 'Crops', kind: 'crop', label: 'Pumpkin'}, + {group: 'Crops', kind: 'crop', label: 'Pumpkin, Crown Prince'}, + {group: 'Members', kind: 'member', label: 'pumpkinfan'}, + {group: 'Yours', kind: 'planting', label: 'Pumpkin in Patch', slug: 'a-patch-pumpkin', detail: 'Planting'}, +]; +const rect = {left: 10, bottom: 20}; + +describe('LinkSuggestionList', () => { + it('lists every match', () => { + render( {}} />); + expect(screen.getAllByRole('option')).toHaveLength(4); + }); + + it('says what each group is', () => { + render( {}} />); + expect(screen.getAllByText(/^(Crops|Members|Yours)$/)).toHaveLength(3); + }); + + it('marks the highlighted match', () => { + render( {}} />); + expect(screen.getByRole('option', {name: /pumpkinfan/})).toHaveAttribute('aria-selected', 'true'); + }); + + it('chooses a match with the mouse', async () => { + const onChoose = vi.fn(); + render(); + await userEvent.click(screen.getByRole('option', {name: /Pumpkin in Patch/})); + expect(onChoose).toHaveBeenCalledWith(items[3]); + }); + + it('draws nothing without matches', () => { + const {container} = render( {}} />); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/app/javascript/components/PostEditor.jsx b/app/javascript/components/PostEditor.jsx index b627ff5cd..84d31b6f5 100644 --- a/app/javascript/components/PostEditor.jsx +++ b/app/javascript/components/PostEditor.jsx @@ -1,8 +1,15 @@ -import React, {useState} from 'react'; +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'}, @@ -24,7 +31,7 @@ function ToolButton({label, active, onClick, children, ...rest}) { ); } -function Toolbar({editor}) { +function Toolbar({editor, onAddPhoto}) { const state = useEditorState({ editor, selector: ({editor: e}) => ({ @@ -82,30 +89,152 @@ function Toolbar({editor}) { > ❝ 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. -export default function PostEditor({name, value = '', id = 'post_body'}) { +// +// 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], + 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); }; @@ -113,7 +242,7 @@ export default function PostEditor({name, value = '', id = 'post_body'}) {
- {[['write', 'Write'], ['markdown', 'Markdown']].map(([key, label]) => ( + {tabs.map(([key, label]) => (
+ {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' && (