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.
- package/LICENSE +21 -0
- package/README.md +57 -2
- package/bin/caracms.mjs +174 -0
- package/bin/gate.mjs +275 -0
- package/bin/init.mjs +712 -0
- package/bin/media.mjs +662 -0
- package/bin/site-runner.mjs +61 -0
- package/bin/templates/Page.astro.txt +31 -0
- package/bin/templates/TextBlock.astro.txt +13 -0
- package/bin/templates/caracms.config.ts.txt +22 -0
- package/bin/templates/catch-all.astro.txt +25 -0
- package/bin/templates/health.ts.txt +6 -0
- package/bin/templates/railway.ts.txt +147 -0
- package/package.json +66 -4
- package/skills/caracms-adopt/SKILL.md +508 -0
- package/skills/caracms-adopt/look-same.mjs +957 -0
- package/skills/caracms-blocks/SKILL.md +376 -0
- package/skills/caracms-content/SKILL.md +201 -0
- package/skills/caracms-tina/SKILL.md +427 -0
- package/skills/caracms-tina/field-kinds.md +88 -0
- package/skills/caracms-tina/tina-to-caracms.mjs +1011 -0
- package/src/block-runs.ts +84 -0
- package/src/cards.ts +158 -0
- package/src/cli/schemas.ts +122 -0
- package/src/cli/site.ts +66 -0
- package/src/cli/validate.ts +471 -0
- package/src/code-routes-disk.ts +67 -0
- package/src/code-routes.ts +134 -0
- package/src/columns.ts +39 -0
- package/src/components/Block.astro +46 -0
- package/src/components/BlockRunTree.astro +45 -0
- package/src/components/Blocks.astro +139 -0
- package/src/components/Buttons.astro +116 -0
- package/src/components/Fonts.astro +14 -0
- package/src/components/Form.astro +221 -0
- package/src/components/Head.astro +62 -0
- package/src/components/Link.astro +68 -0
- package/src/components/Lockup.astro +116 -0
- package/src/components/Media.astro +124 -0
- package/src/components/MediaPlayer.astro +102 -0
- package/src/components/RenderDocument.astro +64 -0
- package/src/components/RichText.astro +59 -0
- package/src/components/Text.astro +81 -0
- package/src/components/attributes.ts +33 -0
- package/src/components/descriptor.ts +45 -0
- package/src/components/form-client.ts +182 -0
- package/src/components/media-player.ts +46 -0
- package/src/components/media-props.ts +35 -0
- package/src/components/sitemap.xml.ts +36 -0
- package/src/config.ts +825 -0
- package/src/content-schema.ts +166 -0
- package/src/content.ts +422 -0
- package/src/document.ts +579 -0
- package/src/editor/README.md +405 -0
- package/src/editor/addresses.ts +61 -0
- package/src/editor/brand.ts +118 -0
- package/src/editor/client/block-list/BlockList.tsx +342 -0
- package/src/editor/client/block-list/Picker.tsx +105 -0
- package/src/editor/client/block-list/UnknownBlock.tsx +119 -0
- package/src/editor/client/block-list/insert-points.ts +56 -0
- package/src/editor/client/block-list/link.ts +71 -0
- package/src/editor/client/block-list/reorder.ts +105 -0
- package/src/editor/client/block-list/summary.ts +118 -0
- package/src/editor/client/collections/CollectionGrid.tsx +143 -0
- package/src/editor/client/collections/CollectionTable.tsx +406 -0
- package/src/editor/client/collections/table.ts +270 -0
- package/src/editor/client/context.tsx +27 -0
- package/src/editor/client/documents/DocMenu.tsx +154 -0
- package/src/editor/client/documents/actions.ts +192 -0
- package/src/editor/client/documents/dialogs.tsx +687 -0
- package/src/editor/client/editor.ts +995 -0
- package/src/editor/client/fields/ButtonsField.tsx +87 -0
- package/src/editor/client/fields/DateField.tsx +200 -0
- package/src/editor/client/fields/FieldControl.tsx +61 -0
- package/src/editor/client/fields/FieldFrame.tsx +109 -0
- package/src/editor/client/fields/GroupField.tsx +113 -0
- package/src/editor/client/fields/HiddenToggle.tsx +51 -0
- package/src/editor/client/fields/LinkField.tsx +307 -0
- package/src/editor/client/fields/ListField.tsx +642 -0
- package/src/editor/client/fields/LockupField.tsx +365 -0
- package/src/editor/client/fields/MediaField.tsx +823 -0
- package/src/editor/client/fields/PageFields.tsx +82 -0
- package/src/editor/client/fields/ReferenceField.tsx +209 -0
- package/src/editor/client/fields/RichTextField.tsx +306 -0
- package/src/editor/client/fields/SelectField.tsx +63 -0
- package/src/editor/client/fields/SeoGroup.tsx +282 -0
- package/src/editor/client/fields/SimpleFields.tsx +89 -0
- package/src/editor/client/fields/TextField.tsx +51 -0
- package/src/editor/client/fields/date.ts +73 -0
- package/src/editor/client/fields/host.tsx +172 -0
- package/src/editor/client/fields/list-summary.ts +85 -0
- package/src/editor/client/fields/lockup-rules.ts +181 -0
- package/src/editor/client/fields/parts.tsx +458 -0
- package/src/editor/client/fields/rich-text-commands.ts +183 -0
- package/src/editor/client/fields/rich-text-doc.ts +156 -0
- package/src/editor/client/fields/rich-text-schema.ts +224 -0
- package/src/editor/client/fields/stale.ts +58 -0
- package/src/editor/client/inbox/InboxStage.tsx +272 -0
- package/src/editor/client/inbox/store.ts +311 -0
- package/src/editor/client/live/format.ts +77 -0
- package/src/editor/client/live/marks.tsx +90 -0
- package/src/editor/client/live/notices.tsx +213 -0
- package/src/editor/client/live/publish.tsx +617 -0
- package/src/editor/client/live/session.tsx +162 -0
- package/src/editor/client/main.tsx +12 -0
- package/src/editor/client/media/LibraryStage.tsx +75 -0
- package/src/editor/client/media/ReplaceDialog.tsx +355 -0
- package/src/editor/client/media/store.ts +618 -0
- package/src/editor/client/nav/DocRow.tsx +122 -0
- package/src/editor/client/nav/search.ts +71 -0
- package/src/editor/client/nav/tree.ts +93 -0
- package/src/editor/client/overlays/OverlayMount.tsx +26 -0
- package/src/editor/client/overlays/editor-marks.ts +159 -0
- package/src/editor/client/overlays/index.ts +50 -0
- package/src/editor/client/overlays/layer.ts +437 -0
- package/src/editor/client/overlays/overlay-css.ts +177 -0
- package/src/editor/client/overlays/rich-text-bar.ts +717 -0
- package/src/editor/client/pane/Pane.tsx +91 -0
- package/src/editor/client/pane/levels/BlockLevel.tsx +123 -0
- package/src/editor/client/pane/levels/CollectionLevel.tsx +115 -0
- package/src/editor/client/pane/levels/DocumentLevel.tsx +93 -0
- package/src/editor/client/pane/levels/DocumentsLevel.tsx +390 -0
- package/src/editor/client/pane/levels/FileLevel.tsx +132 -0
- package/src/editor/client/pane/levels/FormLevel.tsx +550 -0
- package/src/editor/client/pane/levels/MediaLevel.tsx +657 -0
- package/src/editor/client/pane/levels/SettingsM6.tsx +299 -0
- package/src/editor/client/pane/levels/SubmissionsLevel.tsx +185 -0
- package/src/editor/client/pane/parts.tsx +179 -0
- package/src/editor/client/pane/switcher.tsx +165 -0
- package/src/editor/client/preview/driver.ts +640 -0
- package/src/editor/client/preview/field-text.ts +71 -0
- package/src/editor/client/preview/follow-links.ts +234 -0
- package/src/editor/client/preview/layout.ts +37 -0
- package/src/editor/client/preview/link-target.ts +50 -0
- package/src/editor/client/preview/markers.ts +86 -0
- package/src/editor/client/preview/page-edit.ts +632 -0
- package/src/editor/client/preview/page-editor-registry.ts +63 -0
- package/src/editor/client/preview/page-editor.ts +235 -0
- package/src/editor/client/preview/render-sequencer.ts +113 -0
- package/src/editor/client/preview/selection.ts +116 -0
- package/src/editor/client/preview/warnings.ts +61 -0
- package/src/editor/client/shell/App.tsx +101 -0
- package/src/editor/client/shell/Bar.tsx +197 -0
- package/src/editor/client/shell/Brand.tsx +33 -0
- package/src/editor/client/shell/Icons.tsx +87 -0
- package/src/editor/client/shell/NarrowNote.tsx +47 -0
- package/src/editor/client/shell/Popovers.tsx +47 -0
- package/src/editor/client/shell/Stage.tsx +132 -0
- package/src/editor/client/shell/Toast.tsx +61 -0
- package/src/editor/client/state/document-store.ts +465 -0
- package/src/editor/client/state/keeper.ts +713 -0
- package/src/editor/client/state/live.ts +257 -0
- package/src/editor/client/state/schema.ts +42 -0
- package/src/editor/client/state/store.ts +39 -0
- package/src/editor/fonts/OFL-GeistMono.txt +93 -0
- package/src/editor/fonts/OFL-InstrumentSans.txt +93 -0
- package/src/editor/fonts/geist-mono-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/geist-mono-latin-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-ext-wght-normal.woff2 +0 -0
- package/src/editor/fonts/instrument-sans-latin-wght-normal.woff2 +0 -0
- package/src/editor/middleware.ts +62 -0
- package/src/editor/routes/_live.ts +26 -0
- package/src/editor/routes/admin.astro +80 -0
- package/src/editor/routes/create.ts +19 -0
- package/src/editor/routes/delete.ts +19 -0
- package/src/editor/routes/document.ts +39 -0
- package/src/editor/routes/documents.ts +30 -0
- package/src/editor/routes/github.ts +23 -0
- package/src/editor/routes/history.ts +9 -0
- package/src/editor/routes/lock.ts +9 -0
- package/src/editor/routes/login/assets.d.ts +10 -0
- package/src/editor/routes/login/login.ts +43 -0
- package/src/editor/routes/media.ts +16 -0
- package/src/editor/routes/move.ts +17 -0
- package/src/editor/routes/notice.ts +9 -0
- package/src/editor/routes/preview.astro +77 -0
- package/src/editor/routes/publish.ts +9 -0
- package/src/editor/routes/status.ts +9 -0
- package/src/editor/routes/submissions.ts +16 -0
- package/src/editor/routes/try-again.ts +9 -0
- package/src/editor/routes/write.ts +18 -0
- package/src/editor/server/admin-props.ts +151 -0
- package/src/editor/server/cards.ts +61 -0
- package/src/editor/server/create.ts +414 -0
- package/src/editor/server/drafts-store.ts +127 -0
- package/src/editor/server/media/alt.ts +107 -0
- package/src/editor/server/media/bucket.ts +295 -0
- package/src/editor/server/media/config.ts +78 -0
- package/src/editor/server/media/handler.ts +221 -0
- package/src/editor/server/media/library.ts +254 -0
- package/src/editor/server/media/memory.ts +184 -0
- package/src/editor/server/media/mux.ts +241 -0
- package/src/editor/server/media/sniff.ts +599 -0
- package/src/editor/server/media/upload.ts +175 -0
- package/src/editor/server/mode.ts +63 -0
- package/src/editor/server/move.ts +295 -0
- package/src/editor/server/render.tsx +9 -0
- package/src/editor/server/replace.ts +72 -0
- package/src/editor/server/settings.ts +35 -0
- package/src/editor/server/write.ts +366 -0
- package/src/editor/styles/editor.css +502 -0
- package/src/editor/styles/fonts.css +40 -0
- package/src/editor/styles/levels/README.md +4 -0
- package/src/editor/styles/levels/collections.css +99 -0
- package/src/editor/styles/levels/document-level.css +10 -0
- package/src/editor/styles/levels/documents.css +9 -0
- package/src/editor/styles/levels/fields.css +171 -0
- package/src/editor/styles/levels/files.css +95 -0
- package/src/editor/styles/levels/live.css +10 -0
- package/src/editor/styles/levels/m5a-documents.css +51 -0
- package/src/editor/styles/levels/m6.css +82 -0
- package/src/editor/styles/levels/media.css +175 -0
- package/src/editor/styles/levels/switcher.css +3 -0
- package/src/editor/styles/tokens.css +114 -0
- package/src/editor/tsconfig.json +16 -0
- package/src/editor/types.ts +881 -0
- package/src/editor/ui-state.ts +113 -0
- package/src/email.ts +67 -0
- package/src/fields.ts +1129 -0
- package/src/files-route.ts +201 -0
- package/src/files.ts +116 -0
- package/src/forms/email.ts +149 -0
- package/src/forms/endpoint.ts +347 -0
- package/src/forms/inbox.ts +273 -0
- package/src/forms/rate-limit.ts +130 -0
- package/src/forms/route.ts +24 -0
- package/src/forms/rules.ts +278 -0
- package/src/forms/store.ts +486 -0
- package/src/head.ts +125 -0
- package/src/index.ts +434 -0
- package/src/json-source.ts +116 -0
- package/src/links.ts +121 -0
- package/src/live/README.md +43 -0
- package/src/live/auth/allow-rules.ts +70 -0
- package/src/live/auth/allow.ts +128 -0
- package/src/live/auth/auth.ts +155 -0
- package/src/live/auth/email.ts +84 -0
- package/src/live/auth/guard.ts +111 -0
- package/src/live/auth/index.ts +68 -0
- package/src/live/auth/links.ts +101 -0
- package/src/live/auth/login.ts +245 -0
- package/src/live/auth/middleware.ts +53 -0
- package/src/live/auth/request.ts +56 -0
- package/src/live/auth/routes.ts +9 -0
- package/src/live/auth/session.ts +181 -0
- package/src/live/auth/views.ts +355 -0
- package/src/live/autosaves.ts +306 -0
- package/src/live/backend.ts +652 -0
- package/src/live/branches.ts +19 -0
- package/src/live/drafts-store.ts +591 -0
- package/src/live/editor.ts +465 -0
- package/src/live/git.ts +1021 -0
- package/src/live/github.ts +271 -0
- package/src/live/locals.ts +24 -0
- package/src/live/locks.ts +273 -0
- package/src/live/merge.ts +197 -0
- package/src/live/messages.ts +151 -0
- package/src/live/mode.ts +210 -0
- package/src/live/redis.ts +533 -0
- package/src/live/runtime.ts +164 -0
- package/src/live/status.ts +109 -0
- package/src/live/sweep.ts +228 -0
- package/src/live/timings.ts +47 -0
- package/src/live/webhook.ts +39 -0
- package/src/lockup-css.ts +58 -0
- package/src/measure.ts +145 -0
- package/src/media-value.ts +239 -0
- package/src/media.ts +255 -0
- package/src/read.ts +256 -0
- package/src/redirect-middleware.ts +49 -0
- package/src/redirects.ts +76 -0
- package/src/relink.ts +293 -0
- package/src/reserved.ts +24 -0
- package/src/rich-text.ts +690 -0
- package/src/schema-key.ts +63 -0
- package/src/seo.ts +124 -0
- package/src/serialize.ts +697 -0
- package/src/skipped.ts +35 -0
- package/src/slug.ts +121 -0
- package/src/text-styles.ts +62 -0
- package/src/virtual.d.ts +28 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The formatting bar of rich text typed on the page (on-page rich text, Decision 9;
|
|
3
|
+
* the mockup's RT states), and the link popover it opens. Plain DOM in the overlay
|
|
4
|
+
* layer of the iframe being typed in (`#cms-overlays > .cms-rt`, outside `<body>`, so
|
|
5
|
+
* the morph never sees it), so focus moving between the text, the bar and the
|
|
6
|
+
* popover's search never leaves that iframe: page-edit keeps the typing session on.
|
|
7
|
+
*
|
|
8
|
+
* - **Shown whenever the text has focus** (a caret or a selection) or focus is in the
|
|
9
|
+
* bar's popover: while page-edit's `pageEditor` has a session. Torn down with the
|
|
10
|
+
* page editor (read-only, another document, the width, the level closing).
|
|
11
|
+
* - **The field's controls only:** the paragraph style (when the field allows headings:
|
|
12
|
+
* Paragraph, Heading 2, and Heading 3 where allowed), Bold, Italic, Bulleted list,
|
|
13
|
+
* Numbered list, Link (when it allows links), each with its active state. They run
|
|
14
|
+
* the sidebar's commands (rich-text-commands.ts, the schema's marks), and never take
|
|
15
|
+
* focus from the text (mousedown is prevented: the selection stays).
|
|
16
|
+
* - **Placement:** above the block the caret is in (a paragraph, a heading, or a whole
|
|
17
|
+
* list), lined up with the field's left edge, 12px clear on screen; with no room
|
|
18
|
+
* above, below the block; for a block taller than the frame, by the caret's own line.
|
|
19
|
+
* **It moves only when the caret changes block**, not as you type, wrap or select,
|
|
20
|
+
* unless the block grows under it (it never covers the block being typed). Inside the
|
|
21
|
+
* frame; sizes are `calc(var(--k) * Npx)`, positions in document coordinates.
|
|
22
|
+
* - **The style menu and the link popover** open on the bar's side away from the text,
|
|
23
|
+
* or past the block when there's no room that way, never over the block.
|
|
24
|
+
* - **Link and ⌘K** open the popover: the sidebar's document list and search
|
|
25
|
+
* (parts.tsx → linkHits), the link rule's message under the search, an address
|
|
26
|
+
* that fails it kept if used; with nothing selected, picking inserts the document's
|
|
27
|
+
* title as the link's text (as RichTextField.tsx does). Enter picks the highlighted
|
|
28
|
+
* row; Esc (or ⌘K) closes it and returns to the text with its selection.
|
|
29
|
+
* - **M6c G4 (Decision 7): File…** beside the search lists the library's files in the
|
|
30
|
+
* popover (found by name; Upload… too); picking one links the words to its address
|
|
31
|
+
* (with nothing selected, the file's name is the link's text), and the popover stays
|
|
32
|
+
* on the file: its name, type and size, and for a PDF New tab / Download (`?download`).
|
|
33
|
+
* - **The linked words keep a wash** (`--select-halo`) while the popover is open:
|
|
34
|
+
* ProseMirror's selection doesn't paint once focus is in the search.
|
|
35
|
+
* - **Esc order:** the menu or popover first (here), then the field, then the level
|
|
36
|
+
* (page-edit.ts, which skips an Esc this prevented).
|
|
37
|
+
*
|
|
38
|
+
* Layer redraws leave it alone (`data-cms-keep`, layer.ts): taking out a node that
|
|
39
|
+
* holds focus would blur the popover's search and end the session.
|
|
40
|
+
*/
|
|
41
|
+
import type { Editor as TiptapEditor } from '@tiptap/core';
|
|
42
|
+
import { fileAddress, fileKeyOf, fileTypeOf } from '../../../files.ts';
|
|
43
|
+
import { validateLink } from '../../../links.ts';
|
|
44
|
+
import type { ListedDocument } from '../../types.ts';
|
|
45
|
+
import { fileSize, mediaFor } from '../media/store.ts';
|
|
46
|
+
import type { Editor } from '../editor.ts';
|
|
47
|
+
import { docIcon } from '../nav/DocRow.tsx';
|
|
48
|
+
import { documentAt, firstHit, linkHits, type LinkHit } from '../fields/parts.tsx';
|
|
49
|
+
import { setBlockStyle, toggleList, type BlockStyle } from '../fields/rich-text-commands.ts';
|
|
50
|
+
import type { PageEdit, PageEditorState } from '../preview/page-edit.ts';
|
|
51
|
+
import { KEEP, type Overlays } from './layer.ts';
|
|
52
|
+
|
|
53
|
+
const STYLES = [
|
|
54
|
+
{ v: 'p', name: 'Paragraph', cls: 'st-p' },
|
|
55
|
+
{ v: 'h2', name: 'Heading 2', cls: 'st-h2' },
|
|
56
|
+
{ v: 'h3', name: 'Heading 3', cls: 'st-h3' },
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
/** On screen, in px (× k in the frame): the frame's margin, the bar's clearance from the block, the menu's from the bar. */
|
|
60
|
+
const EDGE = 8;
|
|
61
|
+
const GAP = 12;
|
|
62
|
+
const MENU_GAP = 6;
|
|
63
|
+
const POPOVER_WIDTH = 320;
|
|
64
|
+
|
|
65
|
+
const isLinkKey = (event: KeyboardEvent) => (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k';
|
|
66
|
+
|
|
67
|
+
const esc = (s: string) => s.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!);
|
|
68
|
+
|
|
69
|
+
/** parts.tsx's extra icons (the shell's sprite doesn't have them), as markup. */
|
|
70
|
+
const EXTRA: Record<string, string> = {
|
|
71
|
+
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"/>',
|
|
72
|
+
ul: '<path d="M6 4h8M6 8h8M6 12h8"/><g fill="currentColor" stroke="none"><circle cx="2.8" cy="4" r="1"/><circle cx="2.8" cy="8" r="1"/><circle cx="2.8" cy="12" r="1"/></g>',
|
|
73
|
+
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"/>',
|
|
74
|
+
file: '<path d="M4 1.5h5.5L12.5 4.5v10H4zM9.5 1.5v3h3M6 8.5h4M6 11h4"/>',
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** An icon drawn inline: the iframe can't reach /admin's sprite, so the symbol's shapes are copied from it. */
|
|
78
|
+
function icon(name: string): string {
|
|
79
|
+
const shapes = EXTRA[name] ?? document.getElementById(name.startsWith('b-') ? name : `i-${name}`)?.innerHTML;
|
|
80
|
+
return shapes ? `<svg class="cms-i" viewBox="0 0 16 16" aria-hidden="true">${shapes}</svg>` : '';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
interface Active {
|
|
84
|
+
bold: boolean;
|
|
85
|
+
italic: boolean;
|
|
86
|
+
link: boolean;
|
|
87
|
+
list: 'ul' | 'ol' | null;
|
|
88
|
+
style: BlockStyle;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function activeOf(ed: TiptapEditor): Active {
|
|
92
|
+
return {
|
|
93
|
+
bold: ed.isActive('bold'),
|
|
94
|
+
italic: ed.isActive('italic'),
|
|
95
|
+
link: !!ed.schema.marks.link && ed.isActive('link'),
|
|
96
|
+
list: ed.isActive('bulletList') ? 'ul' : ed.isActive('orderedList') ? 'ol' : null,
|
|
97
|
+
style: ed.isActive('heading', { level: 2 }) ? 'h2' : ed.isActive('heading', { level: 3 }) ? 'h3' : 'p',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The bar's DOM in one frame's layer, while it shows. */
|
|
102
|
+
interface Shown {
|
|
103
|
+
state: PageEditorState;
|
|
104
|
+
doc: Document;
|
|
105
|
+
win: Window;
|
|
106
|
+
root: HTMLElement;
|
|
107
|
+
holds: HTMLElement;
|
|
108
|
+
wrap: HTMLElement;
|
|
109
|
+
bar: HTMLElement;
|
|
110
|
+
menu: HTMLElement | null;
|
|
111
|
+
pop: HTMLElement | null;
|
|
112
|
+
input: HTMLInputElement | null;
|
|
113
|
+
/** The options the bar's buttons were built for. */
|
|
114
|
+
built: string;
|
|
115
|
+
/** Where the bar was put, and for which block (it moves when the caret changes block). */
|
|
116
|
+
placed: { block: Element; index: number; mode: 'above' | 'below' | 'line'; above: boolean; left: number; top: number } | null;
|
|
117
|
+
stop: () => void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Keep the bar up to date with the page editor. Returns a stop. */
|
|
121
|
+
export function startRichTextBar(editor: Editor, overlays: Overlays, pageEdit: PageEdit): () => void {
|
|
122
|
+
let shown: Shown | null = null;
|
|
123
|
+
/** What's open from the bar. */
|
|
124
|
+
let menu = false;
|
|
125
|
+
/** `files`: File… is showing the library's files (M6c G4). */
|
|
126
|
+
let link: { query: string; editing: boolean; files?: boolean } | null = null;
|
|
127
|
+
/** The browser's file dialog is open from the popover's Upload…: its focus going elsewhere doesn't close it. */
|
|
128
|
+
let choosing = false;
|
|
129
|
+
/** An upload from the popover, its row while it goes. */
|
|
130
|
+
let uploading: { tag: string; name: string } | null = null;
|
|
131
|
+
let uploadError: string | null = null;
|
|
132
|
+
const media = mediaFor(editor);
|
|
133
|
+
const stopFiles = [media.files.subscribe(() => drawLinkBody()), media.uploads.subscribe(() => uploading && drawLinkBody())];
|
|
134
|
+
|
|
135
|
+
const linkable = (): ListedDocument[] => editor.documents.get().filter((d) => typeof d.url === 'string');
|
|
136
|
+
|
|
137
|
+
function hide() {
|
|
138
|
+
const was = shown;
|
|
139
|
+
shown = null;
|
|
140
|
+
menu = false;
|
|
141
|
+
link = null;
|
|
142
|
+
if (!was) return;
|
|
143
|
+
was.stop();
|
|
144
|
+
was.root.remove();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function show(state: PageEditorState) {
|
|
148
|
+
const frame = state.frame;
|
|
149
|
+
const doc = frame.document;
|
|
150
|
+
const win = frame.window;
|
|
151
|
+
if (!doc || !win) return;
|
|
152
|
+
let layer = overlays.layerOf(frame);
|
|
153
|
+
if (!layer) {
|
|
154
|
+
overlays.flush();
|
|
155
|
+
layer = overlays.layerOf(frame);
|
|
156
|
+
}
|
|
157
|
+
if (!layer) return;
|
|
158
|
+
const root = doc.createElement('div');
|
|
159
|
+
root.className = 'cms-rt';
|
|
160
|
+
root.setAttribute(KEEP, '');
|
|
161
|
+
const holds = doc.createElement('div');
|
|
162
|
+
holds.className = 'cms-holds';
|
|
163
|
+
const wrap = doc.createElement('div');
|
|
164
|
+
wrap.className = 'cms-rtw';
|
|
165
|
+
const bar = doc.createElement('div');
|
|
166
|
+
bar.className = 'cms-rtbar';
|
|
167
|
+
bar.setAttribute('role', 'toolbar');
|
|
168
|
+
bar.setAttribute('aria-label', 'Formatting');
|
|
169
|
+
wrap.appendChild(bar);
|
|
170
|
+
root.append(holds, wrap);
|
|
171
|
+
layer.appendChild(root);
|
|
172
|
+
|
|
173
|
+
// The bar's buttons never take focus from the text (the selection stays); the search does.
|
|
174
|
+
const onDown = (event: MouseEvent) => {
|
|
175
|
+
if (!(event.target as Element).closest?.('input')) event.preventDefault();
|
|
176
|
+
};
|
|
177
|
+
const onClick = (event: MouseEvent) => {
|
|
178
|
+
const button = (event.target as Element).closest?.('button');
|
|
179
|
+
if (!button || !shown) return;
|
|
180
|
+
event.preventDefault();
|
|
181
|
+
act(button as HTMLButtonElement);
|
|
182
|
+
};
|
|
183
|
+
wrap.addEventListener('mousedown', onDown);
|
|
184
|
+
wrap.addEventListener('click', onClick);
|
|
185
|
+
const ed = state.editor;
|
|
186
|
+
const onChange = () => update();
|
|
187
|
+
ed.on('transaction', onChange);
|
|
188
|
+
ed.on('focus', onChange);
|
|
189
|
+
shown = {
|
|
190
|
+
state,
|
|
191
|
+
doc,
|
|
192
|
+
win,
|
|
193
|
+
root,
|
|
194
|
+
holds,
|
|
195
|
+
wrap,
|
|
196
|
+
bar,
|
|
197
|
+
menu: null,
|
|
198
|
+
pop: null,
|
|
199
|
+
input: null,
|
|
200
|
+
built: '',
|
|
201
|
+
placed: null,
|
|
202
|
+
stop() {
|
|
203
|
+
wrap.removeEventListener('mousedown', onDown);
|
|
204
|
+
wrap.removeEventListener('click', onClick);
|
|
205
|
+
ed.off('transaction', onChange);
|
|
206
|
+
ed.off('focus', onChange);
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
update();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The bar's buttons, for the field's options (built once per set of options). */
|
|
213
|
+
function build(s: Shown) {
|
|
214
|
+
const { headings, links, heading } = s.state.options;
|
|
215
|
+
const key = `${headings.join(',')}|${links}|${!!heading}`;
|
|
216
|
+
if (s.built === key) return;
|
|
217
|
+
s.built = key;
|
|
218
|
+
const sep = '<span class="cms-rtsep" aria-hidden="true"></span>';
|
|
219
|
+
const button = (cmd: string, label: string, title: string, inner: string, extra = '') =>
|
|
220
|
+
`<button type="button" data-rtc="${cmd}" aria-label="${label}" title="${title}" aria-pressed="false"${extra}>${inner}</button>`;
|
|
221
|
+
s.bar.innerHTML =
|
|
222
|
+
(headings.length
|
|
223
|
+
? `<button type="button" class="cms-st" data-rtc="style" aria-haspopup="menu" aria-expanded="false" aria-label="Paragraph style: Paragraph">Paragraph${icon('chev-d')}</button>${sep}`
|
|
224
|
+
: '') +
|
|
225
|
+
button('bold', 'Bold', 'Bold (⌘B)', '<b>B</b>') +
|
|
226
|
+
// M9 Decision 11: a heading field is bold and line breaks only.
|
|
227
|
+
(heading
|
|
228
|
+
? ''
|
|
229
|
+
: button('italic', 'Italic', 'Italic (⌘I)', '<i>I</i>') + sep + button('ul', 'Bulleted list', 'Bulleted list', icon('ul')) + button('ol', 'Numbered list', 'Numbered list', icon('ol'))) +
|
|
230
|
+
(links ? sep + button('link', 'Link', 'Link (⌘K)', icon('link'), ' aria-haspopup="dialog"') : '');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Active states, what's open, where it all sits. */
|
|
234
|
+
function update() {
|
|
235
|
+
const s = shown;
|
|
236
|
+
if (!s) return;
|
|
237
|
+
const ed = s.state.editor;
|
|
238
|
+
if (ed.isDestroyed) return;
|
|
239
|
+
build(s);
|
|
240
|
+
const a = activeOf(ed);
|
|
241
|
+
const set = (cmd: string, name: string, on: boolean) => s.bar.querySelector(`[data-rtc="${cmd}"]`)?.setAttribute(name, String(on));
|
|
242
|
+
set('bold', 'aria-pressed', a.bold);
|
|
243
|
+
set('italic', 'aria-pressed', a.italic);
|
|
244
|
+
set('ul', 'aria-pressed', a.list === 'ul');
|
|
245
|
+
set('ol', 'aria-pressed', a.list === 'ol');
|
|
246
|
+
set('link', 'aria-pressed', !!link || a.link);
|
|
247
|
+
const style = s.bar.querySelector<HTMLElement>('[data-rtc="style"]');
|
|
248
|
+
if (style) {
|
|
249
|
+
const name = STYLES.find((x) => x.v === a.style)!.name;
|
|
250
|
+
style.removeAttribute('aria-pressed');
|
|
251
|
+
style.setAttribute('aria-expanded', String(menu));
|
|
252
|
+
style.setAttribute('aria-label', `Paragraph style: ${name}`);
|
|
253
|
+
// The style's name is the button's first child, a text node (the mockup's: no wrapper around it).
|
|
254
|
+
const label = style.firstChild;
|
|
255
|
+
if (label?.nodeType === 3 && label.textContent !== name) label.textContent = name;
|
|
256
|
+
}
|
|
257
|
+
drawMenu(s, a);
|
|
258
|
+
place(s);
|
|
259
|
+
drawHolds(s);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function drawMenu(s: Shown, a: Active) {
|
|
263
|
+
if (!menu) {
|
|
264
|
+
s.menu?.remove();
|
|
265
|
+
s.menu = null;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const allowed = STYLES.filter((x) => x.v === 'p' || s.state.options.headings.includes(x.v));
|
|
269
|
+
const html =
|
|
270
|
+
allowed
|
|
271
|
+
.map(
|
|
272
|
+
(x) =>
|
|
273
|
+
`<button type="button" role="menuitemradio" aria-checked="${a.style === x.v}" data-rtc="set" data-v="${x.v}"><span class="${x.cls}">${x.name}</span>${a.style === x.v ? `<span class="k">${icon('check')}</span>` : ''}</button>`,
|
|
274
|
+
)
|
|
275
|
+
.join('');
|
|
276
|
+
if (!s.menu) {
|
|
277
|
+
s.menu = s.doc.createElement('div');
|
|
278
|
+
s.menu.className = 'cms-rtmenu';
|
|
279
|
+
s.menu.setAttribute('role', 'menu');
|
|
280
|
+
s.menu.setAttribute('aria-label', 'Paragraph style');
|
|
281
|
+
s.wrap.appendChild(s.menu);
|
|
282
|
+
}
|
|
283
|
+
if (s.menu.innerHTML !== html) s.menu.innerHTML = html;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---- The link popover ----
|
|
287
|
+
|
|
288
|
+
function openLink() {
|
|
289
|
+
const s = shown;
|
|
290
|
+
if (!s || !s.state.options.links) return;
|
|
291
|
+
const ed = s.state.editor;
|
|
292
|
+
const href = ed.getAttributes('link').href as string | undefined;
|
|
293
|
+
menu = false;
|
|
294
|
+
link = { query: href ?? '', editing: href !== undefined };
|
|
295
|
+
const pop = s.doc.createElement('div');
|
|
296
|
+
pop.className = 'cms-lpop';
|
|
297
|
+
pop.setAttribute('role', 'dialog');
|
|
298
|
+
pop.setAttribute('aria-label', 'Where the link goes');
|
|
299
|
+
pop.innerHTML = `<div class="cms-top"><div class="find">${icon('search')}<input value="" placeholder="Find a page, or type an address" aria-label="Find a page, or type an address" autocomplete="off" spellcheck="false" data-link-find=""></div><button type="button" class="cms-btn" data-files="" title="Link to a file in the library">${icon('file')}File…</button></div><div class="cms-lpop-body"></div>`;
|
|
300
|
+
const input = pop.querySelector<HTMLInputElement>('input[data-link-find]')!;
|
|
301
|
+
// Upload…'s file input, beside the popover (not in it: its own controls are the popover's).
|
|
302
|
+
s.wrap.querySelector('input[data-rt-file]')?.remove();
|
|
303
|
+
const chooser = s.doc.createElement('input');
|
|
304
|
+
chooser.type = 'file';
|
|
305
|
+
chooser.hidden = true;
|
|
306
|
+
chooser.accept = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.csv,.zip';
|
|
307
|
+
chooser.setAttribute('data-rt-file', '');
|
|
308
|
+
s.wrap.appendChild(chooser);
|
|
309
|
+
chooser.addEventListener('change', () => {
|
|
310
|
+
choosing = false;
|
|
311
|
+
const file = chooser.files?.[0];
|
|
312
|
+
chooser.value = '';
|
|
313
|
+
if (file) void uploadFromPopover(file);
|
|
314
|
+
});
|
|
315
|
+
// A link to a file opens on the file (its name, and for a PDF how it opens).
|
|
316
|
+
input.value = fileKeyOf(link.query) && link.editing ? '' : link.query;
|
|
317
|
+
input.addEventListener('input', () => {
|
|
318
|
+
if (!link) return;
|
|
319
|
+
link = link.files ? { ...link, files: true } : { ...link, query: input.value };
|
|
320
|
+
drawLinkBody();
|
|
321
|
+
place(shown!);
|
|
322
|
+
});
|
|
323
|
+
input.addEventListener('keydown', (event) => {
|
|
324
|
+
if (event.isComposing) return;
|
|
325
|
+
if (event.key === 'Enter') {
|
|
326
|
+
event.preventDefault();
|
|
327
|
+
if (link?.files) {
|
|
328
|
+
const first = shownFiles()[0];
|
|
329
|
+
if (first) pick(fileAddress(first.key), first.name);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const hit = firstHit(linkHits(link?.query ?? '', editor.documents.get(), editor.site.collections));
|
|
333
|
+
if (hit) pick(hit.href);
|
|
334
|
+
} else if (event.key === 'Escape' || isLinkKey(event)) {
|
|
335
|
+
// Claimed, so page-edit's Esc (leave the field, go up a level) skips it.
|
|
336
|
+
event.preventDefault();
|
|
337
|
+
pageEdit.claimKey(event);
|
|
338
|
+
closeLink(true);
|
|
339
|
+
}
|
|
340
|
+
});
|
|
341
|
+
// Focus going anywhere but the popover (the text, out of the frame) closes it.
|
|
342
|
+
pop.addEventListener('focusout', (event) => {
|
|
343
|
+
const to = event.relatedTarget as Node | null;
|
|
344
|
+
if (choosing) return;
|
|
345
|
+
if (!to || !pop.contains(to)) closeLink(false);
|
|
346
|
+
});
|
|
347
|
+
s.pop = pop;
|
|
348
|
+
s.input = input;
|
|
349
|
+
s.wrap.appendChild(pop);
|
|
350
|
+
drawLinkBody();
|
|
351
|
+
update();
|
|
352
|
+
input.focus({ preventScroll: true });
|
|
353
|
+
input.setSelectionRange(input.value.length, input.value.length);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** The library's files the popover lists: found by name, as typed in the search. */
|
|
357
|
+
function shownFiles() {
|
|
358
|
+
const q = (shown?.input?.value ?? '').trim().toLowerCase();
|
|
359
|
+
return (media.files.get().list ?? []).filter((f) => !q || f.name.toLowerCase().includes(q));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function uploadFromPopover(file: File) {
|
|
363
|
+
const tag = `rt-page-file-${Date.now()}`;
|
|
364
|
+
uploading = { tag, name: file.name };
|
|
365
|
+
uploadError = null;
|
|
366
|
+
drawLinkBody();
|
|
367
|
+
const sent = await media.uploadFile(file, tag);
|
|
368
|
+
uploading = null;
|
|
369
|
+
if (sent.ok) pick(fileAddress(sent.file.key), sent.file.name);
|
|
370
|
+
else {
|
|
371
|
+
uploadError = sent.error || null;
|
|
372
|
+
drawLinkBody();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** M6c G4: the popover's body for files (File…), or for a link to a file. Null: the pages' body. */
|
|
377
|
+
function fileBody(): string | null {
|
|
378
|
+
if (!link) return null;
|
|
379
|
+
const tile = '<span class="cms-ftype" aria-hidden="true"></span>';
|
|
380
|
+
if (link.files) {
|
|
381
|
+
const all = media.files.get();
|
|
382
|
+
const list = shownFiles();
|
|
383
|
+
const q = (shown?.input?.value ?? '').trim();
|
|
384
|
+
let rows = `<div class="sw-h">Files</div>`;
|
|
385
|
+
const up = uploading ? media.uploads.get().find((u) => u.id === uploading!.tag) : null;
|
|
386
|
+
if (uploading) rows += `<div class="drow" role="status">${tile}<span class="nm">${esc(uploading.name)}</span><span class="meta">Uploading · ${up?.pct ?? 0}%</span></div>`;
|
|
387
|
+
if (uploadError) rows += `<p class="cms-msg" role="status">${icon('warn')}<span>${esc(uploadError)}</span></p>`;
|
|
388
|
+
if (all.unset) rows += `<div class="sw-more">${esc(all.unset.error)}</div>`;
|
|
389
|
+
rows += list
|
|
390
|
+
.slice(0, 8)
|
|
391
|
+
.map((f, i) => `<button type="button" class="drow${i === 0 && q ? ' is-active' : ''}" data-file="${esc(f.key)}">${tile}<span class="nm">${esc(f.name)}</span><span class="meta">${esc(f.type)}${f.bytes !== null ? ` · ${esc(fileSize(f.bytes))}` : ''}</span></button>`)
|
|
392
|
+
.join('');
|
|
393
|
+
if (list.length > 8) rows += `<div class="sw-more">${list.length - 8} more: keep typing</div>`;
|
|
394
|
+
if (all.list && list.length === 0) rows += `<div class="sw-more">${q ? `No file called “${esc(q)}”.` : 'No files yet.'}</div>`;
|
|
395
|
+
rows += `<button type="button" class="drow" data-upload="">${icon('upload')}<span class="nm">Upload a file…</span></button><button type="button" class="drow" data-pages="">${icon('chev-l')}<span class="nm">Back to pages</span></button>`;
|
|
396
|
+
return `<div class="cms-lpop-list">${rows}</div>`;
|
|
397
|
+
}
|
|
398
|
+
const file = link.editing ? fileKeyOf(link.query.trim()) : null;
|
|
399
|
+
if (!file) return null;
|
|
400
|
+
const entry = media.files.get().list?.find((f) => f.key === file.key);
|
|
401
|
+
const missing = !!media.files.get().list && !entry;
|
|
402
|
+
const type = fileTypeOf(file.key)!;
|
|
403
|
+
const name = entry?.name ?? file.key.slice(file.key.lastIndexOf('/') + 1);
|
|
404
|
+
const facts = missing ? "isn't in the library" : `${type.label}${entry?.bytes != null ? ` · ${fileSize(entry.bytes)}` : ''}`;
|
|
405
|
+
let html = `<div class="cms-picked" data-picked-file="${esc(file.key)}">${missing ? icon('warn') : tile}<span class="ft"><b>${esc(name)}</b><small>${esc(facts)}</small></span><button type="button" class="cms-ib" data-remove="" aria-label="Remove the link">${icon('x')}</button></div>`;
|
|
406
|
+
if (type.opensInTab && !missing) {
|
|
407
|
+
html += `<div class="cms-opens"><span class="lbl">When clicked</span><span class="cms-seg" role="radiogroup" aria-label="When clicked"><button type="button" role="radio" aria-checked="${!file.download}" data-opens="tab">${icon('ext')}New tab</button><button type="button" role="radio" aria-checked="${file.download}" data-opens="download">${icon('download')}Download</button></span></div>`;
|
|
408
|
+
}
|
|
409
|
+
const foot = missing
|
|
410
|
+
? 'This link goes nowhere until it points at a file in the library.'
|
|
411
|
+
: !type.opensInTab
|
|
412
|
+
? `A ${esc(type.label)} file doesn’t open in the browser: on the site, clicking it saves the file.`
|
|
413
|
+
: null;
|
|
414
|
+
return foot ? `${html}<div class="cms-lpop-foot">${foot}</div>` : html;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function drawLinkBody() {
|
|
418
|
+
const s = shown;
|
|
419
|
+
if (!s?.pop || !link) return;
|
|
420
|
+
const files = fileBody();
|
|
421
|
+
if (files !== null) {
|
|
422
|
+
s.pop.querySelector('.cms-lpop-body')!.innerHTML = files;
|
|
423
|
+
s.input!.placeholder = link.files ? 'Find a file' : 'Find a page, or type an address';
|
|
424
|
+
s.input!.setAttribute('aria-label', s.input!.placeholder);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
s.input!.placeholder = 'Find a page, or type an address';
|
|
428
|
+
s.input!.setAttribute('aria-label', s.input!.placeholder);
|
|
429
|
+
const hits = linkHits(link.query, editor.documents.get(), editor.site.collections);
|
|
430
|
+
const { address, groups } = hits;
|
|
431
|
+
const rule = address ? validateLink(address.href) : undefined;
|
|
432
|
+
const collections = editor.site.collections;
|
|
433
|
+
const docIconOf = (doc: ListedDocument | null) => docIcon(collections.find((c) => c.name === doc?.collection));
|
|
434
|
+
const row = (h: LinkHit, active: boolean) =>
|
|
435
|
+
`<button type="button" class="drow${active ? ' is-active' : ''}" data-href="${esc(h.href)}">${icon(docIconOf(h.doc))}<span class="nm">${h.crumb ? `<span class="crumb">${esc(h.crumb)} / </span>` : ''}${esc(h.doc?.label || h.doc?.title || '')}</span><span class="meta"><span class="mono">${esc(h.href)}</span></span></button>`;
|
|
436
|
+
let list = '';
|
|
437
|
+
if (address) {
|
|
438
|
+
const kind = address.href.startsWith('mailto:') ? 'mail' : address.href.startsWith('/') ? 'page' : 'ext';
|
|
439
|
+
list += `<button type="button" class="drow is-active" data-href="${esc(address.href)}">${icon(kind)}<span class="nm">Link to <span class="mono">${esc(address.href)}</span></span></button>`;
|
|
440
|
+
}
|
|
441
|
+
for (const g of groups) {
|
|
442
|
+
list += `<div class="sw-h">${esc(g.label)}</div>`;
|
|
443
|
+
list += g.hits.map((h) => row(h, !address && h === groups[0]!.hits[0])).join('');
|
|
444
|
+
if (g.more > 0) list += `<div class="sw-more">${g.more} more: keep typing</div>`;
|
|
445
|
+
}
|
|
446
|
+
if (!address && groups.length === 0) list += `<div class="sw-more">No page called “${esc(link.query.trim())}”.</div>`;
|
|
447
|
+
if (link.editing) list += `<button type="button" class="drow" data-remove="">${icon('x')}<span class="nm">Remove link</span></button>`;
|
|
448
|
+
const msg = rule ? `<p class="cms-msg" id="cms-rt-msg" role="status">${icon('warn')}<span>${esc(rule)}</span></p>` : '';
|
|
449
|
+
const body = s.pop.querySelector('.cms-lpop-body')!;
|
|
450
|
+
body.innerHTML = `${msg}<div class="cms-lpop-list">${list}</div><div class="cms-lpop-foot">Or type an address.</div>`;
|
|
451
|
+
if (rule) {
|
|
452
|
+
s.input!.setAttribute('aria-invalid', 'true');
|
|
453
|
+
s.input!.setAttribute('aria-describedby', 'cms-rt-msg');
|
|
454
|
+
} else {
|
|
455
|
+
s.input!.removeAttribute('aria-invalid');
|
|
456
|
+
s.input!.removeAttribute('aria-describedby');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** Close the popover; `back`: focus returns to the text, with its selection (ProseMirror puts it back). */
|
|
461
|
+
function closeLink(back: boolean) {
|
|
462
|
+
const s = shown;
|
|
463
|
+
if (!link && !s?.pop) return;
|
|
464
|
+
link = null;
|
|
465
|
+
if (!s) return;
|
|
466
|
+
const pop = s.pop;
|
|
467
|
+
s.pop = null;
|
|
468
|
+
s.input = null;
|
|
469
|
+
// Focus moves to the text before the search goes: taking out a focused node blurs it out of the frame.
|
|
470
|
+
// (The popover's focusout then finds it already closed.)
|
|
471
|
+
if (back && !s.state.editor.isDestroyed) s.state.view.focus();
|
|
472
|
+
pop?.remove();
|
|
473
|
+
update();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** A row picked: the selection becomes the link; with none, the document's title (a file's name) is the link's text (as the sidebar does). */
|
|
477
|
+
function pick(href: string, words?: string) {
|
|
478
|
+
const s = shown;
|
|
479
|
+
if (!s) return;
|
|
480
|
+
const ed = s.state.editor;
|
|
481
|
+
closeLink(true);
|
|
482
|
+
if (ed.isDestroyed) return;
|
|
483
|
+
if (ed.state.selection.empty && !ed.isActive('link')) {
|
|
484
|
+
const doc = documentAt(href, linkable());
|
|
485
|
+
const text = words ?? (doc ? doc.label || doc.title : href);
|
|
486
|
+
const from = ed.state.selection.from;
|
|
487
|
+
ed.chain().insertContent({ type: 'text', text, marks: [{ type: 'link', attrs: { href } }] }).run();
|
|
488
|
+
// The new words stay selected, so a file's popover (below) is over them.
|
|
489
|
+
if (fileKeyOf(href)) ed.commands.setTextSelection({ from, to: from + text.length });
|
|
490
|
+
} else {
|
|
491
|
+
ed.chain().extendMarkRange('link').setMark('link', { href }).run();
|
|
492
|
+
}
|
|
493
|
+
// M6c G4: a link to a file opens again on the file, for New tab / Download.
|
|
494
|
+
if (fileKeyOf(href)) openLink();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---- The bar's buttons ----
|
|
498
|
+
|
|
499
|
+
function act(button: HTMLButtonElement) {
|
|
500
|
+
const s = shown!;
|
|
501
|
+
const ed = s.state.editor;
|
|
502
|
+
if (ed.isDestroyed) return;
|
|
503
|
+
// M6c G4: File…, a file picked, New tab / Download, Upload…, back to pages.
|
|
504
|
+
if (button.hasAttribute('data-files') && link) {
|
|
505
|
+
link = { ...link, files: true };
|
|
506
|
+
if (media.files.get().list === null && !media.files.get().unset) void media.loadImages();
|
|
507
|
+
s.input!.value = '';
|
|
508
|
+
drawLinkBody();
|
|
509
|
+
s.input!.focus({ preventScroll: true });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
if (button.dataset.file !== undefined) {
|
|
513
|
+
const file = media.files.get().list?.find((f) => f.key === button.dataset.file);
|
|
514
|
+
if (file) pick(fileAddress(file.key), file.name);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (button.dataset.opens !== undefined && link) {
|
|
518
|
+
const file = fileKeyOf(link.query.trim());
|
|
519
|
+
if (!file) return;
|
|
520
|
+
const hash = link.query.indexOf('#');
|
|
521
|
+
pick(`${fileAddress(file.key, { download: button.dataset.opens === 'download' })}${hash === -1 ? '' : link.query.slice(hash)}`);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
if (button.hasAttribute('data-upload')) {
|
|
525
|
+
choosing = true;
|
|
526
|
+
s.win.addEventListener('focus', () => setTimeout(() => (choosing = false), 400), { once: true });
|
|
527
|
+
s.wrap.querySelector<HTMLInputElement>('input[data-rt-file]')?.click();
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
if (button.hasAttribute('data-pages') && link) {
|
|
531
|
+
link = { query: '', editing: link.editing };
|
|
532
|
+
s.input!.value = '';
|
|
533
|
+
drawLinkBody();
|
|
534
|
+
s.input!.focus({ preventScroll: true });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (button.hasAttribute('data-remove')) {
|
|
538
|
+
closeLink(true);
|
|
539
|
+
ed.chain().extendMarkRange('link').unsetMark('link').run();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (button.classList.contains('drow')) {
|
|
543
|
+
if (button.dataset.href !== undefined) pick(button.dataset.href);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const cmd = button.dataset.rtc;
|
|
547
|
+
if (cmd === 'link') {
|
|
548
|
+
if (link) closeLink(true);
|
|
549
|
+
else openLink();
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
// Anything else from the bar goes back to the text first.
|
|
553
|
+
if (link) closeLink(true);
|
|
554
|
+
if (cmd === 'style') {
|
|
555
|
+
menu = !menu;
|
|
556
|
+
update();
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
menu = false;
|
|
560
|
+
if (cmd === 'set') setBlockStyle(ed, button.dataset.v as BlockStyle);
|
|
561
|
+
else if (cmd === 'bold' || cmd === 'italic') ed.chain().toggleMark(cmd).run();
|
|
562
|
+
else if (cmd === 'ul' || cmd === 'ol') toggleList(ed, cmd === 'ul' ? 'bulletList' : 'orderedList');
|
|
563
|
+
update();
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// ---- Placement ----
|
|
567
|
+
|
|
568
|
+
const kOf = (s: Shown) => {
|
|
569
|
+
const scale = editor.stageLayout.get()?.[s.state.width].scale ?? 1;
|
|
570
|
+
return 1 / (scale > 0 ? scale : 1);
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
/** The top-level block the caret is in (a paragraph, a heading, a whole list), and its index. */
|
|
574
|
+
function caretBlock(s: Shown): { el: Element; index: number } | null {
|
|
575
|
+
const { view } = s.state;
|
|
576
|
+
const { doc, selection } = view.state;
|
|
577
|
+
if (doc.childCount === 0) return null;
|
|
578
|
+
const $head = selection.$head;
|
|
579
|
+
const index = Math.min($head.index(0), doc.childCount - 1);
|
|
580
|
+
const node = view.nodeDOM($head.posAtIndex(index, 0));
|
|
581
|
+
const el = node && node.nodeType === 1 ? (node as Element) : (view.dom.children[index] ?? null);
|
|
582
|
+
return el ? { el, index } : null;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function place(s: Shown) {
|
|
586
|
+
const block = caretBlock(s);
|
|
587
|
+
if (!block) return;
|
|
588
|
+
const { win, doc, wrap, bar } = s;
|
|
589
|
+
const k = kOf(s);
|
|
590
|
+
const edge = EDGE * k;
|
|
591
|
+
const gap = GAP * k;
|
|
592
|
+
const vw = doc.documentElement.clientWidth;
|
|
593
|
+
const vh = win.innerHeight;
|
|
594
|
+
const bh = bar.offsetHeight;
|
|
595
|
+
const bw = bar.offsetWidth;
|
|
596
|
+
const bb = block.el.getBoundingClientRect();
|
|
597
|
+
const sx = win.scrollX;
|
|
598
|
+
const sy = win.scrollY;
|
|
599
|
+
let p = s.placed;
|
|
600
|
+
// It moves only when the caret changes block (or, by the caret's line, as the caret moves),
|
|
601
|
+
// and when the block has grown under it.
|
|
602
|
+
const covers = p && p.mode !== 'line' && p.top - sy < bb.bottom && p.top - sy + bh > bb.top;
|
|
603
|
+
if (!p || p.block !== block.el || p.mode === 'line' || covers) {
|
|
604
|
+
let above = bb.top - gap - bh >= edge;
|
|
605
|
+
let top = above ? bb.top - gap - bh : bb.bottom + gap;
|
|
606
|
+
let mode: 'above' | 'below' | 'line' = above ? 'above' : 'below';
|
|
607
|
+
// A block taller than the frame: by the caret's own line instead.
|
|
608
|
+
if (!above && top + bh > vh - edge) {
|
|
609
|
+
const head = s.state.view.state.selection.head;
|
|
610
|
+
const line = s.state.view.coordsAtPos(head);
|
|
611
|
+
above = line.top - gap - bh >= edge;
|
|
612
|
+
top = above ? line.top - gap - bh : line.bottom + gap;
|
|
613
|
+
mode = 'line';
|
|
614
|
+
}
|
|
615
|
+
const left = Math.max(edge, Math.min(s.state.view.dom.getBoundingClientRect().left, vw - bw - edge));
|
|
616
|
+
p = { block: block.el, index: block.index, mode, above, left: left + sx, top: top + sy };
|
|
617
|
+
s.placed = p;
|
|
618
|
+
wrap.style.left = `${p.left}px`;
|
|
619
|
+
wrap.style.top = `${p.top}px`;
|
|
620
|
+
wrap.dataset.place = above ? 'above' : 'below';
|
|
621
|
+
wrap.dataset.block = String(block.index);
|
|
622
|
+
if (mode === 'line') wrap.dataset.line = '';
|
|
623
|
+
else delete wrap.dataset.line;
|
|
624
|
+
}
|
|
625
|
+
// The menu and the popover: on the bar's side away from the text, or past the block if there's no room.
|
|
626
|
+
const topV = p.top - sy;
|
|
627
|
+
const leftV = p.left - sx;
|
|
628
|
+
const g = MENU_GAP * k;
|
|
629
|
+
for (const f of [s.menu, s.pop]) {
|
|
630
|
+
if (!f) continue;
|
|
631
|
+
if (f === s.pop) f.style.width = `${Math.min(POPOVER_WIDTH * k, vw - 2 * edge)}px`;
|
|
632
|
+
const fh = f.offsetHeight;
|
|
633
|
+
const fw = f.offsetWidth;
|
|
634
|
+
let ft = p.above ? topV - g - fh : topV + bh + g;
|
|
635
|
+
if (p.above && ft < edge) ft = bb.bottom + gap;
|
|
636
|
+
else if (!p.above && ft + fh > vh - edge && bb.top - gap - fh >= edge) ft = bb.top - gap - fh;
|
|
637
|
+
f.style.top = `${ft - topV}px`;
|
|
638
|
+
f.style.left = `${Math.max(edge, Math.min(leftV, vw - fw - edge)) - leftV}px`;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** The linked words' wash while the popover is open (ProseMirror's selection doesn't paint then). */
|
|
643
|
+
function drawHolds(s: Shown) {
|
|
644
|
+
const { view } = s.state;
|
|
645
|
+
const { from, to, empty } = view.state.selection;
|
|
646
|
+
if (!s.pop || empty) {
|
|
647
|
+
if (s.holds.firstChild) s.holds.replaceChildren();
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
const a = view.domAtPos(from);
|
|
651
|
+
const b = view.domAtPos(to);
|
|
652
|
+
const range = s.doc.createRange();
|
|
653
|
+
range.setStart(a.node, a.offset);
|
|
654
|
+
range.setEnd(b.node, b.offset);
|
|
655
|
+
const sx = s.win.scrollX;
|
|
656
|
+
const sy = s.win.scrollY;
|
|
657
|
+
const rects = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
|
|
658
|
+
s.holds.replaceChildren(
|
|
659
|
+
...rects.map((r) => {
|
|
660
|
+
const hold = s.doc.createElement('div');
|
|
661
|
+
hold.className = 'cms-hold';
|
|
662
|
+
hold.setAttribute('style', `left:${r.left + sx}px;top:${r.top + sy}px;width:${r.width}px;height:${r.height}px`);
|
|
663
|
+
return hold;
|
|
664
|
+
}),
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// ---- Following the page editor ----
|
|
669
|
+
|
|
670
|
+
const follow = (state: PageEditorState | null) => {
|
|
671
|
+
const visible = !!state && state.session && state.width === editor.view.get().width && !editor.access.get().readOnly;
|
|
672
|
+
if (!visible) {
|
|
673
|
+
hide();
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
if (shown && shown.state.editor === state.editor && shown.state.frame === state.frame) {
|
|
677
|
+
shown.state = state;
|
|
678
|
+
update();
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
hide();
|
|
682
|
+
show(state);
|
|
683
|
+
};
|
|
684
|
+
|
|
685
|
+
const stops = [
|
|
686
|
+
pageEdit.pageEditor.subscribe((state) => follow(state)),
|
|
687
|
+
editor.access.subscribe(() => follow(pageEdit.pageEditor.get())),
|
|
688
|
+
editor.stageLayout.subscribe(() => {
|
|
689
|
+
if (!shown) return;
|
|
690
|
+
shown.placed = null;
|
|
691
|
+
update();
|
|
692
|
+
}),
|
|
693
|
+
// ⌘K is the link on the page (never the document switcher); Esc closes the menu or popover first.
|
|
694
|
+
pageEdit.onEditorKey((event) => {
|
|
695
|
+
if (!shown) return false;
|
|
696
|
+
if (isLinkKey(event)) {
|
|
697
|
+
if (link) closeLink(true);
|
|
698
|
+
else openLink();
|
|
699
|
+
return true;
|
|
700
|
+
}
|
|
701
|
+
if (event.key === 'Escape' && (menu || link)) {
|
|
702
|
+
menu = false;
|
|
703
|
+
if (link) closeLink(true);
|
|
704
|
+
else update();
|
|
705
|
+
return true;
|
|
706
|
+
}
|
|
707
|
+
return false;
|
|
708
|
+
}),
|
|
709
|
+
];
|
|
710
|
+
follow(pageEdit.pageEditor.get());
|
|
711
|
+
|
|
712
|
+
return () => {
|
|
713
|
+
for (const stop of stops) stop();
|
|
714
|
+
for (const stop of stopFiles) stop();
|
|
715
|
+
hide();
|
|
716
|
+
};
|
|
717
|
+
}
|