A calm place to write long-form, and publish it to the open social web. skypress.blog/
0

Configure Feed

Select the types of activity you want to include in your feed.

at trunk 9.1 kB View raw
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}