A calm place to write long-form, and publish it to the open social web.
skypress.blog/
1import {
2 useCallback,
3 useMemo,
4 useRef,
5 useState,
6 type KeyboardEvent as ReactKeyboardEvent,
7} from 'react';
8import { createBlock, type BlockInstance } from '@wordpress/blocks';
9import {
10 BlockEditorProvider,
11 BlockList,
12 BlockTools,
13 BlockToolbar,
14 BlockInspector,
15 Inserter,
16 WritingFlow,
17 ObserveTyping,
18 BlockEditorKeyboardShortcuts,
19} from '@wordpress/block-editor';
20import { Button, Popover, SlotFillProvider } from '@wordpress/components';
21import { ShortcutProvider } from '@wordpress/keyboard-shortcuts';
22import { useStateWithHistory } from '@wordpress/compose';
23import { cog, undo as undoIcon, redo as redoIcon } from '@wordpress/icons';
24
25// Default rich-text formats (bold, italic, link, inline code, …) register as a
26// side effect of importing the package.
27import '@wordpress/format-library';
28
29// Side-effect import: must run AFTER @wordpress/block-editor so our
30// editor.MediaUpload filter registers last and replaces the bundled
31// wp.media-based one (see the module).
32import '../lib/media/registerMediaUpload';
33
34// The editor island owns the editor + component + block styles. Reading pages
35// import only the *frontend* block styles (Decision 0003) — never these.
36import '@wordpress/components/build-style/style.css';
37import '@wordpress/block-editor/build-style/style.css';
38import '@wordpress/block-editor/build-style/content.css';
39// (@wordpress/format-library ships no `build-style` export subpath; the inline
40// format UI is styled by the components + block-editor stylesheets above.)
41import '@wordpress/block-library/build-style/common.css';
42import '@wordpress/block-library/build-style/style.css';
43import '@wordpress/block-library/build-style/editor.css';
44import '@wordpress/block-library/build-style/theme.css';
45
46import { registerSkyPressBlocks } from '../lib/blocks/serialize';
47import { ALLOWED_BLOCKS } from '../lib/blocks/allowlist';
48import type { MediaUploadHandler } from '../lib/media/mediaUpload';
49import { registerMentionFormat } from '../lib/editor/mention-format';
50import { registerMentionAutocompleter } from '../lib/editor/mention-autocompleter';
51import { registerEmbedPreviewMiddleware } from '../lib/editor/embed-preview';
52import type { BlockNode } from '../lib/blocks/render';
53
54export const SPIKE_BLOCKS_KEY = 'skypress:spike:blocks';
55
56/** Rebuild editor block instances from stored block nodes (for editing, SP5). */
57function toEditorBlocks( nodes: BlockNode[] ): BlockInstance[] {
58 return nodes.map( ( node ) =>
59 createBlock(
60 node.name,
61 { ...node.attributes },
62 toEditorBlocks( node.innerBlocks ?? [] )
63 )
64 );
65}
66
67interface SkyEditorProps {
68 /** Receives the live block tree on every change (for the publish flow, SP2). */
69 onChange?: ( blocks: BlockInstance[] ) => void;
70 /** Custom Gutenberg media handler — uploads to the PDS as a blob (SP3). */
71 mediaUpload?: MediaUploadHandler;
72 /** Existing article content to load when editing (SP5). */
73 initialBlocks?: BlockNode[];
74}
75
76/**
77 * Standalone Gutenberg editor, composed directly from `@wordpress/block-editor`
78 * (Decision 0021 — we no longer wrap `@automattic/isolated-block-editor`). The
79 * `BlockEditorProvider` holds the block tree; `BlockTools` + `WritingFlow` +
80 * `ObserveTyping` + `BlockList` are the canvas; undo/redo is app-level via
81 * `useStateWithHistory`. Restricted to the curated block set (Decision 0002).
82 *
83 * The live block tree is forwarded on every change via `onChange` (the structured
84 * array — not HTML) so the Studio can publish it, and persisted as a local draft.
85 *
86 * Rendered with `client:only="react"` so its (heavy) bundle never reaches reading
87 * pages (Decision 0001 / 0003).
88 */
89export default function SkyEditor( { onChange, mediaUpload, initialBlocks }: SkyEditorProps ) {
90 // Register the curated block set + the `@`-mention format/autocompleter, and
91 // build the initial editor blocks — once per mount, before `BlockList` first
92 // renders (all idempotent). Studio remounts SkyEditor (keyed) when switching
93 // articles, so reading `initialBlocks` at mount is sufficient.
94 const initialValue = useMemo( () => {
95 registerSkyPressBlocks();
96 registerMentionFormat();
97 registerMentionAutocompleter();
98 registerEmbedPreviewMiddleware();
99 return initialBlocks && initialBlocks.length > 0 ? toEditorBlocks( initialBlocks ) : [];
100 // eslint-disable-next-line react-hooks/exhaustive-deps
101 }, [] );
102
103 const {
104 value,
105 setValue: setBlocks,
106 undo,
107 redo,
108 hasUndo,
109 hasRedo,
110 } = useStateWithHistory< BlockInstance[] >( initialValue );
111 // `@wordpress/compose` types `value` as `unknown` (its generic isn't propagated
112 // to the return) even though it round-trips exactly what `setValue` was given.
113 const blocks = value as BlockInstance[];
114
115 // Empty until the first change. Kept in the DOM as an `aria-live` region so
116 // "Draft saved" updates are announced; the `:empty` rule clips it out of view
117 // (not `display: none`, which would drop it from the a11y tree) while idle.
118 const [ status, setStatus ] = useState< string >( '' );
119 const [ showInspector, setShowInspector ] = useState( false );
120 const inspectorToggleRef = useRef< HTMLButtonElement >( null );
121
122 // Persist a local draft and forward the live tree to the publish flow.
123 const forward = useCallback(
124 ( next: BlockInstance[] ) => {
125 window.localStorage.setItem( SPIKE_BLOCKS_KEY, JSON.stringify( next ) );
126 setStatus( `Draft saved · ${ next.length } block(s)` );
127 onChange?.( next );
128 },
129 [ onChange ]
130 );
131
132 // `onInput` = transient edits (typing) → staged into the pending history record;
133 // `onChange` = persistent edits (insert/remove/move) → a new undo step. Both keep
134 // the publish state current.
135 const onInput = useCallback(
136 ( next: BlockInstance[] ) => {
137 setBlocks( next, true );
138 forward( next );
139 },
140 [ setBlocks, forward ]
141 );
142 const onChangeBlocks = useCallback(
143 ( next: BlockInstance[] ) => {
144 setBlocks( next, false );
145 forward( next );
146 },
147 [ setBlocks, forward ]
148 );
149
150 const settings = useMemo(
151 () => ( {
152 // A custom mediaUpload routes uploads to the PDS as a blob (SP3).
153 ...( mediaUpload ? { mediaUpload } : {} ),
154 // Restrict the inserter/transforms to the curated set. The registry is
155 // already pruned to it (registerSkyPressBlocks); this scopes the UI too.
156 allowedBlockTypes: [ ...ALLOWED_BLOCKS ],
157 // Render the selected block's tools in a fixed `<BlockToolbar>` we place in
158 // the header (vs. a popover that floats by the block). The framework
159 // recommends this for a custom-composed editor — the floating toolbar needs
160 // iframe/content-ref plumbing that a bespoke inline canvas doesn't provide.
161 hasFixedToolbar: true,
162 } ),
163 [ mediaUpload ]
164 );
165
166 // Undo/redo from the keyboard. Capture phase so we win over contentEditable's
167 // native history before rich-text handles the key.
168 const onKeyDownCapture = useCallback(
169 ( event: ReactKeyboardEvent ) => {
170 const mod = event.metaKey || event.ctrlKey;
171 if ( ! mod || event.key.toLowerCase() !== 'z' ) {
172 return;
173 }
174 event.preventDefault();
175 if ( event.shiftKey ) {
176 redo();
177 } else {
178 undo();
179 }
180 },
181 [ undo, redo ]
182 );
183
184 return (
185 <ShortcutProvider>
186 <SlotFillProvider>
187 <BlockEditorProvider
188 value={ blocks }
189 onInput={ onInput }
190 onChange={ onChangeBlocks }
191 settings={ settings }
192 >
193 { /* eslint-disable-next-line jsx-a11y/no-static-element-interactions */ }
194 <div className="skypress-editor" onKeyDownCapture={ onKeyDownCapture }>
195 <div className="skypress-editor__toolbar">
196 <Inserter rootClientId={ undefined } />
197 { /* The selected block's formatting/transform tools (bold,
198 italic, link, the @-mention format, …). Empty when no
199 block is selected. */ }
200 <BlockToolbar hideDragHandle />
201 <div className="skypress-editor__toolbar-spacer" />
202 <Button
203 icon={ undoIcon }
204 label="Undo"
205 onClick={ undo }
206 disabled={ ! hasUndo }
207 />
208 <Button
209 icon={ redoIcon }
210 label="Redo"
211 onClick={ redo }
212 disabled={ ! hasRedo }
213 />
214 <Button
215 ref={ inspectorToggleRef }
216 icon={ cog }
217 label="Block settings"
218 isPressed={ showInspector }
219 aria-expanded={ showInspector }
220 onClick={ () => setShowInspector( ( open ) => ! open ) }
221 />
222 { showInspector && (
223 <Popover
224 anchor={ inspectorToggleRef.current }
225 placement="bottom-end"
226 onClose={ () => setShowInspector( false ) }
227 className="skypress-editor__inspector"
228 >
229 <BlockInspector />
230 </Popover>
231 ) }
232 </div>
233
234 <div className="skypress-editor__body editor-styles-wrapper">
235 <BlockTools>
236 <BlockEditorKeyboardShortcuts.Register />
237 <WritingFlow>
238 <ObserveTyping>
239 <BlockList />
240 </ObserveTyping>
241 </WritingFlow>
242 </BlockTools>
243 </div>
244
245 <Popover.Slot />
246
247 <p className="skypress-editor__status" aria-live="polite">
248 { status }
249 </p>
250 </div>
251 </BlockEditorProvider>
252 </SlotFillProvider>
253 </ShortcutProvider>
254 );
255}