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,632 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Editing from the page (Decision 12; M2 → "click to edit", "text typed on the
|
|
3
|
+
* page"), in the iframe on screen:
|
|
4
|
+
*
|
|
5
|
+
* - **Click to edit:** a click opens that block's level and picks the field under
|
|
6
|
+
* it ("Picked from the page"). A plain text field is then typed right there; a
|
|
7
|
+
* lockup opens its sidebar field (lockups are edited only in the sidebar); a
|
|
8
|
+
* click on a skipped block's pill opens that block (the overlay's own button).
|
|
9
|
+
* - **Typing on the page:** plain text fields are `contenteditable="plaintext-only"`
|
|
10
|
+
* in preview renders (Text.astro). What's typed goes to the store with
|
|
11
|
+
* `origin: 'page'`; the driver mirrors it into the other two iframes instead of
|
|
12
|
+
* rendering (Decision 13), and the sidebar field follows. One-line fields take no
|
|
13
|
+
* line breaks (Enter leaves the field, a paste's breaks become spaces).
|
|
14
|
+
* - **Rich text typed on the page** (on-page rich text, Decisions 1, 5–8, 10): a
|
|
15
|
+
* top-level rich-text field (`tabindex="0"` in preview renders, RichText.astro)
|
|
16
|
+
* gets the page editor (page-editor.ts: /admin's own Tiptap mounted on the field's
|
|
17
|
+
* element) in the iframe on screen, at most one: mounted when the pointer enters
|
|
18
|
+
* the field or focus reaches it (a tap, Tab), so the first press lands on it.
|
|
19
|
+
* **A typing session** is focus in the editor or in the overlay layer of its iframe
|
|
20
|
+
* (where its formatting bar and link popover live); while it's on, the editor stays mounted
|
|
21
|
+
* and `typing` stays set. It's torn down when no session is on and the pointer has
|
|
22
|
+
* left the field, when the block's level closes, another document opens or the
|
|
23
|
+
* width changes, and when the tab goes read-only; the element is left exactly as a
|
|
24
|
+
* render. Each edit is stored with `origin: 'page'` (the driver mirrors it); a
|
|
25
|
+
* change from elsewhere is put into the mounted editor (Decision 8). Rich text in
|
|
26
|
+
* a list item, and a shared entry on a page that uses it, stay in the sidebar.
|
|
27
|
+
* - **Hover:** the block and field under the pointer, for the overlays.
|
|
28
|
+
* - **Following the sidebar:** a block or field picked in the sidebar is scrolled
|
|
29
|
+
* into view in the preview if it's out of sight.
|
|
30
|
+
* - **Keys in the page:** Esc leaves the field, then goes up a level, as in the sidebar;
|
|
31
|
+
* in rich text the formatting bar's menu or link popover closes first (Esc and ⌘K
|
|
32
|
+
* reach the bar through `onEditorKey`; overlays/rich-text-bar.ts).
|
|
33
|
+
*
|
|
34
|
+
* The caret on the page survives every re-render whether focus is here or in the
|
|
35
|
+
* sidebar: the driver saves and restores each iframe's selection around a morph
|
|
36
|
+
* (selection.ts; Decision 13). Nothing here changes that.
|
|
37
|
+
*/
|
|
38
|
+
import type { Editor } from '../editor.ts';
|
|
39
|
+
import type { Editor as TiptapEditor } from '@tiptap/core';
|
|
40
|
+
import type { EditorView } from '@tiptap/pm/view';
|
|
41
|
+
import { createStore, type Store } from '../state/store.ts';
|
|
42
|
+
import { sameDoc, type Width } from '../../types.ts';
|
|
43
|
+
import type { PreviewFrame } from './driver.ts';
|
|
44
|
+
import { fieldText } from './field-text.ts';
|
|
45
|
+
import { blockElement, blockIndexOf, blockRootOf, fieldElement, skippedAnchor, skippedTop } from './markers.ts';
|
|
46
|
+
import { mountPageEditor, pointInField, positionOf, type MountedPageEditor } from './page-editor.ts';
|
|
47
|
+
import { mountedIn, setMounted } from './page-editor-registry.ts';
|
|
48
|
+
import { fieldsOfBlock } from '../state/schema.ts';
|
|
49
|
+
import { fieldAt, getAtPath, isKeyPath, richTextOptions } from '../../../fields.ts';
|
|
50
|
+
import type { RichTextOptions } from '../../../rich-text.ts';
|
|
51
|
+
|
|
52
|
+
export interface Typing {
|
|
53
|
+
/** The block's key in the document store. */
|
|
54
|
+
key: string;
|
|
55
|
+
field: string;
|
|
56
|
+
width: Width;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface PageHover {
|
|
60
|
+
/** The block's index in the rendered document. */
|
|
61
|
+
block: number;
|
|
62
|
+
field: string | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The page editor mounted in the iframe on screen (on-page rich text), for its formatting bar (overlays/rich-text-bar.ts). */
|
|
66
|
+
export interface PageEditorState {
|
|
67
|
+
/** The block's key in the document store, and the (top-level) rich-text field. */
|
|
68
|
+
key: string;
|
|
69
|
+
field: string;
|
|
70
|
+
width: Width;
|
|
71
|
+
frame: PreviewFrame;
|
|
72
|
+
/** The mounted Tiptap editor (its events: 'transaction', 'selectionUpdate', 'focus', 'blur'). */
|
|
73
|
+
editor: TiptapEditor;
|
|
74
|
+
view: EditorView;
|
|
75
|
+
/** The field's options (its headings, links): the bar shows only its controls. */
|
|
76
|
+
options: RichTextOptions;
|
|
77
|
+
/** A typing session is on: focus is in the editor or in its iframe's overlay layer. */
|
|
78
|
+
session: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PageEdit {
|
|
82
|
+
/** The field being typed on the page (plain text, or rich text during a typing session), or null. */
|
|
83
|
+
typing: Store<Typing | null>;
|
|
84
|
+
/** The page editor mounted in the iframe on screen, or null (published on mount, teardown and session changes). */
|
|
85
|
+
pageEditor: Store<PageEditorState | null>;
|
|
86
|
+
/** What the pointer is over in the iframe on screen, or null. */
|
|
87
|
+
hover: Store<PageHover | null>;
|
|
88
|
+
/**
|
|
89
|
+
* Keys pressed in the page editor, before its own (on-page rich text, Decision 2: the
|
|
90
|
+
* bar's ⌘K and Esc, rich-text-bar.ts). Return true when handled. Returns a remove.
|
|
91
|
+
*/
|
|
92
|
+
onEditorKey(handler: (event: KeyboardEvent) => boolean): () => void;
|
|
93
|
+
/** A key the bar took elsewhere (its popover's search): page-edit's Esc skips it, as it does `onEditorKey`'s. */
|
|
94
|
+
claimKey(event: Event): void;
|
|
95
|
+
/** Scroll the iframe on screen so a block (or a field in it) is in view. */
|
|
96
|
+
reveal(index: number, field?: string | null, options?: { instant?: boolean }): void;
|
|
97
|
+
destroy(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const LAYER = '#cms-overlays';
|
|
101
|
+
const made = new WeakMap<Editor, PageEdit>();
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Focus a field in the block level's sidebar (a lockup's first line), once it's
|
|
105
|
+
* rendered. `field` may be a dotted path (M5a Decision 16): the control for that
|
|
106
|
+
* path (`data-path`) inside the field's frame, once a list has opened the row that
|
|
107
|
+
* holds it (`credits.1.name`); the field's first control otherwise.
|
|
108
|
+
*/
|
|
109
|
+
export function focusSidebarField(field: string, tries = 12): void {
|
|
110
|
+
// The field's frame: the longest `data-ff` the path starts with, at a path-part
|
|
111
|
+
// boundary (a group's fields have frames of their own: `left.button`).
|
|
112
|
+
const frameOf = (): { frame: HTMLElement; name: string } | null => {
|
|
113
|
+
let best: { frame: HTMLElement; name: string } | null = null;
|
|
114
|
+
for (const frame of document.querySelectorAll<HTMLElement>('[data-pane-level="block"] .field-frame[data-ff], [data-pane-level="document"] [data-form] .field-frame[data-ff]')) {
|
|
115
|
+
const name = frame.dataset.ff!;
|
|
116
|
+
if ((field === name || field.startsWith(`${name}.`)) && (!best || name.length > best.name.length)) best = { frame, name };
|
|
117
|
+
}
|
|
118
|
+
return best;
|
|
119
|
+
};
|
|
120
|
+
// M3's controls first (a lockup's lines before its Main lines / Phone lines switch), then any control.
|
|
121
|
+
const controls = 'input:not([disabled]),textarea:not([disabled]),select:not([disabled]),button.btn:not([disabled])';
|
|
122
|
+
const any = '[contenteditable="true"],button:not([disabled])';
|
|
123
|
+
const attempt = (left: number) => {
|
|
124
|
+
// The block level's fields, or (M5a) the form of a fields document, a shared block or Site Settings.
|
|
125
|
+
const found = frameOf();
|
|
126
|
+
const frame = found?.frame;
|
|
127
|
+
const exact = frame?.querySelector<HTMLElement>(`[data-path="${CSS.escape(field)}"]`);
|
|
128
|
+
// A nested path waits for its row to open; past the tries, the field's own control will do.
|
|
129
|
+
const input = exact ?? (field === found?.name || left === 0 ? (frame?.querySelector<HTMLElement>(controls) ?? frame?.querySelector<HTMLElement>(any)) : null);
|
|
130
|
+
if (!frame || !input) {
|
|
131
|
+
if (left > 0) requestAnimationFrame(() => attempt(left - 1));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
(input.closest('.item, .btnf, .ref') ?? frame).scrollIntoView({ block: 'nearest', behavior: reducedMotion() ? 'auto' : 'smooth' });
|
|
135
|
+
input.focus({ preventScroll: true });
|
|
136
|
+
if (input instanceof HTMLInputElement && /^(text|search|url|tel)$/.test(input.type)) input.setSelectionRange(input.value.length, input.value.length);
|
|
137
|
+
};
|
|
138
|
+
requestAnimationFrame(() => attempt(tries));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A top-level text or rich-text field, or one in a group (`left.heading`), is typed on the
|
|
143
|
+
* page (M5a Decision 13; on-page rich text, Decision 1); everything else (a list item's
|
|
144
|
+
* field, a lockup) opens in the sidebar.
|
|
145
|
+
*/
|
|
146
|
+
const typedOnPage = (kind: string | null, field: string) => (kind === 'text' || kind === 'richText') && isKeyPath(field);
|
|
147
|
+
|
|
148
|
+
/** A rich-text field the page editor can mount on: top level, focusable in the preview render (RichText.astro). */
|
|
149
|
+
const isPageRichText = (el: Element | null | undefined): el is HTMLElement =>
|
|
150
|
+
!!el && el.matches('[data-cms-kind="richText"][data-cms-field][tabindex]') && isKeyPath(el.getAttribute('data-cms-field')!);
|
|
151
|
+
|
|
152
|
+
const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
153
|
+
|
|
154
|
+
export function pageEditFor(editor: Editor): PageEdit | null {
|
|
155
|
+
const driver = editor.preview;
|
|
156
|
+
if (!driver || typeof window === 'undefined') return null;
|
|
157
|
+
const existing = made.get(editor);
|
|
158
|
+
if (existing) return existing;
|
|
159
|
+
|
|
160
|
+
const typing = createStore<Typing | null>(null);
|
|
161
|
+
const hover = createStore<PageHover | null>(null);
|
|
162
|
+
const pageEditor = createStore<PageEditorState | null>(null);
|
|
163
|
+
const editorKeys = new Set<(event: KeyboardEvent) => boolean>();
|
|
164
|
+
/** Keys an `onEditorKey` handler took (ProseMirror prevents every Esc, so defaultPrevented can't say). */
|
|
165
|
+
const claimed = new WeakSet<Event>();
|
|
166
|
+
const stops: (() => void)[] = [];
|
|
167
|
+
|
|
168
|
+
/** The preview shows the document the sidebar has open (not the old one while another opens). */
|
|
169
|
+
const live = () => {
|
|
170
|
+
const status = editor.previewStatus.get();
|
|
171
|
+
return !status.opening && sameDoc(status.showing, editor.doc.get().ref);
|
|
172
|
+
};
|
|
173
|
+
const visible = (frame: PreviewFrame) => frame.width === editor.view.get().width;
|
|
174
|
+
/** M4 (Decision 9): read-only (a lock, held edits, signed out): no clicking to edit, no typing on the page. */
|
|
175
|
+
const readOnly = () => editor.access.get().readOnly;
|
|
176
|
+
const elementOf = (target: EventTarget | null): Element | null =>
|
|
177
|
+
target && (target as Node).nodeType === 1 ? (target as Element) : ((target as Node | null)?.parentElement ?? null);
|
|
178
|
+
const inLayer = (el: Element) => !!el.closest(LAYER);
|
|
179
|
+
|
|
180
|
+
/** The block (index and key) and field an element belongs to. */
|
|
181
|
+
function locate(el: Element) {
|
|
182
|
+
const blockEl = blockRootOf(el);
|
|
183
|
+
if (!blockEl) return null;
|
|
184
|
+
const index = blockIndexOf(blockEl);
|
|
185
|
+
const key = editor.doc.keyAt(index);
|
|
186
|
+
if (!Number.isInteger(index) || !key) return null;
|
|
187
|
+
const fieldEl = el.closest<HTMLElement>('[data-cms-field]');
|
|
188
|
+
const field = fieldEl && blockEl.contains(fieldEl) ? fieldEl.dataset.cmsField! : null;
|
|
189
|
+
return { index, key, blockEl, fieldEl: field ? fieldEl! : null, field, kind: fieldEl?.dataset.cmsKind ?? null };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const isMultiline = (key: string, field: string) => {
|
|
193
|
+
const block = editor.doc.block(key);
|
|
194
|
+
const session = editor.doc.get();
|
|
195
|
+
const descriptor = block ? fieldAt(fieldsOfBlock(editor.site, session.document, session.ref.collection, block), field) : undefined;
|
|
196
|
+
return descriptor?.kind === 'text' ? descriptor.multiline : false;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// ---- The page editor (on-page rich text) ----
|
|
200
|
+
|
|
201
|
+
interface Active {
|
|
202
|
+
frame: PreviewFrame;
|
|
203
|
+
doc: Document;
|
|
204
|
+
key: string;
|
|
205
|
+
field: string;
|
|
206
|
+
pe: MountedPageEditor;
|
|
207
|
+
session: boolean;
|
|
208
|
+
}
|
|
209
|
+
/** The mounted page editor (at most one, in the iframe on screen). */
|
|
210
|
+
let active: Active | null = null;
|
|
211
|
+
/** The page-typed rich-text field under the pointer in the iframe on screen. */
|
|
212
|
+
let pointerOn: HTMLElement | null = null;
|
|
213
|
+
/** The primary button is down in the iframe, and the editor a press mounted (by focus) that its release places the caret in. */
|
|
214
|
+
let pressing: { x: number; y: number } | null = null;
|
|
215
|
+
let pressMounted: Active | null = null;
|
|
216
|
+
/** Torn down by a morph (Decision 6): remounted after it, with this selection. */
|
|
217
|
+
let resume: { key: string; field: string; width: Width; anchor: number; head: number; focused: boolean } | null = null;
|
|
218
|
+
|
|
219
|
+
const publish = () =>
|
|
220
|
+
pageEditor.set(
|
|
221
|
+
active
|
|
222
|
+
? { key: active.key, field: active.field, width: active.frame.width, frame: active.frame, editor: active.pe.editor, view: active.pe.view, options: active.pe.options, session: active.session }
|
|
223
|
+
: null,
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const richOptions = (key: string, field: string) => {
|
|
227
|
+
const block = editor.doc.block(key);
|
|
228
|
+
const session = editor.doc.get();
|
|
229
|
+
const descriptor = block?.known ? fieldAt(fieldsOfBlock(editor.site, session.document, session.ref.collection, block), field) : undefined;
|
|
230
|
+
return descriptor?.kind === 'richText' ? richTextOptions(descriptor) : null;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/** Mount the page editor on a field (tearing down any other). Null where it isn't typed on the page. */
|
|
234
|
+
function mountOn(frame: PreviewFrame, el: HTMLElement): Active | null {
|
|
235
|
+
if (active && active.pe.el === el && !active.pe.destroyed) return active;
|
|
236
|
+
const doc = frame.document;
|
|
237
|
+
// Never in the hidden widths (they're laid out; they get the mirror), never read-only (Decision 10).
|
|
238
|
+
if (!doc || !visible(frame) || !live() || readOnly() || !isPageRichText(el) || !el.isConnected || el.ownerDocument !== doc) return null;
|
|
239
|
+
const at = locate(el);
|
|
240
|
+
if (!at?.field || at.fieldEl !== el) return null;
|
|
241
|
+
const options = richOptions(at.key, at.field);
|
|
242
|
+
if (!options) return null;
|
|
243
|
+
teardown();
|
|
244
|
+
const { key, field } = at;
|
|
245
|
+
// One task: ProseMirror swaps the children synchronously (no await here).
|
|
246
|
+
const pe = mountPageEditor(el, {
|
|
247
|
+
value: getAtPath(editor.doc.block(key)?.data, field),
|
|
248
|
+
options,
|
|
249
|
+
onChange: (value) => {
|
|
250
|
+
if (!readOnly()) editor.doc.setField(key, field, value, { origin: 'page' });
|
|
251
|
+
},
|
|
252
|
+
onKeyDown: (event) => {
|
|
253
|
+
const handled = [...editorKeys].some((handler) => handler(event));
|
|
254
|
+
if (handled) claimed.add(event);
|
|
255
|
+
return handled;
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
const next: Active = { frame, doc, key, field, pe, session: false };
|
|
259
|
+
active = next;
|
|
260
|
+
setMounted(doc, {
|
|
261
|
+
el,
|
|
262
|
+
key,
|
|
263
|
+
field,
|
|
264
|
+
syncAttributes: (from) => pe.syncAttributes(from),
|
|
265
|
+
teardownForMorph() {
|
|
266
|
+
if (active !== next) return;
|
|
267
|
+
resume = { key, field, width: frame.width, ...pe.selection(), focused: next.session };
|
|
268
|
+
teardown({ blur: false });
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
publish();
|
|
272
|
+
return next;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Tear the page editor down (Decision 5): the element is left exactly as a render of the current value. */
|
|
276
|
+
function teardown(options: { blur?: boolean } = {}) {
|
|
277
|
+
const was = active;
|
|
278
|
+
if (!was) return;
|
|
279
|
+
active = null;
|
|
280
|
+
if (mountedIn(was.doc)?.el === was.pe.el) setMounted(was.doc, null);
|
|
281
|
+
was.pe.destroy();
|
|
282
|
+
// Focus doesn't stay on a field that can't be typed in (blur the element, never Tiptap's commands.blur()).
|
|
283
|
+
if (options.blur !== false && was.doc.activeElement === was.pe.el) was.pe.el.blur();
|
|
284
|
+
const now = typing.get();
|
|
285
|
+
if (now && now.key === was.key && now.field === was.field && now.width === was.frame.width) typing.set(null);
|
|
286
|
+
publish();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** A session starts: focus reached the editor (a click, a tap, Tab, or a remount after a morph). */
|
|
290
|
+
function startSession(frame: PreviewFrame, a: Active) {
|
|
291
|
+
a.session = true;
|
|
292
|
+
typing.set({ key: a.key, field: a.field, width: frame.width });
|
|
293
|
+
publish();
|
|
294
|
+
const view = editor.view.get();
|
|
295
|
+
if (!editor.fieldsOpen(view) || view.block !== a.key || view.field !== a.field) editor.openBlock(a.key, a.field, { picked: true });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function attach(frame: PreviewFrame): () => void {
|
|
299
|
+
const doc = frame.document!;
|
|
300
|
+
const on = <K extends keyof DocumentEventMap>(type: K, fn: (event: DocumentEventMap[K]) => void, capture = false) => {
|
|
301
|
+
doc.addEventListener(type, fn as EventListener, capture);
|
|
302
|
+
return () => doc.removeEventListener(type, fn as EventListener, capture);
|
|
303
|
+
};
|
|
304
|
+
const offs = [
|
|
305
|
+
on('mousemove', (event) => {
|
|
306
|
+
if (!visible(frame) || !live() || readOnly()) return;
|
|
307
|
+
const el = elementOf(event.target);
|
|
308
|
+
const at = el && !inLayer(el) ? locate(el) : null;
|
|
309
|
+
const next = at ? { block: at.index, field: at.field } : null;
|
|
310
|
+
const now = hover.get();
|
|
311
|
+
if (now?.block !== next?.block || now?.field !== next?.field) hover.set(next);
|
|
312
|
+
// The page editor is mounted before the first press lands (Decision 5); never mid-press.
|
|
313
|
+
const rich = at?.field && typedOnPage(at.kind, at.field) && isPageRichText(at.fieldEl) ? at.fieldEl : null;
|
|
314
|
+
pointerOn = rich;
|
|
315
|
+
if (rich) {
|
|
316
|
+
// While a session is on in this frame the pointer doesn't matter (Decision 5): the other field mounts on its focus.
|
|
317
|
+
const session = active?.session && active.frame === frame;
|
|
318
|
+
if (event.buttons === 0 && !session) mountOn(frame, rich);
|
|
319
|
+
} else if (active && active.frame === frame && !active.session) teardown();
|
|
320
|
+
}),
|
|
321
|
+
// A press that mounts the editor by focus (the pointer crossed the field while another session was on,
|
|
322
|
+
// so mousemove left it alone): the browser put its caret in the render's text, which the mount replaced.
|
|
323
|
+
// When the press is released the selection goes from where it was pressed to where it was
|
|
324
|
+
// released: a caret for a click, the range for a drag.
|
|
325
|
+
on(
|
|
326
|
+
'mousedown',
|
|
327
|
+
(event) => {
|
|
328
|
+
pressing = event.button === 0 ? { x: event.clientX, y: event.clientY } : null;
|
|
329
|
+
pressMounted = null;
|
|
330
|
+
},
|
|
331
|
+
true,
|
|
332
|
+
),
|
|
333
|
+
on(
|
|
334
|
+
'mouseup',
|
|
335
|
+
(event) => {
|
|
336
|
+
const a = pressMounted;
|
|
337
|
+
const from = pressing;
|
|
338
|
+
pressing = null;
|
|
339
|
+
pressMounted = null;
|
|
340
|
+
if (!a || !from || active !== a || a.pe.destroyed) return;
|
|
341
|
+
const head = a.pe.view.posAtCoords({ left: event.clientX, top: event.clientY });
|
|
342
|
+
const anchor = a.pe.view.posAtCoords({ left: from.x, top: from.y }) ?? head;
|
|
343
|
+
if (head && anchor) a.pe.select(anchor.pos, head.pos);
|
|
344
|
+
},
|
|
345
|
+
true,
|
|
346
|
+
),
|
|
347
|
+
on('mouseleave', () => {
|
|
348
|
+
hover.set(null);
|
|
349
|
+
pointerOn = null;
|
|
350
|
+
if (active && active.frame === frame && !active.session) teardown();
|
|
351
|
+
}),
|
|
352
|
+
on('click', (event) => {
|
|
353
|
+
if (!visible(frame) || !live() || readOnly()) return;
|
|
354
|
+
const el = elementOf(event.target);
|
|
355
|
+
if (!el || inLayer(el)) return;
|
|
356
|
+
const at = locate(el);
|
|
357
|
+
if (!at) return;
|
|
358
|
+
editor.openBlock(at.key, at.field, { picked: !!at.field });
|
|
359
|
+
// A nested path (`credits.1.name`, `buttons.0.label`) opens its field and the row that holds it.
|
|
360
|
+
if (at.field && !typedOnPage(at.kind, at.field)) focusSidebarField(at.field);
|
|
361
|
+
}),
|
|
362
|
+
on('focusin', (event) => {
|
|
363
|
+
const el = elementOf(event.target);
|
|
364
|
+
// Focus moving into the overlay layer (the formatting bar, the link popover's search) keeps the session on.
|
|
365
|
+
if (el && inLayer(el)) {
|
|
366
|
+
if (active && active.frame === frame && !active.session) {
|
|
367
|
+
active.session = true;
|
|
368
|
+
publish();
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (isPageRichText(el)) {
|
|
373
|
+
if (!live()) return;
|
|
374
|
+
if (readOnly()) {
|
|
375
|
+
el.blur();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
let a = active && active.pe.el === el ? active : null;
|
|
379
|
+
if (!a) {
|
|
380
|
+
// Mounted by focus (a tap, Tab): the caret goes where the browser put it.
|
|
381
|
+
const point = pointInField(el);
|
|
382
|
+
a = mountOn(frame, el);
|
|
383
|
+
if (!a) return;
|
|
384
|
+
if (pressing) pressMounted = a;
|
|
385
|
+
// ProseMirror missed this focus event (it wasn't there yet): give it one.
|
|
386
|
+
const win = frame.window as (Window & typeof globalThis) | null;
|
|
387
|
+
if (win) a.pe.view.dom.dispatchEvent(new win.FocusEvent('focus'));
|
|
388
|
+
const pos = point ? positionOf(a.pe.view, point) : null;
|
|
389
|
+
if (pos !== null) a.pe.select(pos);
|
|
390
|
+
}
|
|
391
|
+
startSession(frame, a);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (!el?.matches('[data-cms-kind="text"]') || !live()) return;
|
|
395
|
+
if (readOnly()) {
|
|
396
|
+
(el as HTMLElement).blur();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const at = locate(el);
|
|
400
|
+
if (!at?.field) return;
|
|
401
|
+
typing.set({ key: at.key, field: at.field, width: frame.width });
|
|
402
|
+
const view = editor.view.get();
|
|
403
|
+
if (!editor.fieldsOpen(view) || view.block !== at.key || view.field !== at.field) editor.openBlock(at.key, at.field, { picked: true });
|
|
404
|
+
}),
|
|
405
|
+
on('focusout', (event) => {
|
|
406
|
+
const el = elementOf(event.target);
|
|
407
|
+
if (active && active.frame === frame && el && (el === active.pe.el || inLayer(el))) {
|
|
408
|
+
const to = elementOf(event.relatedTarget);
|
|
409
|
+
// Between the text and the layer (the bar, the popover's search): still one session.
|
|
410
|
+
if (to && to.ownerDocument === doc && (active.pe.el.contains(to) || inLayer(to))) return;
|
|
411
|
+
active.session = false;
|
|
412
|
+
const now = typing.get();
|
|
413
|
+
if (now && now.key === active.key && now.field === active.field && now.width === frame.width) typing.set(null);
|
|
414
|
+
publish();
|
|
415
|
+
if (pointerOn !== active.pe.el) teardown({ blur: false });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const now = typing.get();
|
|
419
|
+
if (!now || !el?.matches('[data-cms-kind="text"]') || now.width !== frame.width) return;
|
|
420
|
+
typing.set(null);
|
|
421
|
+
}),
|
|
422
|
+
on('input', (event) => {
|
|
423
|
+
const el = elementOf(event.target)?.closest('[data-cms-kind="text"]');
|
|
424
|
+
if (!el || !live() || readOnly()) return;
|
|
425
|
+
const at = locate(el);
|
|
426
|
+
if (!at?.field) return;
|
|
427
|
+
editor.doc.setField(at.key, at.field, fieldText(el, isMultiline(at.key, at.field)), { origin: 'page' });
|
|
428
|
+
}),
|
|
429
|
+
on('beforeinput', (event) => {
|
|
430
|
+
const el = elementOf(event.target)?.closest('[data-cms-kind="text"]');
|
|
431
|
+
if (!el) return;
|
|
432
|
+
if (readOnly()) {
|
|
433
|
+
event.preventDefault();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const at = locate(el);
|
|
437
|
+
if (!at?.field || isMultiline(at.key, at.field)) return;
|
|
438
|
+
if (event.inputType === 'insertParagraph' || event.inputType === 'insertLineBreak') event.preventDefault();
|
|
439
|
+
}),
|
|
440
|
+
on('paste', (event) => {
|
|
441
|
+
const el = elementOf(event.target)?.closest('[data-cms-kind="text"]');
|
|
442
|
+
if (!el) return;
|
|
443
|
+
const at = locate(el);
|
|
444
|
+
if (!at?.field || isMultiline(at.key, at.field)) return;
|
|
445
|
+
if (readOnly()) {
|
|
446
|
+
event.preventDefault();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const text = event.clipboardData?.getData('text/plain');
|
|
450
|
+
if (text === undefined) return;
|
|
451
|
+
event.preventDefault();
|
|
452
|
+
doc.execCommand('insertText', false, text.replace(/\s*\n\s*/g, ' '));
|
|
453
|
+
}),
|
|
454
|
+
on('keydown', (event) => {
|
|
455
|
+
const el = elementOf(event.target);
|
|
456
|
+
const field = el?.closest<HTMLElement>('[data-cms-kind="text"]');
|
|
457
|
+
const rich = el && active && active.frame === frame && active.pe.el.contains(el) ? active : null;
|
|
458
|
+
// ⌘\ hides or shows the sidebar from the page too (App.tsx has it for the editor's own window).
|
|
459
|
+
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && (event.key === '\\' || event.code === 'Backslash')) {
|
|
460
|
+
event.preventDefault();
|
|
461
|
+
editor.setSideHidden(editor.ui.get().side !== 'hidden');
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
// ⌘K opens the document switcher from the page too (App.tsx has it for the editor's own window).
|
|
465
|
+
// In a rich-text field the rich-text bar claims it first, for the link popover.
|
|
466
|
+
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k') {
|
|
467
|
+
if (claimed.has(event) || event.defaultPrevented) return;
|
|
468
|
+
event.preventDefault();
|
|
469
|
+
editor.setPop(editor.view.get().pop === 'switcher' ? null : 'switcher');
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (event.key === 'Escape') {
|
|
473
|
+
// The bar's menu or link popover closed first (rich-text-bar.ts): then the field, then the level.
|
|
474
|
+
if (claimed.has(event)) return;
|
|
475
|
+
event.preventDefault();
|
|
476
|
+
if (field) field.blur();
|
|
477
|
+
// Leave the rich-text field (view.dom.blur(): Tiptap's commands.blur() clears /admin's selection).
|
|
478
|
+
else if (rich) rich.pe.view.dom.blur();
|
|
479
|
+
else editor.goUp();
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (event.key === 'Enter' && field) {
|
|
483
|
+
const at = locate(field);
|
|
484
|
+
if (at?.field && !isMultiline(at.key, at.field)) {
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
field.blur();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}),
|
|
490
|
+
];
|
|
491
|
+
return () => offs.forEach((off) => off());
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// A hook's cleanup runs before its next run for that frame (after every morph too), so
|
|
495
|
+
// listeners are kept per document instead: attached once when a frame's document loads.
|
|
496
|
+
const attached = new WeakSet<Document>();
|
|
497
|
+
const detachers: (() => void)[] = [];
|
|
498
|
+
stops.push(
|
|
499
|
+
driver.registerFrameHook((frame) => {
|
|
500
|
+
const doc = frame.document;
|
|
501
|
+
if (!doc || attached.has(doc)) return;
|
|
502
|
+
attached.add(doc);
|
|
503
|
+
detachers.push(attach(frame));
|
|
504
|
+
}),
|
|
505
|
+
);
|
|
506
|
+
stops.push(() => detachers.forEach((detach) => detach()));
|
|
507
|
+
|
|
508
|
+
// After a morph: a torn-down (or detached) page editor is remounted when the session
|
|
509
|
+
// or the pointer is still there (Decision 6); a reloaded document drops it.
|
|
510
|
+
stops.push(
|
|
511
|
+
driver.registerFrameHook((frame, reason) => {
|
|
512
|
+
const doc = frame.document;
|
|
513
|
+
if (active && active.frame.width === frame.width && (reason === 'load' || active.doc !== doc || !active.pe.el.isConnected)) teardown({ blur: false });
|
|
514
|
+
if (!doc || reason !== 'morph') return;
|
|
515
|
+
const r = resume && resume.width === frame.width ? resume : null;
|
|
516
|
+
if (r) resume = null;
|
|
517
|
+
const pointer = pointerOn && pointerOn.isConnected && pointerOn.ownerDocument === doc ? pointerOn : null;
|
|
518
|
+
if (r && (r.focused || pointer)) {
|
|
519
|
+
const index = editor.doc.indexOf(r.key);
|
|
520
|
+
const el = index >= 0 ? fieldElement(doc, index, r.field, 'richText') : null;
|
|
521
|
+
const a = el ? mountOn(frame, el) : null;
|
|
522
|
+
if (a) a.pe.select(r.anchor, r.head, r.focused);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (pointer && !active) mountOn(frame, pointer);
|
|
526
|
+
}),
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
function reveal(index: number, field?: string | null, options: { instant?: boolean } = {}) {
|
|
530
|
+
const frame = driver?.frames().find((f) => f.width === editor.view.get().width);
|
|
531
|
+
const doc = frame?.document;
|
|
532
|
+
const win = frame?.window;
|
|
533
|
+
if (!doc || !win) return;
|
|
534
|
+
const block = blockElement(doc, index);
|
|
535
|
+
const el = (field && block?.querySelector<HTMLElement>(`[data-cms-field="${CSS.escape(field)}"]`)) || block;
|
|
536
|
+
// A skipped block has no element, only its comment (skipped.ts): scroll to where it would be.
|
|
537
|
+
const anchor = el ? null : skippedAnchor(doc, index);
|
|
538
|
+
if (!el && !anchor) return;
|
|
539
|
+
const r = el ? el.getBoundingClientRect() : { top: skippedTop(anchor!), bottom: skippedTop(anchor!) };
|
|
540
|
+
const vh = win.innerHeight;
|
|
541
|
+
if (!options.instant && r.top >= 40 && r.bottom <= vh - 40) return;
|
|
542
|
+
if (options.instant && r.top >= 0 && r.top <= vh - 40) return;
|
|
543
|
+
const scale = editor.stageLayout.get()?.[frame!.width].scale ?? 1;
|
|
544
|
+
const y = win.scrollY + r.top - Math.min(vh * 0.2, 120 / scale);
|
|
545
|
+
win.scrollTo({ top: Math.max(0, y), behavior: options.instant || reducedMotion() ? 'auto' : 'smooth' });
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Follow the sidebar: a block or field picked there scrolls into view; leaving the block ends typing.
|
|
549
|
+
stops.push(
|
|
550
|
+
editor.view.subscribe((view, previous) => {
|
|
551
|
+
const now = typing.get();
|
|
552
|
+
if (now && (!editor.fieldsOpen(view) || view.block !== now.key)) {
|
|
553
|
+
const frame = driver.frames().find((f) => f.width === now.width);
|
|
554
|
+
const focused = frame?.document?.activeElement as HTMLElement | null | undefined;
|
|
555
|
+
focused?.blur?.();
|
|
556
|
+
typing.set(null);
|
|
557
|
+
}
|
|
558
|
+
if (!editor.fieldsOpen(view) || !view.block || view.picked || !live()) return;
|
|
559
|
+
const widthChanged = view.width !== previous.width;
|
|
560
|
+
if (view.block === previous.block && view.field === previous.field && previous.level === view.level && !widthChanged) return;
|
|
561
|
+
const index = editor.doc.indexOf(view.block);
|
|
562
|
+
if (index >= 0) requestAnimationFrame(() => reveal(index, view.field, { instant: widthChanged }));
|
|
563
|
+
}),
|
|
564
|
+
);
|
|
565
|
+
stops.push(editor.view.subscribe((view, previous) => view.width !== previous.width && hover.set(null)));
|
|
566
|
+
// The page editor goes when the width changes or its block's level closes (Decision 5).
|
|
567
|
+
stops.push(
|
|
568
|
+
editor.view.subscribe((view, previous) => {
|
|
569
|
+
if (!active) return;
|
|
570
|
+
if (view.width !== previous.width) {
|
|
571
|
+
pointerOn = null;
|
|
572
|
+
teardown();
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
const wasOpen = editor.fieldsOpen(previous) && previous.block === active.key;
|
|
576
|
+
const isOpen = editor.fieldsOpen(view) && view.block === active.key;
|
|
577
|
+
if (wasOpen && !isOpen) teardown();
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
// A change from elsewhere reaches the mounted editor (Decision 8: without an update, its own echo
|
|
581
|
+
// ignored); another document opening, or its block going, tears it down.
|
|
582
|
+
stops.push(
|
|
583
|
+
editor.doc.subscribe((_session, change) => {
|
|
584
|
+
if (!active) return;
|
|
585
|
+
if (change.kind === 'load') {
|
|
586
|
+
teardown();
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const block = editor.doc.block(active.key);
|
|
590
|
+
if (!block) {
|
|
591
|
+
teardown();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
active.pe.setValue(block.data[active.field]);
|
|
595
|
+
}),
|
|
596
|
+
);
|
|
597
|
+
// Going read-only mid-typing (a lock arrived, signed out): leave the field on the page.
|
|
598
|
+
stops.push(
|
|
599
|
+
editor.access.subscribe((access) => {
|
|
600
|
+
if (!access.readOnly) return;
|
|
601
|
+
hover.set(null);
|
|
602
|
+
// Decision 10: the page editor goes at once; the value already stored stays.
|
|
603
|
+
resume = null;
|
|
604
|
+
teardown();
|
|
605
|
+
const now = typing.get();
|
|
606
|
+
if (!now) return;
|
|
607
|
+
const frame = driver.frames().find((f) => f.width === now.width);
|
|
608
|
+
(frame?.document?.activeElement as HTMLElement | null | undefined)?.blur?.();
|
|
609
|
+
typing.set(null);
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
|
|
613
|
+
const pageEdit: PageEdit = {
|
|
614
|
+
typing,
|
|
615
|
+
pageEditor,
|
|
616
|
+
hover,
|
|
617
|
+
onEditorKey(handler) {
|
|
618
|
+
editorKeys.add(handler);
|
|
619
|
+
return () => editorKeys.delete(handler);
|
|
620
|
+
},
|
|
621
|
+
claimKey(event) {
|
|
622
|
+
claimed.add(event);
|
|
623
|
+
},
|
|
624
|
+
reveal,
|
|
625
|
+
destroy() {
|
|
626
|
+
teardown();
|
|
627
|
+
for (const stop of stops) stop();
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
made.set(editor, pageEdit);
|
|
631
|
+
return pageEdit;
|
|
632
|
+
}
|