@morphemecms/cms 0.11.0 → 0.12.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 (89) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/astro.mjs +2 -1
  3. package/lib/appId.d.mts +1 -0
  4. package/lib/appId.mjs +6 -0
  5. package/lib/apps.mjs +13 -3
  6. package/lib/apps.test.mjs +10 -1
  7. package/lib/contracts.d.mts +3 -2
  8. package/lib/contracts.mjs +3 -2
  9. package/lib/folderChanges.d.mts +29 -0
  10. package/lib/folderChanges.mjs +34 -0
  11. package/lib/section-ranges.d.mts +1 -1
  12. package/lib/section-ranges.mjs +8 -14
  13. package/lib/section-ranges.test.mjs +26 -5
  14. package/lib/storeEntries.d.mts +3 -1
  15. package/lib/storeEntries.mjs +22 -8
  16. package/lib/storeEntries.test.mjs +56 -7
  17. package/lib/text-sections.d.mts +11 -1
  18. package/lib/text-sections.mjs +18 -8
  19. package/lib/text-sections.test.mjs +34 -1
  20. package/lib/video-id.d.mts +6 -2
  21. package/lib/video-id.mjs +5 -1
  22. package/lib/video.d.mts +6 -2
  23. package/lib/video.mjs +5 -1
  24. package/lib/without-h1.d.mts +1 -0
  25. package/lib/without-h1.mjs +19 -4
  26. package/lib/without-h1.test.mjs +30 -1
  27. package/package.json +3 -2
  28. package/scripts/app-screenshots.mjs +2 -1
  29. package/scripts/check-content.mjs +2 -1
  30. package/scripts/check-site-types.mjs +26 -0
  31. package/scripts/site-types.mjs +14 -0
  32. package/scripts/site-types.test.mjs +21 -0
  33. package/site/activeTheme.test.mjs +52 -0
  34. package/site/appSelf.d.mts +2 -0
  35. package/site/appSelf.mjs +23 -0
  36. package/site/appSelf.test.mjs +35 -0
  37. package/site/content.ts +5 -2
  38. package/site/siteModules.mjs +2 -1
  39. package/site/theme.mjs +19 -8
  40. package/src/App.tsx +1 -1
  41. package/src/AppList.tsx +24 -9
  42. package/src/BlockDialog.tsx +38 -17
  43. package/src/CreateFab.tsx +8 -0
  44. package/src/DataForm.tsx +7 -17
  45. package/src/EntryForm.tsx +1 -1
  46. package/src/FormFrame.tsx +4 -3
  47. package/src/Library.tsx +4 -11
  48. package/src/ListFab.tsx +13 -0
  49. package/src/MarkdownEditor.tsx +88 -47
  50. package/src/Media.tsx +4 -13
  51. package/src/Redirects.tsx +6 -14
  52. package/src/Screen.tsx +13 -3
  53. package/src/StartFab.tsx +5 -7
  54. package/src/ThemeStore.tsx +33 -12
  55. package/src/apps.ts +2 -3
  56. package/src/drawerYield.d.mts +3 -0
  57. package/src/drawerYield.mjs +10 -0
  58. package/src/drawerYield.test.mjs +22 -0
  59. package/src/editor.css +55 -22
  60. package/src/fabScreens.test.mjs +28 -0
  61. package/src/fabScreens.ts +16 -0
  62. package/src/legacyMove.test.mjs +137 -0
  63. package/src/legacyMove.ts +131 -0
  64. package/src/markdown.mjs +10 -7
  65. package/src/markdown.test.mjs +16 -2
  66. package/src/menuOpen.ts +20 -4
  67. package/src/movedRoutes.mjs +2 -2
  68. package/src/movedRoutes.test.mjs +5 -0
  69. package/src/pageH1Panel.d.mts +4 -0
  70. package/src/pageH1Panel.mjs +31 -0
  71. package/src/pageH1Panel.test.mjs +56 -0
  72. package/src/pagePreview.tsx +22 -12
  73. package/src/sectionFrame.ts +3 -3
  74. package/src/sectionState.d.mts +3 -3
  75. package/src/sectionState.mjs +6 -6
  76. package/src/sectionState.test.mjs +14 -2
  77. package/src/sheetPlacement.d.mts +6 -4
  78. package/src/sheetPlacement.mjs +15 -10
  79. package/src/sheetPlacement.test.mjs +16 -9
  80. package/src/sidePanel.ts +38 -0
  81. package/src/store.ts +32 -27
  82. package/src/topBarSlot.tsx +7 -15
  83. package/src/ui.tsx +26 -15
  84. package/src/virtual.d.ts +6 -0
  85. package/starter/package.json +1 -1
  86. package/theme/components/PageHeading.astro +22 -0
  87. package/theme/layouts/PageLayout.astro +37 -20
  88. package/theme/styles/global.css +2 -1
  89. package/theme/styles/ui.css +40 -10
@@ -7,11 +7,12 @@
7
7
  // (the standard blocks, each app's under its name), the one picked as a chip. Also the sheet of a section's
8
8
  // settings (its colour, SectionSettings.tsx), which goes the same way as a block's fields: Annuller, Gem, the question when closed changed.
9
9
  // The fields and the section settings are in the theme's side sheet (sheetPlacement.mjs): a standard one beside the page from
10
- // medium (not modal: the page stays usable, .editor-side-sheet in editor.css), a modal one over the page below it
10
+ // expanded (not modal: the page stays usable, .editor-side-sheet in editor.css), a modal one over the page below it
11
11
  // (fields only; the section settings are a sheet from the bottom there). Both end with Gem (Indsæt) and Annuller.
