@caretcms/core 0.3.0 → 0.4.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 +116 -11
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +78 -5
- package/dist/index.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +8 -0
- package/dist/loader.js.map +1 -1
- package/dist/markdown/paragraphs.d.ts +14 -0
- package/dist/markdown/paragraphs.d.ts.map +1 -0
- package/dist/markdown/paragraphs.js +61 -0
- package/dist/markdown/paragraphs.js.map +1 -0
- package/dist/markdown/remark.d.ts.map +1 -1
- package/dist/markdown/remark.js +1 -0
- package/dist/markdown/remark.js.map +1 -1
- package/dist/markdown/satteri.d.ts.map +1 -1
- package/dist/markdown/satteri.js +3 -1
- package/dist/markdown/satteri.js.map +1 -1
- package/dist/markdown/stamp.d.ts +0 -9
- package/dist/markdown/stamp.d.ts.map +1 -1
- package/dist/markdown/stamp.js +6 -0
- package/dist/markdown/stamp.js.map +1 -1
- package/dist/providers/deployment/github.d.ts +27 -0
- package/dist/providers/deployment/github.d.ts.map +1 -0
- package/dist/providers/deployment/github.js +180 -0
- package/dist/providers/deployment/github.js.map +1 -0
- package/dist/providers/deployment/simulated.d.ts +10 -0
- package/dist/providers/deployment/simulated.d.ts.map +1 -0
- package/dist/providers/deployment/simulated.js +29 -0
- package/dist/providers/deployment/simulated.js.map +1 -0
- package/dist/runtime/authorization.d.ts +11 -0
- package/dist/runtime/authorization.d.ts.map +1 -0
- package/dist/runtime/authorization.js +29 -0
- package/dist/runtime/authorization.js.map +1 -0
- package/dist/runtime/collection-policy.d.ts +11 -0
- package/dist/runtime/collection-policy.d.ts.map +1 -0
- package/dist/runtime/collection-policy.js +44 -0
- package/dist/runtime/collection-policy.js.map +1 -0
- package/dist/runtime/content-errors.d.ts +9 -0
- package/dist/runtime/content-errors.d.ts.map +1 -0
- package/dist/runtime/content-errors.js +17 -0
- package/dist/runtime/content-errors.js.map +1 -0
- package/dist/runtime/content.d.ts.map +1 -1
- package/dist/runtime/content.js +13 -4
- package/dist/runtime/content.js.map +1 -1
- package/dist/runtime/deployment-status.d.ts +14 -0
- package/dist/runtime/deployment-status.d.ts.map +1 -0
- package/dist/runtime/deployment-status.js +57 -0
- package/dist/runtime/deployment-status.js.map +1 -0
- package/dist/runtime/draft-state.d.ts +16 -0
- package/dist/runtime/draft-state.d.ts.map +1 -0
- package/dist/runtime/draft-state.js +29 -0
- package/dist/runtime/draft-state.js.map +1 -0
- package/dist/runtime/i18n.d.ts +13 -2
- package/dist/runtime/i18n.d.ts.map +1 -1
- package/dist/runtime/i18n.js +26 -4
- package/dist/runtime/i18n.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -0
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/middleware.d.ts.map +1 -1
- package/dist/runtime/middleware.js +21 -2
- package/dist/runtime/middleware.js.map +1 -1
- package/dist/runtime/mutations/contracts.d.ts +9 -0
- package/dist/runtime/mutations/contracts.d.ts.map +1 -1
- package/dist/runtime/mutations/contracts.js +45 -4
- package/dist/runtime/mutations/contracts.js.map +1 -1
- package/dist/runtime/mutations/engine.d.ts +2 -0
- package/dist/runtime/mutations/engine.d.ts.map +1 -1
- package/dist/runtime/mutations/engine.js +115 -63
- package/dist/runtime/mutations/engine.js.map +1 -1
- package/dist/runtime/providers.d.ts +3 -1
- package/dist/runtime/providers.d.ts.map +1 -1
- package/dist/runtime/providers.js +12 -1
- package/dist/runtime/providers.js.map +1 -1
- package/dist/runtime/publish-recovery.d.ts +32 -0
- package/dist/runtime/publish-recovery.d.ts.map +1 -0
- package/dist/runtime/publish-recovery.js +98 -0
- package/dist/runtime/publish-recovery.js.map +1 -0
- package/dist/runtime/publish.d.ts +10 -4
- package/dist/runtime/publish.d.ts.map +1 -1
- package/dist/runtime/publish.js +104 -67
- package/dist/runtime/publish.js.map +1 -1
- package/dist/runtime/rebuild-webhook.d.ts +4 -0
- package/dist/runtime/rebuild-webhook.d.ts.map +1 -1
- package/dist/runtime/rebuild-webhook.js +12 -2
- package/dist/runtime/rebuild-webhook.js.map +1 -1
- package/dist/runtime/request-context.d.ts +3 -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 +28 -2
- package/dist/runtime/rewrite.js.map +1 -1
- package/dist/runtime/rich-allowlist.js +1 -1
- package/dist/runtime/rich-allowlist.js.map +1 -1
- package/dist/runtime/routes/admin-entry.d.ts.map +1 -1
- package/dist/runtime/routes/admin-entry.js +24 -12
- package/dist/runtime/routes/admin-entry.js.map +1 -1
- package/dist/runtime/routes/auth-session.d.ts.map +1 -1
- package/dist/runtime/routes/auth-session.js +6 -0
- package/dist/runtime/routes/auth-session.js.map +1 -1
- package/dist/runtime/routes/collection-list.d.ts.map +1 -1
- package/dist/runtime/routes/collection-list.js +164 -40
- package/dist/runtime/routes/collection-list.js.map +1 -1
- package/dist/runtime/routes/deployment.d.ts +5 -0
- package/dist/runtime/routes/deployment.d.ts.map +1 -0
- package/dist/runtime/routes/deployment.js +38 -0
- package/dist/runtime/routes/deployment.js.map +1 -0
- package/dist/runtime/routes/draft.d.ts.map +1 -1
- package/dist/runtime/routes/draft.js +10 -3
- package/dist/runtime/routes/draft.js.map +1 -1
- package/dist/runtime/routes/entries.d.ts.map +1 -1
- package/dist/runtime/routes/entries.js +74 -39
- 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 +37 -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 +4 -1
- 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 +127 -33
- 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 -0
- package/dist/runtime/routes/schema.js.map +1 -1
- package/dist/runtime/routes/studio-home.d.ts.map +1 -1
- package/dist/runtime/routes/studio-home.js +3 -1
- package/dist/runtime/routes/studio-home.js.map +1 -1
- package/dist/runtime/routes/upload.d.ts.map +1 -1
- package/dist/runtime/routes/upload.js +3 -0
- package/dist/runtime/routes/upload.js.map +1 -1
- package/dist/runtime/schema-registry.d.ts.map +1 -1
- package/dist/runtime/schema-registry.js +2 -0
- package/dist/runtime/schema-registry.js.map +1 -1
- package/dist/runtime/storage/entry-commit.d.ts +8 -0
- package/dist/runtime/storage/entry-commit.d.ts.map +1 -0
- package/dist/runtime/storage/entry-commit.js +45 -0
- package/dist/runtime/storage/entry-commit.js.map +1 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts +5 -0
- package/dist/runtime/storage/filesystem-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/filesystem-adapter.js +12 -3
- package/dist/runtime/storage/filesystem-adapter.js.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.d.ts +5 -23
- package/dist/runtime/storage/frontmatter-codec.d.ts.map +1 -1
- package/dist/runtime/storage/frontmatter-codec.js +156 -13
- package/dist/runtime/storage/frontmatter-codec.js.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.d.ts +7 -0
- package/dist/runtime/storage/in-memory-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/in-memory-adapter.js +6 -0
- package/dist/runtime/storage/in-memory-adapter.js.map +1 -1
- package/dist/runtime/storage/markdown-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/markdown-adapter.js +18 -20
- package/dist/runtime/storage/markdown-adapter.js.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.d.ts +12 -4
- package/dist/runtime/storage/session-overlay-adapter.d.ts.map +1 -1
- package/dist/runtime/storage/session-overlay-adapter.js +100 -13
- package/dist/runtime/storage/session-overlay-adapter.js.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.d.ts +5 -0
- package/dist/runtime/storage/sidecar-meta-store.d.ts.map +1 -1
- package/dist/runtime/storage/sidecar-meta-store.js +48 -5
- package/dist/runtime/storage/sidecar-meta-store.js.map +1 -1
- package/dist/runtime/utils.d.ts.map +1 -1
- package/dist/runtime/utils.js +3 -4
- package/dist/runtime/utils.js.map +1 -1
- package/dist/types.d.ts +123 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -3
- package/static/cms/admin-entry.css +9 -0
- package/static/cms/admin-entry.js +251 -687
- package/static/cms/dev-toolbar/app.js +78 -19
- package/static/cms/editor/config.js +35 -15
- package/static/cms/editor/content-map.css +8 -1
- package/static/cms/editor/content-map.js +62 -23
- package/static/cms/editor/deployment-status.js +66 -0
- package/static/cms/editor/draft-client.js +87 -0
- package/static/cms/editor/guards.js +20 -0
- package/static/cms/editor/helpers.js +30 -1
- package/static/cms/editor/highlight.js +1 -0
- package/static/cms/editor/image-edit.js +43 -1
- package/static/cms/editor/image-utils.js +23 -7
- package/static/cms/editor/link-follow.js +122 -41
- package/static/cms/editor/link-ui.css +10 -1
- package/static/cms/editor/linkify.js +2 -0
- package/static/cms/editor/md-block-edit.js +140 -37
- package/static/cms/editor/md-paragraphs.js +98 -0
- package/static/cms/editor/md-serialize.js +1 -1
- package/static/cms/editor/panel.js +43 -15
- package/static/cms/editor/preview-mode.js +44 -0
- package/static/cms/editor/publish-result.js +100 -0
- package/static/cms/editor/rich-toolbar.js +58 -15
- package/static/cms/editor/sanitize.js +6 -2
- package/static/cms/editor/save-queue.js +75 -17
- package/static/cms/editor/section-controls/activation.js +3 -0
- package/static/cms/editor/section-controls/api.js +25 -7
- package/static/cms/editor/section-controls/index.js +60 -8
- package/static/cms/editor/section-controls/model.js +39 -4
- package/static/cms/editor/section-controls/mutations.js +15 -0
- package/static/cms/editor/section-controls/page-context.js +9 -0
- package/static/cms/editor/section-controls/pickers.js +53 -2
- package/static/cms/editor/section-controls/reorder.js +23 -0
- package/static/cms/editor/section-controls/selection.js +6 -0
- package/static/cms/editor/section-controls/spacing-drag.js +16 -0
- package/static/cms/editor/section-controls/ui-elements.js +7 -0
- package/static/cms/editor/section-controls/utils.js +7 -0
- package/static/cms/editor/section-controls.css +18 -3
- package/static/cms/editor/security.js +13 -2
- package/static/cms/editor/stega-hydrate.js +17 -4
- package/static/cms/editor/sync.js +208 -10
- package/static/cms/editor/text-edit.js +37 -3
- package/static/cms/editor/text-link-interactions.js +59 -17
- package/static/cms/editor/toast.js +23 -9
- package/static/cms/editor/toolbar.css +5 -0
- package/static/cms/editor/toolbar.js +91 -101
- package/static/cms/editor.js +2 -1
- package/static/cms/studio/entry-loader.js +102 -0
- package/static/cms/studio/field-model.js +110 -0
- package/static/cms/studio/fields.js +319 -0
- package/static/cms/studio/history-client.js +74 -0
- package/static/cms/studio/mutation-client.js +74 -0
- package/static/cms/studio/sync-client.js +107 -0
- package/static/cms/studio/upload-client.js +109 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} DraftState
|
|
3
|
+
* @property {number} count
|
|
4
|
+
* @property {boolean} canPublish
|
|
5
|
+
* @property {boolean} retryRebuild
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {object} RetryDeploymentResult
|
|
9
|
+
* @property {boolean} deploymentTracked
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export class CmsDraftRequestError extends Error {}
|
|
13
|
+
|
|
14
|
+
/** @param {Response} response */
|
|
15
|
+
async function requireOk(response) {
|
|
16
|
+
if (!response.ok) {
|
|
17
|
+
throw new CmsDraftRequestError(`CMS request failed (${response.status})`);
|
|
18
|
+
}
|
|
19
|
+
return response;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {unknown} value */
|
|
23
|
+
function record(value) {
|
|
24
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
25
|
+
? /** @type {Record<string, unknown>} */ (value)
|
|
26
|
+
: {};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* HTTP client for editor-specific draft and deploy-retry operations.
|
|
31
|
+
*
|
|
32
|
+
* @param {{
|
|
33
|
+
* buildUrl: (path: string) => string,
|
|
34
|
+
* fetchImpl?: typeof fetch,
|
|
35
|
+
* }} options
|
|
36
|
+
*/
|
|
37
|
+
export function createDraftClient(options) {
|
|
38
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
39
|
+
const headers = { 'Content-Type': 'application/json', 'x-caret-request': '1' };
|
|
40
|
+
|
|
41
|
+
/** @param {'GET' | 'POST' | 'DELETE'} method @param {string} path @param {string | undefined} body */
|
|
42
|
+
function request(method, path, body) {
|
|
43
|
+
return fetchImpl(options.buildUrl(path), {
|
|
44
|
+
method,
|
|
45
|
+
headers,
|
|
46
|
+
credentials: 'same-origin',
|
|
47
|
+
...(body === undefined ? {} : { body }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function getState() {
|
|
52
|
+
const response = await requireOk(await request('GET', '/draft', undefined));
|
|
53
|
+
const data = record(await response.json());
|
|
54
|
+
return /** @type {DraftState} */ ({
|
|
55
|
+
count: typeof data.count === 'number' && Number.isFinite(data.count)
|
|
56
|
+
? Math.max(0, Math.trunc(data.count))
|
|
57
|
+
: 0,
|
|
58
|
+
canPublish: data.canPublish !== false,
|
|
59
|
+
retryRebuild: data.retryRebuild === true,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function publish() {
|
|
64
|
+
const response = await requireOk(await request('POST', '/publish', '{}'));
|
|
65
|
+
return /** @type {unknown} */ (await response.json());
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function discard() {
|
|
69
|
+
await requireOk(await request('DELETE', '/draft', undefined));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function retryDeployment() {
|
|
73
|
+
const response = await requireOk(await request(
|
|
74
|
+
'POST',
|
|
75
|
+
'/publish',
|
|
76
|
+
JSON.stringify({ retryRebuild: true }),
|
|
77
|
+
));
|
|
78
|
+
const data = record(await response.json());
|
|
79
|
+
const rebuild = record(data.rebuild);
|
|
80
|
+
if (rebuild.ok !== true) throw new CmsDraftRequestError('Deployment retry failed');
|
|
81
|
+
return /** @type {RetryDeploymentResult} */ ({
|
|
82
|
+
deploymentTracked: data.deploymentTracked === true,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { getState, publish, discard, retryDeployment };
|
|
87
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** @param {{ dirtyEls: Set<Element> }} state */
|
|
1
2
|
export function mountEditorGuards(state) {
|
|
2
3
|
document.body.classList.add('cms-edit-mode');
|
|
3
4
|
|
|
@@ -15,6 +16,25 @@ export function mountEditorGuards(state) {
|
|
|
15
16
|
const target = e.target;
|
|
16
17
|
if (!(target instanceof Element)) return;
|
|
17
18
|
|
|
19
|
+
// Preserve the browser's native open-in-new-tab gesture for editable
|
|
20
|
+
// links. A normal click still enters editing below.
|
|
21
|
+
if (e.metaKey || e.ctrlKey) {
|
|
22
|
+
const modifierLink = target.closest('a');
|
|
23
|
+
if (modifierLink?.matches('[data-caret]') || modifierLink?.querySelector('[data-caret]')) {
|
|
24
|
+
e.preventDefault();
|
|
25
|
+
e.stopPropagation();
|
|
26
|
+
try {
|
|
27
|
+
const destination = new URL(modifierLink.href, window.location.href);
|
|
28
|
+
if (['http:', 'https:', 'mailto:', 'tel:'].includes(destination.protocol)) {
|
|
29
|
+
window.open(destination.href, '_blank', 'noopener,noreferrer');
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
// Malformed destinations never execute from edit mode.
|
|
33
|
+
}
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
18
38
|
// Allow clicks inside the CMS toolbar
|
|
19
39
|
if (target.closest('.cms-toolbar')) return;
|
|
20
40
|
|
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} ParsedCaretBinding
|
|
3
|
+
* @property {string | null} collection
|
|
4
|
+
* @property {string | null} id
|
|
5
|
+
* @property {string} field
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {object} ResolvedCaretBinding
|
|
10
|
+
* @property {string} collection
|
|
11
|
+
* @property {string} id
|
|
12
|
+
* @property {string} field
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {string} attr
|
|
17
|
+
* @returns {ParsedCaretBinding | null}
|
|
18
|
+
*/
|
|
1
19
|
export function parseCaretAttr(attr) {
|
|
2
20
|
const parts = attr.split('::');
|
|
3
21
|
if (parts.length === 3) return { collection: parts[0], id: parts[1], field: parts[2] };
|
|
@@ -11,6 +29,9 @@ export function parseCaretAttr(attr) {
|
|
|
11
29
|
* If the binding already has collection+id, returns as-is.
|
|
12
30
|
* If field-only, walks up to nearest [data-caret-scope] ancestor.
|
|
13
31
|
* Returns null if scope cannot be resolved.
|
|
32
|
+
* @param {Element} el
|
|
33
|
+
* @param {ParsedCaretBinding | null} parsed
|
|
34
|
+
* @returns {ResolvedCaretBinding | null}
|
|
14
35
|
*/
|
|
15
36
|
export function resolveBinding(el, parsed) {
|
|
16
37
|
if (!parsed) return null;
|
|
@@ -18,7 +39,9 @@ export function resolveBinding(el, parsed) {
|
|
|
18
39
|
// segment ("::x::y") must stay a (rejected) triple like the server treats
|
|
19
40
|
// it, not silently fall back to scope resolution and save elsewhere.
|
|
20
41
|
// Held to the rewrite engine by tests/unit/caret-parser-parity.test.ts.
|
|
21
|
-
if (parsed.collection !== null && parsed.id !== null)
|
|
42
|
+
if (parsed.collection !== null && parsed.id !== null) {
|
|
43
|
+
return { collection: parsed.collection, id: parsed.id, field: parsed.field };
|
|
44
|
+
}
|
|
22
45
|
|
|
23
46
|
// Walk up to find nearest data-caret-scope
|
|
24
47
|
let node = el.parentElement;
|
|
@@ -38,6 +61,8 @@ export function resolveBinding(el, parsed) {
|
|
|
38
61
|
/**
|
|
39
62
|
* Get the full data-caret key for an element, resolving scope if needed.
|
|
40
63
|
* Returns "collection::id::field" or null if unresolvable.
|
|
64
|
+
* @param {Element} el
|
|
65
|
+
* @returns {string | null}
|
|
41
66
|
*/
|
|
42
67
|
export function getResolvedKey(el) {
|
|
43
68
|
const attr = el.getAttribute('data-caret');
|
|
@@ -48,6 +73,10 @@ export function getResolvedKey(el) {
|
|
|
48
73
|
return `${resolved.collection}::${resolved.id}::${resolved.field}`;
|
|
49
74
|
}
|
|
50
75
|
|
|
76
|
+
/**
|
|
77
|
+
* @param {Element} el
|
|
78
|
+
* @param {boolean} success
|
|
79
|
+
*/
|
|
51
80
|
export function flash(el, success) {
|
|
52
81
|
const cls = success ? 'cms-saved' : 'cms-error';
|
|
53
82
|
el.classList.add(cls);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
let highlightActive = false;
|
|
16
16
|
|
|
17
|
+
/** @param {Element} highlightBtn @param {(message: string, type: 'success' | 'error') => void} showToast */
|
|
17
18
|
export function toggleHighlight(highlightBtn, showToast) {
|
|
18
19
|
highlightActive = !highlightActive;
|
|
19
20
|
document.body.classList.toggle('cms-highlight-all', highlightActive);
|
|
@@ -2,6 +2,40 @@ import { buildCmsUrl } from './config.js';
|
|
|
2
2
|
import { uploadHeaders } from './security.js';
|
|
3
3
|
import { resolveBinding } from './helpers.js';
|
|
4
4
|
|
|
5
|
+
/** @typedef {{ collection: string, id: string, field: string }} ResolvedCaretBinding */
|
|
6
|
+
/** @typedef {{ collection: string | null, id: string | null, field: string }} ParsedCaretBinding */
|
|
7
|
+
/** @typedef {{ ok: true, revision?: number } | { ok: false, reason: 'conflict' | 'unauthorized' | 'error', latestValue?: unknown }} SaveResult */
|
|
8
|
+
/**
|
|
9
|
+
* @typedef {((message: string, kind: 'success' | 'error') => unknown) & {
|
|
10
|
+
* conflict: (options: {
|
|
11
|
+
* message: string,
|
|
12
|
+
* onKeepMine: () => void | Promise<void>,
|
|
13
|
+
* onLoadLatest: () => void,
|
|
14
|
+
* }) => unknown,
|
|
15
|
+
* }} ConflictToast
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {unknown} value
|
|
20
|
+
* @returns {string | null}
|
|
21
|
+
*/
|
|
22
|
+
export function readUploadUrl(value) {
|
|
23
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
24
|
+
const url = /** @type {Record<string, unknown>} */ (value).url;
|
|
25
|
+
return typeof url === 'string' && url.trim() ? url.trim() : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} options
|
|
30
|
+
* @param {(attr: string) => ParsedCaretBinding | null} options.parseCaretAttr
|
|
31
|
+
* @param {(element: Element, success: boolean) => void} options.flash
|
|
32
|
+
* @param {(file: File) => Promise<File>} options.compressImage
|
|
33
|
+
* @param {(image: HTMLImageElement, url: string) => void} options.updateEmblaCarousel
|
|
34
|
+
* @param {(collection: string, id: string, field: string, value: string) => Promise<SaveResult>} options.saveField
|
|
35
|
+
* @param {(state: 'saving' | 'error', text: string) => void} options.setStatus
|
|
36
|
+
* @param {ConflictToast} options.showToast
|
|
37
|
+
* @param {() => void} options.onUnauthorized
|
|
38
|
+
*/
|
|
5
39
|
export function mountImageEditors({
|
|
6
40
|
parseCaretAttr,
|
|
7
41
|
flash,
|
|
@@ -12,6 +46,7 @@ export function mountImageEditors({
|
|
|
12
46
|
showToast,
|
|
13
47
|
onUnauthorized,
|
|
14
48
|
}) {
|
|
49
|
+
/** @param {string} marker @param {string} src */
|
|
15
50
|
const syncBoundImages = (marker, src) => {
|
|
16
51
|
document.querySelectorAll('[data-caret]').forEach((node) => {
|
|
17
52
|
if (!(node instanceof HTMLImageElement)) return;
|
|
@@ -85,6 +120,12 @@ export function mountImageEditors({
|
|
|
85
120
|
// (we hold the stored `mineUrl`); only writing it into the entry conflicted.
|
|
86
121
|
// Keep the uploaded image shown and let the user choose, rather than
|
|
87
122
|
// silently swapping in whatever landed elsewhere.
|
|
123
|
+
/**
|
|
124
|
+
* @param {ResolvedCaretBinding} resolved
|
|
125
|
+
* @param {string} marker
|
|
126
|
+
* @param {string} mineUrl
|
|
127
|
+
* @param {unknown} latestValue
|
|
128
|
+
*/
|
|
88
129
|
const runImageConflict = (resolved, marker, mineUrl, latestValue) => {
|
|
89
130
|
showToast.conflict({
|
|
90
131
|
message: 'This image changed elsewhere while you were editing.',
|
|
@@ -146,7 +187,8 @@ export function mountImageEditors({
|
|
|
146
187
|
return;
|
|
147
188
|
}
|
|
148
189
|
if (!uploadRes.ok) throw new Error('Upload failed');
|
|
149
|
-
const
|
|
190
|
+
const url = readUploadUrl(await uploadRes.json());
|
|
191
|
+
if (!url) throw new Error('Invalid upload response');
|
|
150
192
|
|
|
151
193
|
// Point at the durable uploaded URL now, so the shown image survives the
|
|
152
194
|
// optimistic blob being revoked in `finally` (and any conflict prompt).
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @param {File} file
|
|
3
|
+
* @param {number} [maxWidth]
|
|
4
|
+
* @param {number} [quality]
|
|
5
|
+
* @returns {Promise<File>}
|
|
6
|
+
*/
|
|
1
7
|
export async function compressImage(file, maxWidth = 1600, quality = 0.82) {
|
|
2
8
|
if (file.size < 200000 && file.type === 'image/webp') return file;
|
|
3
9
|
|
|
@@ -22,6 +28,10 @@ export async function compressImage(file, maxWidth = 1600, quality = 0.82) {
|
|
|
22
28
|
canvas.width = width;
|
|
23
29
|
canvas.height = height;
|
|
24
30
|
const ctx = canvas.getContext('2d');
|
|
31
|
+
if (!ctx) {
|
|
32
|
+
bitmap.close();
|
|
33
|
+
return file;
|
|
34
|
+
}
|
|
25
35
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
26
36
|
bitmap.close();
|
|
27
37
|
|
|
@@ -29,7 +39,7 @@ export async function compressImage(file, maxWidth = 1600, quality = 0.82) {
|
|
|
29
39
|
const isPng = file.type === 'image/png';
|
|
30
40
|
const outType = isPng ? 'image/png' : 'image/webp';
|
|
31
41
|
|
|
32
|
-
const blob = await new Promise((resolve) =>
|
|
42
|
+
const blob = await new Promise((/** @type {(value: Blob | null) => void} */ resolve) =>
|
|
33
43
|
canvas.toBlob((b) => resolve(b), outType, isPng ? undefined : quality),
|
|
34
44
|
);
|
|
35
45
|
|
|
@@ -40,6 +50,10 @@ export async function compressImage(file, maxWidth = 1600, quality = 0.82) {
|
|
|
40
50
|
return new File([blob], name, { type: outType });
|
|
41
51
|
}
|
|
42
52
|
|
|
53
|
+
/**
|
|
54
|
+
* @param {HTMLImageElement} img
|
|
55
|
+
* @param {string} newUrl
|
|
56
|
+
*/
|
|
43
57
|
export function updateEmblaCarousel(img, newUrl) {
|
|
44
58
|
// Find the closest Embla container
|
|
45
59
|
const emblaContainer = img.closest('.embla');
|
|
@@ -47,10 +61,12 @@ export function updateEmblaCarousel(img, newUrl) {
|
|
|
47
61
|
|
|
48
62
|
// Find the slide this image belongs to
|
|
49
63
|
const slide = img.closest('.embla__slide');
|
|
50
|
-
if (!slide) return;
|
|
64
|
+
if (!(slide instanceof HTMLElement)) return;
|
|
51
65
|
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
66
|
+
const index = slide.dataset.index;
|
|
67
|
+
if (!index) return;
|
|
68
|
+
const slideIndex = Number.parseInt(index, 10);
|
|
69
|
+
if (Number.isNaN(slideIndex)) return;
|
|
54
70
|
|
|
55
71
|
// Update the data-full attribute for lightbox
|
|
56
72
|
slide.dataset.full = newUrl;
|
|
@@ -59,7 +75,7 @@ export function updateEmblaCarousel(img, newUrl) {
|
|
|
59
75
|
const thumbBtn = document.querySelector(`.thumb-btn[data-index="${slideIndex}"]`);
|
|
60
76
|
if (thumbBtn) {
|
|
61
77
|
const thumbImg = thumbBtn.querySelector('img');
|
|
62
|
-
if (thumbImg) {
|
|
78
|
+
if (thumbImg instanceof HTMLImageElement) {
|
|
63
79
|
thumbImg.src = newUrl;
|
|
64
80
|
// Add a flash effect to show it updated
|
|
65
81
|
thumbBtn.classList.add('cms-thumb-updated');
|
|
@@ -69,10 +85,10 @@ export function updateEmblaCarousel(img, newUrl) {
|
|
|
69
85
|
|
|
70
86
|
// Update lightbox thumbnails if visible
|
|
71
87
|
const lightboxThumb = document.querySelector(`.lightbox-thumb[data-index="${slideIndex}"]`);
|
|
72
|
-
if (lightboxThumb) {
|
|
88
|
+
if (lightboxThumb instanceof HTMLElement) {
|
|
73
89
|
lightboxThumb.dataset.src = newUrl;
|
|
74
90
|
const lbImg = lightboxThumb.querySelector('img');
|
|
75
|
-
if (lbImg) lbImg.src = newUrl;
|
|
91
|
+
if (lbImg instanceof HTMLImageElement) lbImg.src = newUrl;
|
|
76
92
|
}
|
|
77
93
|
|
|
78
94
|
// Dispatch custom event that the product page can listen to
|
|
@@ -5,22 +5,32 @@
|
|
|
5
5
|
* module restores a visible way to follow the link without leaving edit mode.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
/** @type {HTMLAnchorElement | null} */
|
|
8
9
|
let activeBtn = null;
|
|
10
|
+
/** @type {HTMLAnchorElement | null} */
|
|
9
11
|
let activeAnchor = null;
|
|
12
|
+
/** @type {number | null} */
|
|
10
13
|
let hideTimer = null;
|
|
11
14
|
|
|
15
|
+
/** @param {string | null} href @returns {boolean} */
|
|
12
16
|
function isFollowableHref(href) {
|
|
13
17
|
if (!href) return false;
|
|
14
18
|
const trimmed = href.trim();
|
|
15
19
|
if (!trimmed || trimmed === "#") return false;
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(trimmed, window.location.href);
|
|
22
|
+
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
18
26
|
}
|
|
19
27
|
|
|
28
|
+
/** @param {string} href @returns {boolean} */
|
|
20
29
|
function isExternal(href) {
|
|
21
30
|
return /^https?:\/\//i.test(href);
|
|
22
31
|
}
|
|
23
32
|
|
|
33
|
+
/** @param {string} href @returns {string} */
|
|
24
34
|
function shortLabel(href) {
|
|
25
35
|
if (isExternal(href)) {
|
|
26
36
|
try {
|
|
@@ -32,45 +42,94 @@ function shortLabel(href) {
|
|
|
32
42
|
return href;
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
function cancelHide() {
|
|
46
|
+
if (hideTimer !== null) {
|
|
47
|
+
clearTimeout(hideTimer);
|
|
48
|
+
hideTimer = null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
35
52
|
function removeBtn() {
|
|
36
53
|
if (activeBtn) {
|
|
37
54
|
activeBtn.remove();
|
|
38
55
|
activeBtn = null;
|
|
39
56
|
activeAnchor = null;
|
|
40
57
|
}
|
|
41
|
-
|
|
42
|
-
clearTimeout(hideTimer);
|
|
43
|
-
hideTimer = null;
|
|
44
|
-
}
|
|
58
|
+
cancelHide();
|
|
45
59
|
}
|
|
46
60
|
|
|
61
|
+
/** @param {HTMLAnchorElement} anchor @param {HTMLAnchorElement} btn */
|
|
47
62
|
function position(anchor, btn) {
|
|
63
|
+
if (!anchor.isConnected || !btn.isConnected) {
|
|
64
|
+
removeBtn();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
48
67
|
const rect = anchor.getBoundingClientRect();
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
68
|
+
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
|
69
|
+
removeBtn();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const gap = 8;
|
|
74
|
+
const margin = 8;
|
|
75
|
+
const width = btn.offsetWidth;
|
|
76
|
+
const height = btn.offsetHeight;
|
|
77
|
+
const candidates = [
|
|
78
|
+
{ left: rect.right + gap, top: rect.top + (rect.height - height) / 2 },
|
|
79
|
+
{ left: rect.left, top: rect.bottom + gap },
|
|
80
|
+
{ left: rect.left, top: rect.top - height - gap },
|
|
81
|
+
{ left: rect.left - width - gap, top: rect.top + (rect.height - height) / 2 },
|
|
82
|
+
];
|
|
83
|
+
const otherEditables = Array.from(document.querySelectorAll('[data-caret]'))
|
|
84
|
+
.filter((el) => !anchor.contains(el))
|
|
85
|
+
.map((el) => el.getBoundingClientRect());
|
|
86
|
+
|
|
87
|
+
/** @param {{ left: number, top: number }} candidate */
|
|
88
|
+
function overlapsEditable(candidate) {
|
|
89
|
+
const box = {
|
|
90
|
+
left: candidate.left,
|
|
91
|
+
top: candidate.top,
|
|
92
|
+
right: candidate.left + width,
|
|
93
|
+
bottom: candidate.top + height,
|
|
94
|
+
};
|
|
95
|
+
return otherEditables.some((other) =>
|
|
96
|
+
box.left < other.right && box.right > other.left && box.top < other.bottom && box.bottom > other.top
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const placed = candidates.find((candidate) =>
|
|
101
|
+
candidate.left >= margin
|
|
102
|
+
&& candidate.top >= margin
|
|
103
|
+
&& candidate.left + width <= window.innerWidth - margin
|
|
104
|
+
&& candidate.top + height <= window.innerHeight - margin
|
|
105
|
+
&& !overlapsEditable(candidate)
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
btn.classList.toggle('cms-link-follow-compact', !placed);
|
|
109
|
+
const fallback = {
|
|
110
|
+
left: Math.min(window.innerWidth - width - margin, Math.max(margin, rect.right - width)),
|
|
111
|
+
top: Math.min(window.innerHeight - height - margin, Math.max(margin, rect.top)),
|
|
112
|
+
};
|
|
113
|
+
const target = placed || fallback;
|
|
114
|
+
btn.style.top = `${target.top}px`;
|
|
115
|
+
btn.style.left = `${target.left}px`;
|
|
55
116
|
}
|
|
56
117
|
|
|
57
118
|
function scheduleHide() {
|
|
58
|
-
|
|
119
|
+
cancelHide();
|
|
59
120
|
hideTimer = window.setTimeout(removeBtn, 180);
|
|
60
121
|
}
|
|
61
122
|
|
|
123
|
+
/** @param {HTMLAnchorElement} anchor */
|
|
62
124
|
function show(anchor) {
|
|
63
125
|
if (activeAnchor === anchor && activeBtn) {
|
|
64
|
-
|
|
65
|
-
clearTimeout(hideTimer);
|
|
66
|
-
hideTimer = null;
|
|
67
|
-
}
|
|
126
|
+
cancelHide();
|
|
68
127
|
return;
|
|
69
128
|
}
|
|
70
129
|
removeBtn();
|
|
71
130
|
|
|
72
131
|
const href = anchor.getAttribute("href");
|
|
73
|
-
if (!isFollowableHref(href)) return;
|
|
132
|
+
if (!isFollowableHref(href) || typeof href !== 'string') return;
|
|
74
133
|
|
|
75
134
|
const external = isExternal(href);
|
|
76
135
|
const btn = document.createElement("a");
|
|
@@ -81,20 +140,20 @@ function show(anchor) {
|
|
|
81
140
|
btn.target = "_blank";
|
|
82
141
|
btn.rel = "noopener noreferrer";
|
|
83
142
|
}
|
|
84
|
-
btn.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
`;
|
|
143
|
+
btn.setAttribute('aria-label', `Open ${external ? "link" : "page"}: ${shortLabel(href)}`);
|
|
144
|
+
const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
145
|
+
icon.setAttribute('viewBox', '0 0 16 16');
|
|
146
|
+
icon.setAttribute('fill', 'none');
|
|
147
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
148
|
+
icon.innerHTML = '<path d="M5 11 L11 5" stroke="currentColor" stroke-width="1.4" stroke-linecap="square"/><path d="M6 5 L11 5 L11 10" stroke="currentColor" stroke-width="1.4" stroke-linecap="square"/>';
|
|
149
|
+
const label = document.createElement('span');
|
|
150
|
+
label.textContent = `Open ${external ? "link" : "page"}`;
|
|
151
|
+
const destination = document.createElement('em');
|
|
152
|
+
destination.textContent = shortLabel(href);
|
|
153
|
+
btn.append(icon, label, destination);
|
|
92
154
|
|
|
93
155
|
btn.addEventListener("mouseenter", () => {
|
|
94
|
-
|
|
95
|
-
clearTimeout(hideTimer);
|
|
96
|
-
hideTimer = null;
|
|
97
|
-
}
|
|
156
|
+
cancelHide();
|
|
98
157
|
});
|
|
99
158
|
btn.addEventListener("mouseleave", scheduleHide);
|
|
100
159
|
|
|
@@ -105,18 +164,27 @@ function show(anchor) {
|
|
|
105
164
|
}
|
|
106
165
|
|
|
107
166
|
export function mountLinkFollowAffordances() {
|
|
167
|
+
/** @param {Element} target @returns {Element | null} */
|
|
168
|
+
function editableFromTarget(target) {
|
|
169
|
+
const direct = target.closest('[data-caret]');
|
|
170
|
+
if (direct) return direct;
|
|
171
|
+
return target.closest('.cms-img-wrapper')?.querySelector('img[data-caret]') || null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** @param {Element} editable @returns {HTMLAnchorElement | null} */
|
|
175
|
+
function anchorFromEditable(editable) {
|
|
176
|
+
return editable instanceof HTMLAnchorElement ? editable : editable.closest('a');
|
|
177
|
+
}
|
|
178
|
+
|
|
108
179
|
document.addEventListener("mouseover", (e) => {
|
|
109
180
|
const target = e.target;
|
|
110
181
|
if (!(target instanceof Element)) return;
|
|
111
182
|
if (target.closest(".cms-link-follow")) return;
|
|
112
183
|
|
|
113
|
-
const editable = target
|
|
184
|
+
const editable = editableFromTarget(target);
|
|
114
185
|
if (!editable) return;
|
|
115
186
|
|
|
116
|
-
|
|
117
|
-
if (editable instanceof HTMLImageElement) return;
|
|
118
|
-
|
|
119
|
-
const anchor = editable.matches("a") ? editable : editable.closest("a");
|
|
187
|
+
const anchor = anchorFromEditable(editable);
|
|
120
188
|
if (!anchor) return;
|
|
121
189
|
|
|
122
190
|
show(anchor);
|
|
@@ -127,12 +195,8 @@ export function mountLinkFollowAffordances() {
|
|
|
127
195
|
if (!(target instanceof Element)) return;
|
|
128
196
|
if (target.closest(".cms-link-follow")) return;
|
|
129
197
|
|
|
130
|
-
const editable = target
|
|
131
|
-
const anchor = editable
|
|
132
|
-
? editable.matches("a")
|
|
133
|
-
? editable
|
|
134
|
-
: editable.closest("a")
|
|
135
|
-
: null;
|
|
198
|
+
const editable = editableFromTarget(target);
|
|
199
|
+
const anchor = editable ? anchorFromEditable(editable) : null;
|
|
136
200
|
if (anchor === activeAnchor) scheduleHide();
|
|
137
201
|
});
|
|
138
202
|
|
|
@@ -141,4 +205,21 @@ export function mountLinkFollowAffordances() {
|
|
|
141
205
|
};
|
|
142
206
|
window.addEventListener("scroll", reposition, { passive: true });
|
|
143
207
|
window.addEventListener("resize", reposition);
|
|
208
|
+
|
|
209
|
+
document.addEventListener('focusin', (e) => {
|
|
210
|
+
const target = e.target;
|
|
211
|
+
if (!(target instanceof Element)) return;
|
|
212
|
+
if (target.closest('.cms-link-follow')) {
|
|
213
|
+
cancelHide();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const editable = editableFromTarget(target);
|
|
217
|
+
const anchor = editable ? anchorFromEditable(editable) : null;
|
|
218
|
+
if (anchor) show(anchor);
|
|
219
|
+
});
|
|
220
|
+
document.addEventListener('focusout', (e) => {
|
|
221
|
+
const target = e.target;
|
|
222
|
+
if (!(target instanceof Element)) return;
|
|
223
|
+
if (editableFromTarget(target)) scheduleHide();
|
|
224
|
+
});
|
|
144
225
|
}
|
|
@@ -148,7 +148,7 @@
|
|
|
148
148
|
--------------------------------------------------------------------------- */
|
|
149
149
|
|
|
150
150
|
.cms-link-follow {
|
|
151
|
-
position:
|
|
151
|
+
position: fixed;
|
|
152
152
|
z-index: 100000;
|
|
153
153
|
display: inline-flex;
|
|
154
154
|
align-items: center;
|
|
@@ -169,6 +169,15 @@
|
|
|
169
169
|
animation: cms-link-follow-in 0.16s ease-out;
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
.cms-link-follow.cms-link-follow-compact span,
|
|
173
|
+
.cms-link-follow.cms-link-follow-compact em {
|
|
174
|
+
display: none;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.cms-link-follow.cms-link-follow-compact {
|
|
178
|
+
padding: 7px;
|
|
179
|
+
}
|
|
180
|
+
|
|
172
181
|
.cms-link-follow:hover {
|
|
173
182
|
background: rgba(59, 130, 246, 0.18);
|
|
174
183
|
border-color: var(--cms-accent);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** @param {string} s */
|
|
1
2
|
export function escapeHtml(s) {
|
|
2
3
|
return s
|
|
3
4
|
.replace(/&/g, '&')
|
|
@@ -6,6 +7,7 @@ export function escapeHtml(s) {
|
|
|
6
7
|
.replace(/"/g, '"');
|
|
7
8
|
}
|
|
8
9
|
|
|
10
|
+
/** @param {string} text */
|
|
9
11
|
export function clientLinkify(text) {
|
|
10
12
|
if (!text) return '';
|
|
11
13
|
|