A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import { useLayoutEffect, useRef } from 'react';
2import type { BlockInstance } from '@wordpress/blocks';
3import SkyEditor from './SkyEditor';
4import CoverImagePicker from './CoverImagePicker';
5import type { MediaUploadHandler } from '../lib/media/mediaUpload';
6import type { CoverUpload } from '../lib/media/cover';
7import type { BlockNode } from '../lib/blocks/render';
8
9interface Props {
10 title: string;
11 onTitleChange: ( value: string ) => void;
12 lede: string;
13 onLedeChange: ( value: string ) => void;
14 /** Live block instances on every editor change — the parent normalises/stores as it needs. */
15 onBlocksChange: ( blocks: BlockInstance[] ) => void;
16 mediaUpload?: MediaUploadHandler;
17 initialBlocks?: BlockNode[];
18 cover: CoverUpload | null;
19 /** When provided, the cover picker renders and uploads through this handler (eager or deferred). */
20 onCoverUpload?: ( file: File ) => Promise< CoverUpload >;
21 onCoverChange: ( cover: CoverUpload | null ) => void;
22}
23
24/**
25 * The shared writing surface for both the editor (`/editor`) and the writing-first page
26 * (`/write`): the borderless title + lede headings above the framed block canvas, plus the
27 * optional per-article cover picker. Presentational — it owns no document state, only the
28 * textareas' auto-grow. Each island wires the content state, the media-upload handler, and
29 * (for the cover) the upload path that fits its flow: eager PDS upload in the editor, deferred
30 * `data:`-URL hold in the writing-first flow. Lives only in `client:only` islands — it pulls in
31 * `SkyEditor`, which is browser-only (Decision 0003).
32 */
33export default function EditorCanvas( {
34 title,
35 onTitleChange,
36 lede,
37 onLedeChange,
38 onBlocksChange,
39 mediaUpload,
40 initialBlocks,
41 cover,
42 onCoverUpload,
43 onCoverChange,
44}: Props ) {
45 const titleRef = useRef< HTMLTextAreaElement >( null );
46 const ledeRef = useRef< HTMLTextAreaElement >( null );
47
48 // Grow the title textarea to fit its content so long titles wrap into view instead of
49 // clipping on one line. Layout effect so it sizes before paint.
50 useLayoutEffect( () => {
51 const el = titleRef.current;
52 if ( ! el ) {
53 return;
54 }
55 el.style.height = 'auto';
56 el.style.height = `${ el.scrollHeight }px`;
57 }, [ title ] );
58
59 // Same auto-grow for the lede (and on hydrate from an edit-load / restored draft).
60 useLayoutEffect( () => {
61 const el = ledeRef.current;
62 if ( ! el ) {
63 return;
64 }
65 el.style.height = 'auto';
66 el.style.height = `${ el.scrollHeight }px`;
67 }, [ lede ] );
68
69 return (
70 <>
71 <textarea
72 ref={ titleRef }
73 className="studio__title"
74 rows={ 1 }
75 placeholder="Add title"
76 aria-label="Article title"
77 value={ title }
78 // Single-line semantically: let it wrap visually, but don't let Enter insert a
79 // literal newline into the stored value.
80 onKeyDown={ ( event ) => {
81 if ( event.key === 'Enter' ) {
82 event.preventDefault();
83 }
84 } }
85 onChange={ ( event ) => onTitleChange( event.target.value ) }
86 />
87 <textarea
88 ref={ ledeRef }
89 className="studio__lede"
90 rows={ 1 }
91 maxLength={ 3000 }
92 placeholder="Add a subtitle…"
93 aria-label="Subtitle"
94 value={ lede }
95 onChange={ ( event ) => onLedeChange( event.target.value ) }
96 />
97 { lede.length > 200 && (
98 <p className="studio__lede-hint">
99 Long subtitles get truncated on the Bluesky card.
100 </p>
101 ) }
102 <SkyEditor
103 onChange={ onBlocksChange }
104 mediaUpload={ mediaUpload }
105 initialBlocks={ initialBlocks }
106 />
107 { onCoverUpload && (
108 <CoverImagePicker cover={ cover } onUpload={ onCoverUpload } onChange={ onCoverChange } />
109 ) }
110 </>
111 );
112}