12
12
  // Their open, close, focus and Escape are the theme's (sideSheet() and standardSideSheet(), sideSheetControl.ts); leave() is their beforeClose.
13
- // Another block tapped while one is open closes the open one first (the question when changed). Exposes useBlockDialog, IMAGE.
14
- import { useEffect, useId, useRef, useState, type ReactElement } from 'react';
13
+ // Another block tapped while one is open closes the open one first (the question when changed). In the standard side sheet a
14
+ // card already in the text also has "Remove" after Cancel in the actions (the answer 'remove'). Exposes useBlockDialog, IMAGE.
15
+ import { useEffect, useId, useRef, useState, type CSSProperties, type ReactElement } from 'react';
15
16
  import { ArrowsDownUp, Image } from '@phosphor-icons/react';
16
17
  import {
17
18
  Button,
@@ -46,6 +47,7 @@ import { snackbar } from '../lib/snackbar';
46
47
  import { closeChanged } from './ask';
47
48
  import SectionSettings from './SectionSettings';
48
49
  import { sheetLook } from './sheetPlacement.mjs';
50
+ import { setPanelOpen } from './sidePanel';
49
51
  import { SiteSideSheet, siteSideSheet, siteSideSheetDialogClass, siteStandardSideSheet } from './themeUi';
50
52
 
51
53
  export type BlockAsk = {
@@ -58,13 +60,17 @@ export type BlockAsk = {
58
60
  * than once, unless the template says how many (have: the types in the text now, one for each, IMAGE for a picture).
59
61
  * image: a picture is offered first, and chosen as the type IMAGE. */
60
62
  choose: (offer?: { image?: boolean; have?: string[] }) => Promise<{ type: string; props: BlockProps } | null>;
61
- /** New props for a block in the text; null when cancelled. isNew: one about to be put in ("Indsæt"). */
62
- edit: (type: string, props: BlockProps, isNew?: boolean) => Promise<BlockProps | null>;
63
+ /** New props for a block in the text; null when cancelled. isNew: one about to be put in ("Indsæt"). removable: the
64
+ * card may be removed, so the side panel has "Remove"; 'remove' when it is tapped (never when removable is not true). */
65
+ edit: (type: string, props: BlockProps, isNew?: boolean, removable?: boolean) => Promise<BlockProps | 'remove' | null>;
63
66
  /** The settings of the section the cursor is in (now its colour; null: none); the new ones, or null when cancelled. */
64
67
  section: (color: string | null) => Promise<{ color: string | null } | null>;
65
68
  };
66
69
 
67
- type Open = { step: 'choose'; image?: boolean; have?: string[] } | { step: 'fields'; type: string; isNew: boolean } | { step: 'section' };
70
+ type Open =
71
+ | { step: 'choose'; image?: boolean; have?: string[] }
72
+ | { step: 'fields'; type: string; isNew: boolean; removable?: boolean }
73
+ | { step: 'section' };
68
74
 
69
75
  /** The type a section's settings are answered with (not a block's). */
70
76
  const SECTION = 'section';
@@ -99,7 +105,7 @@ const picture: SiteBlock = {
99
105
  },
100
106
  ],
101
107
  };
102
- type Answer = { type: string; props: BlockProps } | null;
108
+ type Answer = { type: string; props: BlockProps } | 'remove' | null;
103
109
 
104
110
  // Text as searched: lower case, without accents (as in PagePicker.tsx).
105
111
  const plain = (s: string) =>
