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,365 @@
1
+ /**
2
+ * The lockup field (Decision 12; M2 → the lockup field; the mockup's lockupField):
3
+ * one input row per line, edited in the sidebar only.
4
+ *
5
+ * - **Keys** (lockup-rules.ts → lineKey): Enter starts a new line at the caret, ⌫ at
6
+ * a line's start joins it to the line above, ↑/↓ move between lines.
7
+ * - **Main lines / Phone lines** (on Phone): Phone lines are the same words broken
8
+ * differently, so only Enter (between words) and ⌫ (at a line's start) work there;
9
+ * anything else nudges the hint. "Give Phone its own lines" splits the longest line
10
+ * at the most even space (splitEvenly). Editing the main lines keeps the phone
11
+ * breaks if the word count holds, otherwise drops them with an inline notice
12
+ * (withMainLines). "Use main lines" drops them with Undo, and the file holds the
13
+ * plain list again (Decision 10).
14
+ * - On Desktop and Tablet, a lockup with phone lines says so ("Phone has its own lines").
15
+ * - **Warnings** (none blocks anything, M2 Decision 12): "Too long for mobile:
16
+ * shorten a line or split it" (M2 Decision 17), from the size the phone iframe
17
+ * computed (previewStatus.warnings; M1 → E7), with the fix offered; characters the
18
+ * font doesn't cover (Decision 15), from the render's markers.
19
+ */
20
+ import { useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent } from 'react';
21
+ import { titleCase, type LockupField as LockupDescriptor } from '../../../fields.ts';
22
+ import { own } from '../../../document.ts';
23
+ import { sameDoc } from '../../types.ts';
24
+ import { useEditor, useStore } from '../context.tsx';
25
+ import { Icon } from '../shell/Icons.tsx';
26
+ import { FieldFrame } from './FieldFrame.tsx';
27
+ import { joinUp, lineKey, lockupValue, splitLine, withMainLines, withoutPhone, withPhone, type LineCaret, type LineSet } from './lockup-rules.ts';
28
+ import { fieldElement } from '../preview/markers.ts';
29
+
30
+ export const PHONE_LINES_DROPPED = 'Phone uses the main lines again';
31
+
32
+ /** Characters the font doesn't cover, per line set, as the latest render marked them (Decision 15). */
33
+ function useUncovered(index: number, name: string): { main: string[]; phone: string[] } {
34
+ const editor = useEditor();
35
+ const [found, setFound] = useState<{ main: string[]; phone: string[] }>({ main: [], phone: [] });
36
+ useEffect(() => {
37
+ const driver = editor.preview;
38
+ if (!driver) return;
39
+ const read = () => {
40
+ const doc = driver.frames().find((f) => f.width === 'desktop')?.document;
41
+ const heading = doc ? fieldElement(doc, index, name, 'lockup') : null;
42
+ const of = (set: string) => {
43
+ const raw = heading?.querySelector<HTMLElement>(`[data-cms-lines="${set}"]`)?.dataset.cmsUncovered;
44
+ try {
45
+ return raw ? (JSON.parse(raw) as string[]) : [];
46
+ } catch {
47
+ return [];
48
+ }
49
+ };
50
+ const next = { main: of('main'), phone: of('phone') };
51
+ setFound((prev) => (JSON.stringify(prev) === JSON.stringify(next) ? prev : next));
52
+ };
53
+ read();
54
+ const stops = [driver.onMorph(read), driver.onLoad(read)];
55
+ return () => stops.forEach((s) => s());
56
+ }, [editor, index, name]);
57
+ return found;
58
+ }
59
+
60
+ /** Lines as text with a <br> between them (the mockup's lk-ref and lk-phone-sum). */
61
+ const withBreaks = (lines: readonly string[]) => lines.flatMap((line, i) => (i ? [<br key={i} />, line] : [line]));
62
+
63
+ const quote = (chars: string[]) => chars.map((c) => `“${c}”`).join(', ');
64
+
65
+ export function LockupField(props: { blockKey: string; index: number; name: string; label: string; field: LockupDescriptor; value: unknown }) {
66
+ const editor = useEditor();
67
+ const { blockKey, name } = props;
68
+ const value = lockupValue(props.value);
69
+ const hasPhone = !!value.phone;
70
+ const width = useStore(editor.view, (v) => v.width);
71
+ const onPhone = width === 'phone';
72
+ const [chosen, setChosen] = useState<LineSet | null>(null);
73
+ const mode: LineSet = onPhone ? (chosen ?? (hasPhone ? 'phone' : 'main')) : 'main';
74
+ const ph = mode === 'phone' && hasPhone;
75
+ const [dropped, setDropped] = useState(false);
76
+ const pending = useRef<{ set: LineSet; focus: LineCaret } | null>(null);
77
+ const rows = useRef<HTMLDivElement>(null);
78
+ const under = useRef<HTMLDivElement>(null);
79
+ const style = own(editor.site.textStyles, props.field.style);
80
+ const styleName = style ? titleCase(style.name) : titleCase(props.field.style);
81
+ const labelId = `lk-${blockKey}-${name}`;
82
+
83
+ // The warning, from the phone iframe's computed size, for the document the preview shows.
84
+ const showing = useStore(editor.previewStatus, (s) => s.showing);
85
+ const warnings = useStore(editor.previewStatus, (s) => s.warnings);
86
+ const live = sameDoc(showing, editor.doc.get().ref);
87
+ const warning = live ? warnings.find((w) => w.block === props.index && w.field === name) : undefined;
88
+ const uncovered = useUncovered(props.index, name);
89
+
90
+ // Where the caret goes after a split, a join or a new line.
91
+ useLayoutEffect(() => {
92
+ const want = pending.current;
93
+ if (!want) return;
94
+ const input = rows.current?.querySelector<HTMLInputElement>(`input[data-set="${want.set}"][data-line="${want.focus.line}"]`);
95
+ if (!input) return;
96
+ pending.current = null;
97
+ input.focus();
98
+ input.setSelectionRange(want.focus.caret, want.focus.caret);
99
+ });
100
+
101
+ // Phone lines: nothing that would change the words gets in (paste, drop, autocorrect).
102
+ useEffect(() => {
103
+ const el = rows.current;
104
+ if (!el) return;
105
+ const guard = (event: Event) => {
106
+ if ((event.target as Element | null)?.matches?.('input[data-set="phone"]')) {
107
+ event.preventDefault();
108
+ nudge();
109
+ }
110
+ };
111
+ el.addEventListener('beforeinput', guard);
112
+ return () => el.removeEventListener('beforeinput', guard);
113
+ });
114
+
115
+ function nudge() {
116
+ const u = under.current;
117
+ if (u) {
118
+ u.classList.remove('nudge');
119
+ void u.offsetWidth;
120
+ u.classList.add('nudge');
121
+ }
122
+ editor.announce('Phone lines keep the same words. Change the words in the main lines.');
123
+ }
124
+
125
+ function setMain(lines: string[]) {
126
+ const next = withMainLines(value, lines);
127
+ editor.doc.setField(blockKey, name, next.value);
128
+ if (next.dropped) {
129
+ setDropped(true);
130
+ setChosen('main');
131
+ editor.announce('Words changed; Phone uses the main lines again.');
132
+ }
133
+ }
134
+
135
+ function setLines(set: LineSet, lines: string[]) {
136
+ if (set === 'main') setMain(lines);
137
+ else editor.doc.setField(blockKey, name, { lines: value.lines, phone: lines });
138
+ }
139
+
140
+ function onKeyDown(event: KeyboardEvent<HTMLInputElement>, set: LineSet, line: number) {
141
+ const input = event.currentTarget;
142
+ const lines = set === 'phone' ? value.phone ?? [] : value.lines;
143
+ const start = input.selectionStart ?? 0;
144
+ const end = input.selectionEnd ?? start;
145
+ const action = lineKey(set, event.key, { line, start, end }, { meta: event.metaKey, ctrl: event.ctrlKey, alt: event.altKey });
146
+ if (action === 'pass') return;
147
+ if (action === 'up' || action === 'down') {
148
+ const next = rows.current?.querySelector<HTMLInputElement>(`input[data-set="${set}"][data-line="${line + (action === 'down' ? 1 : -1)}"]`);
149
+ if (next) {
150
+ event.preventDefault();
151
+ next.focus();
152
+ }
153
+ return;
154
+ }
155
+ event.preventDefault();
156
+ if (action === 'refuse') {
157
+ if (set === 'phone') nudge();
158
+ return;
159
+ }
160
+ const edit = action === 'split' ? splitLine(lines, set, { line, caret: start }) : joinUp(lines, line);
161
+ if (!edit) {
162
+ nudge();
163
+ return;
164
+ }
165
+ pending.current = { set, focus: edit.focus };
166
+ setLines(set, edit.lines);
167
+ }
168
+
169
+ function givePhone() {
170
+ editor.doc.setField(blockKey, name, withPhone(value));
171
+ setChosen('phone');
172
+ setDropped(false);
173
+ editor.view.update({ field: name, picked: false });
174
+ if (!onPhone) editor.setWidth('phone');
175
+ pending.current = { set: 'phone', focus: { line: Math.min(1, Math.max(0, withPhone(value).phone!.length - 1)), caret: 0 } };
176
+ editor.announce('Phone has its own lines.');
177
+ }
178
+
179
+ function dropPhone() {
180
+ editor.doc.setField(blockKey, name, withoutPhone(value), { undoLabel: PHONE_LINES_DROPPED });
181
+ setChosen('main');
182
+ }
183
+
184
+ function addLine() {
185
+ pending.current = { set: 'main', focus: { line: value.lines.length, caret: 0 } };
186
+ setMain([...value.lines, '']);
187
+ }
188
+
189
+ const lines = ph ? value.phone! : value.lines;
190
+ const warnLines = warning?.set === 'phone' && value.phone ? value.phone : value.lines;
191
+ const longest = warnLines.reduce((a, b) => (b.length > a.length ? b : a), '');
192
+ const missing = [...new Set([...uncovered.main, ...uncovered.phone])];
193
+
194
+ return (
195
+ <FieldFrame
196
+ blockKey={blockKey}
197
+ name={name}
198
+ label={props.label}
199
+ help={props.field.help}
200
+ labelId={labelId}
201
+ className={`lk-field${ph ? ' phone' : ''}`}
202
+ phone={ph}
203
+ chips={
204
+ hasPhone && !onPhone ? (
205
+ <span className="chip phone">
206
+ <Icon name="phone" size="xs" />
207
+ Phone lines
208
+ </span>
209
+ ) : null
210
+ }
211
+ meta={<span className="field-meta">Lockup · {styleName}</span>}
212
+ >
213
+ {onPhone ? (
214
+ <div className="mini-seg" role="radiogroup" aria-label="Which lines to edit">
215
+ <button type="button" role="radio" aria-checked={mode === 'main'} onClick={() => setChosen('main')}>
216
+ Main lines
217
+ </button>
218
+ <button type="button" role="radio" className="ph" aria-checked={mode === 'phone'} onClick={() => setChosen('phone')}>
219
+ <Icon name="phone" size="xs" />
220
+ Phone lines{hasPhone ? null : <span className="none"> · none</span>}
221
+ </button>
222
+ </div>
223
+ ) : null}
224
+
225
+ {mode === 'phone' && !hasPhone ? (
226
+ <div className="note phone tight">
227
+ <Icon name="phone" />
228
+ <div>
229
+ <b>Phone uses the main lines</b>
230
+ <div className="acts">
231
+ <button type="button" className="btn sm phone" onClick={givePhone}>
232
+ Give Phone its own lines
233
+ </button>
234
+ </div>
235
+ </div>
236
+ </div>
237
+ ) : (
238
+ <>
239
+ <div className="lk-rows" role="group" aria-labelledby={labelId} ref={rows}>
240
+ {lines.map((line, i) => (
241
+ <div className="lk-row" key={i}>
242
+ <span className="n" aria-hidden="true">
243
+ {i + 1}
244
+ </span>
245
+ <input
246
+ value={line}
247
+ data-set={ph ? 'phone' : 'main'}
248
+ data-line={i}
249
+ aria-label={`Line ${i + 1}${ph ? ' on Phone' : ''}`}
250
+ spellCheck={false}
251
+ autoComplete="off"
252
+ onKeyDown={(e) => onKeyDown(e, ph ? 'phone' : 'main', i)}
253
+ onChange={(e) => {
254
+ if (ph) return;
255
+ const next = [...value.lines];
256
+ next[i] = e.target.value;
257
+ setMain(next);
258
+ }}
259
+ />
260
+ </div>
261
+ ))}
262
+ </div>
263
+ {ph ? (
264
+ <div className="lk-under phone" ref={under}>
265
+ <Icon name="check" size="xs" />
266
+ <span>Same words, broken for phones.</span>
267
+ </div>
268
+ ) : (
269
+ <div className="lk-under" ref={under}>
270
+ <button type="button" className="linkbtn subtle" onClick={addLine}>
271
+ <Icon name="plus" size="xs" />
272
+ Add line
273
+ </button>
274
+ </div>
275
+ )}
276
+ </>
277
+ )}
278
+
279
+ {dropped && !hasPhone ? (
280
+ <div className="note phone tight lk-dropped" style={{ marginTop: 'var(--space-2)' }}>
281
+ <Icon name="phone" />
282
+ <div>
283
+ <p>Words changed; Phone uses the main lines again.</p>
284
+ </div>
285
+ </div>
286
+ ) : null}
287
+
288
+ <div className="lk-status">
289
+ {warning?.tooLong ? (
290
+ <div className="note warn tight" role="status" data-warning="too-long">
291
+ <Icon name="warn" />
292
+ <div>
293
+ <b>Too long for mobile: shorten a line or split it</b>
294
+ <p>
295
+ On a phone, “{longest}” sets the heading at {Math.round(warning.px)}px. {styleName}'s smallest size is {Math.round(warning.minPx)}px.
296
+ </p>
297
+ {hasPhone ? null : (
298
+ <div className="acts">
299
+ {!onPhone ? (
300
+ <button type="button" className="btn sm" onClick={() => editor.setWidth('phone')}>
301
+ <Icon name="phone" size="sm" />
302
+ Show on Phone
303
+ </button>
304
+ ) : null}
305
+ <button type="button" className="btn sm phone" onClick={givePhone}>
306
+ Give Phone its own lines
307
+ </button>
308
+ </div>
309
+ )}
310
+ </div>
311
+ </div>
312
+ ) : warning ? (
313
+ <p className="lk-ok" data-warning="fits">
314
+ <Icon name="check" size="xs" />
315
+ Fits on Phone{hasPhone ? ' with its own lines' : ''} · {Math.round(warning.px)}px
316
+ </p>
317
+ ) : null}
318
+ {missing.length > 0 ? (
319
+ <div className="note warn tight lk-uncovered" role="status" data-warning="uncovered" style={{ marginTop: 'var(--space-2)' }}>
320
+ <Icon name="warn" />
321
+ <div>
322
+ <p>
323
+ {quote(missing)} {missing.length === 1 ? "isn't" : "aren't"} in this font, so this line may not fit.
324
+ </p>
325
+ </div>
326
+ </div>
327
+ ) : null}
328
+ </div>
329
+
330
+ {!onPhone && hasPhone ? (
331
+ <div className="lk-phone-sum">
332
+ <Icon name="phone" size="xs" />
333
+ <span className="h">Phone has its own lines</span>
334
+ <button
335
+ type="button"
336
+ className="linkbtn"
337
+ onClick={() => {
338
+ setChosen('phone');
339
+ editor.view.update({ field: name, picked: false });
340
+ editor.setWidth('phone');
341
+ }}
342
+ >
343
+ Edit on Phone
344
+ </button>
345
+ <span className="l">{withBreaks(value.phone!)}</span>
346
+ </div>
347
+ ) : null}
348
+
349
+ {ph ? (
350
+ <>
351
+ <div className="lk-ref">
352
+ <div className="h">Main lines · Tablet and Desktop</div>
353
+ <div className="l">{withBreaks(value.lines)}</div>
354
+ </div>
355
+ <div className="lk-acts">
356
+ <button type="button" className="btn sm" onClick={dropPhone}>
357
+ Use main lines
358
+ </button>
359
+ <span>Drops Phone's own lines</span>
360
+ </div>
361
+ </>
362
+ ) : null}
363
+ </FieldFrame>
364
+ );
365
+ }