@caretcms/core 0.1.2 → 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/README.md +28 -10
- package/dist/contracts.d.ts +1 -0
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +1 -0
- package/dist/contracts.js.map +1 -1
- package/dist/index.d.ts +18 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +109 -21
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts +17 -6
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +33 -14
- package/dist/loader.js.map +1 -1
- package/dist/markdown/block-context.d.ts +21 -0
- package/dist/markdown/block-context.d.ts.map +1 -0
- package/dist/markdown/block-context.js +27 -0
- package/dist/markdown/block-context.js.map +1 -0
- package/dist/markdown/canonical-body.d.ts +30 -0
- package/dist/markdown/canonical-body.d.ts.map +1 -0
- package/dist/markdown/canonical-body.js +54 -0
- package/dist/markdown/canonical-body.js.map +1 -0
- package/dist/markdown/contracts.d.ts +66 -0
- package/dist/markdown/contracts.d.ts.map +1 -0
- package/dist/markdown/contracts.js +102 -0
- package/dist/markdown/contracts.js.map +1 -0
- package/dist/markdown/html-to-snodes.d.ts +26 -0
- package/dist/markdown/html-to-snodes.d.ts.map +1 -0
- package/dist/markdown/html-to-snodes.js +118 -0
- package/dist/markdown/html-to-snodes.js.map +1 -0
- package/dist/markdown/remark.d.ts +35 -0
- package/dist/markdown/remark.d.ts.map +1 -0
- package/dist/markdown/remark.js +66 -0
- package/dist/markdown/remark.js.map +1 -0
- package/dist/markdown/satteri.d.ts +19 -0
- package/dist/markdown/satteri.d.ts.map +1 -0
- package/dist/markdown/satteri.js +120 -0
- package/dist/markdown/satteri.js.map +1 -0
- package/dist/markdown/serialize.d.ts +60 -0
- package/dist/markdown/serialize.d.ts.map +1 -0
- package/dist/markdown/serialize.js +264 -0
- package/dist/markdown/serialize.js.map +1 -0
- package/dist/markdown/splice.d.ts +32 -0
- package/dist/markdown/splice.d.ts.map +1 -0
- package/dist/markdown/splice.js +44 -0
- package/dist/markdown/splice.js.map +1 -0
- package/dist/markdown/stamp.d.ts +68 -0
- package/dist/markdown/stamp.d.ts.map +1 -0
- package/dist/markdown/stamp.js +131 -0
- package/dist/markdown/stamp.js.map +1 -0
- package/dist/runtime/auth/rate-limiter.d.ts.map +1 -1
- package/dist/runtime/auth/rate-limiter.js +5 -0
- package/dist/runtime/auth/rate-limiter.js.map +1 -1
- package/dist/runtime/auth/session.d.ts +2 -3
- package/dist/runtime/auth/session.d.ts.map +1 -1
- package/dist/runtime/auth/session.js +52 -21
- package/dist/runtime/auth/session.js.map +1 -1
- package/dist/runtime/content.d.ts.map +1 -1
- package/dist/runtime/content.js +4 -2
- package/dist/runtime/content.js.map +1 -1
- package/dist/runtime/middleware.d.ts.map +1 -1
- package/dist/runtime/middleware.js +21 -4
- package/dist/runtime/middleware.js.map +1 -1
- package/dist/runtime/mutations/contracts.d.ts +25 -1
- package/dist/runtime/mutations/contracts.d.ts.map +1 -1
- package/dist/runtime/mutations/contracts.js +67 -0
- package/dist/runtime/mutations/contracts.js.map +1 -1
- package/dist/runtime/mutations/engine.d.ts +6 -1
- package/dist/runtime/mutations/engine.d.ts.map +1 -1
- package/dist/runtime/mutations/engine.js +151 -42
- package/dist/runtime/mutations/engine.js.map +1 -1
- package/dist/runtime/publish.d.ts +13 -1
- package/dist/runtime/publish.d.ts.map +1 -1
- package/dist/runtime/publish.js +75 -4
- package/dist/runtime/publish.js.map +1 -1
- package/dist/runtime/request-context.d.ts +9 -1
- package/dist/runtime/request-context.d.ts.map +1 -1
- package/dist/runtime/request-context.js.map +1 -1
- package/dist/runtime/rewrite.d.ts.map +1 -1
- package/dist/runtime/rewrite.js +109 -3
- package/dist/runtime/rewrite.js.map +1 -1
- package/dist/runtime/rich-allowlist.d.ts.map +1 -1
- package/dist/runtime/rich-allowlist.js +5 -1
- package/dist/runtime/rich-allowlist.js.map +1 -1
- package/dist/runtime/routes/admin.d.ts.map +1 -1
- package/dist/runtime/routes/admin.js +6 -2
- package/dist/runtime/routes/admin.js.map +1 -1
- package/dist/runtime/routes/entries.d.ts.map +1 -1
- package/dist/runtime/routes/entries.js +3 -2
- package/dist/runtime/routes/entries.js.map +1 -1
- package/dist/runtime/routes/history.d.ts.map +1 -1
- package/dist/runtime/routes/history.js +46 -21
- package/dist/runtime/routes/history.js.map +1 -1
- package/dist/runtime/routes/mutate.d.ts.map +1 -1
- package/dist/runtime/routes/mutate.js +40 -6
- package/dist/runtime/routes/mutate.js.map +1 -1
- package/dist/runtime/routes/publish.d.ts.map +1 -1
- package/dist/runtime/routes/publish.js +5 -2
- package/dist/runtime/routes/publish.js.map +1 -1
- package/dist/runtime/routes/schema.d.ts.map +1 -1
- package/dist/runtime/routes/schema.js +4 -1
- package/dist/runtime/routes/schema.js.map +1 -1
- package/dist/runtime/routes/theme-stylesheet.d.ts.map +1 -1
- package/dist/runtime/routes/theme-stylesheet.js +11 -1
- package/dist/runtime/routes/theme-stylesheet.js.map +1 -1
- package/dist/runtime/storage/atomic-write.d.ts.map +1 -1
- package/dist/runtime/storage/atomic-write.js +8 -3
- package/dist/runtime/storage/atomic-write.js.map +1 -1
- package/dist/runtime/storage/markdown-adapter.d.ts +19 -0
- package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/markdown-adapter.js +41 -0
- package/dist/runtime/storage/markdown-adapter.js.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.d.ts +8 -0
- package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.js +24 -2
- package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
- package/dist/runtime/utils.d.ts +10 -0
- package/dist/runtime/utils.d.ts.map +1 -1
- package/dist/runtime/utils.js +16 -0
- package/dist/runtime/utils.js.map +1 -1
- package/dist/types.d.ts +40 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/static/cms/admin-entry.js +36 -2
- package/static/cms/editor/guards.js +1 -1
- package/static/cms/editor/image-edit.js +41 -11
- package/static/cms/editor/image.css +7 -1
- package/static/cms/editor/md-block-edit.js +176 -0
- package/static/cms/editor/md-serialize.js +301 -0
- package/static/cms/editor/panel.css +69 -25
- package/static/cms/editor/panel.js +39 -0
- package/static/cms/editor/rich-toolbar.js +4 -3
- package/static/cms/editor/sanitize.js +2 -1
- package/static/cms/editor/sync.js +1 -1
- package/static/cms/editor/text-edit.js +68 -17
- package/static/cms/editor/text-link-interactions.js +4 -4
- package/static/cms/editor/toast.css +114 -2
- package/static/cms/editor/toast.js +121 -22
- package/static/cms/editor/tokens-and-text.css +64 -3
- package/static/cms/editor/toolbar.css +19 -6
- package/static/cms/editor/toolbar.js +64 -23
- package/static/cms/editor.js +13 -2
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
/* ---------------------------------------------------------------------------
|
|
2
|
-
Studio Panel — sliding left
|
|
2
|
+
Studio Panel — sliding left drawer with iframe
|
|
3
|
+
|
|
4
|
+
The panel is an OVERLAY drawer: it slides over the page and does not reflow
|
|
5
|
+
host layout. Earlier versions pushed `body` and shifted a hard-coded
|
|
6
|
+
`#main-header`, which silently broke on any site not using that selector or
|
|
7
|
+
using absolute/transformed layout. The only element still shifted is the CMS
|
|
8
|
+
toolbar, which is our own chrome (always present) — no host coupling.
|
|
9
|
+
|
|
10
|
+
Hosts that DO want content pushed aside (rather than overlaid) can opt in by
|
|
11
|
+
adding the `caret-push-content` class to <body>. See docs.
|
|
3
12
|
--------------------------------------------------------------------------- */
|
|
4
13
|
|
|
14
|
+
:root {
|
|
15
|
+
--cms-panel-width: 380px;
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
.cms-studio-panel {
|
|
6
19
|
position: fixed;
|
|
7
20
|
top: 0;
|
|
8
21
|
left: 0;
|
|
9
22
|
bottom: 0;
|
|
10
|
-
width:
|
|
23
|
+
width: var(--cms-panel-width);
|
|
24
|
+
max-width: 100vw;
|
|
11
25
|
z-index: 100000;
|
|
12
26
|
background: #0c0e12;
|
|
13
27
|
border-right: 1px solid rgba(59, 130, 246, 0.12);
|
|
@@ -31,29 +45,46 @@
|
|
|
31
45
|
display: block;
|
|
32
46
|
}
|
|
33
47
|
|
|
34
|
-
/*
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
48
|
+
/* Loading skeleton shown until the Studio iframe reports load. */
|
|
49
|
+
.cms-studio-panel-loading {
|
|
50
|
+
position: absolute;
|
|
51
|
+
inset: 0;
|
|
52
|
+
display: flex;
|
|
53
|
+
align-items: center;
|
|
54
|
+
justify-content: center;
|
|
55
|
+
background: #0c0e12;
|
|
56
|
+
color: rgba(255, 255, 255, 0.5);
|
|
57
|
+
font-family: var(--cms-font);
|
|
58
|
+
font-size: 12px;
|
|
59
|
+
z-index: 1;
|
|
60
|
+
transition: opacity 0.3s ease;
|
|
38
61
|
}
|
|
39
62
|
|
|
40
|
-
|
|
41
|
-
|
|
63
|
+
.cms-studio-panel-loading[hidden] {
|
|
64
|
+
opacity: 0;
|
|
65
|
+
pointer-events: none;
|
|
42
66
|
}
|
|
43
67
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
left: 380px;
|
|
47
|
-
transition: left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
68
|
+
.cms-studio-panel-loading-text {
|
|
69
|
+
margin-left: 10px;
|
|
48
70
|
}
|
|
49
71
|
|
|
50
|
-
|
|
51
|
-
|
|
72
|
+
.cms-studio-panel-spinner {
|
|
73
|
+
width: 22px;
|
|
74
|
+
height: 22px;
|
|
75
|
+
border: 2px solid rgba(255, 255, 255, 0.15);
|
|
76
|
+
border-top-color: var(--cms-accent, #3b82f6);
|
|
77
|
+
border-radius: 50%;
|
|
78
|
+
animation: cms-panel-spin 0.7s linear infinite;
|
|
52
79
|
}
|
|
53
80
|
|
|
54
|
-
|
|
81
|
+
@keyframes cms-panel-spin {
|
|
82
|
+
to { transform: rotate(360deg); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Shift the CMS toolbar (our chrome) so the drawer doesn't cover it. */
|
|
55
86
|
body.cms-panel-open .cms-toolbar {
|
|
56
|
-
left:
|
|
87
|
+
left: var(--cms-panel-width);
|
|
57
88
|
transition: left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
58
89
|
}
|
|
59
90
|
|
|
@@ -61,6 +92,16 @@ body.cms-panel-open.no-transition .cms-toolbar {
|
|
|
61
92
|
transition: none;
|
|
62
93
|
}
|
|
63
94
|
|
|
95
|
+
/* Opt-in: push host content aside instead of overlaying it. */
|
|
96
|
+
body.cms-panel-open.caret-push-content {
|
|
97
|
+
padding-left: var(--cms-panel-width);
|
|
98
|
+
transition: padding-left 0.5s cubic-bezier(0.16, 1, 0.3, 1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
body.cms-panel-open.caret-push-content.no-transition {
|
|
102
|
+
transition: none;
|
|
103
|
+
}
|
|
104
|
+
|
|
64
105
|
/* Studio button active state */
|
|
65
106
|
.cms-studio-btn.active {
|
|
66
107
|
background: rgba(59, 130, 246, 0.3);
|
|
@@ -69,23 +110,26 @@ body.cms-panel-open.no-transition .cms-toolbar {
|
|
|
69
110
|
box-shadow: 0 0 8px rgba(59, 130, 246, 0.15);
|
|
70
111
|
}
|
|
71
112
|
|
|
72
|
-
|
|
73
|
-
/* Mobile: full-width overlay, no content push */
|
|
113
|
+
/* Mobile: full-width overlay, no toolbar shift. */
|
|
74
114
|
@media (max-width: 768px) {
|
|
75
115
|
.cms-studio-panel {
|
|
76
116
|
width: 100%;
|
|
77
117
|
box-shadow: none;
|
|
78
118
|
}
|
|
79
119
|
|
|
80
|
-
body.cms-panel-open
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
body.cms-panel-open #main-header {
|
|
120
|
+
body.cms-panel-open .cms-toolbar,
|
|
121
|
+
body.cms-panel-open.caret-push-content {
|
|
85
122
|
left: 0;
|
|
123
|
+
padding-left: 0;
|
|
86
124
|
}
|
|
125
|
+
}
|
|
87
126
|
|
|
88
|
-
|
|
89
|
-
|
|
127
|
+
@media (prefers-reduced-motion: reduce) {
|
|
128
|
+
.cms-studio-panel,
|
|
129
|
+
body.cms-panel-open .cms-toolbar,
|
|
130
|
+
body.cms-panel-open.caret-push-content,
|
|
131
|
+
.cms-studio-panel-spinner {
|
|
132
|
+
transition: none;
|
|
133
|
+
animation: none;
|
|
90
134
|
}
|
|
91
135
|
}
|
|
@@ -9,22 +9,51 @@ export function mountStudioPanel({ studioButton }) {
|
|
|
9
9
|
|
|
10
10
|
const panel = document.createElement('div');
|
|
11
11
|
panel.className = 'cms-studio-panel';
|
|
12
|
+
// A dialog-like region so AT users understand the drawer context.
|
|
13
|
+
panel.setAttribute('role', 'dialog');
|
|
14
|
+
panel.setAttribute('aria-label', 'Content Studio');
|
|
15
|
+
panel.setAttribute('aria-hidden', 'true');
|
|
16
|
+
|
|
17
|
+
// Loading skeleton shown until the iframe reports load — the iframe is blank
|
|
18
|
+
// until then, which reads as "broken".
|
|
19
|
+
const loading = document.createElement('div');
|
|
20
|
+
loading.className = 'cms-studio-panel-loading';
|
|
21
|
+
loading.innerHTML =
|
|
22
|
+
'<span class="cms-studio-panel-spinner" aria-hidden="true"></span>' +
|
|
23
|
+
'<span class="cms-studio-panel-loading-text">Loading Studio…</span>';
|
|
24
|
+
panel.appendChild(loading);
|
|
12
25
|
|
|
13
26
|
const iframe = document.createElement('iframe');
|
|
14
27
|
iframe.src = STUDIO_PATH;
|
|
15
28
|
iframe.title = 'Content Studio';
|
|
29
|
+
iframe.addEventListener('load', () => {
|
|
30
|
+
loading.setAttribute('hidden', 'hidden');
|
|
31
|
+
});
|
|
16
32
|
panel.appendChild(iframe);
|
|
17
33
|
document.body.appendChild(panel);
|
|
18
34
|
|
|
35
|
+
// Remember what had focus so we can restore it on close.
|
|
36
|
+
let lastFocused = null;
|
|
37
|
+
|
|
19
38
|
function openPanel(animate = true) {
|
|
39
|
+
lastFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
20
40
|
if (!animate) {
|
|
21
41
|
panel.classList.add('no-transition');
|
|
22
42
|
document.body.classList.add('no-transition');
|
|
23
43
|
}
|
|
24
44
|
panel.classList.add('open');
|
|
45
|
+
panel.setAttribute('aria-hidden', 'false');
|
|
25
46
|
document.body.classList.add('cms-panel-open');
|
|
26
47
|
studioButton?.classList.add('active');
|
|
48
|
+
studioButton?.setAttribute('aria-expanded', 'true');
|
|
27
49
|
sessionStorage.setItem('cms-panel-open', '1');
|
|
50
|
+
// Move focus into the drawer so keyboard users aren't left behind the
|
|
51
|
+
// covering panel. The iframe is focusable and hands focus to its own content.
|
|
52
|
+
if (animate) {
|
|
53
|
+
try {
|
|
54
|
+
iframe.focus({ preventScroll: true });
|
|
55
|
+
} catch (e) {}
|
|
56
|
+
}
|
|
28
57
|
if (!animate) {
|
|
29
58
|
requestAnimationFrame(() => {
|
|
30
59
|
requestAnimationFrame(() => {
|
|
@@ -37,9 +66,19 @@ export function mountStudioPanel({ studioButton }) {
|
|
|
37
66
|
|
|
38
67
|
function closePanel() {
|
|
39
68
|
panel.classList.remove('open');
|
|
69
|
+
panel.setAttribute('aria-hidden', 'true');
|
|
40
70
|
document.body.classList.remove('cms-panel-open');
|
|
41
71
|
studioButton?.classList.remove('active');
|
|
72
|
+
studioButton?.setAttribute('aria-expanded', 'false');
|
|
42
73
|
sessionStorage.setItem('cms-panel-open', '0');
|
|
74
|
+
// Return focus to the trigger (or wherever it was) so it isn't lost.
|
|
75
|
+
const restore = lastFocused || studioButton;
|
|
76
|
+
if (restore instanceof HTMLElement) {
|
|
77
|
+
try {
|
|
78
|
+
restore.focus({ preventScroll: true });
|
|
79
|
+
} catch (e) {}
|
|
80
|
+
}
|
|
81
|
+
lastFocused = null;
|
|
43
82
|
}
|
|
44
83
|
|
|
45
84
|
function togglePanel() {
|
|
@@ -175,11 +175,12 @@ function onSelectionChange() {
|
|
|
175
175
|
return;
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
// Check if selection is inside a rich element
|
|
178
|
+
// Check if selection is inside a rich element (or a markdown body block,
|
|
179
|
+
// which shares the same inline formatting commands)
|
|
179
180
|
const anchor = sel.anchorNode;
|
|
180
181
|
const richEl = anchor instanceof Element
|
|
181
|
-
? anchor.closest('[data-caret-rich]')
|
|
182
|
-
: anchor?.parentElement?.closest('[data-caret-rich]');
|
|
182
|
+
? anchor.closest('[data-caret-rich], [data-caret-md]')
|
|
183
|
+
: anchor?.parentElement?.closest('[data-caret-rich], [data-caret-md]');
|
|
183
184
|
|
|
184
185
|
if (!richEl) {
|
|
185
186
|
hide();
|
|
@@ -17,7 +17,8 @@ export const ALLOWED_ATTRS = {
|
|
|
17
17
|
a: new Set(['href', 'target', 'rel']),
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
// `(?!\/)` rejects protocol-relative URLs (`//evil.com`). Mirrors core exactly.
|
|
21
|
+
export const SAFE_HREF_RE = /^(?:https?:|mailto:|tel:|\/(?!\/))/i;
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Does `cls` match any pattern? (exact, `prefix-*`, or lone `*`)
|
|
@@ -25,7 +25,7 @@ export function mountStudioSync({ studioIframe, state, showToast, clientLinkify
|
|
|
25
25
|
const els = document.querySelectorAll(`[data-caret="${selector}"]`);
|
|
26
26
|
els.forEach((el) => {
|
|
27
27
|
// Skip elements being actively edited to prevent overwriting unsaved work
|
|
28
|
-
if (
|
|
28
|
+
if (state.dirtyEls.has(el) || el === document.activeElement) return;
|
|
29
29
|
|
|
30
30
|
if (el instanceof HTMLImageElement) {
|
|
31
31
|
// Image element
|
|
@@ -5,6 +5,20 @@ import { sanitizeHtml } from './sanitize.js';
|
|
|
5
5
|
const snapshots = new WeakMap();
|
|
6
6
|
let keydownBound = false;
|
|
7
7
|
|
|
8
|
+
// `contenteditable="plaintext-only"` isn't supported in older Firefox (pre-136);
|
|
9
|
+
// setting it there leaves the element non-editable. Feature-detect once and fall
|
|
10
|
+
// back to "true" (rich contenteditable) — the blur handler saves via textContent
|
|
11
|
+
// for non-rich fields, so the stored value stays plain-text regardless.
|
|
12
|
+
const PLAINTEXT_ONLY_SUPPORTED = (() => {
|
|
13
|
+
try {
|
|
14
|
+
const probe = document.createElement('div');
|
|
15
|
+
probe.setAttribute('contenteditable', 'plaintext-only');
|
|
16
|
+
return probe.contentEditable === 'plaintext-only';
|
|
17
|
+
} catch (e) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
|
|
8
22
|
export function mountTextEditors({
|
|
9
23
|
state,
|
|
10
24
|
parseCaretAttr,
|
|
@@ -52,7 +66,10 @@ export function mountTextEditors({
|
|
|
52
66
|
const isRich = el.hasAttribute('data-caret-rich');
|
|
53
67
|
const isLinkable = !isRich && el.hasAttribute('data-caret-raw');
|
|
54
68
|
|
|
55
|
-
el.setAttribute(
|
|
69
|
+
el.setAttribute(
|
|
70
|
+
'contenteditable',
|
|
71
|
+
isRich || !PLAINTEXT_ONLY_SUPPORTED ? 'true' : 'plaintext-only',
|
|
72
|
+
);
|
|
56
73
|
el.classList.add('cms-editable');
|
|
57
74
|
if (isRich) el.classList.add('cms-rich');
|
|
58
75
|
if (isLinkable) el.classList.add('cms-linkable');
|
|
@@ -95,9 +112,48 @@ export function mountTextEditors({
|
|
|
95
112
|
el.addEventListener('input', () => {
|
|
96
113
|
const snapshot = snapshots.get(el);
|
|
97
114
|
const current = isRich ? el.innerHTML : (el.textContent || '');
|
|
98
|
-
|
|
115
|
+
if (current !== snapshot) state.dirtyEls.add(el);
|
|
116
|
+
else state.dirtyEls.delete(el);
|
|
99
117
|
});
|
|
100
118
|
|
|
119
|
+
// Save-conflict resolution: never silently discard the user's edit. Keep
|
|
120
|
+
// their text in the field and offer an explicit choice. "Keep mine" re-saves
|
|
121
|
+
// (the save queue refreshed the cached revision on the 409, so the retry
|
|
122
|
+
// targets the current revision and overwrites); "Load latest" replaces the
|
|
123
|
+
// field with the server value. The element stays dirty until resolved.
|
|
124
|
+
const runConflict = (resolved, mine, latestValue) => {
|
|
125
|
+
state.dirtyEls.add(el);
|
|
126
|
+
el.classList.add('cms-conflict');
|
|
127
|
+
showToast.conflict({
|
|
128
|
+
message: 'This content changed elsewhere while you were editing.',
|
|
129
|
+
onKeepMine: async () => {
|
|
130
|
+
el.classList.remove('cms-conflict');
|
|
131
|
+
el.classList.add('cms-saving');
|
|
132
|
+
const retry = await saveField(resolved.collection, resolved.id, resolved.field, mine);
|
|
133
|
+
el.classList.remove('cms-saving');
|
|
134
|
+
flash(el, retry.ok);
|
|
135
|
+
if (retry.ok) {
|
|
136
|
+
state.dirtyEls.delete(el);
|
|
137
|
+
syncBoundText(attr, mine);
|
|
138
|
+
showToast('Your version saved', 'success');
|
|
139
|
+
} else if (retry.reason === 'conflict') {
|
|
140
|
+
runConflict(resolved, mine, retry.latestValue);
|
|
141
|
+
} else if (retry.reason !== 'unauthorized') {
|
|
142
|
+
showToast('Failed to save. Try again.', 'error');
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
onLoadLatest: () => {
|
|
146
|
+
el.classList.remove('cms-conflict');
|
|
147
|
+
state.dirtyEls.delete(el);
|
|
148
|
+
const value = typeof latestValue === 'string' ? latestValue : mine;
|
|
149
|
+
applyTextValue(el, value);
|
|
150
|
+
snapshots.set(el, isRich ? sanitizeHtml(value) : value);
|
|
151
|
+
syncBoundText(attr, value);
|
|
152
|
+
showToast('Loaded latest version', 'success');
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
101
157
|
el.addEventListener('blur', async () => {
|
|
102
158
|
removeLinkHint();
|
|
103
159
|
|
|
@@ -107,7 +163,7 @@ export function mountTextEditors({
|
|
|
107
163
|
// Skip save if Escape was pressed
|
|
108
164
|
if (el._caretSkipSave) {
|
|
109
165
|
el._caretSkipSave = false;
|
|
110
|
-
state.
|
|
166
|
+
state.dirtyEls.delete(el);
|
|
111
167
|
if (isRich) {
|
|
112
168
|
el.innerHTML = snapshots.get(el) || '';
|
|
113
169
|
} else if (isLinkable) {
|
|
@@ -147,24 +203,19 @@ export function mountTextEditors({
|
|
|
147
203
|
const result = await saveField(resolved.collection, resolved.id, resolved.field, current);
|
|
148
204
|
el.classList.remove('cms-saving');
|
|
149
205
|
flash(el, result.ok);
|
|
150
|
-
state.dirtyEl = null;
|
|
151
206
|
|
|
152
|
-
// Sync all duplicated text bindings
|
|
153
207
|
if (result.ok) {
|
|
208
|
+
state.dirtyEls.delete(el);
|
|
154
209
|
syncBoundText(attr, current);
|
|
155
210
|
showToast('Content saved', 'success');
|
|
156
|
-
} else {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
showToast('Content changed elsewhere. Loaded latest value.', 'error');
|
|
165
|
-
} else if (result.reason !== 'unauthorized') {
|
|
166
|
-
showToast('Failed to save. Changes reverted.', 'error');
|
|
167
|
-
}
|
|
211
|
+
} else if (result.reason === 'conflict') {
|
|
212
|
+
// Preserve the user's edit and let them choose — don't overwrite it.
|
|
213
|
+
runConflict(resolved, current, result.latestValue);
|
|
214
|
+
} else if (result.reason !== 'unauthorized') {
|
|
215
|
+
// Genuine failure (not an auth redirect): revert to the pre-edit snapshot.
|
|
216
|
+
state.dirtyEls.delete(el);
|
|
217
|
+
syncBoundText(attr, snapshot);
|
|
218
|
+
showToast('Failed to save. Changes reverted.', 'error');
|
|
168
219
|
}
|
|
169
220
|
});
|
|
170
221
|
});
|
|
@@ -177,10 +177,10 @@ export function createTextLinkInteractions({
|
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
state.
|
|
180
|
+
state.dirtyEls.add(focused);
|
|
181
181
|
} else if (url === '' && existingLink) {
|
|
182
182
|
document.execCommand('unlink', false, null);
|
|
183
|
-
state.
|
|
183
|
+
state.dirtyEls.add(focused);
|
|
184
184
|
}
|
|
185
185
|
|
|
186
186
|
state.linkPopupEl = null;
|
|
@@ -208,7 +208,7 @@ export function createTextLinkInteractions({
|
|
|
208
208
|
newSel.removeAllRanges();
|
|
209
209
|
newSel.addRange(range);
|
|
210
210
|
document.execCommand('insertText', false, `[${selectedText}](${url})`);
|
|
211
|
-
state.
|
|
211
|
+
state.dirtyEls.add(focused);
|
|
212
212
|
}
|
|
213
213
|
|
|
214
214
|
state.linkPopupEl = null;
|
|
@@ -235,7 +235,7 @@ export function createTextLinkInteractions({
|
|
|
235
235
|
} else {
|
|
236
236
|
focused.textContent = snapshot;
|
|
237
237
|
}
|
|
238
|
-
state.
|
|
238
|
+
state.dirtyEls.delete(focused);
|
|
239
239
|
}
|
|
240
240
|
focused._caretSkipSave = true;
|
|
241
241
|
focused.blur();
|
|
@@ -2,11 +2,20 @@
|
|
|
2
2
|
Toast notifications
|
|
3
3
|
--------------------------------------------------------------------------- */
|
|
4
4
|
|
|
5
|
-
.cms-toast {
|
|
5
|
+
.cms-toast-stack {
|
|
6
6
|
position: fixed;
|
|
7
7
|
bottom: 62px;
|
|
8
8
|
right: 24px;
|
|
9
9
|
z-index: 100000;
|
|
10
|
+
display: flex;
|
|
11
|
+
flex-direction: column-reverse; /* newest nearest the toolbar */
|
|
12
|
+
align-items: flex-end;
|
|
13
|
+
gap: 8px;
|
|
14
|
+
max-width: min(360px, calc(100vw - 48px));
|
|
15
|
+
pointer-events: none; /* the region is inert; interactive toasts opt back in */
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
.cms-toast {
|
|
10
19
|
display: flex;
|
|
11
20
|
align-items: center;
|
|
12
21
|
gap: 10px;
|
|
@@ -16,7 +25,7 @@
|
|
|
16
25
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
|
17
26
|
font-family: var(--cms-font);
|
|
18
27
|
font-size: 12px;
|
|
19
|
-
color: rgba(255, 255, 255, 0.
|
|
28
|
+
color: rgba(255, 255, 255, 0.86);
|
|
20
29
|
border-radius: 2px;
|
|
21
30
|
opacity: 0;
|
|
22
31
|
transform: translateY(8px) scale(0.98);
|
|
@@ -24,22 +33,114 @@
|
|
|
24
33
|
pointer-events: none;
|
|
25
34
|
}
|
|
26
35
|
|
|
36
|
+
/* Sticky / actionable toasts must receive clicks (close + conflict buttons). */
|
|
37
|
+
.cms-toast.cms-toast-interactive {
|
|
38
|
+
pointer-events: auto;
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
.cms-toast.cms-toast-out {
|
|
28
42
|
animation: cms-toast-out 0.3s ease forwards;
|
|
29
43
|
}
|
|
30
44
|
|
|
45
|
+
.cms-toast-msg {
|
|
46
|
+
flex: 1 1 auto;
|
|
47
|
+
line-height: 1.4;
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
.cms-toast-icon {
|
|
32
51
|
width: 16px;
|
|
33
52
|
height: 16px;
|
|
34
53
|
flex-shrink: 0;
|
|
35
54
|
}
|
|
36
55
|
|
|
56
|
+
.cms-toast-icon-wrap {
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
flex-shrink: 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
37
61
|
.cms-toast-success { border-left: 3px solid var(--cms-green); }
|
|
38
62
|
.cms-toast-success .cms-toast-icon { color: var(--cms-green); }
|
|
39
63
|
|
|
40
64
|
.cms-toast-error { border-left: 3px solid var(--cms-red); }
|
|
41
65
|
.cms-toast-error .cms-toast-icon { color: var(--cms-red); }
|
|
42
66
|
|
|
67
|
+
.cms-toast-warning { border-left: 3px solid var(--cms-amber, #eab308); }
|
|
68
|
+
.cms-toast-warning .cms-toast-icon { color: var(--cms-amber, #eab308); }
|
|
69
|
+
|
|
70
|
+
/* Conflict prompt lays the action buttons below the message. */
|
|
71
|
+
.cms-toast-warning {
|
|
72
|
+
flex-wrap: wrap;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.cms-toast-actions {
|
|
76
|
+
display: flex;
|
|
77
|
+
gap: 8px;
|
|
78
|
+
width: 100%;
|
|
79
|
+
margin-top: 4px;
|
|
80
|
+
justify-content: flex-end;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.cms-toast-btn {
|
|
84
|
+
font-family: var(--cms-font);
|
|
85
|
+
font-size: 11px;
|
|
86
|
+
font-weight: 500;
|
|
87
|
+
padding: 5px 12px;
|
|
88
|
+
border-radius: 2px;
|
|
89
|
+
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
90
|
+
background: transparent;
|
|
91
|
+
color: rgba(255, 255, 255, 0.78);
|
|
92
|
+
cursor: pointer;
|
|
93
|
+
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.cms-toast-btn:hover {
|
|
97
|
+
background: rgba(255, 255, 255, 0.06);
|
|
98
|
+
color: rgba(255, 255, 255, 0.95);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.cms-toast-btn-primary {
|
|
102
|
+
border-color: var(--cms-blue, #3b82f6);
|
|
103
|
+
color: #fff;
|
|
104
|
+
background: var(--cms-blue, #3b82f6);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.cms-toast-btn-primary:hover {
|
|
108
|
+
filter: brightness(1.1);
|
|
109
|
+
background: var(--cms-blue, #3b82f6);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.cms-toast-btn:focus-visible,
|
|
113
|
+
.cms-toast-close:focus-visible {
|
|
114
|
+
outline: 2px solid var(--cms-blue, #3b82f6);
|
|
115
|
+
outline-offset: 2px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.cms-toast-close {
|
|
119
|
+
flex-shrink: 0;
|
|
120
|
+
width: 20px;
|
|
121
|
+
height: 20px;
|
|
122
|
+
margin-left: 2px;
|
|
123
|
+
padding: 0;
|
|
124
|
+
border: none;
|
|
125
|
+
background: transparent;
|
|
126
|
+
color: rgba(255, 255, 255, 0.5);
|
|
127
|
+
font-size: 18px;
|
|
128
|
+
line-height: 1;
|
|
129
|
+
cursor: pointer;
|
|
130
|
+
border-radius: 2px;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.cms-toast-close:hover {
|
|
134
|
+
color: rgba(255, 255, 255, 0.9);
|
|
135
|
+
background: rgba(255, 255, 255, 0.06);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* An editable region awaiting a conflict decision. */
|
|
139
|
+
.cms-editable.cms-conflict {
|
|
140
|
+
outline: 2px solid var(--cms-amber, #eab308) !important;
|
|
141
|
+
outline-offset: 4px;
|
|
142
|
+
}
|
|
143
|
+
|
|
43
144
|
@keyframes cms-toast-in {
|
|
44
145
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
45
146
|
}
|
|
@@ -48,3 +149,14 @@
|
|
|
48
149
|
to { opacity: 0; transform: translateY(12px) scale(0.98); }
|
|
49
150
|
}
|
|
50
151
|
|
|
152
|
+
@media (prefers-reduced-motion: reduce) {
|
|
153
|
+
.cms-toast {
|
|
154
|
+
animation: none;
|
|
155
|
+
opacity: 1;
|
|
156
|
+
transform: none;
|
|
157
|
+
}
|
|
158
|
+
.cms-toast.cms-toast-out {
|
|
159
|
+
animation: none;
|
|
160
|
+
opacity: 0;
|
|
161
|
+
}
|
|
162
|
+
}
|