mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-10-29 10:18:29 -04:00
150 lines
5.6 KiB
React
150 lines
5.6 KiB
React
import React, {useState} from 'react';
|
|
import {Node} from '@tiptap/core';
|
|
import {NodeViewWrapper, ReactNodeViewRenderer} from '@tiptap/react';
|
|
|
|
// What each size looks like, as a tiny picture, so it never rests on words alone.
|
|
const SIZES = [
|
|
['wide', 'Wide', <rect key="a" x="1" y="1" width="22" height="12" rx="1" fill="currentColor" />],
|
|
['half', 'Half', [
|
|
<rect key="a" x="1" y="1" width="13" height="12" rx="1" fill="currentColor" />,
|
|
<path key="b" d="M17 4h6M17 7h6M17 10h6" stroke="currentColor" strokeWidth="1.5" opacity=".6" />,
|
|
]],
|
|
['pair', 'Side by side', [
|
|
<rect key="a" x="1" y="1" width="10" height="12" rx="1" fill="currentColor" />,
|
|
<rect key="b" x="13" y="1" width="10" height="12" rx="1" fill="currentColor" />,
|
|
]],
|
|
];
|
|
|
|
//  or , alone on its line, as
|
|
// the server reads it (see Haml::Filters::GrowstuffMarkdown).
|
|
const PATTERN = /^!\[([^[\]]*)\]\(photo:(\d+)(?:\/(wide|half|pair))?\)[ \t]*(?:\n|$)/;
|
|
|
|
const clean = (caption) => String(caption || '').replace(/[[\]\r\n]+/g, ' ').trim();
|
|
|
|
// The photo in the text, with the controls beside it: caption, size, which
|
|
// photo stands for the post, and removing it from the text.
|
|
function PhotoFigureView({node, updateAttributes, deleteNode, selected, extension}) {
|
|
const {getPhoto, getCardPhotoId, setCardPhoto} = extension.options;
|
|
const {photoId, caption, size} = node.attrs;
|
|
const photo = getPhoto(photoId);
|
|
const [cardPhotoId, setCardPhotoId] = useState(getCardPhotoId());
|
|
const isCard = cardPhotoId === photoId;
|
|
|
|
async function makeCard() {
|
|
if (await setCardPhoto(photoId)) setCardPhotoId(photoId);
|
|
}
|
|
|
|
return (
|
|
<NodeViewWrapper className={`photo-figure${selected ? ' photo-figure-selected' : ''}`} contentEditable={false}>
|
|
<div className={`photo-figure-picture photo-figure-${size}`}>
|
|
<div className="photo-figure-polaroid">
|
|
{photo
|
|
? <img src={photo.fullsize_url} alt={caption || photo.title} />
|
|
: <div className="photo-figure-missing">Photo {photoId}</div>}
|
|
<div className="photo-figure-caption">{caption}</div>
|
|
</div>
|
|
</div>
|
|
<div className="photo-figure-controls">
|
|
<label className="fw-bold small d-block">
|
|
Caption
|
|
<input
|
|
className="form-control form-control-sm"
|
|
value={caption}
|
|
onChange={(event) => updateAttributes({caption: clean(event.target.value)})}
|
|
/>
|
|
</label>
|
|
<div className="my-2">
|
|
<span className="fw-bold small d-block mb-1" id={`size-${photoId}`}>How big in the post</span>
|
|
<div className="btn-group btn-group-sm d-flex" role="group" aria-labelledby={`size-${photoId}`}>
|
|
{SIZES.map(([value, label, shapes]) => (
|
|
<button
|
|
key={value}
|
|
type="button"
|
|
className={`btn btn-outline-secondary photo-figure-size${size === value ? ' active' : ''}`}
|
|
aria-pressed={size === value}
|
|
onClick={() => updateAttributes({size: value})}
|
|
>
|
|
<svg width="24" height="14" viewBox="0 0 24 14" aria-hidden="true">{shapes}</svg>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<span className="text-muted small">Side by side sits this photo next to the one after it.</span>
|
|
</div>
|
|
<div className="d-flex gap-2">
|
|
<button
|
|
type="button"
|
|
className={`btn btn-sm btn-outline-secondary${isCard ? ' active' : ''}`}
|
|
aria-pressed={isCard}
|
|
onClick={makeCard}
|
|
>
|
|
★ Card photo
|
|
</button>
|
|
<button type="button" className="btn btn-sm btn-outline-secondary" onClick={deleteNode}>Remove</button>
|
|
</div>
|
|
</div>
|
|
</NodeViewWrapper>
|
|
);
|
|
}
|
|
|
|
// A Flickr photo in the body, drawn as a polaroid with its caption. Options:
|
|
// getPhoto(id) finds a photo ({fullsize_url, title}), getCardPhotoId() says which
|
|
// photo is the post's card photo, and setCardPhoto(id) makes one, resolving to
|
|
// whether it worked.
|
|
export default Node.create({
|
|
name: 'photoFigure',
|
|
group: 'block',
|
|
atom: true,
|
|
draggable: true,
|
|
selectable: true,
|
|
|
|
addOptions() {
|
|
return {getPhoto: () => null, getCardPhotoId: () => null, setCardPhoto: async () => false};
|
|
},
|
|
|
|
addAttributes() {
|
|
return {
|
|
photoId: {default: null, parseHTML: (el) => Number(el.getAttribute('data-photo-id'))},
|
|
caption: {default: '', parseHTML: (el) => el.getAttribute('data-caption') || ''},
|
|
size: {default: 'wide', parseHTML: (el) => el.getAttribute('data-size') || 'wide'},
|
|
};
|
|
},
|
|
|
|
parseHTML() {
|
|
return [{tag: 'div[data-photo-figure]'}];
|
|
},
|
|
|
|
renderHTML({node}) {
|
|
return ['div', {
|
|
'data-photo-figure': '',
|
|
'data-photo-id': node.attrs.photoId,
|
|
'data-caption': node.attrs.caption,
|
|
'data-size': node.attrs.size,
|
|
}];
|
|
},
|
|
|
|
addNodeView() {
|
|
return ReactNodeViewRenderer(PhotoFigureView);
|
|
},
|
|
|
|
markdownTokenizer: {
|
|
name: 'photoFigure',
|
|
level: 'block',
|
|
start: (src) => src.search(/^!\[[^[\]]*\]\(photo:\d+/m),
|
|
tokenize(src) {
|
|
const match = PATTERN.exec(src);
|
|
if (!match) return undefined;
|
|
return {type: 'photoFigure', raw: match[0], caption: match[1], photoId: Number(match[2]), size: match[3] || 'wide'};
|
|
},
|
|
},
|
|
|
|
parseMarkdown(token) {
|
|
return {type: 'photoFigure', attrs: {photoId: token.photoId, caption: token.caption, size: token.size}};
|
|
},
|
|
|
|
renderMarkdown(node) {
|
|
const {photoId, caption, size} = node.attrs;
|
|
return ``;
|
|
},
|
|
});
|