caracms 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (281) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -2
  3. package/bin/caracms.mjs +174 -0
  4. package/bin/gate.mjs +275 -0
  5. package/bin/init.mjs +712 -0
  6. package/bin/media.mjs +662 -0
  7. package/bin/site-runner.mjs +61 -0
  8. package/bin/templates/Page.astro.txt +31 -0
  9. package/bin/templates/TextBlock.astro.txt +13 -0
  10. package/bin/templates/caracms.config.ts.txt +22 -0
  11. package/bin/templates/catch-all.astro.txt +25 -0
  12. package/bin/templates/health.ts.txt +6 -0
  13. package/bin/templates/railway.ts.txt +147 -0
  14. package/package.json +66 -4
  15. package/skills/caracms-adopt/SKILL.md +508 -0
  16. package/skills/caracms-adopt/look-same.mjs +957 -0
  17. package/skills/caracms-blocks/SKILL.md +376 -0
  18. package/skills/caracms-content/SKILL.md +201 -0
  19. package/skills/caracms-tina/SKILL.md +427 -0
  20. package/skills/caracms-tina/field-kinds.md +88 -0
  21. package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
  22. package/src/block-runs.ts +84 -0
  23. package/src/cards.ts +158 -0
  24. package/src/cli/schemas.ts +122 -0
  25. package/src/cli/site.ts +66 -0
  26. package/src/cli/validate.ts +471 -0
  27. package/src/code-routes-disk.ts +67 -0
  28. package/src/code-routes.ts +134 -0
  29. package/src/columns.ts +39 -0
  30. package/src/components/Block.astro +46 -0
  31. package/src/components/BlockRunTree.astro +45 -0
  32. package/src/components/Blocks.astro +139 -0
  33. package/src/components/Buttons.astro +116 -0
  34. package/src/components/Fonts.astro +14 -0
  35. package/src/components/Form.astro +221 -0
  36. package/src/components/Head.astro +62 -0
  37. package/src/components/Link.astro +68 -0
  38. package/src/components/Lockup.astro +116 -0
  39. package/src/components/Media.astro +124 -0
  40. package/src/components/MediaPlayer.astro +102 -0
  41. package/src/components/RenderDocument.astro +64 -0
  42. package/src/components/RichText.astro +59 -0
  43. package/src/components/Text.astro +81 -0
  44. package/src/components/attributes.ts +33 -0
  45. package/src/components/descriptor.ts +45 -0
  46. package/src/components/form-client.ts +182 -0
  47. package/src/components/media-player.ts +46 -0
  48. package/src/components/media-props.ts +35 -0
  49. package/src/components/sitemap.xml.ts +36 -0
  50. package/src/config.ts +825 -0
  51. package/src/content-schema.ts +166 -0
  52. package/src/content.ts +422 -0
  53. package/src/document.ts +579 -0
  54. package/src/editor/README.md +405 -0
  55. package/src/editor/addresses.ts +61 -0
  56. package/src/editor/brand.ts +118 -0
  57. package/src/editor/client/block-list/BlockList.tsx +342 -0
  58. package/src/editor/client/block-list/Picker.tsx +105 -0
  59. package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
  60. package/src/editor/client/block-list/insert-points.ts +56 -0
  61. package/src/editor/client/block-list/link.ts +71 -0
  62. package/src/editor/client/block-list/reorder.ts +105 -0
  63. package/src/editor/client/block-list/summary.ts +118 -0
  64. package/src/editor/client/collections/CollectionGrid.tsx +143 -0
  65. package/src/editor/client/collections/CollectionTable.tsx +406 -0
  66. package/src/editor/client/collections/table.ts +270 -0
  67. package/src/editor/client/context.tsx +27 -0
  68. package/src/editor/client/documents/DocMenu.tsx +154 -0
  69. package/src/editor/client/documents/actions.ts +192 -0
  70. package/src/editor/client/documents/dialogs.tsx +687 -0
  71. package/src/editor/client/editor.ts +995 -0
  72. package/src/editor/client/fields/ButtonsField.tsx +87 -0
  73. package/src/editor/client/fields/DateField.tsx +200 -0
  74. package/src/editor/client/fields/FieldControl.tsx +61 -0
  75. package/src/editor/client/fields/FieldFrame.tsx +109 -0
  76. package/src/editor/client/fields/GroupField.tsx +113 -0
  77. package/src/editor/client/fields/HiddenToggle.tsx +51 -0
  78. package/src/editor/client/fields/LinkField.tsx +307 -0
  79. package/src/editor/client/fields/ListField.tsx +642 -0
  80. package/src/editor/client/fields/LockupField.tsx +365 -0
  81. package/src/editor/client/fields/MediaField.tsx +823 -0
  82. package/src/editor/client/fields/PageFields.tsx +82 -0
  83. package/src/editor/client/fields/ReferenceField.tsx +209 -0
  84. package/src/editor/client/fields/RichTextField.tsx +306 -0
  85. package/src/editor/client/fields/SelectField.tsx +63 -0
  86. package/src/editor/client/fields/SeoGroup.tsx +282 -0
  87. package/src/editor/client/fields/SimpleFields.tsx +89 -0
  88. package/src/editor/client/fields/TextField.tsx +51 -0
  89. package/src/editor/client/fields/date.ts +73 -0
  90. package/src/editor/client/fields/host.tsx +172 -0
  91. package/src/editor/client/fields/list-summary.ts +85 -0
  92. package/src/editor/client/fields/lockup-rules.ts +181 -0
  93. package/src/editor/client/fields/parts.tsx +458 -0
  94. package/src/editor/client/fields/rich-text-commands.ts +183 -0
  95. package/src/editor/client/fields/rich-text-doc.ts +156 -0
  96. package/src/editor/client/fields/rich-text-schema.ts +224 -0
  97. package/src/editor/client/fields/stale.ts +58 -0
  98. package/src/editor/client/inbox/InboxStage.tsx +272 -0
  99. package/src/editor/client/inbox/store.ts +311 -0
  100. package/src/editor/client/live/format.ts +77 -0
  101. package/src/editor/client/live/marks.tsx +90 -0
  102. package/src/editor/client/live/notices.tsx +213 -0
  103. package/src/editor/client/live/publish.tsx +617 -0
  104. package/src/editor/client/live/session.tsx +162 -0
  105. package/src/editor/client/main.tsx +12 -0
  106. package/src/editor/client/media/LibraryStage.tsx +75 -0
  107. package/src/editor/client/media/ReplaceDialog.tsx +355 -0
  108. package/src/editor/client/media/store.ts +618 -0
  109. package/src/editor/client/nav/DocRow.tsx +122 -0
  110. package/src/editor/client/nav/search.ts +71 -0
  111. package/src/editor/client/nav/tree.ts +93 -0
  112. package/src/editor/client/overlays/OverlayMount.tsx +26 -0
  113. package/src/editor/client/overlays/editor-marks.ts +159 -0
  114. package/src/editor/client/overlays/index.ts +50 -0
  115. package/src/editor/client/overlays/layer.ts +437 -0
  116. package/src/editor/client/overlays/overlay-css.ts +177 -0
  117. package/src/editor/client/overlays/rich-text-bar.ts +717 -0
  118. package/src/editor/client/pane/Pane.tsx +91 -0
  119. package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
  120. package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
  121. package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
  122. package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
  123. package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
  124. package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
  125. package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
  126. package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
  127. package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
  128. package/src/editor/client/pane/parts.tsx +179 -0
  129. package/src/editor/client/pane/switcher.tsx +165 -0
  130. package/src/editor/client/preview/driver.ts +640 -0
  131. package/src/editor/client/preview/field-text.ts +71 -0
  132. package/src/editor/client/preview/follow-links.ts +234 -0
  133. package/src/editor/client/preview/layout.ts +37 -0
  134. package/src/editor/client/preview/link-target.ts +50 -0
  135. package/src/editor/client/preview/markers.ts +86 -0
  136. package/src/editor/client/preview/page-edit.ts +632 -0
  137. package/src/editor/client/preview/page-editor-registry.ts +63 -0
  138. package/src/editor/client/preview/page-editor.ts +235 -0
  139. package/src/editor/client/preview/render-sequencer.ts +113 -0
  140. package/src/editor/client/preview/selection.ts +116 -0
  141. package/src/editor/client/preview/warnings.ts +61 -0
  142. package/src/editor/client/shell/App.tsx +101 -0
  143. package/src/editor/client/shell/Bar.tsx +197 -0
  144. package/src/editor/client/shell/Brand.tsx +33 -0
  145. package/src/editor/client/shell/Icons.tsx +87 -0
  146. package/src/editor/client/shell/NarrowNote.tsx +47 -0
  147. package/src/editor/client/shell/Popovers.tsx +47 -0
  148. package/src/editor/client/shell/Stage.tsx +132 -0
  149. package/src/editor/client/shell/Toast.tsx +61 -0
  150. package/src/editor/client/state/document-store.ts +465 -0
  151. package/src/editor/client/state/keeper.ts +713 -0
  152. package/src/editor/client/state/live.ts +257 -0
  153. package/src/editor/client/state/schema.ts +42 -0
  154. package/src/editor/client/state/store.ts +39 -0
  155. package/src/editor/fonts/OFL-GeistMono.txt +93 -0
  156. package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
  157. package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
  158. package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
  159. package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
  160. package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
  161. package/src/editor/middleware.ts +62 -0
  162. package/src/editor/routes/_live.ts +26 -0
  163. package/src/editor/routes/admin.astro +80 -0
  164. package/src/editor/routes/create.ts +19 -0
  165. package/src/editor/routes/delete.ts +19 -0
  166. package/src/editor/routes/document.ts +39 -0
  167. package/src/editor/routes/documents.ts +30 -0
  168. package/src/editor/routes/github.ts +23 -0
  169. package/src/editor/routes/history.ts +9 -0
  170. package/src/editor/routes/lock.ts +9 -0
  171. package/src/editor/routes/login/assets.d.ts +10 -0
  172. package/src/editor/routes/login/login.ts +43 -0
  173. package/src/editor/routes/media.ts +16 -0
  174. package/src/editor/routes/move.ts +17 -0
  175. package/src/editor/routes/notice.ts +9 -0
  176. package/src/editor/routes/preview.astro +77 -0
  177. package/src/editor/routes/publish.ts +9 -0
  178. package/src/editor/routes/status.ts +9 -0
  179. package/src/editor/routes/submissions.ts +16 -0
  180. package/src/editor/routes/try-again.ts +9 -0
  181. package/src/editor/routes/write.ts +18 -0
  182. package/src/editor/server/admin-props.ts +151 -0
  183. package/src/editor/server/cards.ts +61 -0
  184. package/src/editor/server/create.ts +414 -0
  185. package/src/editor/server/drafts-store.ts +127 -0
  186. package/src/editor/server/media/alt.ts +107 -0
  187. package/src/editor/server/media/bucket.ts +295 -0
  188. package/src/editor/server/media/config.ts +78 -0
  189. package/src/editor/server/media/handler.ts +221 -0
  190. package/src/editor/server/media/library.ts +254 -0
  191. package/src/editor/server/media/memory.ts +184 -0
  192. package/src/editor/server/media/mux.ts +241 -0
  193. package/src/editor/server/media/sniff.ts +599 -0
  194. package/src/editor/server/media/upload.ts +175 -0
  195. package/src/editor/server/mode.ts +63 -0
  196. package/src/editor/server/move.ts +295 -0
  197. package/src/editor/server/render.tsx +9 -0
  198. package/src/editor/server/replace.ts +72 -0
  199. package/src/editor/server/settings.ts +35 -0
  200. package/src/editor/server/write.ts +366 -0
  201. package/src/editor/styles/editor.css +502 -0
  202. package/src/editor/styles/fonts.css +40 -0
  203. package/src/editor/styles/levels/README.md +4 -0
  204. package/src/editor/styles/levels/collections.css +99 -0
  205. package/src/editor/styles/levels/document-level.css +10 -0
  206. package/src/editor/styles/levels/documents.css +9 -0
  207. package/src/editor/styles/levels/fields.css +171 -0
  208. package/src/editor/styles/levels/files.css +95 -0
  209. package/src/editor/styles/levels/live.css +10 -0
  210. package/src/editor/styles/levels/m5a-documents.css +51 -0
  211. package/src/editor/styles/levels/m6.css +82 -0
  212. package/src/editor/styles/levels/media.css +175 -0
  213. package/src/editor/styles/levels/switcher.css +3 -0
  214. package/src/editor/styles/tokens.css +114 -0
  215. package/src/editor/tsconfig.json +16 -0
  216. package/src/editor/types.ts +881 -0
  217. package/src/editor/ui-state.ts +113 -0
  218. package/src/email.ts +67 -0
  219. package/src/fields.ts +1129 -0
  220. package/src/files-route.ts +201 -0
  221. package/src/files.ts +116 -0
  222. package/src/forms/email.ts +149 -0
  223. package/src/forms/endpoint.ts +347 -0
  224. package/src/forms/inbox.ts +273 -0
  225. package/src/forms/rate-limit.ts +130 -0
  226. package/src/forms/route.ts +24 -0
  227. package/src/forms/rules.ts +278 -0
  228. package/src/forms/store.ts +486 -0
  229. package/src/head.ts +125 -0
  230. package/src/index.ts +434 -0
  231. package/src/json-source.ts +116 -0
  232. package/src/links.ts +121 -0
  233. package/src/live/README.md +43 -0
  234. package/src/live/auth/allow-rules.ts +70 -0
  235. package/src/live/auth/allow.ts +128 -0
  236. package/src/live/auth/auth.ts +155 -0
  237. package/src/live/auth/email.ts +84 -0
  238. package/src/live/auth/guard.ts +111 -0
  239. package/src/live/auth/index.ts +68 -0
  240. package/src/live/auth/links.ts +101 -0
  241. package/src/live/auth/login.ts +245 -0
  242. package/src/live/auth/middleware.ts +53 -0
  243. package/src/live/auth/request.ts +56 -0
  244. package/src/live/auth/routes.ts +9 -0
  245. package/src/live/auth/session.ts +181 -0
  246. package/src/live/auth/views.ts +355 -0
  247. package/src/live/autosaves.ts +306 -0
  248. package/src/live/backend.ts +652 -0
  249. package/src/live/branches.ts +19 -0
  250. package/src/live/drafts-store.ts +591 -0
  251. package/src/live/editor.ts +465 -0
  252. package/src/live/git.ts +1021 -0
  253. package/src/live/github.ts +271 -0
  254. package/src/live/locals.ts +24 -0
  255. package/src/live/locks.ts +273 -0
  256. package/src/live/merge.ts +197 -0
  257. package/src/live/messages.ts +151 -0
  258. package/src/live/mode.ts +210 -0
  259. package/src/live/redis.ts +533 -0
  260. package/src/live/runtime.ts +164 -0
  261. package/src/live/status.ts +109 -0
  262. package/src/live/sweep.ts +228 -0
  263. package/src/live/timings.ts +47 -0
  264. package/src/live/webhook.ts +39 -0
  265. package/src/lockup-css.ts +58 -0
  266. package/src/measure.ts +145 -0
  267. package/src/media-value.ts +239 -0
  268. package/src/media.ts +255 -0
  269. package/src/read.ts +256 -0
  270. package/src/redirect-middleware.ts +49 -0
  271. package/src/redirects.ts +76 -0
  272. package/src/relink.ts +293 -0
  273. package/src/reserved.ts +24 -0
  274. package/src/rich-text.ts +690 -0
  275. package/src/schema-key.ts +63 -0
  276. package/src/seo.ts +124 -0
  277. package/src/serialize.ts +697 -0
  278. package/src/skipped.ts +35 -0
  279. package/src/slug.ts +121 -0
  280. package/src/text-styles.ts +62 -0
  281. package/src/virtual.d.ts +28 -0
