@charlescms/astro 0.1.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 +366 -0
- package/SECURITY.md +77 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/connector/worker.js +505 -0
- package/connector/wrangler.toml +15 -0
- package/package.json +92 -0
- package/scripts/check-licenses.js +45 -0
- package/scripts/check-package.js +62 -0
- package/scripts/setup.js +719 -0
- package/scripts/update-vendored-site.js +71 -0
- package/src/admin.astro +314 -0
- package/src/analyzer.js +639 -0
- package/src/asset-images.js +130 -0
- package/src/astro-frontmatter.js +17 -0
- package/src/boot.js +35 -0
- package/src/client.js +347 -0
- package/src/connector-client.js +185 -0
- package/src/content-bridge.js +162 -0
- package/src/content-panel.js +440 -0
- package/src/data-analyzer.js +304 -0
- package/src/edit-affordance.js +463 -0
- package/src/editor-styles.js +243 -0
- package/src/element-editor.js +355 -0
- package/src/fields.js +6 -0
- package/src/frontmatter.js +153 -0
- package/src/ids.js +20 -0
- package/src/index.js +681 -0
- package/src/js-ast.js +140 -0
- package/src/markdown-analyzer.js +95 -0
- package/src/media-preview.js +58 -0
- package/src/panel-manager.js +133 -0
- package/src/publishing.js +457 -0
- package/src/rich-text-editor.js +209 -0
- package/src/routes.js +21 -0
- package/src/runtime-controller.js +206 -0
- package/src/sanitize.js +150 -0
- package/src/section-editor.js +437 -0
- package/src/source-edit.js +310 -0
- package/src/source-map-runtime.js +184 -0
- package/src/staged-panel.js +145 -0
- package/src/toolbar.js +128 -0
- package/src/versions-panel.js +112 -0
package/src/js-ast.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { parse, parseExpression } from "@babel/parser";
|
|
2
|
+
|
|
3
|
+
// AST-based reading of Astro frontmatter, using the Babel parser (full JS/TS
|
|
4
|
+
// grammar, exact source offsets). This is what lets arbitrarily nested const
|
|
5
|
+
// data structures resolve to a precise byte span. It is still NOT a JavaScript
|
|
6
|
+
// evaluator: only string/object/array literal shapes become resolvable; calls,
|
|
7
|
+
// template literals, conditions and identifiers stay dynamic. Anything Babel
|
|
8
|
+
// cannot parse returns null so the caller falls back to the conservative
|
|
9
|
+
// string-aware scanner — a parse problem degrades to a safe skip, never a write.
|
|
10
|
+
const PARSE_OPTIONS = { sourceType: "module", plugins: ["typescript", "topLevelAwait"] };
|
|
11
|
+
|
|
12
|
+
export function parseFrontmatterModule(code) {
|
|
13
|
+
try {
|
|
14
|
+
return parse(code, PARSE_OPTIONS);
|
|
15
|
+
} catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Parses a bare value (a JSON data-collection file is one object/array, not a
|
|
21
|
+
// program). Returns the value node or null when it cannot be parsed.
|
|
22
|
+
export function parseValueExpression(code) {
|
|
23
|
+
try {
|
|
24
|
+
return parseExpression(code, { plugins: ["typescript"] });
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Strips the type-only wrappers that can sit around a value (`x as const`,
|
|
31
|
+
// `x satisfies T`, `x!`, `(x)`) so callers see the underlying literal.
|
|
32
|
+
export function unwrapValue(node) {
|
|
33
|
+
let current = node;
|
|
34
|
+
while (current && (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression"
|
|
35
|
+
|| current.type === "TSNonNullExpression" || current.type === "ParenthesizedExpression")) {
|
|
36
|
+
current = current.expression;
|
|
37
|
+
}
|
|
38
|
+
return current;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Builds a map of `const NAME` → value tree for every top-level const whose
|
|
42
|
+
// initializer is a resolvable literal shape. Returns null when the frontmatter
|
|
43
|
+
// cannot be parsed, signalling the caller to use the scanner fallback.
|
|
44
|
+
export function buildConstantTrees(code, baseOffset) {
|
|
45
|
+
const ast = parseFrontmatterModule(code);
|
|
46
|
+
if (!ast) return null;
|
|
47
|
+
const trees = new Map();
|
|
48
|
+
for (const statement of ast.program.body) {
|
|
49
|
+
if (statement.type !== "VariableDeclaration" || statement.kind !== "const") continue;
|
|
50
|
+
for (const declaration of statement.declarations) {
|
|
51
|
+
if (declaration.id.type !== "Identifier" || !declaration.init) continue;
|
|
52
|
+
const tree = toValueTree(declaration.init, code, baseOffset);
|
|
53
|
+
if (tree) trees.set(declaration.id.name, tree);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return trees;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Resolves a template expression (e.g. `team[0].links.site`) against the const
|
|
60
|
+
// trees, returning the exact source span of the string it points at, or null if
|
|
61
|
+
// the path is dynamic, not a pure data access, or does not land on a string.
|
|
62
|
+
export function resolveExpressionTree(trees, expression) {
|
|
63
|
+
let node;
|
|
64
|
+
try {
|
|
65
|
+
node = parseExpression(expression, { plugins: ["typescript"] });
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const resolved = walkPath(trees, node);
|
|
70
|
+
return resolved?.type === "string"
|
|
71
|
+
? { value: resolved.value, start: resolved.start, end: resolved.end }
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Reduces an initializer AST to the minimal tree we can resolve to exact string
|
|
76
|
+
// spans. Offsets are mapped from the parsed slice to absolute source offsets via
|
|
77
|
+
// baseOffset. Escaped strings are dropped (kept locked) so the existing write
|
|
78
|
+
// path is never asked to re-escape into a JS string literal.
|
|
79
|
+
function toValueTree(node, code, baseOffset) {
|
|
80
|
+
if (!node) return null;
|
|
81
|
+
switch (node.type) {
|
|
82
|
+
case "StringLiteral": {
|
|
83
|
+
const start = node.start + 1;
|
|
84
|
+
const end = node.end - 1;
|
|
85
|
+
const value = code.slice(start, end);
|
|
86
|
+
if (value.includes("\\")) return null;
|
|
87
|
+
return { type: "string", value, start: baseOffset + start, end: baseOffset + end };
|
|
88
|
+
}
|
|
89
|
+
case "ObjectExpression": {
|
|
90
|
+
const props = new Map();
|
|
91
|
+
for (const property of node.properties) {
|
|
92
|
+
if (property.type !== "ObjectProperty" || property.computed) continue;
|
|
93
|
+
const key = objectKey(property.key);
|
|
94
|
+
if (key == null) continue;
|
|
95
|
+
const child = toValueTree(property.value, code, baseOffset);
|
|
96
|
+
if (child) props.set(key, child);
|
|
97
|
+
}
|
|
98
|
+
return { type: "object", props };
|
|
99
|
+
}
|
|
100
|
+
case "ArrayExpression":
|
|
101
|
+
// Holes (sparse arrays) and non-resolvable elements stay as null so index
|
|
102
|
+
// access lines up with the source positions.
|
|
103
|
+
return { type: "array", elements: node.elements.map((element) => toValueTree(element, code, baseOffset)) };
|
|
104
|
+
case "TSAsExpression":
|
|
105
|
+
case "TSSatisfiesExpression":
|
|
106
|
+
case "TSNonNullExpression":
|
|
107
|
+
// `[...] as const`, `x satisfies T`, `x!` — unwrap the type wrapper.
|
|
108
|
+
return toValueTree(node.expression, code, baseOffset);
|
|
109
|
+
default:
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function objectKey(key) {
|
|
115
|
+
if (key.type === "Identifier") return key.name;
|
|
116
|
+
if (key.type === "StringLiteral") return key.value;
|
|
117
|
+
if (key.type === "NumericLiteral") return String(key.value);
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Walks a member/index access chain against the value trees. Only plain data
|
|
122
|
+
// access is followed — a call, optional chain, or computed non-literal property
|
|
123
|
+
// returns null so the expression stays dynamic.
|
|
124
|
+
function walkPath(trees, node) {
|
|
125
|
+
if (!node) return null;
|
|
126
|
+
if (node.type === "Identifier") return trees.get(node.name) || null;
|
|
127
|
+
if (node.type === "TSNonNullExpression") return walkPath(trees, node.expression);
|
|
128
|
+
if (node.type === "MemberExpression" && !node.optional) {
|
|
129
|
+
const base = walkPath(trees, node.object);
|
|
130
|
+
if (!base) return null;
|
|
131
|
+
if (node.computed) {
|
|
132
|
+
const property = node.property;
|
|
133
|
+
if (property.type === "NumericLiteral" && base.type === "array") return base.elements[property.value] || null;
|
|
134
|
+
if (property.type === "StringLiteral" && base.type === "object") return base.props.get(property.value) || null;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (node.property.type === "Identifier" && base.type === "object") return base.props.get(node.property.name) || null;
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { fromMarkdown } from "mdast-util-from-markdown";
|
|
2
|
+
import { gfmFromMarkdown } from "mdast-util-gfm";
|
|
3
|
+
import { toString } from "mdast-util-to-string";
|
|
4
|
+
import { gfm } from "micromark-extension-gfm";
|
|
5
|
+
import { getFrontmatter } from "./analyzer.js";
|
|
6
|
+
import { createEntryId } from "./ids.js";
|
|
7
|
+
|
|
8
|
+
// Maps safe Markdown blocks to exact source ranges. Complex blocks such as code,
|
|
9
|
+
// tables, raw HTML, and ambiguous MDX stay locked because a visual edit could
|
|
10
|
+
// otherwise change their structure or meaning.
|
|
11
|
+
const allowedMarks = new Set(["text", "emphasis", "strong", "inlineCode", "link", "break"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Map safe Markdown body blocks to exact source ranges for editing.
|
|
15
|
+
*
|
|
16
|
+
* Complex blocks (code, tables, raw HTML, ambiguous MDX) stay locked because a
|
|
17
|
+
* visual edit could change their structure or meaning.
|
|
18
|
+
*
|
|
19
|
+
* @param {string} source Full `.md`/`.mdx` file contents.
|
|
20
|
+
* @param {string} file File path; non-Markdown inputs return no entries.
|
|
21
|
+
* @returns {{entries: Object}} Map of entry id → Markdown block descriptor.
|
|
22
|
+
*/
|
|
23
|
+
export function transformMarkdownSource(source, file) {
|
|
24
|
+
if (!/\.(?:md|mdx)$/.test(file)) return { entries: {} };
|
|
25
|
+
const frontmatter = getFrontmatter(source);
|
|
26
|
+
const bodyOffset = frontmatter?.bodyOffset || 0;
|
|
27
|
+
const body = source.slice(bodyOffset);
|
|
28
|
+
let tree;
|
|
29
|
+
try {
|
|
30
|
+
tree = fromMarkdown(body, {
|
|
31
|
+
extensions: [gfm()],
|
|
32
|
+
mdastExtensions: [gfmFromMarkdown()]
|
|
33
|
+
});
|
|
34
|
+
} catch {
|
|
35
|
+
return { entries: {} };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const entries = {};
|
|
39
|
+
for (const node of tree.children || []) {
|
|
40
|
+
if (node.type === "heading") {
|
|
41
|
+
addInlineEntry(entries, source, file, node, `h${node.depth}`, bodyOffset);
|
|
42
|
+
} else if (node.type === "paragraph") {
|
|
43
|
+
addInlineEntry(entries, source, file, node, "p", bodyOffset);
|
|
44
|
+
} else if (node.type === "blockquote") {
|
|
45
|
+
const paragraph = singleParagraph(node.children);
|
|
46
|
+
if (paragraph) addInlineEntry(entries, source, file, paragraph, "blockquote", bodyOffset);
|
|
47
|
+
} else if (node.type === "list") {
|
|
48
|
+
for (const item of node.children || []) {
|
|
49
|
+
const paragraph = singleParagraph(item.children);
|
|
50
|
+
if (paragraph) addInlineEntry(entries, source, file, paragraph, "li", bodyOffset);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { entries };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addInlineEntry(entries, source, file, node, tag, bodyOffset) {
|
|
58
|
+
const children = node.children || [];
|
|
59
|
+
if (children.length === 0 || !children.every(isAllowedInlineNode)) return;
|
|
60
|
+
const first = children[0]?.position?.start?.offset;
|
|
61
|
+
const last = children.at(-1)?.position?.end?.offset;
|
|
62
|
+
if (!Number.isInteger(first) || !Number.isInteger(last) || last <= first) return;
|
|
63
|
+
|
|
64
|
+
const start = bodyOffset + first;
|
|
65
|
+
const end = bodyOffset + last;
|
|
66
|
+
const oldValue = source.slice(start, end);
|
|
67
|
+
if (!oldValue.trim() || isAmbiguousMdx(file, oldValue)) return;
|
|
68
|
+
|
|
69
|
+
// Every inline block is edited as rich text (bold, italic, inline code,
|
|
70
|
+
// links), so the experience is consistent and a plain paragraph can gain
|
|
71
|
+
// formatting. The serializer round-trips it back to safe inline Markdown.
|
|
72
|
+
const operation = { kind: "markdown-block", name: "body", start, end, oldValue };
|
|
73
|
+
const id = createEntryId(file, [operation]);
|
|
74
|
+
entries[id] = {
|
|
75
|
+
id,
|
|
76
|
+
file,
|
|
77
|
+
tag,
|
|
78
|
+
text: toString(node),
|
|
79
|
+
operations: [operation]
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isAllowedInlineNode(node) {
|
|
84
|
+
if (!allowedMarks.has(node.type)) return false;
|
|
85
|
+
if (node.type === "link" && typeof node.url !== "string") return false;
|
|
86
|
+
return (node.children || []).every(isAllowedInlineNode);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function singleParagraph(children = []) {
|
|
90
|
+
return children.length === 1 && children[0].type === "paragraph" ? children[0] : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isAmbiguousMdx(file, value) {
|
|
94
|
+
return file.endsWith(".mdx") && (/<\/?[A-Za-z]|[{}]/.test(value));
|
|
95
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// In-session previews for replaced/uploaded images.
|
|
2
|
+
//
|
|
3
|
+
// A static host doesn't serve a newly committed asset until the site rebuilds, so
|
|
4
|
+
// pointing an <img> straight at the new path shows a broken image until then — and
|
|
5
|
+
// the preview is lost the moment you navigate away. This registry remembers the
|
|
6
|
+
// chosen file as a blob URL keyed by its committed public path, shows it instantly,
|
|
7
|
+
// and re-applies it every time the editor re-binds the page (across SPA
|
|
8
|
+
// navigation). The committed PATH always remains the value written to source; the
|
|
9
|
+
// blob is display-only and is forgotten on a full reload, by which point the real
|
|
10
|
+
// asset is normally live.
|
|
11
|
+
const previews = new Map(); // "/uploads/x.jpg" -> blob: URL
|
|
12
|
+
|
|
13
|
+
function normalizePath(path) {
|
|
14
|
+
return String(path || "").split(/[?#]/)[0];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Map a repo path (public/uploads/x.jpg) or a public URL (/uploads/x.jpg) to the
|
|
18
|
+
// site-absolute path an <img src> uses.
|
|
19
|
+
export function publicPathFromRepoPath(repoPath) {
|
|
20
|
+
const clean = normalizePath(repoPath).replace(/^\/+/, "");
|
|
21
|
+
if (!clean) return "";
|
|
22
|
+
return `/${clean.startsWith("public/") ? clean.slice("public/".length) : clean}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerMediaPreview(publicPath, file) {
|
|
26
|
+
const key = normalizePath(publicPath);
|
|
27
|
+
if (!key || !file) return;
|
|
28
|
+
try {
|
|
29
|
+
const previous = previews.get(key);
|
|
30
|
+
if (previous) URL.revokeObjectURL(previous);
|
|
31
|
+
previews.set(key, URL.createObjectURL(file));
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function mediaPreviewFor(path) {
|
|
36
|
+
return previews.get(normalizePath(path));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The real committed path for an element, never the display-only blob — used for
|
|
40
|
+
// source writes and for reading a field's value back.
|
|
41
|
+
export function mediaSrcValue(element) {
|
|
42
|
+
return element?.dataset?.charlescmsMediaPath || element?.getAttribute?.("src") || "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Point every <img>/<source> whose committed path has a registered preview at the
|
|
46
|
+
// blob, remembering the real path in a data attribute so reads and serialization
|
|
47
|
+
// keep using the path. Safe to call repeatedly (idempotent).
|
|
48
|
+
export function applyMediaPreviews(root = document) {
|
|
49
|
+
if (!previews.size || !root?.querySelectorAll) return;
|
|
50
|
+
for (const node of root.querySelectorAll("img, source")) {
|
|
51
|
+
const path = normalizePath(node.dataset.charlescmsMediaPath || node.getAttribute("src"));
|
|
52
|
+
const url = previews.get(path);
|
|
53
|
+
if (url && node.getAttribute("src") !== url) {
|
|
54
|
+
node.dataset.charlescmsMediaPath = path;
|
|
55
|
+
node.setAttribute("src", url);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Owns the lifecycle and viewport placement of floating editor panels.
|
|
2
|
+
//
|
|
3
|
+
// Feature modules only create panel markup and call `mountPanel`. This module
|
|
4
|
+
// centralizes cleanup, resize/scroll listeners, mobile placement, and the rule
|
|
5
|
+
// that a panel should avoid covering the element being edited.
|
|
6
|
+
export function createPanelManager({ state }) {
|
|
7
|
+
function closeEditor() {
|
|
8
|
+
// Inline editors provide their own finish function because they temporarily
|
|
9
|
+
// replace page content instead of mounting a separate panel.
|
|
10
|
+
if (state.active?.inline) {
|
|
11
|
+
state.active.finish(false);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
state.active?.cleanup?.();
|
|
15
|
+
state.active?.dialog?.remove();
|
|
16
|
+
state.active = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function mountPanel(dialog, { item = null } = {}) {
|
|
20
|
+
document.body.append(dialog);
|
|
21
|
+
const anchor = item?.element || null;
|
|
22
|
+
const position = () => positionPanel(dialog, anchor);
|
|
23
|
+
const observer = new ResizeObserver(position);
|
|
24
|
+
observer.observe(dialog);
|
|
25
|
+
window.addEventListener("resize", position);
|
|
26
|
+
window.addEventListener("scroll", position, true);
|
|
27
|
+
// Enter confirms the panel — a single keystroke to save, like any form. In a
|
|
28
|
+
// multi-line textarea, plain Enter still inserts a newline; Cmd/Ctrl+Enter
|
|
29
|
+
// saves there. Escape closes (handled by the panel's own Cancel/×).
|
|
30
|
+
const onKeydown = (event) => {
|
|
31
|
+
if (event.key !== "Enter" || event.isComposing) return;
|
|
32
|
+
if (event.target?.tagName === "BUTTON") return;
|
|
33
|
+
if (event.target?.tagName === "TEXTAREA" && !(event.metaKey || event.ctrlKey)) return;
|
|
34
|
+
const save = dialog.querySelector("[data-save], [data-save-section], [data-draft-save]");
|
|
35
|
+
if (save && !save.disabled) { event.preventDefault(); save.click(); }
|
|
36
|
+
};
|
|
37
|
+
dialog.addEventListener("keydown", onKeydown);
|
|
38
|
+
requestAnimationFrame(() => {
|
|
39
|
+
position();
|
|
40
|
+
if (anchor) keepAnchorVisible(anchor, dialog);
|
|
41
|
+
});
|
|
42
|
+
state.active = {
|
|
43
|
+
dialog,
|
|
44
|
+
item,
|
|
45
|
+
cleanup() {
|
|
46
|
+
observer.disconnect();
|
|
47
|
+
window.removeEventListener("resize", position);
|
|
48
|
+
window.removeEventListener("scroll", position, true);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function positionPanel(dialog, anchor) {
|
|
54
|
+
const viewport = { width: window.innerWidth, height: window.innerHeight };
|
|
55
|
+
const toolbarTop = state.toolbar?.getBoundingClientRect().top || viewport.height;
|
|
56
|
+
const margin = viewport.width <= 700 ? 12 : 16;
|
|
57
|
+
const availableBottom = toolbarTop - margin;
|
|
58
|
+
dialog.style.maxHeight = `${Math.max(220, availableBottom - margin)}px`;
|
|
59
|
+
|
|
60
|
+
// Mobile panels use the full safe width above the toolbar.
|
|
61
|
+
if (viewport.width <= 700) {
|
|
62
|
+
dialog.style.left = `${margin}px`;
|
|
63
|
+
dialog.style.right = `${margin}px`;
|
|
64
|
+
dialog.style.top = "auto";
|
|
65
|
+
dialog.style.bottom = `${Math.max(margin, viewport.height - toolbarTop + margin)}px`;
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Utility panels such as Content and Versions have no page anchor. Keep
|
|
70
|
+
// those predictably docked top-right instead of inheriting mobile geometry.
|
|
71
|
+
if (!anchor) {
|
|
72
|
+
dialog.style.left = "auto";
|
|
73
|
+
dialog.style.right = `${margin}px`;
|
|
74
|
+
dialog.style.top = `${margin}px`;
|
|
75
|
+
dialog.style.bottom = "auto";
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Anchored desktop editors sit beside the selected element and preserve as
|
|
80
|
+
// much of the live preview as possible.
|
|
81
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
82
|
+
const panelRect = dialog.getBoundingClientRect();
|
|
83
|
+
const width = panelRect.width;
|
|
84
|
+
const height = Math.min(panelRect.height, availableBottom - margin);
|
|
85
|
+
const gap = 16;
|
|
86
|
+
const clampX = (value) => Math.max(margin, Math.min(value, viewport.width - width - margin));
|
|
87
|
+
const clampY = (value) => Math.max(margin, Math.min(value, availableBottom - height));
|
|
88
|
+
const candidates = [
|
|
89
|
+
{ x: anchorRect.right + gap, y: clampY(anchorRect.top) },
|
|
90
|
+
{ x: anchorRect.left - width - gap, y: clampY(anchorRect.top) },
|
|
91
|
+
{ x: clampX(anchorRect.left), y: anchorRect.bottom + gap },
|
|
92
|
+
{ x: clampX(anchorRect.left), y: anchorRect.top - height - gap },
|
|
93
|
+
{ x: viewport.width - width - margin, y: margin },
|
|
94
|
+
{ x: margin, y: margin }
|
|
95
|
+
].map(({ x, y }) => ({ x: clampX(x), y: clampY(y) }));
|
|
96
|
+
|
|
97
|
+
// Prefer zero overlap, then the shortest visual distance from the anchor.
|
|
98
|
+
const best = candidates
|
|
99
|
+
.map((candidate) => ({
|
|
100
|
+
...candidate,
|
|
101
|
+
score: overlapArea(
|
|
102
|
+
{ left: candidate.x, right: candidate.x + width, top: candidate.y, bottom: candidate.y + height },
|
|
103
|
+
anchorRect
|
|
104
|
+
) * 1000 + Math.abs(candidate.x - anchorRect.left) + Math.abs(candidate.y - anchorRect.top)
|
|
105
|
+
}))
|
|
106
|
+
.sort((a, b) => a.score - b.score)[0];
|
|
107
|
+
|
|
108
|
+
dialog.style.left = `${best.x}px`;
|
|
109
|
+
dialog.style.right = "auto";
|
|
110
|
+
dialog.style.top = `${best.y}px`;
|
|
111
|
+
dialog.style.bottom = "auto";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function keepAnchorVisible(anchor, dialog) {
|
|
115
|
+
if (window.innerWidth > 700) return;
|
|
116
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
117
|
+
const safeTop = 16;
|
|
118
|
+
const safeBottom = dialog.getBoundingClientRect().top - 16;
|
|
119
|
+
if (anchorRect.bottom > safeBottom) {
|
|
120
|
+
window.scrollBy({ top: anchorRect.bottom - safeBottom, behavior: "smooth" });
|
|
121
|
+
} else if (anchorRect.top < safeTop) {
|
|
122
|
+
window.scrollBy({ top: anchorRect.top - safeTop, behavior: "smooth" });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { closeEditor, mountPanel };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function overlapArea(a, b) {
|
|
130
|
+
const width = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
|
|
131
|
+
const height = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
|
|
132
|
+
return width * height;
|
|
133
|
+
}
|