@@ -124,13 +130,13 @@ export function useBlockDialog(
124
130
  ): [BlockAsk, ReactElement] {
125
131
  const blockOf = (type: string) => (type === IMAGE ? picture : type === HTML ? htmlBlock : list.find((b) => b.type === type));
126
132
  const dialog = useRef<HTMLDialogElement>(null);
127
- // The modal side sheet's <dialog> (below medium) and its controls; the standard one's box (from medium).
133
+ // The modal side sheet's <dialog> (below expanded) and its controls; the standard one's box (from expanded).
128
134
  const sideDialog = useRef<HTMLDialogElement>(null);
129
135
  const sideControls = useRef<ReturnType<typeof siteSideSheet> | null>(null);
130
136
  const standard = useRef<HTMLDivElement>(null);
131
137
  const standardControls = useRef<ReturnType<typeof siteStandardSideSheet> | null>(null);
132
138
  const headingId = useId();
133
- const medium = useAtLeast('medium');
139
+ const expanded = useAtLeast('expanded');
134
140
  const answer = useRef<(a: Answer) => void>(() => {});
135
141
  const [open, setOpen] = useState<Open | null>(null);
136
142
  // What is open now (set at once, where `open` follows a render later), the look it has, when the last one has closed
@@ -186,7 +192,7 @@ export function useBlockDialog(
186
192
  };
187
193
 
188
194
  // Which dialog is open follows the look of the step: the sheet from the bottom, or the modal side sheet (below).
189
- const look: string = open ? sheetLook(open.step, medium) : 'sheet';
195
+ const look: string = open ? sheetLook(open.step, expanded) : 'sheet';
190
196
  lookNow.current = look;
191
197
  useEffect(() => {
192
198
  const sheet = dialog.current;
@@ -202,7 +208,7 @@ export function useBlockDialog(
202
208
  return false;
203
209
  };
204
210
 
205
- // The modal side sheet (below medium).
211
+ // The modal side sheet (below expanded).
206
212
  useEffect(() => {
207
213
  const d = sideDialog.current;
208
214
  if (look !== 'modal-side-sheet' || !d) return;
@@ -214,11 +220,12 @@ export function useBlockDialog(
214
220
  };
215
221
  }, [look]);
216
222
 
217
- // The standard side sheet (from medium): the page makes room for it (editor.css: [data-side-panel]), the focus moves
223
+ // The standard side sheet (from expanded): the page makes room for it (editor.css: [data-side-panel]), the focus moves
218
224
  // into it and back to what had it before when it closes.
219
225
  useEffect(() => {
220
226
  if (look !== 'side-sheet') return;
221
227
  document.documentElement.setAttribute('data-side-panel', '');
228
+ setPanelOpen(true);
222
229
  const box = standard.current?.querySelector<HTMLElement>('[data-side-sheet-container]');
223
230
  if (box) {
224
231
  standardControls.current = siteStandardSideSheet(box, { beforeClose });
@@ -227,6 +234,7 @@ export function useBlockDialog(
227
234
  return () => {
228
235
  standardControls.current = null;
229
236
  document.documentElement.removeAttribute('data-side-panel');
237
+ setPanelOpen(false);
230
238
  };
231
239
  }, [look]);
232
240
 
@@ -254,16 +262,23 @@ export function useBlockDialog(
254
262
  list,
255
263
  template,
256
264
  data,
257
- choose: (offer) => ask({ step: 'choose', image: offer?.image, have: offer?.have }),
265
+ choose: async (offer) => {
266
+ const answered = await ask({ step: 'choose', image: offer?.image, have: offer?.have });
267
+ return answered === 'remove' ? null : answered;
268
+ },
258
269
  section: async (color) => {
259
270
  const answered = await ask({ step: 'section' }, { color: color ?? '' });
260
- return answered && { color: answered.props.color ? String(answered.props.color) : null };
271
+ if (!answered || answered === 'remove') return null;
272
+ return { color: answered.props.color ? String(answered.props.color) : null };
261
273
  },
262
- edit: async (type, props, isNew = false) => {
274
+ edit: async (type, props, isNew = false, removable = false) => {
263
275
  const fields = blockOf(type)?.fields ?? [];
264
276
  // A block on the page with what a field it has no value for means; a new one with the defaults.
265
- const a = await ask({ step: 'fields', type, isNew }, isNew ? { ...newValue(fields), ...props } : savedValue(fields, props));
266
- return a && a.props;
277
+ const a = await ask(
278
+ { step: 'fields', type, isNew, removable },
279
+ isNew ? { ...newValue(fields), ...props } : savedValue(fields, props),
280
+ );
281
+ return a === 'remove' ? a : a && a.props;
267
282
  },
268
283
  };
269
284
 
@@ -378,6 +393,12 @@ export function useBlockDialog(
378
393
  <Button kind="outlined" onClick={() => void leave()}>
379
394
  {t.common.cancel}
380
395
  </Button>
396
+ {open.step === 'fields' && open.removable && !open.isNew && look === 'side-sheet' && (
397
+ // After Save and Cancel in the start-aligned row, in the error colour (the theme's text button with its colour variable).
398
+ <Button kind="text" style={{ '--button-text-text': 'var(--error)' } as CSSProperties} onClick={() => done('remove')}>
399
+ {t.common.remove}
400
+ </Button>
401
+ )}
381
402
  </>
382
403
  }
383
404
  >
@@ -0,0 +1,8 @@
1
+ // A screen's one create action as a FAB (Files, Redirects, a single-file list form): a button, busy and
2
+ // disabled as the screen says. Exposes CreateFab. Uses FabMenu (ui.tsx).
3
+ import { Plus } from '@phosphor-icons/react';
4
+ import { FabMenu } from './ui';
5
+
6
+ export function CreateFab({ label, onClick, busy, disabled }: { label: string; onClick: () => void; busy?: boolean; disabled?: boolean }) {
7
+ return <FabMenu icon={Plus} label={label} items={[{ icon: Plus, label, onClick, busy, disabled }]} />;
8
+ }
package/src/DataForm.tsx CHANGED
@@ -4,7 +4,6 @@
4
4
  // is opened as changes that are not saved. Exposes DataForm (default). Its values from the file and back: dataFileValues.ts;
5
5
  // a menu's fields: DataFileFields.tsx.
6
6
  import { useMemo, useState } from 'react';
7
- import { Plus } from '@phosphor-icons/react';
8
7
  import { entryName, slugify, slugOf, type DataFile, type FilesCollection, type FolderCollection } from './schema';
9
8
  import { config, header, keptBy, sharing, siteName, usesOf } from '@site/editor';
10
9
  import { apps, asWritten, collections } from './site';
@@ -12,6 +11,7 @@ import { kindDraw } from './apps';
12
11
  import { ConflictError, type RepoFile } from './github';
13
12
  import { clean, newItem, newValue, problemAt } from './check';
14
13
  import { FieldInput, FieldList, Fields, useWrongField, WrongField, type FieldContext } from './fields';
14
+ import { CreateFab } from './CreateFab';
15
15
  import { DataFileFields } from './DataFileFields';
16
16
  import { dataOf, fieldsOf, listOnly, valuesOf } from './dataFileValues';
17
17
  import SectionsField from './SectionsField';
@@ -21,7 +21,7 @@ import { splitPicture } from '../lib/crop.mjs';
21
21
  import { useMediaPicker } from './Media';
22
22
  import FormFrame, { savedStatus, useSavedAt } from './FormFrame';
23
23
  import { savedOn, VersionNotice, VersionsButton, WithVersions, type ShownVersion } from './Versions';
24
- import { Button, CheckList, FoldSection, FormColumns, Group, IconButton, Preview, FormHeading, SharePreview, Notice, ListItem } from './ui';
24
+ import { Button, CheckList, FoldSection, FormColumns, Group, Preview, FormHeading, SharePreview, Notice, ListItem } from './ui';
25
25
  import { SearchPreview } from './SearchPreview';
26
26
  import { searchedAs } from './googleResult.mjs';
27
27
  import { plain, SEO_FIELDS, seoRating, SHARE_FIELDS, sharedAs } from './seo';
@@ -34,7 +34,7 @@ type Props = {
34
34
  dataFile?: DataFile;
35
35
  file: RepoFile;
36
36
  env: Env;
37
- /** A new entry of this kind (FolderCollection.kinds), picked under "+". */
37
+ /** A new entry of this kind (FolderCollection.kinds), picked in the list's create FAB menu. */
38
38
  kind?: string;
39
39
  };
40
40
 
@@ -294,7 +294,7 @@ function Form({
294
294
  label: v.label,
295
295
  node: draw({ ...values, ...v.props }, { lists: env.lists, testimonials: env.testimonials, relations: env.relations, site: env.site }),
296
296
  }));
297
- // A file that is a list (the menus) is added to with "+" in the top bar, like the other lists; its own fields
297
+ // A file that is a list (the menus) is added to with the FAB, like the other lists; its own fields
298
298
  // ("Aktiveret") stand above it.
299
299
  const root = dataFile?.root;
300
300
  // Where the entry is used on the site (a tag: its posts, testimonials, promotions and blocks), each opening it.
@@ -318,19 +318,7 @@ function Form({
318
318
  }
319
319
  status={fixed ? savedStatus({ savedAt, saving, deleting }) : undefined}
320
320
  view={view}
321
- actions={
322
- <>
323
- {!isNew && <VersionsButton env={env} path={base.path} onPick={onVersion} />}
324
- {root && (
325
- <IconButton
326
- icon={Plus}
327
- label={t.forms.addOne(root.singular!)}
328
-
329
- onClick={() => setItems([...items, newItem(root)])}
330
- />
331
- )}
332
- </>
333
- }
321
+ actions={!isNew && <VersionsButton env={env} path={base.path} onPick={onVersion} />}
334
322
  dirty={dirty}
335
323
  saving={saving || deleting}
336
324
  deleting={deleting}
@@ -424,6 +412,8 @@ function Form({
424
412
  ))}
425
413
  </Group>
426
414
  )}
415
+ {/* A single-file list: a new item in it. */}
416
+ {root && <CreateFab label={t.forms.addOne(root.singular!)} onClick={() => setItems([...items, newItem(root)])} />}
427
417
  </FormFrame>
428
418
  {picker}
429
419
  </WrongField.Provider>
package/src/EntryForm.tsx CHANGED
@@ -641,7 +641,7 @@ function Form({
641
641
  <>
642
642
  <FoldSection {...sectionProps('content')}>
643
643
  {collection.fields.filter((f) => TOP.includes(f.name) && !STATUS.includes(f.name)).map(field)}
644
- <div ref={textBox} className="scroll-mt-[calc(5rem+env(safe-area-inset-top))]">
644
+ <div ref={textBox} className="scroll-mt-[calc(1rem+env(safe-area-inset-top))]">
645
645
  <span className="mb-1 block font-medium">{t.forms.text}</span>
646
646
  <MarkdownEditor
647
647
  ref={text}
package/src/FormFrame.tsx CHANGED
@@ -3,7 +3,8 @@
3
3
  // of the screen with "Gem" (across the bar on a phone, at its end from medium), which is only there when something
4
4
  // has been changed. Messages are shown by the app (Env.notify); the first "Gem" asks whether the editor may also send notifications.
5
5
  // Leaving with changes that are not saved asks first, to save them or not (in the editor's dialog;
6
- // closing or reloading the page asks in the browser's own). "Forhåndsvis" beside "Gem" and in the top bar shows the page with
6
+ // closing or reloading the page asks in the browser's own). "Forhåndsvis" beside "Gem" and, as a page action (Screen.tsx `pageActions`:
7
+ // the end of the title row from expanded, the top bar below it), shows the page with
7
8
  // what is in the form, before it is saved (pagePreview.tsx).
8
9
  import { useEffect, useRef, useState, type ReactNode } from 'react';
9
10
  import { ArrowSquareOut, Binoculars, Trash } from '@phosphor-icons/react';
@@ -177,9 +178,9 @@ export default function FormFrame<T extends string>({
177
178
  drawer={drawer}
178
179
  tabs={tabs}
179
180
  back={{ guard: leaveTo, ...back }}
180
- actions={
181
+ actions={actions}
182
+ pageActions={
181
183
  <>
182
- {actions}
183
184
  {onPreview && <IconButton icon={Binoculars} label={t.preview.button} aria-haspopup="dialog" onClick={onPreview} />}
184
185
  {view && <IconLink href={view} target="_blank" rel="noopener" label={t.app.viewOnSite} icon={ArrowSquareOut} />}
185
186
  </>
package/src/Library.tsx CHANGED
@@ -21,7 +21,6 @@ import {
21
21
  ListBullets,
22
22
  Moon,
23
23
  Palette,
24
- Plus,
25
24
  Ruler,
26
25
  Shapes,
27
26
  SignOut,
@@ -33,6 +32,7 @@ import { config, countsOf, header, relationsOf, siteName } from '@site/editor';
33
32
  import { iconPack, iconPacks } from './iconPacks';
34
33
  import { theme, themes, type ThemeInfo } from './themes';
35
34
  import { StartFab } from './StartFab';
35
+ import { ListFab } from './ListFab';
36
36
  import { StartStatus } from './StartStatus';
37
37
  import {
38
38
  appearance,
@@ -60,14 +60,12 @@ import {
60
60
  Group,
61
61
  IconButton,
62
62
  LeadingIcon,
63
- NewChoice,
64
63
  SearchRow,
65
64
  useKept,
66
65
  SegmentedButton,
67
66
  UnfoldButton,
68
67
  GroupTitle,
69
68
  SwitchRow,
70
- IconLink,
71
69
  ScoreRing,
72
70
  scoreOf,
73
71
  Button,
@@ -178,7 +176,7 @@ function PartRow({ id, label, href, count }: { id: string } & ReturnType<typeof
178
176
  }
179
177
 
180
178
  // The start screen: the site's name, the CMS version and when the site was last updated (built on Cloudflare, App.tsx), as StartStatus.tsx shows them, under it, and the FAB
181
- // menu for a new page or post (StartFab.tsx). The parts of the site are in the main drawer (MainMenu.tsx); the rest is left empty for now.
179
+ // menu for a new page or post (StartFab.tsx); each list has its own FAB for a new entry (ListFab.tsx). The parts of the site are in the main drawer (MainMenu.tsx); the rest is left empty for now.
182
180
  export function Start({ builtAt }: { builtAt: Date | null }) {
183
181
  return (
184
182
  <Screen
@@ -1374,13 +1372,6 @@ export function List({ collection, tab, files, env }: { collection: Collection;
1374
1372
  onClick={() => (location.hash = `slettet/${collection.id}`)}
1375
1373
  />
1376
1374
  )}
1377
- {/* Of more than one kind (a pop-up: Standard or an app's, e.g. Mailchimp): a sheet to pick it. */}
1378
- {collection.create &&
1379
- (kinds.length > 1 ? (
1380
- <NewChoice title={collection.create} options={kinds} onChange={(kind) => (location.hash = `#ny/${collection.id}/${kind}`)} />
1381
- ) : (
1382
- <IconLink href={`#ny/${collection.id}`} label={collection.create!} icon={Plus} />
1383
- ))}
1384
1375
  </>
1385
1376
  }
1386
1377
  >
@@ -1479,6 +1470,8 @@ export function List({ collection, tab, files, env }: { collection: Collection;
1479
1470
  ) : (
1480
1471
  <p className="mb-8 px-1 text-muted">{t.common.noMatch}</p>
1481
1472
  )}
1473
+ {/* A new entry: the list's FAB, or a FAB menu of its kinds when it has several (a pop-up: Standard or an app's, e.g. Mailchimp). */}
1474
+ <ListFab collection={collection} kinds={kinds} />
1482
1475
  </Screen>
1483
1476
  );
1484
1477
  }
@@ -0,0 +1,13 @@
1
+ // The create FAB of a list (List in Library.tsx): its own create action, or a menu of its kinds when it has several.
2
+ // Exposes ListFab. Uses FabMenu (ui.tsx), listFab (fabScreens.ts) and the icons of site.ts.
3
+ import { Plus } from '@phosphor-icons/react';
4
+ import { listFab } from './fabScreens';
5
+ import { icons } from './site';
6
+ import { FabMenu } from './ui';
7
+
8
+ export function ListFab({ collection, kinds }: { collection: { id: string; create?: string }; kinds: { value: string; label: string }[] }) {
9
+ // One action is the FAB's (the +); each of several kinds has its list's icon.
10
+ const actions = listFab(collection, kinds);
11
+ const icon = actions.length > 1 ? (icons[collection.id] ?? Plus) : Plus;
12
+ return <FabMenu icon={Plus} label={collection.create ?? ''} items={actions.map((a) => ({ ...a, icon }))} />;
13
+ }
@@ -1,7 +1,8 @@
1
1
  // The text of a page or blog post: Milkdown (packages/editor/src/markdown.mjs) with a small toolbar.
2
2
  // The text is shown in the site's own style (the "prose" classes of the pages). On pages the site's
3
3
  // blocks (src/apps/<app>/blocks/) can be put in the text with a button; each is a card with the block as it looks
4
- // on the site, which opens a menu (Ret, hide on the site, move, Fjern), with a free line kept between
4
+ // on the site, which opens a menu (hide on the site, move; below expanded also Ret, Fjern; from expanded a tap opens the
5
+ // side panel instead), with a free line kept between
5
6
  // two cards to write in. A fixed page's sections are such a text too (SectionsField.tsx), so both look
6
7
  // and work the same. The last tool, "Section settings", opens the section's sheet (BlockDialog.tsx).
7
8
  import {
@@ -64,7 +65,8 @@ import { sectionFrame } from './sectionFrame';
64
65
  import { IMAGE, type BlockAsk } from './BlockDialog';
65
66
  import SectionSettingsIcon from './SectionSettingsIcon';
66
67
  import { askLink } from './LinkSheet';
67
- import { Menu, MenuItem, Toolbar, ToolbarButton } from './ui';
68
+ import { atLeastNow, Menu, MenuItem, Toolbar, ToolbarButton } from './ui';
69
+ import { editsInPanel } from './sheetPlacement.mjs';
68
70
  import { shownAt } from './images';
69
71
  import { cropStyle, joinPicture, splitPicture } from '../lib/crop.mjs';
70
72
  import { describe, htmlBlock, type BlockProps } from './blocks';
@@ -283,8 +285,16 @@ const moves = (view: EditorView, i: number | null, locked: string[] = []): CardI
283
285
  };
284
286
 
285
287
  // The row of icons on a card (a block or a picture) in the text, opened by tapping it: items, as
286
- // they are when it opens. Returns how to close it.
287
- function cardMenu(view: EditorView, dom: HTMLElement, card: HTMLElement, name: string, items: () => (CardItem | undefined | '')[]) {
288
+ // they are when it opens (no row when there are none). `tap` runs first on a tap that opens the row (not on one that
289
+ // closes it): from expanded it opens the card's side panel. Returns how to close it.
290
+ function cardMenu(
291
+ view: EditorView,
292
+ dom: HTMLElement,
293
+ card: HTMLElement,
294
+ name: string,
295
+ items: () => (CardItem | undefined | false | '')[],
296
+ tap?: () => void,
297
+ ) {
288
298
  let menu: HTMLElement | null = null;
289
299
  function close() {
290
300
  menu?.remove();
@@ -304,12 +314,13 @@ function cardMenu(view: EditorView, dom: HTMLElement, card: HTMLElement, name: s
304
314
  // `focus` (the arrow used to move the card, when it opens again on the moved card).
305
315
  function open(keyboard: boolean, focus?: string) {
306
316
  closeOpenMenu?.();
317
+ const shown = items().filter((it): it is CardItem => !!it);
318
+ if (!shown.length) return;
307
319
  menu = dom.appendChild(document.createElement('div'));
308
320
  menu.className = 'site-block-menu';
309
321
  menu.setAttribute('role', 'menu');
310
322
  menu.setAttribute('aria-label', name);
311
- for (const it of items()) {
312
- if (!it) continue;
323
+ for (const it of shown) {
313
324
  const b = menu.appendChild(document.createElement('button'));
314
325
  b.type = 'button';
315
326
  b.setAttribute('role', 'menuitem');
@@ -356,7 +367,11 @@ function cardMenu(view: EditorView, dom: HTMLElement, card: HTMLElement, name: s
356
367
  document.addEventListener('pointercancel', unlock, { capture: true, once: true });
357
368
  timer = window.setTimeout(unlock, 1000);
358
369
  });
359
- card.addEventListener('click', (e) => (menu ? close() : open(e.detail === 0)));
370
+ card.addEventListener('click', (e) => {
371
+ if (menu) return close();
372
+ tap?.();
373
+ open(e.detail === 0);
374
+ });
360
375
  return close;
361
376
  }
362
377
 
@@ -397,7 +412,8 @@ function pictureCard(node: Node, view: EditorView, getPos: () => number | undefi
397
412
  if (cut) img.style.cssText = cut.img;
398
413
  if (node.attrs.title) img.title = node.attrs.title;
399
414
  const describe = async () => {
400
- const props = await sheet()?.edit(IMAGE, { src, alt: node.attrs.alt ?? '', format, crop, decorative });
415
+ const props = await sheet()?.edit(IMAGE, { src, alt: node.attrs.alt ?? '', format, crop, decorative }, false, true);
416
+ if (props === 'remove') return remove();
401
417
  const pos = getPos();
402
418
  if (!props || pos === undefined) return;
403
419
  view.dispatch(
@@ -408,14 +424,23 @@ function pictureCard(node: Node, view: EditorView, getPos: () => number | undefi
408
424
  const pos = getPos();
409
425
  if (pos !== undefined) view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize));
410
426
  };
411
- const close = cardMenu(view, dom, card, t.text.image, () => {
412
- const pos = getPos();
413
- return [
414
- { icon: pencilIcon, label: t.common.edit, action: describe },
415
- ...moves(view, pos === undefined ? null : view.state.doc.resolve(pos).index(0), sheet()?.template?.locked),
416
- { icon: trashIcon, label: t.common.remove, action: remove, danger: true },
417
- ];
418
- });
427
+ // From expanded a picture's sheet is the side panel: a tap opens it, and the menu has no pen or trash (Remove is in the panel).
428
+ const inPanelNow = () => editsInPanel(atLeastNow('expanded'), true);
429
+ const close = cardMenu(
430
+ view,
431
+ dom,
432
+ card,
433
+ t.text.image,
434
+ () => {
435
+ const pos = getPos();
436
+ return [
437
+ !inPanelNow() && { icon: pencilIcon, label: t.common.edit, action: describe },
438
+ ...moves(view, pos === undefined ? null : view.state.doc.resolve(pos).index(0), sheet()?.template?.locked),
439
+ !inPanelNow() && { icon: trashIcon, label: t.common.remove, action: remove, danger: true },
440
+ ];
441
+ },
442
+ () => inPanelNow() && void describe(),
443
+ );
419
444
  return { dom, update: () => false, stopEvent: () => true, ignoreMutation: () => true, destroy: close };
420
445
  }
421
446
 
@@ -468,7 +493,7 @@ async function editLink(view: EditorView) {
468
493
  /** What applies where the cursor is, or to all of the selection: 'strong', 'emphasis', 'link',
469
494
  * 'h2', 'h3', 'text' (a paragraph, not in a list or quote), 'bullet', 'ordered' and 'quote'; with a colour
470
495
  * (text-colors.mjs) 'textColor' and the colour's class ('color-accent'). For the toolbar and the selection's menu,
471
- * 'section' and 'section-<colour>' in a top-level H2 (sectionState.mjs), which mark them. */
496
+ * 'section' and 'section-<colour>' in a top-level H1 or H2 (sectionState.mjs), which mark them. */
472
497
  export function activeOf(state: EditorView['state']): Set<string> {
473
498
  const { selection, storedMarks, schema } = state;
474
499
  const { $from, from, to, empty } = selection;
@@ -528,11 +553,11 @@ type Look = {
528
553
  // The levels of the top-level headings of a document, in order.
529
554
  const levelsOf = (doc: EditorView['state']['doc']): number[] => topHeadings(doc).map(({ node }) => node.attrs.level);
530
555
 
531
- // Whether a heading (H2) is selected, or a part of one.
532
- function inH2(state: EditorView['state']) {
556
+ // Whether a heading that starts a section (H1 or H2) is selected, or a part of one.
557
+ function inSectionHeading(state: EditorView['state']) {
533
558
  let found = false;
534
559
  const { from, to } = state.selection;
535
- state.doc.nodesBetween(from, to, (n) => void (found ||= n.type.name === 'heading' && n.attrs.level === 2));
560
+ state.doc.nodesBetween(from, to, (n) => void (found ||= n.type.name === 'heading' && n.attrs.level <= 2));
536
561
  return found;
537
562
  }
538
563
 
@@ -544,7 +569,7 @@ const inLabel = (state: EditorView['state']) => state.selection.$from.parent.typ
544
569
  // page's headings (`h1`: only the levels headingChoices gives for the text), a heading or a subheading, a label above the heading; the ones that apply are marked. At a still
545
570
  // cursor, bold, italic, link and colour act on the whole word, the headings on the line; a move or a keypress hides the
546
571
  // row. On a phone, where the phone's own menu is over the selection and its handles under it, the row is at the bottom
547
- // of the screen, over the keyboard. sections: no colour in a heading (H2), the name of a section.
572
+ // of the screen, over the keyboard. sections: no colour in a heading (H1 or H2), the name of a section.
548
573
  function selectionMenu(ctx: Ctx, h1: boolean, sections: boolean, colors: (button: HTMLElement) => void) {
549
574
  return new Plugin({
550
575
  view(view) {
@@ -588,7 +613,7 @@ function selectionMenu(ctx: Ctx, h1: boolean, sections: boolean, colors: (button
588
613
  label: t.text.color,
589
614
  on: (s) => marked(s, 'textColor'),
590
615
  action: colors,
591
- off: (s) => inLabel(s) || (sections && inH2(s)),
616
+ off: (s) => inLabel(s) || (sections && inSectionHeading(s)),
592
617
  word: true,
593
618
  },
594
619
  ...(h1 ? [1, 2, 3] : [2, 3]).map((level) => ({
@@ -783,8 +808,20 @@ function blockCard(
783
808
  const pos = getPos();
784
809
  return pos === undefined ? null : view.state.doc.resolve(pos).index(0);
785
810
  };
811
+ // What the template (collections.ts) says of this block: a locked one is only edited, and the last shown one of a block
812
+ // the page must have cannot be hidden or removed (stays).
813
+ const keeps = () => {
814
+ const rules = asker()?.template;
815
+ const type = node.attrs.type;
816
+ const locked = !!rules?.locked?.includes(type);
817
+ const shownOfType = blocksOf(view.state.doc).filter((b) => b.type === type && b.hidden === false).length;
818
+ // (Not written with "!" before "hidden", which Tailwind would take for a class for the site.)
819
+ const last = hidden === false && !!rules?.required?.includes(type) && shownOfType <= 1;
820
+ return { rules, locked, stays: locked || last };
821
+ };
786
822
  const edit = async () => {
787
- const props = await asker()?.edit(node.attrs.type, node.attrs.props);
823
+ const props = await asker()?.edit(node.attrs.type, node.attrs.props, false, !keeps().stays);
824
+ if (props === 'remove') return remove();
788
825
  const here = at();
789
826
  // The block's fields as changed; still hidden if it was.
790
827
  if (props && here)
@@ -806,26 +843,29 @@ function blockCard(
806
843
  // The choices that can be made here: no "Ret" without fields (but to where it is edited instead,
807
844
  // Block.edit), no move past the start or end. By the template (collections.ts): a locked block is
808
845
  // only edited, and the last shown one of a block the page must have cannot be hidden or removed.
809
- const close = cardMenu(view, dom, card, name, () => {
810
- const i = top();
811
- const rules = asker()?.template;
812
- const type = node.attrs.type;
813
- const locked = !!rules?.locked?.includes(type);
814
- const shownOfType = blocksOf(view.state.doc).filter((b) => b.type === type && b.hidden === false).length;
815
- // (Not written with "!" before "hidden", which Tailwind would take for a class for the site.)
816
- const last = hidden === false && !!rules?.required?.includes(type) && shownOfType <= 1;
817
- const stays = locked || last;
818
- return [
819
- block?.fields.length
820
- ? { icon: pencilIcon, label: t.common.edit, action: edit }
821
- : block?.edit && { icon: pencilIcon, label: block.edit.text, action: () => leaveTo(`#${block.edit!.to}`) },
822
- block?.shown || (stays && hidden === false)
823
- ? undefined
824
- : { icon: hidden ? eyeIcon : eyeSlashIcon, label: hidden ? t.text.showOnPage : t.text.hideOnPage, action: toggle },
825
- ...(locked ? [] : moves(view, i, rules?.locked)),
826
- stays ? undefined : { icon: trashIcon, label: t.common.remove, action: remove, danger: true },
827
- ];
828
- });
846
+ // From expanded the block's fields are in the side panel: a tap opens it, and the menu has no pen or trash (Remove is in the panel).
847
+ const inPanelNow = () => editsInPanel(atLeastNow('expanded'), !!block?.fields.length);
848
+ const close = cardMenu(
849
+ view,
850
+ dom,
851
+ card,
852
+ name,
853
+ () => {
854
+ const i = top();
855
+ const { rules, locked, stays } = keeps();
856
+ return [
857
+ block?.fields.length
858
+ ? !inPanelNow() && { icon: pencilIcon, label: t.common.edit, action: edit }
859
+ : block?.edit && { icon: pencilIcon, label: block.edit.text, action: () => leaveTo(`#${block.edit!.to}`) },
860
+ block?.shown || (stays && hidden === false)
861
+ ? undefined
862
+ : { icon: hidden ? eyeIcon : eyeSlashIcon, label: hidden ? t.text.showOnPage : t.text.hideOnPage, action: toggle },
863
+ ...(locked ? [] : moves(view, i, rules?.locked)),
864
+ stays || inPanelNow() ? undefined : { icon: trashIcon, label: t.common.remove, action: remove, danger: true },
865
+ ];
866
+ },
867
+ () => inPanelNow() && void edit(),
868
+ );
829
869
  // A changed block is drawn anew (update false); taps on the card and its menu are not ProseMirror's.
830
870
  return {
831
871
  dom,
@@ -1039,7 +1079,7 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
1039
1079
  if (!src) return;
1040
1080
  const sheet = blocks ?? sheetProp;
1041
1081
  const picked = await sheet?.edit(IMAGE, { src, alt: '' }, true);
1042
- if (picked) run(insertImageCommand, { src: pictureSrc(picked), alt: String(picked.alt ?? '').trim() });
1082
+ if (picked && picked !== 'remove') run(insertImageCommand, { src: pictureSrc(picked), alt: String(picked.alt ?? '').trim() });
1043
1083
  }
1044
1084
 
1045
1085
  // Puts the chosen block where the cursor is (a paragraph is split around it), or after the block
@@ -1113,8 +1153,9 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
1113
1153
  : []),
1114
1154
  { label: t.text.undo, icon: ArrowUUpLeft, action: () => run(undoCommand) },
1115
1155
  { label: t.text.redo, icon: ArrowUUpRight, action: () => run(redoCommand) },
1116
- // The settings of the section the cursor's H2 starts (not a fixed page's text, whose H2s are its sections' names; the front page has it).
1117
- ...(framed && active.includes('section') && (blocks ?? sheetProp)
1156
+ // The settings of the section the cursor's H1 or H2 starts (not a fixed page's text, whose H2s are its sections' names; the front
1157
+ // page has it for its H2s, but not for its H1, which stays the banner without a colour).
1158
+ ...(framed && active.includes('section') && !(sections && active.includes('h1')) && (blocks ?? sheetProp)
1118
1159
  ? [{ label: t.text.sectionSettings, icon: SectionSettingsIcon, action: sectionSettings }]
1119
1160
  : []),
1120
1161
  ];
@@ -1168,7 +1209,7 @@ export default forwardRef<MarkdownHandle, Props>(function MarkdownEditor(
1168
1209
  <Toolbar
1169
1210
  ref={toolbar}
1170
1211
  label={t.text.tools}
1171
- className="editor-tools sticky top-[calc(5rem+1px+env(safe-area-inset-top))] z-10 m-2 justify-center-safe overflow-x-auto rounded-panel border border-line medium:flex-wrap"
1212
+ className="editor-tools sticky top-[env(safe-area-inset-top)] z-10 m-2 justify-center-safe overflow-x-auto rounded-panel border border-line medium:flex-wrap"
1172
1213
  >
1173
1214
  {tools.map((tool) => (
1174
1215
  <ReactFragment key={tool.label}>
package/src/Media.tsx CHANGED
@@ -7,27 +7,18 @@
7
7
  // "Slet billede" (…) when nothing uses it (also not the site's own code, /editor/code-images.json). Its
8
8
  // name, under the title, can be changed with the pencil after it (not one the site's code uses),
9
9
  // saved with the tick: the file moves, the pages that use it get the new address in the same commit,
10
- // and the old address sends on to it (addresses.ts). "+" uploads a new file, saved at once. The same
10
+ // and the old address sends on to it (addresses.ts). The FAB uploads a new file, saved at once. The same
11
11
  // pictures (not the other files) are the gallery a picture is chosen from in the forms (useMediaPicker).
12
12
  // The buttons that run (add, rename, delete, upload new) show the loading indicator while they do.
13
13
  // The funnel in Filer also filters by a tag (fileTagFilter.mjs; only tags some file has).
14
14
  // The clock in the top bar, "Slettede filer", opens the files that were deleted (Deleted.tsx).
15
15
  import { useEffect, useMemo, useRef, useState, type ReactElement } from 'react';
16
- import {
17
- ArrowsDownUp,
18
- CalendarBlank,
19
- Check,
20
- ClockCounterClockwise,
21
- PencilSimple,
22
- Plus,
23
- Trash,
24
- UploadSimple,
25
- X,
26
- } from '@phosphor-icons/react';
16
+ import { ArrowsDownUp, CalendarBlank, Check, ClockCounterClockwise, PencilSimple, Trash, UploadSimple, X } from '@phosphor-icons/react';
27
17
  import { moveRedirects, REDIRECTS } from './addresses';
28
18
  import { changesOf, flatten, readAll, REDIRECT_FILES } from './redirectData';
29
19
  import { ask } from './ask';
30
20
  import { dateName, headed, letterOf } from './Sheet';
21
+ import { CreateFab } from './CreateFab';
31
22
  import Screen from './Screen';
32
23
  import {
33
24
  Button,
@@ -689,7 +680,6 @@ export default function Media({ files, env, open }: { files: RepoFile[]; env: En
689
680
 
690
681
  onClick={() => (location.hash = 'slettet/filer')}
691
682
  />
692
- <IconButton icon={Plus} label={t.media.add} busy={adding} disabled={adding} onClick={add} />
693
683
  </>
694
684
  }
695
685
  >
@@ -727,6 +717,7 @@ export default function Media({ files, env, open }: { files: RepoFile[]; env: En
727
717
  <SheetTitle title={src ? fileName(src) : ''} onClose={() => dialog.current?.close()} />
728
718
  {!wide && src && <SheetBody>{details(src)}</SheetBody>}
729
719
  </BottomSheet>
720
+ <CreateFab label={t.media.add} busy={adding} disabled={adding} onClick={add} />
730
721
  </Screen>
731
722
  );
732
723
  }