@@ -0,0 +1,458 @@
1
+ /**
2
+ * Pieces M5a's fields share: the amber field message (`.field-warn`, for messages
3
+ * that block nothing), the few icons the shell's sprite doesn't have yet, and the
4
+ * document list a link picks from (Decision 4: the site's documents by title and
5
+ * address; picking one stores its URL).
6
+ */
7
+ import { Fragment, useEffect, useMemo, useRef, useState, type FocusEvent, type KeyboardEvent, type ReactNode } from 'react';
8
+ import { fileAddress, fileKeyOf, fileTypeOf } from '../../../files.ts';
9
+ import { validateLink } from '../../../links.ts';
10
+ import { fileSize, mediaFor } from '../media/store.ts';
11
+ import type { FieldWarning } from '../../../fields.ts';
12
+ import { useEditor, useStore } from '../context.tsx';
13
+ import { docIcon } from '../nav/DocRow.tsx';
14
+ import { parentLabel, searchDocuments } from '../nav/search.ts';
15
+ import { Icon } from '../shell/Icons.tsx';
16
+ import type { LibraryFile, ListedDocument } from '../../types.ts';
17
+ import type { Editor } from '../editor.ts';
18
+
19
+ /**
20
+ * Icons the mockup's sprite has and the shell's doesn't yet (shell/Icons.tsx is
21
+ * another phase's file): the same paths, drawn inline with the sprite's classes.
22
+ */
23
+ const EXTRA: Record<string, ReactNode> = {
24
+ link: <path d="M6.5 4.5H5a3.5 3.5 0 0 0 0 7h1.5M9.5 4.5H11a3.5 3.5 0 0 1 0 7H9.5M5.5 8h5" />,
25
+ // M6c G4: a file, and Replace's swap (kept here, not in the shell's sprite, beside the grid's own icons).
26
+ file: <path d="M4 1.5h5.5L12.5 4.5v10H4zM9.5 1.5v3h3M6 8.5h4M6 11h4" />,
27
+ swap: <path d="M3 5h9.5M10 2.5 12.5 5 10 7.5M13 11H3.5M6 8.5 3.5 11 6 13.5" />,
28
+ ul: (
29
+ <>
30
+ <path d="M6 4h8M6 8h8M6 12h8" />
31
+ <g fill="currentColor" stroke="none">
32
+ <circle cx="2.8" cy="4" r="1" />
33
+ <circle cx="2.8" cy="8" r="1" />
34
+ <circle cx="2.8" cy="12" r="1" />
35
+ </g>
36
+ </>
37
+ ),
38
+ ol: <path d="M6.5 4h7.5M6.5 8h7.5M6.5 12h7.5M2.5 2.8l1-.6v3.3M2.2 7.2a.9.9 0 0 1 1.6.5c0 .7-1.6 1.4-1.6 2.1h1.7M2.2 11h1.5l-.8 1 .1 0a.8.8 0 1 1-.8 1" />,
39
+ calendar: (
40
+ <>
41
+ <rect x="2" y="3" width="12" height="11" rx="1.5" />
42
+ <path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3" />
43
+ </>
44
+ ),
45
+ };
46
+
47
+ /** `<Icon>`, or one of the extra icons above. */
48
+ export function FieldIcon({ name, size }: { name: string; size?: 'sm' | 'xs' }) {
49
+ const extra = EXTRA[name];
50
+ if (!extra) return <Icon name={name} size={size} />;
51
+ return (
52
+ <svg className={size ? `i i-${size}` : 'i'} viewBox="0 0 16 16" aria-hidden="true">
53
+ {extra}
54
+ </svg>
55
+ );
56
+ }
57
+
58
+ /** Field messages that block nothing (amber; M5a Decision 15: kept as typed, flagged on the field). */
59
+ export function Warn({ children, quiet, id }: { children: ReactNode; quiet?: boolean; id?: string }) {
60
+ return (
61
+ <p className={`field-warn${quiet ? ' quiet' : ''}`} id={id} role="status">
62
+ <Icon name="warn" size="sm" />
63
+ <span>{children}</span>
64
+ </p>
65
+ );
66
+ }
67
+
68
+ /** One `.field-warn` per warning (deduplicated by message). */
69
+ export function Warnings({ warnings, id, quiet }: { warnings: readonly FieldWarning[]; id?: string; quiet?: boolean }) {
70
+ const messages = [...new Set(warnings.map((w) => w.message))];
71
+ if (messages.length === 0) return null;
72
+ return (
73
+ <>
74
+ {messages.map((m, i) => (
75
+ <Warn key={m} id={i === 0 ? id : undefined} quiet={quiet}>
76
+ {m}
77
+ </Warn>
78
+ ))}
79
+ </>
80
+ );
81
+ }
82
+
83
+ /** Documents with an address, the only ones a link can point at (shared blocks and Site Settings have none). */
84
+ export function useLinkable(): ListedDocument[] {
85
+ const editor = useEditor();
86
+ const documents = useStore(editor.documents);
87
+ return useMemo(() => documents.filter((d) => typeof d.url === 'string'), [documents]);
88
+ }
89
+
90
+ export { documentAt } from '../preview/link-target.ts';
91
+
92
+ /** Does what's typed look like an address rather than a document's name? (The mockup's looksLikeAddr.) */
93
+ export const looksLikeAddress = (q: string) => /^(\/|#|https?:|mailto:|tel:|www\.)/i.test(q.trim()) || /\S\.\S/.test(q.trim()) || /@/.test(q);
94
+
95
+ export interface LinkHit {
96
+ key: string;
97
+ href: string;
98
+ /** A document's title (with its parent's), or null for "Link to <address>". */
99
+ doc: ListedDocument | null;
100
+ crumb: string;
101
+ }
102
+
103
+ export interface LinkHits {
104
+ groups: { label: string; hits: LinkHit[]; more: number }[];
105
+ address: LinkHit | null;
106
+ }
107
+
108
+ /**
109
+ * What the link popover lists for a query: the address itself when it looks like one,
110
+ * then documents, grouped by collection. No React: the sidebar's popover (useLinkHits)
111
+ * and the page's (on-page rich text, overlays/rich-text-bar.ts) list the same.
112
+ */
113
+ export function linkHits(query: string, documents: readonly ListedDocument[], collections: Editor['site']['collections']): LinkHits {
114
+ const q = query.trim();
115
+ const address: LinkHit | null = q && looksLikeAddress(q) ? { key: `addr:${q}`, href: q, doc: null, crumb: '' } : null;
116
+ // M9 Decision 7: a hidden page isn't offered (a link to one only warns).
117
+ const linkable = documents.filter((d) => typeof d.url === 'string' && !d.hidden);
118
+ const groups = q
119
+ ? searchDocuments(q, linkable, collections).map((g) => ({
120
+ label: g.collection.label,
121
+ hits: g.hits.slice(0, 5).map((h) => ({ key: `${h.doc.collection}/${h.doc.path}`, href: h.doc.url!, doc: h.doc, crumb: h.crumb })),
122
+ more: Math.max(0, g.hits.length - 5),
123
+ }))
124
+ : collections
125
+ .map((c) => {
126
+ const docs = linkable.filter((d) => d.collection === c.name);
127
+ return {
128
+ label: c.label,
129
+ hits: docs.slice(0, 4).map((d) => ({ key: `${d.collection}/${d.path}`, href: d.url!, doc: d, crumb: c.nested ? parentLabel(d, documents) : '' })),
130
+ more: Math.max(0, docs.length - 4),
131
+ };
132
+ })
133
+ .filter((g) => g.hits.length > 0);
134
+ return { groups, address };
135
+ }
136
+
137
+ /** linkHits for the sidebar's popover, from the editor's document listing. */
138
+ export function useLinkHits(query: string): LinkHits {
139
+ const editor = useEditor();
140
+ const documents = useStore(editor.documents);
141
+ return useMemo(() => linkHits(query, documents, editor.site.collections), [query, documents, editor.site.collections]);
142
+ }
143
+
144
+ /** The hit Enter picks: the typed address, else the first document listed. */
145
+ export const firstHit = (hits: LinkHits): LinkHit | null => hits.address ?? hits.groups[0]?.hits[0] ?? null;
146
+
147
+ /**
148
+ * The list a link picks from (the mockup's `.lpop`): an optional find input, the
149
+ * typed address ("Link to …", with the link rule's message when it fails), and
150
+ * the site's documents by title and address. `onPick` gets the URL to store.
151
+ */
152
+ export function LinkPopover(props: {
153
+ query: string;
154
+ onQuery?: (q: string) => void;
155
+ onPick: (href: string) => void;
156
+ onClose: () => void;
157
+ /** The find input, for rich text's popover; a link field types into its own box instead. */
158
+ withFind?: boolean;
159
+ /** A "Remove link" row, for rich text's popover over an existing link. */
160
+ onRemove?: () => void;
161
+ /** Focus leaving the popover (rich text closes it when focus goes outside the field). */
162
+ onBlur?: (event: FocusEvent<HTMLDivElement>) => void;
163
+ /**
164
+ * M6c G4 (Decision 7): rich text's File…, beside the find input. The library's files
165
+ * list in the popover (finding by name, Upload… too); picking one calls `onPickFile`.
166
+ * A link to a file (`query` is its address, `editing`) shows the file, and for a PDF
167
+ * New tab / Download (`onPick` with the new address).
168
+ */
169
+ onPickFile?: (file: LibraryFile) => void;
170
+ }) {
171
+ const editor = useEditor();
172
+ const icon = (doc: ListedDocument | null) => docIcon(editor.site.collections.find((c) => c.name === doc?.collection));
173
+ const [filesMode, setFilesMode] = useState(false);
174
+ const choosing = useRef(false);
175
+ const linked = props.onPickFile ? fileKeyOf(props.query.trim()) : null;
176
+ const { groups, address } = useLinkHits(linked || filesMode ? '' : props.query);
177
+ const blur = (event: FocusEvent<HTMLDivElement>) => {
178
+ // The browser's file dialog takes focus from the page: the popover stays for the file.
179
+ if (choosing.current) return;
180
+ props.onBlur?.(event);
181
+ };
182
+ if (props.onPickFile && (filesMode || linked)) {
183
+ return (
184
+ <div className="lpop" role="dialog" aria-label="Where the link goes" onBlur={blur} data-lpop-files="">
185
+ {linked && !filesMode ? (
186
+ <LinkedFile href={props.query} onPick={props.onPick} onRemove={props.onRemove} onChange={() => setFilesMode(true)} onClose={props.onClose} />
187
+ ) : (
188
+ <FilesInPopover
189
+ choosing={choosing}
190
+ onPick={(file) => {
191
+ setFilesMode(false);
192
+ props.onPickFile!(file);
193
+ }}
194
+ onBack={() => (linked ? setFilesMode(false) : (setFilesMode(false), props.onQuery?.('')))} onClose={props.onClose} />
195
+ )}
196
+ </div>
197
+ );
198
+ }
199
+ const rule = address ? validateLink(address.href) : undefined;
200
+ const first = address ?? groups[0]?.hits[0] ?? null;
201
+ const onKey = (event: KeyboardEvent<HTMLInputElement>) => {
202
+ if (event.key === 'Enter') {
203
+ event.preventDefault();
204
+ if (first) props.onPick(first.href);
205
+ } else if (event.key === 'Escape') {
206
+ event.preventDefault();
207
+ props.onClose();
208
+ }
209
+ };
210
+ return (
211
+ <div className="lpop" role="dialog" aria-label="Where the link goes" onBlur={blur}>
212
+ {props.withFind ? (
213
+ <div className={props.onPickFile ? 'top' : undefined}>
214
+ <div className="find">
215
+ <Icon name="search" />
216
+ <input
217
+ className="input"
218
+ value={props.query}
219
+ placeholder="Find a page, or type an address"
220
+ aria-label="Find a page, or type an address"
221
+ autoComplete="off"
222
+ spellCheck={false}
223
+ autoFocus
224
+ data-link-find=""
225
+ onChange={(e) => props.onQuery?.(e.target.value)}
226
+ onKeyDown={onKey}
227
+ />
228
+ </div>
229
+ {props.onPickFile ? (
230
+ <button type="button" className="btn" data-act="link-file" title="Link to a file in the library" onMouseDown={(e) => e.preventDefault()} onClick={() => setFilesMode(true)}>
231
+ <FieldIcon name="file" size="sm" />
232
+ File…
233
+ </button>
234
+ ) : null}
235
+ </div>
236
+ ) : null}
237
+ <div className="lpop-list">
238
+ {address ? (
239
+ <button type="button" className="drow is-active" data-href={address.href} onMouseDown={(e) => e.preventDefault()} onClick={() => props.onPick(address.href)}>
240
+ <Icon name={address.href.startsWith('mailto:') ? 'mail' : address.href.startsWith('/') ? 'page' : 'ext'} />
241
+ <span className="nm">
242
+ Link to <span className="mono">{address.href}</span>
243
+ </span>
244
+ </button>
245
+ ) : null}
246
+ {rule ? <Warn>{rule}</Warn> : null}
247
+ {groups.map((g) => (
248
+ <Fragment key={g.label}>
249
+ <div className="sw-h">{g.label}</div>
250
+ {g.hits.map((h) => (
251
+ <button
252
+ key={h.key}
253
+ type="button"
254
+ className={`drow${!address && h === groups[0]!.hits[0] ? ' is-active' : ''}`}
255
+ data-href={h.href}
256
+ onMouseDown={(e) => e.preventDefault()}
257
+ onClick={() => props.onPick(h.href)}
258
+ >
259
+ <Icon name={icon(h.doc)} />
260
+ <span className="nm">
261
+ {h.crumb ? <span className="crumb">{h.crumb} / </span> : null}
262
+ {h.doc?.label || h.doc?.title}
263
+ </span>
264
+ <span className="meta">
265
+ <span className="mono">{h.href}</span>
266
+ </span>
267
+ </button>
268
+ ))}
269
+ {g.more > 0 ? <div className="sw-more">{g.more} more: keep typing</div> : null}
270
+ </Fragment>
271
+ ))}
272
+ {!address && groups.length === 0 ? <div className="sw-more">No page called “{props.query.trim()}”.</div> : null}
273
+ {props.onRemove ? (
274
+ <button type="button" className="drow" onMouseDown={(e) => e.preventDefault()} onClick={props.onRemove}>
275
+ <Icon name="x" />
276
+ <span className="nm">Remove link</span>
277
+ </button>
278
+ ) : null}
279
+ </div>
280
+ <div className="lpop-foot">Or type an address.</div>
281
+ </div>
282
+ );
283
+ }
284
+
285
+ /** M6c G4: the type tile, small (the mockup's .ftype.sm). */
286
+ const Tile = () => <span className="ftype sm" aria-hidden="true" />;
287
+
288
+ /** A rich-text link to a file, in its popover (the mockup's #rt-link-file): the file, then how it opens. */
289
+ function LinkedFile(props: { href: string; onPick: (href: string) => void; onRemove?: () => void; onChange: () => void; onClose: () => void }) {
290
+ const editor = useEditor();
291
+ const files = useStore(mediaFor(editor).files, (l) => l.list);
292
+ const file = fileKeyOf(props.href.trim())!;
293
+ const entry = files?.find((f) => f.key === file.key);
294
+ const type = fileTypeOf(file.key)!;
295
+ const name = entry?.name ?? file.key.slice(file.key.lastIndexOf('/') + 1);
296
+ const missing = !!files && !entry;
297
+ const hash = props.href.indexOf('#');
298
+ const to = (download: boolean) => `${fileAddress(file.key, { download })}${hash === -1 ? '' : props.href.slice(hash)}`;
299
+ // Focus stays in the popover (the search it replaced is gone), so it doesn't close.
300
+ const first = useRef<HTMLButtonElement>(null);
301
+ useEffect(() => {
302
+ if (!first.current?.closest('.lpop')?.contains(document.activeElement)) first.current?.focus({ preventScroll: true });
303
+ }, []);
304
+ return (
305
+ <>
306
+ <div className="top">
307
+ <button ref={first} type="button" className="btn" data-act="link-file" title="Link to another file" onMouseDown={(e) => e.preventDefault()} onClick={props.onChange}>
308
+ <FieldIcon name="file" size="sm" />
309
+ File…
310
+ </button>
311
+ </div>
312
+ <div className="picked" data-picked-file={file.key}>
313
+ {missing ? <Icon name="warn" /> : <Tile />}
314
+ <span className="ft">
315
+ <b>{name}</b>
316
+ <small>{missing ? "isn't in the library" : `${type.label}${entry?.bytes != null ? ` · ${fileSize(entry.bytes)}` : ''}`}</small>
317
+ </span>
318
+ {props.onRemove ? (
319
+ <button type="button" className="ib" aria-label="Remove the link" onMouseDown={(e) => e.preventDefault()} onClick={props.onRemove}>
320
+ <Icon name="x" size="sm" />
321
+ </button>
322
+ ) : null}
323
+ </div>
324
+ {type.opensInTab && !missing ? (
325
+ <div className="opens">
326
+ <span className="lbl">When clicked</span>
327
+ <div className="mini-seg" role="radiogroup" aria-label="When clicked">
328
+ <button type="button" role="radio" aria-checked={!file.download} data-opens="tab" onMouseDown={(e) => e.preventDefault()} onClick={() => props.onPick(to(false))}>
329
+ <Icon name="ext" />
330
+ New tab
331
+ </button>
332
+ <button type="button" role="radio" aria-checked={file.download} data-opens="download" onMouseDown={(e) => e.preventDefault()} onClick={() => props.onPick(to(true))}>
333
+ <Icon name="download" />
334
+ Download
335
+ </button>
336
+ </div>
337
+ </div>
338
+ ) : null}
339
+ {missing ? (
340
+ <div className="lpop-foot">This link goes nowhere until it points at a file in the library.</div>
341
+ ) : !type.opensInTab ? (
342
+ <div className="lpop-foot">A {type.label} file doesn’t open in the browser: on the site, clicking it saves the file.</div>
343
+ ) : null}
344
+ </>
345
+ );
346
+ }
347
+
348
+ /** File… in rich text's popover: the library's files, found by name, with Upload… (a link takes files only). */
349
+ function FilesInPopover(props: { choosing: { current: boolean }; onPick: (file: LibraryFile) => void; onBack: () => void; onClose: () => void }) {
350
+ const editor = useEditor();
351
+ const media = mediaFor(editor);
352
+ const files = useStore(media.files);
353
+ const uploads = useStore(media.uploads);
354
+ const [find, setFind] = useState('');
355
+ const [error, setError] = useState<string | null>(null);
356
+ const [tag, setTag] = useState<string | null>(null);
357
+ const input = useRef<HTMLInputElement>(null);
358
+ useEffect(() => {
359
+ if (media.files.get().list === null && !media.files.get().unset) void media.loadImages();
360
+ }, [media]);
361
+ const q = find.trim().toLowerCase();
362
+ const list = (files.list ?? []).filter((f) => !q || f.name.toLowerCase().includes(q));
363
+ const up = tag ? uploads.find((u) => u.id === tag) : null;
364
+ const take = async (file: File) => {
365
+ setError(null);
366
+ const id = `rt-file-${Date.now()}`;
367
+ setTag(id);
368
+ const sent = await media.uploadFile(file, id);
369
+ setTag(null);
370
+ if (sent.ok) props.onPick(sent.file);
371
+ else if (sent.error) setError(sent.error);
372
+ };
373
+ return (
374
+ <>
375
+ <div className="top">
376
+ <div className="find">
377
+ <Icon name="search" />
378
+ <input
379
+ className="input"
380
+ value={find}
381
+ placeholder="Find a file"
382
+ aria-label="Find a file"
383
+ autoComplete="off"
384
+ spellCheck={false}
385
+ autoFocus
386
+ data-file-find=""
387
+ onChange={(e) => setFind(e.target.value)}
388
+ onKeyDown={(e) => {
389
+ if (e.key === 'Enter' && list[0]) {
390
+ e.preventDefault();
391
+ props.onPick(list[0]);
392
+ } else if (e.key === 'Escape') {
393
+ e.preventDefault();
394
+ props.onClose();
395
+ }
396
+ }}
397
+ />
398
+ </div>
399
+ <button
400
+ type="button"
401
+ className="btn"
402
+ data-act="lib-upload"
403
+ onMouseDown={(e) => e.preventDefault()}
404
+ onClick={() => {
405
+ props.choosing.current = true;
406
+ // Back from the browser's file dialog (a file chosen or not): blurs count again.
407
+ window.addEventListener('focus', () => setTimeout(() => (props.choosing.current = false), 400), { once: true });
408
+ input.current?.click();
409
+ }}
410
+ >
411
+ <Icon name="upload" size="sm" />
412
+ Upload…
413
+ </button>
414
+ <input
415
+ ref={input}
416
+ type="file"
417
+ hidden
418
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.csv,.zip"
419
+ data-rt-file=""
420
+ onChange={(e) => {
421
+ props.choosing.current = false;
422
+ const file = e.target.files?.[0];
423
+ e.target.value = '';
424
+ if (file) void take(file);
425
+ }}
426
+ />
427
+ </div>
428
+ <div className="lpop-list">
429
+ <div className="sw-h">Files</div>
430
+ {up ? (
431
+ <div className="drow" role="status" aria-label={`${up.name}, uploading, ${up.pct}%`}>
432
+ <Tile />
433
+ <span className="nm">{up.name}</span>
434
+ <span className="meta">Uploading · {up.pct}%</span>
435
+ </div>
436
+ ) : null}
437
+ {error ? <Warn>{error}</Warn> : null}
438
+ {files.unset ? <div className="sw-more">{files.unset.error}</div> : null}
439
+ {list.slice(0, 8).map((f, i) => (
440
+ <button key={f.key} type="button" className={`drow${i === 0 && q ? ' is-active' : ''}`} data-file={f.key} onMouseDown={(e) => e.preventDefault()} onClick={() => props.onPick(f)}>
441
+ <Tile />
442
+ <span className="nm">{f.name}</span>
443
+ <span className="meta">
444
+ {f.type}
445
+ {f.bytes !== null ? ` · ${fileSize(f.bytes)}` : ''}
446
+ </span>
447
+ </button>
448
+ ))}
449
+ {list.length > 8 ? <div className="sw-more">{list.length - 8} more: keep typing</div> : null}
450
+ {files.list && list.length === 0 ? <div className="sw-more">{q ? `No file called “${find.trim()}”.` : 'No files yet.'}</div> : null}
451
+ <button type="button" className="drow" onMouseDown={(e) => e.preventDefault()} onClick={props.onBack}>
452
+ <Icon name="chev-l" />
453
+ <span className="nm">Back to pages</span>
454
+ </button>
455
+ </div>
456
+ </>
457
+ );
458
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Rich text's block commands for a list item that is a textblock (on-page rich
3
+ * text, Decision 3: `<li>` holds its text directly, as `richTextHtml` renders it).
4
+ * Tiptap's list commands assume `li > p` and silently do nothing here, so these
5
+ * replace them. Plain ProseMirror commands (`(state, dispatch) => boolean`) so the
6
+ * keys, the sidebar's toolbar and the page's bar all run the same ones, and node
7
+ * tests can run them on an `EditorState`; `toggleList` and `setBlockStyle` also
8
+ * take a Tiptap `Editor`.
9
+ *
10
+ * - **splitListItem** (Enter in an item): a new item; in an empty item, the item
11
+ * leaves the list as a paragraph (M5a → Resolved decisions, Google Docs).
12
+ * - **liftListItem** (Backspace at an item's start, and the above): the item, alone,
13
+ * leaves its list as a paragraph; the list splits around it.
14
+ * - **toggleList**: paragraphs and headings in the selection become items of that
15
+ * kind of list (joined to a list of that kind just before or after); in a list of
16
+ * that kind, the selected items leave it as paragraphs; in the other kind, the
17
+ * whole list changes kind.
18
+ * - **setBlockStyle** (the style menu): the selection's blocks become that style;
19
+ * a list item leaves its list as that style, the list splitting around it
20
+ * (Decision 9).
21
+ */
22
+ import type { Editor } from '@tiptap/core';
23
+ import { Fragment, Slice, type Node as PMNode, type NodeType } from '@tiptap/pm/model';
24
+ import type { EditorState, Transaction } from '@tiptap/pm/state';
25
+ import { canJoin, ReplaceAroundStep } from '@tiptap/pm/transform';
26
+
27
+ export type Command = (state: EditorState, dispatch?: (tr: Transaction) => void) => boolean;
28
+ export type ListKind = 'bulletList' | 'orderedList';
29
+ export type BlockStyle = 'p' | 'h2' | 'h3';
30
+
31
+ const isList = (node: PMNode) => node.type.name === 'bulletList' || node.type.name === 'orderedList';
32
+
33
+ /** The top-level blocks the selection touches: each with its position in the doc. */
34
+ function blocksInSelection(state: EditorState): { node: PMNode; pos: number }[] {
35
+ const { from, to } = state.selection;
36
+ const out: { node: PMNode; pos: number }[] = [];
37
+ state.doc.forEach((node, pos) => {
38
+ if (pos + node.nodeSize > from && pos <= to) out.push({ node, pos });
39
+ });
40
+ return out;
41
+ }
42
+
43
+ /** The items of `list` (at `listPos`) the range [from, to] touches, with their positions. */
44
+ function itemsInRange(list: PMNode, listPos: number, from: number, to: number): number[] {
45
+ const out: number[] = [];
46
+ list.forEach((item, offset) => {
47
+ const pos = listPos + 1 + offset;
48
+ if (pos + item.nodeSize > from && pos <= to) out.push(pos);
49
+ });
50
+ return out;
51
+ }
52
+
53
+ /**
54
+ * Takes the items at `positions` (each the position before a listItem, in `tr.doc`)
55
+ * out of their lists as `type` blocks, splitting each list around them. Works from
56
+ * the last to the first, so the earlier positions stay valid.
57
+ */
58
+ function liftItems(tr: Transaction, positions: readonly number[], type: NodeType, attrs: Record<string, unknown> | null) {
59
+ for (const pos of [...positions].sort((a, b) => b - a)) {
60
+ const $pos = tr.doc.resolve(pos);
61
+ const list = $pos.parent;
62
+ const index = $pos.index();
63
+ const item = list.child(index);
64
+ let start = pos;
65
+ if (index < list.childCount - 1) tr.split(start + item.nodeSize, 1);
66
+ if (index > 0) {
67
+ tr.split(start, 1);
68
+ start += 2;
69
+ }
70
+ // Now the item is alone in a list at start - 1: replace the list and the item with one block holding its text.
71
+ const listStart = start - 1;
72
+ const listEnd = start + item.nodeSize + 1;
73
+ tr.step(new ReplaceAroundStep(listStart, listEnd, start + 1, start + item.nodeSize - 1, new Slice(Fragment.from(type.create(attrs)), 0, 0), 1, true));
74
+ }
75
+ }
76
+
77
+ /** Wraps the top-level textblock at `pos` (in `tr.doc`) as one item of a new list of `listType`. */
78
+ function wrapAsItem(tr: Transaction, pos: number, listType: NodeType) {
79
+ const node = tr.doc.nodeAt(pos)!;
80
+ const item = tr.doc.type.schema.nodes.listItem!;
81
+ tr.step(new ReplaceAroundStep(pos, pos + node.nodeSize, pos + 1, pos + node.nodeSize - 1, new Slice(Fragment.from(listType.create(null, item.create())), 0, 0), 2, true));
82
+ }
83
+
84
+ /** Joins every pair of adjacent lists of `listType` between `from` and `to` (in `tr.doc`). */
85
+ function joinAdjacent(tr: Transaction, listType: NodeType, from: number, to: number) {
86
+ const joins: number[] = [];
87
+ let prev: PMNode | null = null;
88
+ tr.doc.forEach((node, pos) => {
89
+ if (prev && prev.type === listType && node.type === listType && pos >= from && pos <= to) joins.push(pos);
90
+ prev = node;
91
+ });
92
+ for (const pos of joins.reverse()) if (canJoin(tr.doc, pos)) tr.join(pos);
93
+ }
94
+
95
+ /** Enter in a list item: a new item; in an empty one, it leaves the list as a paragraph. */
96
+ export const splitListItem: Command = (state, dispatch) => {
97
+ const { $from } = state.selection;
98
+ if ($from.parent.type.name !== 'listItem') return false;
99
+ if ($from.parent.content.size === 0 && state.selection.empty) return liftListItem(state, dispatch);
100
+ if (dispatch) {
101
+ const tr = state.tr;
102
+ if (!state.selection.empty) tr.deleteSelection();
103
+ const marks = state.storedMarks || (tr.selection.$to.parentOffset ? tr.selection.$from.marks() : null);
104
+ tr.split(tr.selection.from, 1);
105
+ if (marks) tr.ensureMarks(marks);
106
+ dispatch(tr.scrollIntoView());
107
+ }
108
+ return true;
109
+ };
110
+
111
+ /** Backspace at an item's start (and Enter in an empty one): the item leaves its list as a paragraph. */
112
+ export const liftListItem: Command = (state, dispatch) => {
113
+ const { $from } = state.selection;
114
+ if ($from.parent.type.name !== 'listItem') return false;
115
+ if (dispatch) {
116
+ const tr = state.tr;
117
+ liftItems(tr, [$from.before()], state.schema.nodes.paragraph!, null);
118
+ dispatch(tr.scrollIntoView());
119
+ }
120
+ return true;
121
+ };
122
+
123
+ /** The list button: on, off, or the other kind (see the header). */
124
+ export function toggleListCommand(kind: ListKind): Command {
125
+ return (state, dispatch) => {
126
+ const listType = state.schema.nodes[kind];
127
+ if (!listType) return false;
128
+ const { from, to } = state.selection;
129
+ const blocks = blocksInSelection(state);
130
+ if (!blocks.length) return false;
131
+ const allThisKind = blocks.every((b) => b.node.type === listType);
132
+ if (!dispatch) return true;
133
+ const tr = state.tr;
134
+ if (allThisKind) {
135
+ const items = blocks.flatMap((b) => itemsInRange(b.node, b.pos, from, to));
136
+ liftItems(tr, items, state.schema.nodes.paragraph!, null);
137
+ } else {
138
+ // From the last block to the first, so earlier positions stay valid.
139
+ for (const b of [...blocks].reverse()) {
140
+ if (b.node.type === listType) continue;
141
+ if (isList(b.node)) tr.setNodeMarkup(b.pos, listType);
142
+ else if (b.node.isTextblock) wrapAsItem(tr, b.pos, listType);
143
+ }
144
+ const first = blocks[0]!.pos;
145
+ const last = blocks[blocks.length - 1]!;
146
+ const end = tr.mapping.map(last.pos + last.node.nodeSize);
147
+ joinAdjacent(tr, listType, first, end);
148
+ }
149
+ dispatch(tr.scrollIntoView());
150
+ return true;
151
+ };
152
+ }
153
+
154
+ /** The style menu: the selection's blocks become `style`; a list item leaves its list as it. */
155
+ export function setBlockStyleCommand(style: BlockStyle): Command {
156
+ return (state, dispatch) => {
157
+ const type = style === 'p' ? state.schema.nodes.paragraph! : state.schema.nodes.heading;
158
+ if (!type) return false;
159
+ const attrs = style === 'p' ? null : { level: style === 'h2' ? 2 : 3 };
160
+ const { from, to } = state.selection;
161
+ const blocks = blocksInSelection(state);
162
+ if (!blocks.length) return false;
163
+ if (!dispatch) return true;
164
+ const tr = state.tr;
165
+ const items = blocks.filter((b) => isList(b.node)).flatMap((b) => itemsInRange(b.node, b.pos, from, to));
166
+ liftItems(tr, items, type, attrs);
167
+ tr.setBlockType(tr.mapping.map(from), tr.mapping.map(to), type, attrs);
168
+ dispatch(tr.scrollIntoView());
169
+ return true;
170
+ };
171
+ }
172
+
173
+ const run = (editor: Editor, command: Command) => {
174
+ const ok = command(editor.state, editor.view.dispatch);
175
+ if (ok) editor.view.focus();
176
+ return ok;
177
+ };
178
+
179
+ /** The list button on a Tiptap editor (the sidebar's toolbar, the page's bar). Focuses the editor. */
180
+ export const toggleList = (editor: Editor, kind: ListKind): boolean => run(editor, toggleListCommand(kind));
181
+
182
+ /** The style menu on a Tiptap editor: Paragraph, Heading 2 or Heading 3. Focuses the editor. */
183
+ export const setBlockStyle = (editor: Editor, style: BlockStyle): boolean => run(editor, setBlockStyleCommand(style));