Initial commit for Coolify deploy
This commit is contained in:
@@ -0,0 +1 @@
|
||||
51
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"registries": {}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import importPlugin from 'eslint-plugin-import';
|
||||
import react from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import globals from 'globals';
|
||||
import unicodeEscapePlugin from './eslint.unicode-escapes-plugin.mjs';
|
||||
|
||||
export default [
|
||||
{ ignores: ['node_modules/**', 'dist/**', 'build/**', 'vite.config.js'] },
|
||||
{
|
||||
files: ['**/*.js', '**/*.jsx'],
|
||||
plugins: { react, 'react-hooks': reactHooks, 'import': importPlugin },
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
globals: { ...globals.browser, React: 'readonly', Intl: 'readonly' },
|
||||
},
|
||||
settings: {
|
||||
'react': { version: 'detect' },
|
||||
'import/extensions': ['.js', '.jsx'],
|
||||
'import/resolver': {
|
||||
node: { extensions: ['.js', '.jsx'] },
|
||||
alias: { map: [['@', './src']], extensions: ['.js', '.jsx'] },
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...react.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
...importPlugin.flatConfigs.recommended.rules,
|
||||
|
||||
// Non-critical rules - disabled since code works fine without them
|
||||
'react/prop-types': 'off',
|
||||
'react/no-unescaped-entities': 'off',
|
||||
'react/display-name': 'off', // Non-critical, component works without displayName
|
||||
'react/jsx-uses-react': 'off', // Not needed in React 17+, non-critical
|
||||
'react/react-in-jsx-scope': 'off', // Not needed in React 17+, non-critical
|
||||
'react/jsx-uses-vars': 'off', // Non-critical, code works fine
|
||||
'react/jsx-no-comment-textnodes': 'off', // Non-critical, comments could be visible if put inside the JSX, most cases are just rendering text like '///'
|
||||
|
||||
'no-unused-vars': 'off', // Non-critical, code works fine with unused vars
|
||||
'import/no-named-as-default': 'off', // Can cause runtime import errors, usually fine to leave as is
|
||||
'import/no-named-as-default-member': 'off', // Can cause runtime import errors
|
||||
|
||||
// Critical rules that prevent runtime errors
|
||||
'no-undef': 'error', // Undefined variables cause runtime errors
|
||||
'no-empty': ['error', { allowEmptyCatch: true }], // Empty blocks often signal a bug (e.g. missing body)
|
||||
|
||||
// Override recommended import rules for stricter checking
|
||||
'import/no-self-import': 'error', // Extremely fast rule, breaking results in infinite loop/bundling error
|
||||
|
||||
// Disable expensive rules for performance
|
||||
'import/no-cycle': 'off', // AI rarely makes this error, and the rule is very slow to run
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.jsx'],
|
||||
plugins: { horizons: unicodeEscapePlugin },
|
||||
rules: { 'horizons/no-unicode-escapes-in-jsx': 'warn' },
|
||||
},
|
||||
{ files: ['tools/**/*.js', 'tailwind.config.js'], languageOptions: { globals: globals.node } },
|
||||
];
|
||||
@@ -0,0 +1,61 @@
|
||||
const RULE_ID = 'horizons/no-unicode-escapes-in-jsx';
|
||||
const MAX_DETAILED_FINDINGS = 25;
|
||||
const ESCAPE_FROM_MESSAGE_PATTERN = /^Literal Unicode escape "(\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\}))"/;
|
||||
const GUIDANCE = 'Unicode escapes in JSX text or quoted attributes may be displayed verbatim to website visitors. Replace every escape in affected JSX in place with its intended literal character (for example, use "ç", not "\\u00e7"). Do not move the text into variables, objects, or JSX expressions, and do not wrap escapes in JSX braces ({}).';
|
||||
|
||||
function selectFindingsAcrossFiles(findings) {
|
||||
const findingsByFile = new Map();
|
||||
for (const finding of findings) {
|
||||
const fileFindings = findingsByFile.get(finding.filePath) ?? [];
|
||||
fileFindings.push(finding);
|
||||
findingsByFile.set(finding.filePath, fileFindings);
|
||||
}
|
||||
|
||||
const shown = [];
|
||||
for (let findingIndex = 0; shown.length < MAX_DETAILED_FINDINGS; findingIndex++) {
|
||||
let addedFinding = false;
|
||||
|
||||
for (const fileFindings of findingsByFile.values()) {
|
||||
const finding = fileFindings[findingIndex];
|
||||
if (finding) {
|
||||
shown.push(finding);
|
||||
addedFinding = true;
|
||||
}
|
||||
|
||||
if (shown.length === MAX_DETAILED_FINDINGS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!addedFinding) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return shown;
|
||||
}
|
||||
|
||||
export default function format(results) {
|
||||
const findings = results.flatMap(result =>
|
||||
result.messages
|
||||
.filter(message => message.ruleId === RULE_ID)
|
||||
.map(message => ({ filePath: result.filePath, ...message })),
|
||||
);
|
||||
|
||||
if (findings.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const shown = selectFindingsAcrossFiles(findings);
|
||||
const lines = [
|
||||
GUIDANCE,
|
||||
...shown.map((finding) => {
|
||||
const escape = finding.message.match(ESCAPE_FROM_MESSAGE_PATTERN)?.[1] ?? 'Unicode escape';
|
||||
return `${finding.filePath}:${finding.line}:${finding.column} warning ${escape} ${RULE_ID}`;
|
||||
}),
|
||||
];
|
||||
const omitted = findings.length - shown.length;
|
||||
lines.push(`Found ${findings.length} Unicode escapes; ${omitted} additional findings omitted. Replace all escapes in affected JSX. [horizons-unicode-escapes] total=${findings.length} shown=${shown.length} omitted=${omitted}`);
|
||||
|
||||
return `${lines.join('\n')}\n`;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
const CODE_LIKE_ELEMENTS = new Set(['code', 'pre', 'kbd', 'samp']);
|
||||
const UNICODE_ESCAPE_PATTERN = /\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\})/g;
|
||||
|
||||
function isInsideCodeLikeElement(node) {
|
||||
for (let parent = node.parent; parent; parent = parent.parent) {
|
||||
if (parent.type !== 'JSXElement') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = parent.openingElement.name;
|
||||
if (name.type === 'JSXIdentifier' && CODE_LIKE_ELEMENTS.has(name.name)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export default {
|
||||
rules: {
|
||||
'no-unicode-escapes-in-jsx': {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Warn when Unicode escapes in JSX text or quoted attributes would be shown verbatim to website visitors instead of rendering the intended character',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
found: 'Literal Unicode escape "{{escape}}" in JSX text or a quoted attribute may be displayed verbatim to users. Replace only the escape in place with the intended literal character (for example, use "ç", not "\\u00e7"). Do not move the text into variables, objects, or JSX expressions, and do not wrap the escape in JSX braces ({}).',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
function check(node) {
|
||||
if (isInsideCodeLikeElement(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const text = context.sourceCode.getText(node);
|
||||
for (const match of text.matchAll(UNICODE_ESCAPE_PATTERN)) {
|
||||
const startIndex = node.range[0] + match.index;
|
||||
context.report({
|
||||
node,
|
||||
loc: {
|
||||
start: context.sourceCode.getLocFromIndex(startIndex),
|
||||
end: context.sourceCode.getLocFromIndex(startIndex + match[0].length),
|
||||
},
|
||||
messageId: 'found',
|
||||
data: { escape: match[0] },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
JSXText: check,
|
||||
JSXAttribute(node) {
|
||||
if (node.value?.type === 'Literal') {
|
||||
check(node.value);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "web",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite --host :: --port 3000",
|
||||
"build": "node tools/generate-llms.js || true && vite build --outDir ../../dist/apps/web",
|
||||
"preview": "vite preview --host :: --port 3000",
|
||||
"lint": "eslint . --quiet; lint_status=$?; eslint \"src/**/*.jsx\" --config eslint.config.mjs --format ./eslint.unicode-escapes-formatter.mjs --no-error-on-unmatched-pattern; exit $lint_status",
|
||||
"lint:warn": "eslint .",
|
||||
"start": "vite preview --outDir ../../dist/apps/web --host :: --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emailjs/browser": "^4.1.0",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-select": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"esbuild": "^0.28.2",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/generator": "^7.26.3",
|
||||
"@babel/parser": "^7.26.3",
|
||||
"@babel/traverse": "^7.26.4",
|
||||
"@babel/types": "^7.26.3",
|
||||
"@types/node": "^20.17.10",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"globals": "^15.14.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"terser": "^5.37.0",
|
||||
"vite": "^7.3.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
const ALLOWED_PARENT_ORIGINS = [
|
||||
'https://horizons.hostinger.com',
|
||||
'https://horizons.hostinger.dev',
|
||||
'https://horizons-frontend-local.hostinger.dev',
|
||||
'http://localhost:4000',
|
||||
];
|
||||
|
||||
const IMPORTANT_STYLES = [
|
||||
'display',
|
||||
'position',
|
||||
'flex-direction',
|
||||
'justify-content',
|
||||
'align-items',
|
||||
'width',
|
||||
'height',
|
||||
'padding',
|
||||
'margin',
|
||||
'border',
|
||||
'background-color',
|
||||
'color',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'font-family',
|
||||
'border-radius',
|
||||
'box-shadow',
|
||||
'gap',
|
||||
'grid-template-columns',
|
||||
];
|
||||
|
||||
const PRIMARY_400_COLOR = '#7B68EE';
|
||||
const TEXT_CONTEXT_MAX_LENGTH = 500;
|
||||
const DATA_SELECTION_MODE_ENABLED_ATTRIBUTE = 'data-selection-mode-enabled';
|
||||
const MESSAGE_TYPE_ENABLE_SELECTION_MODE = 'enableSelectionMode';
|
||||
const MESSAGE_TYPE_DISABLE_SELECTION_MODE = 'disableSelectionMode';
|
||||
|
||||
let selectionModeEnabled = false;
|
||||
let currentHoverElement = null;
|
||||
let overlayDiv = null;
|
||||
let selectedOverlayDiv = null;
|
||||
let selectedElement = null;
|
||||
|
||||
|
||||
function injectStyles() {
|
||||
if (document.getElementById('selection-mode-styles')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'selection-mode-styles';
|
||||
style.textContent = `
|
||||
#selection-mode-overlay {
|
||||
position: absolute;
|
||||
border: 2px dashed ${PRIMARY_400_COLOR};
|
||||
pointer-events: none;
|
||||
z-index: 999999;
|
||||
}
|
||||
#selection-mode-selected-overlay {
|
||||
position: absolute;
|
||||
border: 3px solid ${PRIMARY_400_COLOR};
|
||||
pointer-events: none;
|
||||
z-index: 999998;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function getParentOrigin() {
|
||||
if (
|
||||
window.location.ancestorOrigins
|
||||
&& window.location.ancestorOrigins.length > 0
|
||||
) {
|
||||
return window.location.ancestorOrigins[0];
|
||||
}
|
||||
|
||||
if (document.referrer) {
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch {
|
||||
console.warn('[SELECTION MODE] Invalid referrer URL:', document.referrer);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract file path from React Fiber metadata (simplified - only for filePath)
|
||||
* @param {*} node - DOM node
|
||||
* @returns {string|null} - File path if found, null otherwise
|
||||
*/
|
||||
function getFilePathFromNode(node) {
|
||||
const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber'));
|
||||
if (!fiberKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fiber = node[fiberKey];
|
||||
if (!fiber) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Traverse up the fiber tree to find source metadata
|
||||
let currentFiber = fiber;
|
||||
while (currentFiber) {
|
||||
const source = currentFiber._debugSource
|
||||
|| currentFiber.memoizedProps?.__source
|
||||
|| currentFiber.pendingProps?.__source;
|
||||
|
||||
if (source?.fileName) {
|
||||
return source.fileName;
|
||||
}
|
||||
|
||||
currentFiber = currentFiber.return;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a CSS selector path to uniquely identify the element
|
||||
* @param {*} element
|
||||
* @returns {string} CSS selector path
|
||||
*/
|
||||
function getPathToElement(element) {
|
||||
const path = [];
|
||||
let current = element;
|
||||
let depth = 0;
|
||||
const maxDepth = 20; // Prevent infinite loops
|
||||
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
|
||||
let selector = current.nodeName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
path.unshift(selector);
|
||||
break; // ID is unique, stop here
|
||||
}
|
||||
|
||||
if (current.className && typeof current.className === 'string') {
|
||||
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
||||
if (classes.length > 0) {
|
||||
selector += `.${classes.join('.')}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.parentElement) {
|
||||
const siblings = Array.from(current.parentElement.children);
|
||||
const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName);
|
||||
if (sameTypeSiblings.length > 1) {
|
||||
const index = sameTypeSiblings.indexOf(current) + 1;
|
||||
selector += `:nth-of-type(${index})`;
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
function getComputedStyles(element) {
|
||||
const computedStyles = window.getComputedStyle(element);
|
||||
|
||||
return Object.fromEntries(IMPORTANT_STYLES.map((style) => {
|
||||
const styleValue = computedStyles.getPropertyValue(style)?.trim();
|
||||
|
||||
return styleValue && styleValue !== 'none' && styleValue !== 'normal'
|
||||
? [style, styleValue]
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean));
|
||||
}
|
||||
|
||||
function extractDOMContext(element) {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textContent = element.textContent?.trim();
|
||||
|
||||
return {
|
||||
outerHTML: element.outerHTML,
|
||||
selector: getPathToElement(element),
|
||||
attributes: (element.attributes && element.attributes.length > 0)
|
||||
? Object.fromEntries(Array.from(element.attributes).map((attr) => [attr.name, attr.value]))
|
||||
: {},
|
||||
computedStyles: getComputedStyles(element),
|
||||
textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH)
|
||||
? element.textContent?.trim()
|
||||
: null
|
||||
};
|
||||
}
|
||||
|
||||
function createOverlay() {
|
||||
if (overlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
|
||||
overlayDiv = document.createElement('div');
|
||||
overlayDiv.id = 'selection-mode-overlay';
|
||||
document.body.appendChild(overlayDiv);
|
||||
}
|
||||
|
||||
function createSelectedOverlay() {
|
||||
if (selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
injectStyles();
|
||||
|
||||
selectedOverlayDiv = document.createElement('div');
|
||||
selectedOverlayDiv.id = 'selection-mode-selected-overlay';
|
||||
document.body.appendChild(selectedOverlayDiv);
|
||||
}
|
||||
|
||||
function removeOverlay() {
|
||||
if (overlayDiv && overlayDiv.parentNode) {
|
||||
overlayDiv.parentNode.removeChild(overlayDiv);
|
||||
overlayDiv = null;
|
||||
}
|
||||
if (selectedOverlayDiv && selectedOverlayDiv.parentNode) {
|
||||
selectedOverlayDiv.parentNode.removeChild(selectedOverlayDiv);
|
||||
selectedOverlayDiv = null;
|
||||
}
|
||||
}
|
||||
|
||||
function showOverlay(element) {
|
||||
if (!overlayDiv) {
|
||||
createOverlay();
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
overlayDiv.style.left = `${rect.left + window.scrollX}px`;
|
||||
overlayDiv.style.top = `${rect.top + window.scrollY}px`;
|
||||
overlayDiv.style.width = `${rect.width}px`;
|
||||
overlayDiv.style.height = `${rect.height}px`;
|
||||
overlayDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function showSelectedOverlay(element) {
|
||||
if (!selectedOverlayDiv) {
|
||||
createSelectedOverlay();
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
selectedOverlayDiv.style.left = `${rect.left + window.scrollX}px`;
|
||||
selectedOverlayDiv.style.top = `${rect.top + window.scrollY}px`;
|
||||
selectedOverlayDiv.style.width = `${rect.width}px`;
|
||||
selectedOverlayDiv.style.height = `${rect.height}px`;
|
||||
selectedOverlayDiv.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideOverlay() {
|
||||
if (overlayDiv) {
|
||||
overlayDiv.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!element) {
|
||||
hideOverlay();
|
||||
currentHoverElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update if we're hovering a different element
|
||||
if (currentHoverElement !== element) {
|
||||
currentHoverElement = element;
|
||||
|
||||
// Show outline on the element
|
||||
showOverlay(element);
|
||||
}
|
||||
}
|
||||
|
||||
function handleTouchStart(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
if (!touch) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = document.elementFromPoint(touch.clientX, touch.clientY);
|
||||
if (!element) {
|
||||
currentHoverElement = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentHoverElement = element;
|
||||
|
||||
showOverlay(element);
|
||||
}
|
||||
|
||||
function stripFilePath(filePath) {
|
||||
if (!filePath) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const publicHtmlIndex = filePath.indexOf('public_html/');
|
||||
if (publicHtmlIndex !== -1) {
|
||||
return filePath.substring(publicHtmlIndex + 'public_html/'.length);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
function handleClick(event) {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentHoverElement) {
|
||||
const element = document.elementFromPoint(event.clientX, event.clientY);
|
||||
|
||||
if (!element || element === overlayDiv || element === selectedOverlayDiv) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentHoverElement = element;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const domContext = extractDOMContext(currentHoverElement);
|
||||
|
||||
if (!domContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedElement = currentHoverElement;
|
||||
if (selectedElement) {
|
||||
showSelectedOverlay(selectedElement);
|
||||
}
|
||||
|
||||
// Extract file path from React Fiber (if available)
|
||||
const filePath = getFilePathFromNode(currentHoverElement);
|
||||
const strippedFilePath = filePath ? stripFilePath(filePath) : undefined;
|
||||
|
||||
// Send domContext and filePath to parent window
|
||||
const parentOrigin = getParentOrigin();
|
||||
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'elementSelected',
|
||||
payload: {
|
||||
filePath: strippedFilePath,
|
||||
domContext,
|
||||
},
|
||||
},
|
||||
parentOrigin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
hideOverlay();
|
||||
currentHoverElement = null;
|
||||
}
|
||||
|
||||
function enableSelectionMode() {
|
||||
if (selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionModeEnabled = true;
|
||||
document.getElementById('root')?.setAttribute(DATA_SELECTION_MODE_ENABLED_ATTRIBUTE, 'true');
|
||||
|
||||
document.body.style.userSelect = 'none';
|
||||
|
||||
createOverlay();
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
document.addEventListener('touchstart', handleTouchStart, true);
|
||||
document.addEventListener('click', handleClick, true);
|
||||
document.addEventListener('mouseleave', handleMouseLeave, true);
|
||||
}
|
||||
|
||||
function disableSelectionMode() {
|
||||
if (!selectionModeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectionModeEnabled = false;
|
||||
document.getElementById('root')?.removeAttribute(DATA_SELECTION_MODE_ENABLED_ATTRIBUTE);
|
||||
|
||||
document.body.style.userSelect = '';
|
||||
|
||||
hideOverlay();
|
||||
removeOverlay();
|
||||
currentHoverElement = null;
|
||||
selectedElement = null;
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('touchstart', handleTouchStart, true);
|
||||
document.removeEventListener('click', handleClick, true);
|
||||
document.removeEventListener('mouseleave', handleMouseLeave, true);
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data?.type === MESSAGE_TYPE_ENABLE_SELECTION_MODE) {
|
||||
enableSelectionMode();
|
||||
}
|
||||
if (event.data?.type === MESSAGE_TYPE_DISABLE_SELECTION_MODE) {
|
||||
disableSelectionMode();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = resolve(__filename, '..');
|
||||
|
||||
export default function selectionModePlugin() {
|
||||
return {
|
||||
name: 'vite:selection-mode',
|
||||
apply: 'serve',
|
||||
|
||||
transformIndexHtml() {
|
||||
const scriptPath = resolve(__dirname, 'selection-mode-script.js');
|
||||
const scriptContent = readFileSync(scriptPath, 'utf-8');
|
||||
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: scriptContent,
|
||||
injectTo: 'body',
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
const EVENT = 'session-journal:event';
|
||||
const MAX_TEXT = 2000;
|
||||
const MAX_BATCH = 50;
|
||||
const FLUSH_INTERVAL_MS = 1000;
|
||||
|
||||
let queue = [];
|
||||
let flushTimer = null;
|
||||
let connected = false;
|
||||
|
||||
function flushQueue() {
|
||||
if (flushTimer) {
|
||||
clearTimeout(flushTimer);
|
||||
flushTimer = null;
|
||||
}
|
||||
if (!queue.length || !import.meta.hot || !connected) {
|
||||
return;
|
||||
}
|
||||
const batch = queue;
|
||||
queue = [];
|
||||
try {
|
||||
import.meta.hot.send(EVENT, { batch });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function send(kind, data) {
|
||||
if (!import.meta.hot) {
|
||||
return;
|
||||
}
|
||||
queue.push({ kind, t: Date.now(), ...data });
|
||||
if (connected && queue.length >= MAX_BATCH) {
|
||||
flushQueue();
|
||||
} else if (!flushTimer) {
|
||||
flushTimer = setTimeout(flushQueue, FLUSH_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.hot) {
|
||||
import.meta.hot.on('vite:ws:connect', () => {
|
||||
connected = true;
|
||||
flushQueue();
|
||||
});
|
||||
import.meta.hot.on('vite:ws:disconnect', () => {
|
||||
connected = false;
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushQueue();
|
||||
}
|
||||
});
|
||||
window.addEventListener('pagehide', flushQueue);
|
||||
|
||||
function truncate(value) {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
const s = String(value);
|
||||
return s.length > MAX_TEXT ? s.slice(0, MAX_TEXT) + '...' : s;
|
||||
}
|
||||
|
||||
function isSensitiveName(name) {
|
||||
return /password|secret|token|apikey|key|auth|credential|cookie/i.test(String(name || ''));
|
||||
}
|
||||
|
||||
function sanitizeUrl(value) {
|
||||
try {
|
||||
const url = new URL(String(value), location.href);
|
||||
for (const key of [...url.searchParams.keys()]) {
|
||||
if (isSensitiveName(key)) {
|
||||
url.searchParams.set(key, '[redacted]');
|
||||
}
|
||||
}
|
||||
return truncate(url.toString());
|
||||
} catch {
|
||||
return truncate(value);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeValue(name, value) {
|
||||
if (isSensitiveName(name)) {
|
||||
const length = value == null ? 0 : String(value).length;
|
||||
return '[redacted:length=' + length + ']';
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => summarizeValue(name, item));
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return summarizeObject(value);
|
||||
}
|
||||
return truncate(value);
|
||||
}
|
||||
|
||||
function summarizeObject(value) {
|
||||
const out = {};
|
||||
for (const [key, val] of Object.entries(value || {})) {
|
||||
out[key] = summarizeValue(key, val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarizeRequestBody(body) {
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
if (body instanceof FormData) {
|
||||
const out = {};
|
||||
for (const [key, val] of body.entries()) {
|
||||
out[key] = val instanceof File
|
||||
? { fileName: truncate(val.name), type: val.type || null, size: val.size }
|
||||
: summarizeValue(key, val);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (body instanceof URLSearchParams) {
|
||||
const out = {};
|
||||
for (const [key, val] of body.entries()) out[key] = summarizeValue(key, val);
|
||||
return out;
|
||||
}
|
||||
if (typeof body === 'string') {
|
||||
try {
|
||||
return summarizeObject(JSON.parse(body));
|
||||
} catch {
|
||||
return truncate(body);
|
||||
}
|
||||
}
|
||||
if (body && typeof body === 'object' && body.constructor === Object) {
|
||||
return summarizeObject(body);
|
||||
}
|
||||
return '[' + Object.prototype.toString.call(body).slice(8, -1) + ' body]';
|
||||
}
|
||||
|
||||
function isSensitive(el) {
|
||||
if (!el) {
|
||||
return false;
|
||||
}
|
||||
if (el.type === 'password') {
|
||||
return true;
|
||||
}
|
||||
return isSensitiveName(el.name || el.id || el.getAttribute('autocomplete') || '');
|
||||
}
|
||||
|
||||
function compactText(value) {
|
||||
return truncate(String(value || '').replace(/\s+/g, ' ').trim());
|
||||
}
|
||||
|
||||
function labelFromLabelledBy(el) {
|
||||
const ids = (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean);
|
||||
if (!ids.length) {
|
||||
return '';
|
||||
}
|
||||
const parts = ids
|
||||
.map((id) => document.getElementById(id))
|
||||
.filter(Boolean)
|
||||
.map((node) => compactText(node.textContent));
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function labelFromForAttr(el) {
|
||||
if (!el.id) {
|
||||
return '';
|
||||
}
|
||||
try {
|
||||
const escapedId = window.CSS && CSS.escape ? CSS.escape(el.id) : el.id;
|
||||
const labelEl = document.querySelector('label[for="' + escapedId + '"]');
|
||||
return labelEl ? compactText(labelEl.textContent) : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function labelFromWrappingLabel(el) {
|
||||
let cur = el.parentElement;
|
||||
for (let i = 0; i < 4 && cur; i++) {
|
||||
if (cur.tagName === 'LABEL') {
|
||||
const clone = cur.cloneNode(true);
|
||||
for (const inner of clone.querySelectorAll('input, textarea, select')) {
|
||||
inner.remove();
|
||||
}
|
||||
return compactText(clone.textContent);
|
||||
}
|
||||
cur = cur.parentElement;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function fieldLabel(el) {
|
||||
if (!el || el.nodeType !== 1) {
|
||||
return null;
|
||||
}
|
||||
const sources = [
|
||||
el.getAttribute('name'),
|
||||
labelFromLabelledBy(el),
|
||||
el.getAttribute('aria-label'),
|
||||
labelFromForAttr(el),
|
||||
labelFromWrappingLabel(el),
|
||||
el.id,
|
||||
el.getAttribute('placeholder'),
|
||||
];
|
||||
for (const candidate of sources) {
|
||||
const trimmed = candidate ? String(candidate).trim() : '';
|
||||
if (trimmed) {
|
||||
return compactText(trimmed);
|
||||
}
|
||||
}
|
||||
const type = (el.getAttribute('type') || el.tagName || '').toLowerCase();
|
||||
return type ? '[' + type + ']' : null;
|
||||
}
|
||||
|
||||
function fieldValue(el) {
|
||||
if (!el || !('value' in el)) {
|
||||
return { value: '', length: 0, redacted: false };
|
||||
}
|
||||
const raw = el.value == null ? '' : String(el.value);
|
||||
if (isSensitive(el)) {
|
||||
return { value: '[redacted:length=' + raw.length + ']', length: raw.length, redacted: true };
|
||||
}
|
||||
return { value: truncate(raw), length: raw.length, redacted: false };
|
||||
}
|
||||
|
||||
function describeElement(el) {
|
||||
if (!el || el.nodeType !== 1) {
|
||||
return null;
|
||||
}
|
||||
const tag = el.tagName.toLowerCase();
|
||||
const role = el.getAttribute('role') || null;
|
||||
const ariaLabel = el.getAttribute('aria-label') || null;
|
||||
const name = el.getAttribute('name') || null;
|
||||
const type = el.getAttribute('type') || null;
|
||||
const id = el.id || null;
|
||||
const placeholder = el.getAttribute('placeholder') || null;
|
||||
const isInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName);
|
||||
const valueInfo = isInput ? fieldValue(el) : null;
|
||||
const text = isSensitive(el) ? '' : truncate(el.textContent || '');
|
||||
return {
|
||||
tag,
|
||||
role,
|
||||
ariaLabel,
|
||||
name,
|
||||
type,
|
||||
id,
|
||||
placeholder,
|
||||
label: isInput ? fieldLabel(el) : null,
|
||||
value: valueInfo ? valueInfo.value : null,
|
||||
valueLength: valueInfo ? valueInfo.length : 0,
|
||||
text,
|
||||
};
|
||||
}
|
||||
|
||||
function findInteractive(el) {
|
||||
let cur = el;
|
||||
for (let i = 0; i < 6 && cur; i++) {
|
||||
if (cur.nodeType !== 1) { cur = cur.parentNode; continue; }
|
||||
const tag = cur.tagName;
|
||||
if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'LABEL' ||
|
||||
cur.hasAttribute('role') || cur.hasAttribute('onclick')) {
|
||||
return cur;
|
||||
}
|
||||
cur = cur.parentNode;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
send('click', { element: describeElement(findInteractive(e.target)) });
|
||||
}, true);
|
||||
|
||||
document.addEventListener('submit', (e) => {
|
||||
const form = e.target;
|
||||
const fields = [];
|
||||
if (form && form.elements) {
|
||||
for (const el of form.elements) {
|
||||
const label = fieldLabel(el);
|
||||
if (!label || !('value' in el)) {
|
||||
continue;
|
||||
}
|
||||
const v = fieldValue(el);
|
||||
fields.push({
|
||||
label,
|
||||
type: el.type || el.tagName.toLowerCase(),
|
||||
value: v.value,
|
||||
length: v.length,
|
||||
redacted: v.redacted,
|
||||
});
|
||||
}
|
||||
}
|
||||
send('submit', { formId: form.id || null, action: form.action || null, fields });
|
||||
}, true);
|
||||
|
||||
document.addEventListener('focus', (e) => {
|
||||
const target = e.target;
|
||||
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
||||
return;
|
||||
}
|
||||
send('focus', { element: describeElement(target) });
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', (e) => {
|
||||
const target = e.target;
|
||||
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
||||
return;
|
||||
}
|
||||
if (target.type === 'file') {
|
||||
return;
|
||||
}
|
||||
send('change', { element: describeElement(target) });
|
||||
}, true);
|
||||
|
||||
document.addEventListener('blur', (e) => {
|
||||
const target = e.target;
|
||||
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
||||
return;
|
||||
}
|
||||
if (!('value' in target) || !target.value) {
|
||||
return;
|
||||
}
|
||||
send('blur', { element: describeElement(target) });
|
||||
}, true);
|
||||
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input, init = {}) => {
|
||||
const started = performance.now();
|
||||
const url = typeof input === 'string' ? input : input?.url;
|
||||
const method = (init?.method || input?.method || 'GET').toUpperCase();
|
||||
const requestBody = summarizeRequestBody(init?.body);
|
||||
try {
|
||||
const response = await nativeFetch(input, init);
|
||||
const requestUrl = url || response.url;
|
||||
if (!response.ok) {
|
||||
let body = '';
|
||||
try { body = await response.clone().text(); } catch {}
|
||||
send('network.error', {
|
||||
method,
|
||||
url: sanitizeUrl(requestUrl),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
requestBody,
|
||||
response: truncate(body),
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
});
|
||||
}
|
||||
return response;
|
||||
} catch (err) {
|
||||
send('network.error', {
|
||||
method,
|
||||
url: sanitizeUrl(url || ''),
|
||||
requestBody,
|
||||
message: truncate(err && err.message ? err.message : String(err)),
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const NativeXMLHttpRequest = window.XMLHttpRequest;
|
||||
window.XMLHttpRequest = function SessionJournalXMLHttpRequest() {
|
||||
const xhr = new NativeXMLHttpRequest();
|
||||
let method = 'GET';
|
||||
let url = '';
|
||||
let requestBody = null;
|
||||
let started = 0;
|
||||
|
||||
const originalOpen = xhr.open;
|
||||
xhr.open = function (nextMethod, nextUrl, ...rest) {
|
||||
method = String(nextMethod || 'GET').toUpperCase();
|
||||
url = String(nextUrl || '');
|
||||
return originalOpen.call(xhr, nextMethod, nextUrl, ...rest);
|
||||
};
|
||||
|
||||
const originalSend = xhr.send;
|
||||
xhr.send = function (body) {
|
||||
started = performance.now();
|
||||
requestBody = summarizeRequestBody(body);
|
||||
return originalSend.call(xhr, body);
|
||||
};
|
||||
|
||||
xhr.addEventListener('loadend', () => {
|
||||
if (xhr.status >= 400 || xhr.status === 0) {
|
||||
let response = '';
|
||||
try { response = xhr.responseText || ''; } catch {}
|
||||
send('network.error', {
|
||||
transport: 'xhr',
|
||||
method,
|
||||
url: sanitizeUrl(url),
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
requestBody,
|
||||
response: truncate(response),
|
||||
durationMs: Math.round(performance.now() - started),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return xhr;
|
||||
};
|
||||
window.XMLHttpRequest.prototype = NativeXMLHttpRequest.prototype;
|
||||
|
||||
send('load', { url: location.href, title: document.title });
|
||||
|
||||
const origPush = history.pushState;
|
||||
history.pushState = function (...args) {
|
||||
const result = origPush.apply(this, args);
|
||||
send('navigate', { url: location.href, via: 'pushState' });
|
||||
return result;
|
||||
};
|
||||
const origReplace = history.replaceState;
|
||||
history.replaceState = function (...args) {
|
||||
const result = origReplace.apply(this, args);
|
||||
send('navigate', { url: location.href, via: 'replaceState' });
|
||||
return result;
|
||||
};
|
||||
window.addEventListener('popstate', () => {
|
||||
send('navigate', { url: location.href, via: 'popstate' });
|
||||
});
|
||||
|
||||
for (const level of ['error', 'warn']) {
|
||||
const original = console[level];
|
||||
console[level] = (...args) => {
|
||||
try {
|
||||
const text = args.map((arg) => {
|
||||
if (arg instanceof Error) {
|
||||
return arg.stack || arg.message;
|
||||
}
|
||||
if (typeof arg === 'object') {
|
||||
try { return JSON.stringify(arg); } catch { return String(arg); }
|
||||
}
|
||||
return String(arg);
|
||||
}).join(' ');
|
||||
send('console.' + level, { text: truncate(text) });
|
||||
} catch {}
|
||||
original.apply(console, args);
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
send('window.error', {
|
||||
message: truncate(e.message),
|
||||
source: e.filename || null,
|
||||
line: e.lineno || null,
|
||||
col: e.colno || null,
|
||||
stack: e.error && e.error.stack ? truncate(e.error.stack) : null,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const reason = e.reason;
|
||||
send('unhandledrejection', {
|
||||
message: truncate(reason && reason.message ? reason.message : String(reason)),
|
||||
stack: reason && reason.stack ? truncate(reason.stack) : null,
|
||||
});
|
||||
});
|
||||
|
||||
const root = document.getElementById('root');
|
||||
if (root) {
|
||||
let blankTimer = null;
|
||||
const check = () => {
|
||||
if (root.children.length === 0) {
|
||||
if (!blankTimer) {
|
||||
blankTimer = setTimeout(() => {
|
||||
send('root.empty', { url: location.href });
|
||||
blankTimer = null;
|
||||
}, 300);
|
||||
}
|
||||
} else if (blankTimer) {
|
||||
clearTimeout(blankTimer);
|
||||
blankTimer = null;
|
||||
}
|
||||
};
|
||||
new MutationObserver(check).observe(root, { childList: true });
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const VIRTUAL_ID = 'virtual:session-journal-client';
|
||||
const RESOLVED_ID = '\0' + VIRTUAL_ID;
|
||||
const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
const CLIENT_PATH = path.resolve(PLUGIN_DIR, 'session-journal-client.js');
|
||||
const LOG_PATH = path.resolve(
|
||||
PLUGIN_DIR,
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'..',
|
||||
'vault',
|
||||
'temp',
|
||||
'SESSION_JOURNAL.md',
|
||||
);
|
||||
const MAX_BYTES = 512 * 1024;
|
||||
|
||||
function formatTime(ts) {
|
||||
const d = new Date(ts || Date.now());
|
||||
return d.toISOString().replace('T', ' ').slice(0, 23) + 'Z';
|
||||
}
|
||||
|
||||
function formatEntry(event) {
|
||||
const { kind, t, ...rest } = event || {};
|
||||
const head = `## ${formatTime(t)} ${kind || 'unknown'}`;
|
||||
const lines = [head];
|
||||
for (const [key, value] of Object.entries(rest)) {
|
||||
if (value == null || value === '') {
|
||||
continue;
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
lines.push(`- ${key}: ${JSON.stringify(value)}`);
|
||||
} catch {
|
||||
lines.push(`- ${key}: [unserializable]`);
|
||||
}
|
||||
} else {
|
||||
const text = String(value);
|
||||
lines.push(`- ${key}: ${text.includes('\n') ? '\n ' + text.replace(/\n/g, '\n ') : text}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n') + '\n\n';
|
||||
}
|
||||
|
||||
async function appendWithRotation(text) {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(LOG_PATH), { recursive: true });
|
||||
await fs.appendFile(LOG_PATH, text);
|
||||
const { size } = await fs.stat(LOG_PATH);
|
||||
if (size > MAX_BYTES) {
|
||||
const content = await fs.readFile(LOG_PATH, 'utf8');
|
||||
const keep = content.slice(-Math.floor(MAX_BYTES / 2));
|
||||
await fs.writeFile(
|
||||
LOG_PATH,
|
||||
'# SESSION_JOURNAL.md (rotated - earlier entries trimmed)\n\n' + keep,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// The journal should never break the app dev server.
|
||||
}
|
||||
}
|
||||
|
||||
export default function sessionJournalPlugin() {
|
||||
return {
|
||||
name: 'session-journal',
|
||||
apply: 'serve',
|
||||
|
||||
resolveId(id) {
|
||||
if (id === VIRTUAL_ID) {
|
||||
return RESOLVED_ID;
|
||||
}
|
||||
},
|
||||
|
||||
load(id) {
|
||||
if (id === RESOLVED_ID) {
|
||||
return fs.readFile(CLIENT_PATH, 'utf-8');
|
||||
}
|
||||
},
|
||||
|
||||
transformIndexHtml(html) {
|
||||
return html.replace(
|
||||
'</head>',
|
||||
` <script type="module" src="/@id/${VIRTUAL_ID}"></script>\n</head>`,
|
||||
);
|
||||
},
|
||||
|
||||
configureServer(server) {
|
||||
server.ws.on('session-journal:event', (payload) => {
|
||||
const events = Array.isArray(payload?.batch) ? payload.batch : [payload];
|
||||
let text = '';
|
||||
for (const event of events) {
|
||||
text += formatEntry(event);
|
||||
}
|
||||
if (text) {
|
||||
void appendWithRotation(text);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
const SITE_PAGES_ENDPOINT = '/__horizons/site-pages';
|
||||
|
||||
const OUTGOING_SITE_PAGES_MESSAGE = 'sitePages';
|
||||
const INCOMING_REQUEST_SITE_PAGES_MESSAGE = 'request-site-pages';
|
||||
|
||||
const ALLOWED_PARENT_ORIGINS = [
|
||||
'https://horizons.hostinger.com',
|
||||
'https://horizons.hostinger.dev',
|
||||
'https://horizons-frontend-local.hostinger.dev',
|
||||
'http://localhost:4000',
|
||||
];
|
||||
|
||||
function postSitePages(pages) {
|
||||
let parentOrigin = window.location.ancestorOrigins?.[0];
|
||||
if (!parentOrigin && document.referrer) {
|
||||
try {
|
||||
parentOrigin = new URL(document.referrer).origin;
|
||||
} catch {}
|
||||
}
|
||||
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
window.parent.postMessage({ type: OUTGOING_SITE_PAGES_MESSAGE, payload: { pages } }, parentOrigin);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendSitePagesToParent() {
|
||||
if (window.self === window.top) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(SITE_PAGES_ENDPOINT);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
postSitePages(await response.json());
|
||||
} catch (error) {
|
||||
console.error('[site-pages] Failed to send site pages to parent:', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (window.self !== window.top) {
|
||||
window.addEventListener('load', sendSitePagesToParent);
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data?.type === INCOMING_REQUEST_SITE_PAGES_MESSAGE) {
|
||||
sendSitePagesToParent();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import * as t from '@babel/types';
|
||||
|
||||
const ECOMMERCE_API_RELATIVE_PATH = '../../src/api/EcommerceApi.js';
|
||||
|
||||
const HOME_ROUTE_LABEL = 'Main';
|
||||
const PRODUCT_PAGE_FETCH_LIMIT = 100;
|
||||
const MAX_PRODUCT_PAGES = 50;
|
||||
|
||||
/**
|
||||
* Resolves the tag name of a JSX element, unwrapping member expressions (e.g. `Foo.Bar` → `Bar`).
|
||||
*
|
||||
* @param {import('@babel/types').JSXOpeningElement['name']} nameNode
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getJsxElementName(nameNode) {
|
||||
if (t.isJSXIdentifier(nameNode)) {
|
||||
return nameNode.name;
|
||||
}
|
||||
if (t.isJSXMemberExpression(nameNode) && t.isJSXIdentifier(nameNode.property)) {
|
||||
return nameNode.property.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a string literal from an attribute value, including one wrapped in a JSX expression container.
|
||||
*
|
||||
* @param {import('@babel/types').Node|null|undefined} value
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getStringLiteral(value) {
|
||||
if (!value) return null;
|
||||
if (t.isStringLiteral(value)) return value.value;
|
||||
if (t.isJSXExpressionContainer(value) && t.isStringLiteral(value.expression)) {
|
||||
return value.expression.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a JSX element from an attribute value, including one wrapped in a JSX expression container.
|
||||
*
|
||||
* @param {import('@babel/types').Node|null|undefined} value
|
||||
* @returns {import('@babel/types').JSXElement|null}
|
||||
*/
|
||||
function getJsxElement(value) {
|
||||
if (t.isJSXElement(value)) return value;
|
||||
if (t.isJSXExpressionContainer(value) && t.isJSXElement(value.expression)) {
|
||||
return value.expression;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the `path` and element component name from a `<Route>` opening element.
|
||||
*
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||
* @returns {{ routePath: string, componentName: string }|null}
|
||||
*/
|
||||
function parseRoute(openingNode) {
|
||||
if (getJsxElementName(openingNode.name) !== 'Route') return null;
|
||||
|
||||
const pathAttr = openingNode.attributes.find((a) => t.isJSXAttribute(a) && a.name?.name === 'path');
|
||||
const elementAttr = openingNode.attributes.find((a) => t.isJSXAttribute(a) && a.name?.name === 'element');
|
||||
|
||||
const routePath = getStringLiteral(pathAttr?.value);
|
||||
if (routePath === null) return null;
|
||||
|
||||
const element = getJsxElement(elementAttr?.value);
|
||||
const componentName = element && getJsxElementName(element.openingElement.name);
|
||||
if (!componentName) return null;
|
||||
|
||||
return { routePath, componentName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps default import local names to their module source for one import declaration.
|
||||
*
|
||||
* @param {import('@babel/types').ImportDeclaration} importNode
|
||||
* @returns {Record<string, string>}
|
||||
*/
|
||||
function parseImports(importNode) {
|
||||
const source = importNode.source.value;
|
||||
return Object.fromEntries(
|
||||
importNode.specifiers
|
||||
.filter(t.isImportDefaultSpecifier)
|
||||
.map((specifier) => [specifier.local.name, source]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the text of the `<title>` inside a `<Helmet>` element, if present.
|
||||
*
|
||||
* @param {import('@babel/types').JSXElement} jsxElement
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function parseHelmetTitle(jsxElement) {
|
||||
if (getJsxElementName(jsxElement.openingElement.name) !== 'Helmet') return null;
|
||||
|
||||
for (const child of jsxElement.children) {
|
||||
if (!t.isJSXElement(child) || getJsxElementName(child.openingElement.name) !== 'title') continue;
|
||||
|
||||
const titleChild = child.children[0];
|
||||
if (t.isJSXText(titleChild)) return titleChild.value.trim();
|
||||
if (t.isJSXExpressionContainer(titleChild) && t.isStringLiteral(titleChild.expression)) {
|
||||
return titleChild.expression.value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans one file's AST. Router files yield routes + imports; page files yield helmetTitle.
|
||||
*
|
||||
* @param {import('@babel/types').File} ast
|
||||
* @returns {{ routes: Array<{ routePath: string, componentName: string }>, imports: Record<string, string>, helmetTitle: string|null, usesEcommerceApi: boolean }}
|
||||
*/
|
||||
export function scanFile(ast) {
|
||||
const routes = [];
|
||||
const imports = {};
|
||||
let helmetTitle = null;
|
||||
let usesEcommerceApi = false;
|
||||
|
||||
(traverseBabel.default)(ast, {
|
||||
enter(nodePath) {
|
||||
if (nodePath.isJSXOpeningElement()) {
|
||||
const route = parseRoute(nodePath.node);
|
||||
if (route) routes.push(route);
|
||||
return;
|
||||
}
|
||||
if (nodePath.isImportDeclaration()) {
|
||||
Object.assign(imports, parseImports(nodePath.node));
|
||||
if (nodePath.node.source.value.includes('EcommerceApi')) {
|
||||
usesEcommerceApi = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (nodePath.isJSXElement()) {
|
||||
const title = parseHelmetTitle(nodePath.node);
|
||||
if (title) helmetTitle = title;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { routes, imports, helmetTitle, usesEcommerceApi };
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges one file's scan result into the running index, replacing any previous
|
||||
* entries contributed by that same file.
|
||||
*
|
||||
* @param {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }} index
|
||||
* @param {string} fileId
|
||||
* @param {{ routes: Array<{ routePath: string, componentName: string }>, imports: Record<string, string>, helmetTitle: string|null, usesEcommerceApi: boolean }} scan
|
||||
* @returns {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }}
|
||||
*/
|
||||
export function upsertFile(index, fileId, scan) {
|
||||
const routerEntries = [
|
||||
...index.routerEntries.filter((entry) => entry.routerFileId !== fileId),
|
||||
...scan.routes.map(({ routePath, componentName }) => ({
|
||||
routePath,
|
||||
componentName,
|
||||
routerFileId: fileId,
|
||||
imports: scan.imports,
|
||||
})),
|
||||
];
|
||||
|
||||
const titlesByPageFile = Object.fromEntries(
|
||||
Object.entries(index.titlesByPageFile).filter(([key]) => key !== fileId),
|
||||
);
|
||||
if (scan.helmetTitle) {
|
||||
titlesByPageFile[fileId] = scan.helmetTitle;
|
||||
}
|
||||
|
||||
const ecommercePageFiles = Object.fromEntries(
|
||||
Object.entries(index.ecommercePageFiles).filter(([key]) => key !== fileId),
|
||||
);
|
||||
if (scan.usesEcommerceApi) {
|
||||
ecommercePageFiles[fileId] = true;
|
||||
}
|
||||
|
||||
return { routerEntries, titlesByPageFile, ecommercePageFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Chooses a display label for a route: the home label for `/`, otherwise the
|
||||
* helmet title when available, falling back to the component name.
|
||||
*
|
||||
* @param {string} routePath
|
||||
* @param {string} componentName
|
||||
* @param {string|null} helmetTitle
|
||||
* @returns {string}
|
||||
*/
|
||||
export function resolvePageLabel(routePath, componentName, helmetTitle) {
|
||||
if (routePath === '/') return HOME_ROUTE_LABEL;
|
||||
return helmetTitle ?? componentName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Substitutes the first dynamic segment of a route with a concrete value.
|
||||
* The param name is derived from the route (e.g. ":slug", ":productId"), not
|
||||
* assumed to be ":id", since route params are authored by generated code.
|
||||
*
|
||||
* @param {string} routePath
|
||||
* @param {string} productId
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildProductPath(routePath, productId) {
|
||||
const paramToken = routePath.match(/:[^/]+/)?.[0];
|
||||
return paramToken ? routePath.replace(paramToken, productId) : routePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a dynamic product route into one concrete page entry per product.
|
||||
*
|
||||
* @param {Array<{ id: string, title: string }>} products
|
||||
* @param {string} routePath
|
||||
* @param {string} componentName
|
||||
* @returns {Array<{ path: string, label: string, helmetTitle: string, component: string, dynamic: true }>}
|
||||
*/
|
||||
export function mapProductsToPages(products, routePath, componentName) {
|
||||
return products.map((product) => ({
|
||||
path: buildProductPath(routePath, product.id),
|
||||
label: product.title,
|
||||
helmetTitle: product.title,
|
||||
component: componentName,
|
||||
dynamic: true,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every product by paging the store API until a short page is returned,
|
||||
* bounded by MAX_PRODUCT_PAGES. EcommerceApi is imported lazily so non-ecommerce
|
||||
* sites never resolve a module that does not exist for them.
|
||||
*
|
||||
* Import via absolute file URL: after Vite bundles this plugin into
|
||||
* vite.config.js.timestamp-*.mjs, relative imports resolve against the bundle
|
||||
* path instead of the original plugin location.
|
||||
*
|
||||
* @param {string} ecommerceApiPath Absolute filesystem path to EcommerceApi.js
|
||||
* @returns {Promise<Array<{ id: string, title: string }>>}
|
||||
*/
|
||||
async function fetchAllProducts(ecommerceApiPath) {
|
||||
const { getProducts } = await import(pathToFileURL(ecommerceApiPath).href);
|
||||
|
||||
const collected = [];
|
||||
for (let page = 0; page < MAX_PRODUCT_PAGES; page++) {
|
||||
const { products = [] } = await getProducts({
|
||||
limit: PRODUCT_PAGE_FETCH_LIMIT,
|
||||
offset: page * PRODUCT_PAGE_FETCH_LIMIT,
|
||||
});
|
||||
|
||||
collected.push(...products);
|
||||
|
||||
if (products.length < PRODUCT_PAGE_FETCH_LIMIT) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return collected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the flat list of site pages from the scanned index, resolving each route's
|
||||
* page module and expanding ecommerce product routes. Returns only routes actually
|
||||
* detected by the scan (no synthetic home fallback).
|
||||
*
|
||||
* @param {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }} index
|
||||
* @param {(specifier: string, importer: string) => Promise<{ id: string }|null>} resolveModule
|
||||
* @param {string} projectRoot
|
||||
* @returns {Promise<Array<{ path: string, label: string, helmetTitle: string|null, component: string|null, file?: string|null, dynamic?: true }>>}
|
||||
*/
|
||||
export async function buildPageList(index, resolveModule, projectRoot) {
|
||||
const groups = await Promise.all(
|
||||
index.routerEntries.map(async ({ routePath, componentName, routerFileId, imports }) => {
|
||||
const moduleSpecifier = imports[componentName];
|
||||
const resolved = moduleSpecifier
|
||||
? await resolveModule(moduleSpecifier, routerFileId)
|
||||
: null;
|
||||
const pageFileId = resolved?.id ?? null;
|
||||
|
||||
if (routePath.includes(':')) {
|
||||
// Only ecommerce product routes are expanded into concrete pages.
|
||||
// Other dynamic routes (e.g. blog/CMS detail pages) are intentionally
|
||||
// skipped since we have no data source to enumerate their instances.
|
||||
if (!pageFileId || !index.ecommercePageFiles[pageFileId]) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ecommerceApiPath = fileURLToPath(new URL(ECOMMERCE_API_RELATIVE_PATH, import.meta.url));
|
||||
if (!existsSync(ecommerceApiPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const products = await fetchAllProducts(ecommerceApiPath);
|
||||
|
||||
return mapProductsToPages(products, routePath, componentName);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const helmetTitle = pageFileId ? (index.titlesByPageFile[pageFileId] ?? null) : null;
|
||||
|
||||
return [{
|
||||
path: routePath,
|
||||
label: resolvePageLabel(routePath, componentName, helmetTitle),
|
||||
helmetTitle,
|
||||
component: componentName,
|
||||
file: pageFileId
|
||||
? path.relative(projectRoot, pageFileId).split(path.sep).join('/')
|
||||
: null,
|
||||
}];
|
||||
}),
|
||||
);
|
||||
|
||||
return groups.flat();
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import generate from '@babel/generator';
|
||||
import { parse } from '@babel/parser';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import {
|
||||
isJSXIdentifier,
|
||||
isJSXMemberExpression,
|
||||
} from '@babel/types';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const VITE_PROJECT_ROOT = path.resolve(__dirname, '../../../..');
|
||||
|
||||
// Blacklist of components that should not be extracted (utility/non-visual components)
|
||||
const COMPONENT_BLACKLIST = new Set([
|
||||
'Helmet',
|
||||
'HelmetProvider',
|
||||
'Head',
|
||||
'head',
|
||||
'Meta',
|
||||
'meta',
|
||||
'Script',
|
||||
'script',
|
||||
'NoScript',
|
||||
'noscript',
|
||||
'Style',
|
||||
'style',
|
||||
'title',
|
||||
'Title',
|
||||
'link',
|
||||
'Link',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Validates that a file path is safe to access
|
||||
* @param {string} filePath - Relative file path
|
||||
* @returns {{ isValid: boolean, absolutePath?: string, error?: string }} - Object containing validation result
|
||||
*/
|
||||
export function validateFilePath(filePath) {
|
||||
if (!filePath) {
|
||||
return { isValid: false, error: 'Missing filePath' };
|
||||
}
|
||||
|
||||
const absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath);
|
||||
|
||||
if (filePath.includes('..')
|
||||
|| !absoluteFilePath.startsWith(VITE_PROJECT_ROOT)
|
||||
|| absoluteFilePath.includes('node_modules')) {
|
||||
return { isValid: false, error: 'Invalid path' };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(absoluteFilePath)) {
|
||||
return { isValid: false, error: 'File not found' };
|
||||
}
|
||||
|
||||
return { isValid: true, absolutePath: absoluteFilePath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a file into a Babel AST
|
||||
* @param {string} absoluteFilePath - Absolute path to file
|
||||
* @returns {object} Babel AST
|
||||
*/
|
||||
export function parseFileToAST(absoluteFilePath) {
|
||||
const content = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||
|
||||
return parse(content, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript'],
|
||||
errorRecovery: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a JSX opening element at a specific line and column
|
||||
* @param {object} ast - Babel AST
|
||||
* @param {number} line - Line number (1-indexed)
|
||||
* @param {number} column - Column number (0-indexed for get-code-block, 1-indexed for apply-edit)
|
||||
* @returns {object | null} Babel path to the JSX opening element
|
||||
*/
|
||||
export function findJSXElementAtPosition(ast, line, column) {
|
||||
let targetNodePath = null;
|
||||
let closestNodePath = null;
|
||||
let closestDistance = Infinity;
|
||||
const allNodesOnLine = [];
|
||||
|
||||
const visitor = {
|
||||
JSXOpeningElement(path) {
|
||||
const node = path.node;
|
||||
if (node.loc) {
|
||||
// Exact match (with tolerance for off-by-one column differences)
|
||||
if (node.loc.start.line === line
|
||||
&& Math.abs(node.loc.start.column - column) <= 1) {
|
||||
targetNodePath = path;
|
||||
path.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Track all nodes on the same line
|
||||
if (node.loc.start.line === line) {
|
||||
allNodesOnLine.push({
|
||||
path,
|
||||
column: node.loc.start.column,
|
||||
distance: Math.abs(node.loc.start.column - column),
|
||||
});
|
||||
}
|
||||
|
||||
// Track closest match on the same line for fallback
|
||||
if (node.loc.start.line === line) {
|
||||
const distance = Math.abs(node.loc.start.column - column);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// Also check JSXElement nodes that contain the position
|
||||
JSXElement(path) {
|
||||
const node = path.node;
|
||||
if (!node.loc) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this element spans the target line (for multi-line elements)
|
||||
if (node.loc.start.line > line || node.loc.end.line < line) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we're inside this element's range, consider its opening element
|
||||
if (!path.node.openingElement?.loc) {
|
||||
return;
|
||||
}
|
||||
|
||||
const openingLine = path.node.openingElement.loc.start.line;
|
||||
const openingCol = path.node.openingElement.loc.start.column;
|
||||
|
||||
// Prefer elements that start on the exact line
|
||||
if (openingLine === line) {
|
||||
const distance = Math.abs(openingCol - column);
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path.get('openingElement');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle elements that start before the target line
|
||||
if (openingLine < line) {
|
||||
const distance = (line - openingLine) * 100; // Penalize by line distance
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestNodePath = path.get('openingElement');
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
const traverseFunction = traverseBabel.default || traverseBabel;
|
||||
traverseFunction(ast, visitor);
|
||||
|
||||
// Return exact match if found, otherwise return closest match if within reasonable distance
|
||||
// Use larger threshold (50 chars) for same-line elements, 5 lines for multi-line elements
|
||||
const threshold = closestDistance < 100 ? 50 : 500;
|
||||
return targetNodePath || (closestDistance <= threshold ? closestNodePath : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a JSX element name is blacklisted
|
||||
* @param {object} jsxOpeningElement - Babel JSX opening element node
|
||||
* @returns {boolean} True if blacklisted
|
||||
*/
|
||||
function isBlacklistedComponent(jsxOpeningElement) {
|
||||
if (!jsxOpeningElement || !jsxOpeningElement.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle JSXIdentifier (e.g., <Helmet>)
|
||||
if (isJSXIdentifier(jsxOpeningElement.name)) {
|
||||
return COMPONENT_BLACKLIST.has(jsxOpeningElement.name.name);
|
||||
}
|
||||
|
||||
// Handle JSXMemberExpression (e.g., <React.Fragment>)
|
||||
if (isJSXMemberExpression(jsxOpeningElement.name)) {
|
||||
let current = jsxOpeningElement.name;
|
||||
while (isJSXMemberExpression(current)) {
|
||||
current = current.property;
|
||||
}
|
||||
if (isJSXIdentifier(current)) {
|
||||
return COMPONENT_BLACKLIST.has(current.name);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates code from an AST node
|
||||
* @param {object} node - Babel AST node
|
||||
* @param {object} options - Generator options
|
||||
* @returns {string} Generated code
|
||||
*/
|
||||
export function generateCode(node, options = {}) {
|
||||
const generateFunction = generate.default || generate;
|
||||
const output = generateFunction(node, options);
|
||||
return output.code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a full source file from AST with source maps
|
||||
* @param {object} ast - Babel AST
|
||||
* @param {string} sourceFileName - Source file name for source map
|
||||
* @param {string} originalCode - Original source code
|
||||
* @returns {{code: string, map: object}} - Object containing generated code and source map
|
||||
*/
|
||||
export function generateSourceWithMap(ast, sourceFileName, originalCode) {
|
||||
const generateFunction = generate.default || generate;
|
||||
return generateFunction(ast, {
|
||||
sourceMaps: true,
|
||||
sourceFileName,
|
||||
}, originalCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts code blocks from a JSX element at a specific location
|
||||
* @param {string} filePath - Relative file path
|
||||
* @param {number} line - Line number
|
||||
* @param {number} column - Column number
|
||||
* @param {object} [domContext] - Optional DOM context to return on failure
|
||||
* @returns {{success: boolean, filePath?: string, specificLine?: string, error?: string, domContext?: object}} - Object with metadata for LLM
|
||||
*/
|
||||
export function extractCodeBlocks(filePath, line, column, domContext) {
|
||||
try {
|
||||
// Validate file path
|
||||
const validation = validateFilePath(filePath);
|
||||
if (!validation.isValid) {
|
||||
return { success: false, error: validation.error, domContext };
|
||||
}
|
||||
|
||||
// Parse AST
|
||||
const ast = parseFileToAST(validation.absolutePath);
|
||||
|
||||
// Find target node
|
||||
const targetNodePath = findJSXElementAtPosition(ast, line, column);
|
||||
|
||||
if (!targetNodePath) {
|
||||
return { success: false, error: 'Target node not found at specified line/column', domContext };
|
||||
}
|
||||
|
||||
// Check if the target node is a blacklisted component
|
||||
const isBlacklisted = isBlacklistedComponent(targetNodePath.node);
|
||||
|
||||
if (isBlacklisted) {
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
specificLine: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Get specific line code
|
||||
const specificLine = generateCode(targetNodePath.parentPath?.node || targetNodePath.node);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
filePath,
|
||||
specificLine,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ast-utils] Error extracting code blocks:', error);
|
||||
return { success: false, error: 'Failed to extract code blocks', domContext };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project root path
|
||||
*/
|
||||
export { VITE_PROJECT_ROOT };
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Whether a dev-server API request came from the preview page itself.
|
||||
*
|
||||
* This is a cross-origin/CSRF control, NOT authorization. `Sec-Fetch-Site` is
|
||||
* client-supplied and the preview URL needs no credentials, so a crafted request
|
||||
* that sets the header still gets through. What it does stop: browser-driven
|
||||
* cross-origin writes, and clients that send no fetch metadata at all.
|
||||
*
|
||||
* Browsers label the preview's own same-origin `fetch` calls
|
||||
* `Sec-Fetch-Site: same-origin`. Hosts are compared rather than full origins
|
||||
* because the request reaches Vite over plain HTTP behind the proxy, which
|
||||
* forwards the original `Host`.
|
||||
*
|
||||
* @param {import('node:http').IncomingMessage} request
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSameOriginRequest(request) {
|
||||
const site = request.headers['sec-fetch-site'];
|
||||
if (site) {
|
||||
return site === 'same-origin';
|
||||
}
|
||||
|
||||
// Pre-16.4 Safari omits fetch metadata but still sends `Origin` on POST.
|
||||
const origin = request.headers.origin;
|
||||
if (!origin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(origin).host === request.headers.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { exportDraftSnapshot, importDraftSnapshot, getEditState } from "../state/history-state.js";
|
||||
import { exportAnnotationMarkers, importAnnotationMarkers } from "../ui/annotation-panel/annotation-markers.js";
|
||||
import { ParentMessage } from "../constants/messages.js";
|
||||
import { postToParent } from "../utils/parent-frame.js";
|
||||
|
||||
/**
|
||||
* Full draft snapshot for parent-frame persistence (history + annotations).
|
||||
* @returns {{ history: ReturnType<typeof exportDraftSnapshot>, annotations: ReturnType<typeof exportAnnotationMarkers> }}
|
||||
*/
|
||||
export function buildDraftSnapshot() {
|
||||
return {
|
||||
history: exportDraftSnapshot(),
|
||||
annotations: exportAnnotationMarkers(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a previously persisted draft into the live editor session.
|
||||
* @param {{ history?: object, annotations?: object[] }|null|undefined} snapshot
|
||||
* @param {{ onMarkerClick: (comment: object, clientX: number, clientY: number) => void }} options
|
||||
*/
|
||||
export function applyDraftSnapshot(snapshot, { onMarkerClick }) {
|
||||
if (!snapshot || typeof snapshot !== "object") return;
|
||||
|
||||
importDraftSnapshot(snapshot.history);
|
||||
importAnnotationMarkers(snapshot.annotations, onMarkerClick);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates parent with the draft on user action
|
||||
* @param {string} [type] - A {@link ParentMessage} value.
|
||||
* @param {object} [extraPayload]
|
||||
*/
|
||||
export function notifyDraftStateChanged(type = ParentMessage.EDIT_STATE_CHANGED, extraPayload) {
|
||||
const { pathname, search, hash } = window.location;
|
||||
|
||||
postToParent(type, {
|
||||
...getEditState(),
|
||||
...extraPayload,
|
||||
snapshot: buildDraftSnapshot(),
|
||||
route: `${pathname}${search}${hash}`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { ParentMessage } from "../constants/messages.js";
|
||||
import { postToParent } from "../utils/parent-frame.js";
|
||||
import { getEditing } from "../state/editing-state.js";
|
||||
import { getCurrentEdits, clearHistory, getEditState, getActionPointer } from "../state/history-state.js";
|
||||
import { applySessionUndo } from "../utils/session-history-command.js";
|
||||
import { commitCurrentEdit } from "../ui/inline-edit/edit-action.js";
|
||||
import { getComments, setComments } from "../state/annotation-state.js";
|
||||
import { getEditId } from "../constants/selectors.js";
|
||||
import { clearAllAnnotationMarkers } from "../ui/annotation-panel/annotation-markers.js";
|
||||
import { hideAnnotationPanel } from "../ui/annotation-panel/panel.js";
|
||||
import { resetMultiSelectState } from "../state/multi-select-state.js";
|
||||
import { unlockHoverOutline } from "../ui/overlays/hover-outline.js";
|
||||
|
||||
/** Relative URL used to persist draft edits to the server. */
|
||||
const DRAFT_SAVE_API_URL = "/api/draft-save";
|
||||
/** Minimum duration (ms) the parent loading indicator stays visible during a direct-only draft save. */
|
||||
const MIN_LOADING_DISPLAY_MS = 500;
|
||||
|
||||
/**
|
||||
* Rolls back every committed edit in the history stack and clears it, returning
|
||||
* the document to its pre-edit state.
|
||||
*/
|
||||
export function discardAllEdits() {
|
||||
while (getActionPointer() >= 0) {
|
||||
applySessionUndo();
|
||||
}
|
||||
clearHistory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits any active inline edit, rolls back all edits in the history stack,
|
||||
* clears annotations, and notifies the parent frame that the draft was discarded.
|
||||
*/
|
||||
export function handleDraftDiscard() {
|
||||
if (getEditing()?.targetElement?.hasAttribute("contenteditable")) {
|
||||
commitCurrentEdit();
|
||||
}
|
||||
discardAllEdits();
|
||||
hideAnnotationPanel();
|
||||
clearAllAnnotationMarkers();
|
||||
setComments([]);
|
||||
resetMultiSelectState();
|
||||
unlockHoverOutline();
|
||||
postToParent(ParentMessage.DRAFT_DISCARDED);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the current draft edits to the server.
|
||||
*
|
||||
* Commits any active contenteditable edit first, then POSTs the direct edits to
|
||||
* `DRAFT_SAVE_API_URL`. Assisted edits and annotations are applied by the AI
|
||||
* during the stream that follows, so a draft holding only those skips the
|
||||
* request entirely.
|
||||
*
|
||||
* Parent frame messages emitted:
|
||||
* - `DRAFT_SAVE_STARTED` — immediately on call
|
||||
* - `DRAFT_APPLIED` — on success, includes server results and current edit state
|
||||
* - `DRAFT_SAVE_FAILED` — on server error or network failure, includes error message
|
||||
* - `DRAFT_SAVE_FINISHED` — always, delayed by `MIN_LOADING_DISPLAY_MS` for direct-only saves
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function handleDraftSave() {
|
||||
if (getEditing()?.targetElement?.hasAttribute("contenteditable")) {
|
||||
commitCurrentEdit();
|
||||
}
|
||||
postToParent(ParentMessage.DRAFT_SAVE_STARTED);
|
||||
const startTime = Date.now();
|
||||
let hasAssistedEdits = false;
|
||||
try {
|
||||
const allEdits = getCurrentEdits();
|
||||
|
||||
const draft = Object.fromEntries(
|
||||
Object.entries(allEdits)
|
||||
.filter(([, edit]) => !edit.isAssisted)
|
||||
.map(([key, edit]) => {
|
||||
const sourceEdit = { ...edit };
|
||||
delete sourceEdit.editId;
|
||||
return [key, sourceEdit];
|
||||
})
|
||||
);
|
||||
|
||||
const visualEdits = [
|
||||
...Object.values(allEdits).map(edit => ({
|
||||
editId: edit.editId,
|
||||
selectionModes: [edit.selectionMode],
|
||||
instruction: edit.instruction,
|
||||
isAssisted: edit.isAssisted,
|
||||
})),
|
||||
...getComments().map(({ text, elements, selections, attachments }) => ({
|
||||
editId: elements.length === 1 ? getEditId(elements[0]) || undefined : undefined,
|
||||
selectionModes: selections,
|
||||
instruction: {
|
||||
changes: [],
|
||||
annotation: text,
|
||||
...(attachments?.length && {
|
||||
attachments: attachments.map(({ url }) => ({ type: 'image', image: url })),
|
||||
}),
|
||||
},
|
||||
isAssisted: true,
|
||||
})),
|
||||
];
|
||||
|
||||
hasAssistedEdits = visualEdits.some(edit => edit.isAssisted);
|
||||
|
||||
let result = { success: true, results: [] };
|
||||
|
||||
if (Object.keys(draft).length) {
|
||||
const response = await fetch(DRAFT_SAVE_API_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ draft }),
|
||||
});
|
||||
|
||||
result = await response.json();
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
clearHistory();
|
||||
hideAnnotationPanel();
|
||||
clearAllAnnotationMarkers();
|
||||
setComments([]);
|
||||
resetMultiSelectState();
|
||||
unlockHoverOutline();
|
||||
postToParent(ParentMessage.DRAFT_APPLIED, {
|
||||
results: result.results,
|
||||
visualEdits,
|
||||
...getEditState(),
|
||||
});
|
||||
} else {
|
||||
postToParent(ParentMessage.DRAFT_SAVE_FAILED, { error: result.error });
|
||||
console.error(`[vite][visual-editor] Error saving draft: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
postToParent(ParentMessage.DRAFT_SAVE_FAILED, { error: error.message });
|
||||
console.error(`[vite][visual-editor] Error during draft save:`, error);
|
||||
} finally {
|
||||
if (!hasAssistedEdits) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const remaining = MIN_LOADING_DISPLAY_MS - elapsed;
|
||||
if (remaining > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, remaining));
|
||||
}
|
||||
}
|
||||
postToParent(ParentMessage.DRAFT_SAVE_FINISHED);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/** Small chevron-down icon for compact dropdown triggers. */
|
||||
export const ICON_CHEVRON_DOWN_SMALL = `
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" aria-hidden="true">
|
||||
<path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Chevron-down icon for standard dropdown triggers. */
|
||||
export const ICON_CHEVRON_DOWN = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12.0303 7.03033C12.3232 6.73744 12.3232 6.26256 12.0303 5.96967C11.7374 5.67678 11.2626 5.67678 10.9697 5.96967L8 8.93934L5.03033 5.96967C4.73744 5.67678 4.26256 5.67678 3.96967 5.96967C3.67678 6.26256 3.67678 6.73744 3.96967 7.03033L7.46967 10.5303C7.76256 10.8232 8.23744 10.8232 8.53033 10.5303L12.0303 7.03033Z" fill="white"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Bold text-format toolbar icon. */
|
||||
export const ICON_BOLD = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="10" viewBox="0 0 8 10" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.03125 0.0146484C6.41781 0.155626 7.5 1.32626 7.5 2.75L7.48633 3.03125C7.42559 3.62924 7.17072 4.16858 6.78906 4.59082C7.52541 5.13749 8.00391 6.01245 8.00391 7L8 7.1543C7.92229 8.68818 6.69202 9.91814 5.1582 9.99609L5.00391 10H0.861328L0.773438 9.99512C0.370065 9.95414 0.0497858 9.63383 0.00878906 9.23047L0.00390625 9.14258V5.5H0V0.803711C2.4579e-05 0.387669 0.316487 0.045031 0.72168 0.00390625L0.803711 0H4.75L5.03125 0.0146484ZM1.50391 8.5H5.00391C5.83211 8.49974 6.50391 7.82826 6.50391 7C6.50391 6.17174 5.83211 5.50026 5.00391 5.5H1.50391V8.5ZM1.5 4H4.75C5.39701 3.99981 5.9301 3.5081 5.99414 2.87793L6 2.75C6 2.05977 5.44019 1.5002 4.75 1.5H1.5V4Z" fill="white"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Italic text-format toolbar icon. */
|
||||
export const ICON_ITALIC = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8.45775 5C8.92563 5 9.27389 5.43265 9.17357 5.88965L7.62865 12.9248C7.55475 13.2606 7.25764 13.4999 6.9138 13.5C6.44592 13.5 6.09766 13.0673 6.19798 12.6104L7.7429 5.5752C7.8168 5.23941 8.11391 5.0001 8.45775 5Z" fill="white"/>
|
||||
<path d="M9.00072 2.5C9.4148 2.50015 9.75072 2.83588 9.75072 3.25C9.75072 3.66412 9.4148 3.99985 9.00072 4C8.5865 4 8.25072 3.66421 8.25072 3.25C8.25072 2.83579 8.5865 2.5 9.00072 2.5Z" fill="white"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Underline text-format toolbar icon. */
|
||||
export const ICON_UNDERLINE = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12.5771 12.5039C12.9551 12.5425 13.25 12.8618 13.25 13.25C13.25 13.6382 12.9551 13.9575 12.5771 13.9961L12.5 14H3.5C3.08579 14 2.75 13.6642 2.75 13.25C2.75 12.8358 3.08579 12.5 3.5 12.5H12.5L12.5771 12.5039Z" fill="white"/>
|
||||
<path d="M4.75 2.74316C5.16421 2.74316 5.5 3.07895 5.5 3.49316V7.74609L5.5127 8.00098C5.64042 9.2608 6.70376 10.2445 7.99707 10.2461L8.25293 10.2334C9.43054 10.1151 10.3676 9.17942 10.4873 8.00195L10.5 7.74609V3.50098C10.5 3.08676 10.8358 2.75098 11.25 2.75098C11.6642 2.75098 12 3.08676 12 3.50098V7.74609L11.9951 7.95215C11.8912 9.99912 10.2483 11.6398 8.20117 11.7412L7.99512 11.7461C5.85703 11.7435 4.11219 10.0635 4.00488 7.95215L4 7.74609V3.49316C4 3.07895 4.33579 2.74316 4.75 2.74316Z" fill="white"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Align-left text-format toolbar icon. */
|
||||
export const ICON_ALIGN_LEFT = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M10.5771 11.2539C10.9551 11.2925 11.25 11.6118 11.25 12C11.25 12.3882 10.9551 12.7075 10.5771 12.7461L10.5 12.75H3.5C3.08579 12.75 2.75 12.4142 2.75 12C2.75 11.5858 3.08579 11.25 3.5 11.25H10.5L10.5771 11.2539Z" fill="white"/>
|
||||
<path d="M7.5 7.25C7.91421 7.25 8.25 7.58579 8.25 8C8.25 8.41421 7.91421 8.75 7.5 8.75H3.5C3.08579 8.75 2.75 8.41421 2.75 8C2.75 7.58579 3.08579 7.25 3.5 7.25H7.5Z" fill="white"/>
|
||||
<path d="M13.5 3.25C13.9142 3.25007 14.25 3.58583 14.25 4C14.25 4.41417 13.9142 4.74993 13.5 4.75H3.5C3.08579 4.75 2.75 4.41421 2.75 4C2.75 3.58579 3.08579 3.25 3.5 3.25H13.5Z" fill="white"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** “More options” toolbar icon (line height / extras). */
|
||||
export const ICON_MORE = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M12.5 12.5C12.9142 12.5 13.25 12.8358 13.25 13.25C13.25 13.6642 12.9142 14 12.5 14H3.5C3.08579 14 2.75 13.6642 2.75 13.25C2.75 12.8358 3.08579 12.5 3.5 12.5H12.5Z" fill="white"/>
|
||||
<path d="M8.03027 1.60755C8.334 1.60858 8.60681 1.79314 8.72168 2.07435L12.1943 10.5743C12.3507 10.9577 12.1665 11.3953 11.7832 11.5519C11.3998 11.7083 10.9623 11.5241 10.8057 11.1408L8.02148 4.32728L5.19238 11.1447C5.03363 11.5272 4.59541 11.7086 4.21289 11.5499C3.83039 11.3912 3.64899 10.953 3.80762 10.5704L7.33496 2.07044C7.45146 1.78974 7.72636 1.6067 8.03027 1.60755Z" fill="white"/>
|
||||
<path d="M10.0863 7.25C10.5004 7.25013 10.8363 7.58587 10.8363 8C10.8363 8.41413 10.5004 8.74987 10.0863 8.75H5.87732C5.4631 8.75 5.12732 8.41421 5.12732 8C5.12732 7.58579 5.4631 7.25 5.87732 7.25H10.0863Z" fill="white"/>
|
||||
<path d="M12.7811 1.19129C12.6805 0.936238 12.3195 0.936238 12.2189 1.19129L11.6936 2.52336C11.6629 2.60122 11.6012 2.66286 11.5234 2.69357L10.1913 3.21893C9.93624 3.31952 9.93624 3.68048 10.1913 3.78107L11.5234 4.30643C11.6012 4.33714 11.6629 4.39878 11.6936 4.47664L12.2189 5.80871C12.3195 6.06376 12.6805 6.06376 12.7811 5.80872L13.3064 4.47664C13.3371 4.39878 13.3988 4.33714 13.4766 4.30643L14.8087 3.78107C15.0638 3.68048 15.0638 3.31952 14.8087 3.21893L13.4766 2.69357C13.3988 2.66286 13.3371 2.60122 13.3064 2.52336L12.7811 1.19129Z" fill="white"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Link insertion toolbar icon. */
|
||||
export const ICON_LINK = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.82058 2.51974C9.38336 0.956963 11.9171 0.956963 13.4799 2.51974C15.0427 4.08251 15.0427 6.61627 13.4799 8.17904L12.2356 9.42334C11.9427 9.71623 11.4678 9.71623 11.1749 9.42334C10.882 9.13044 10.882 8.65557 11.1749 8.36268L12.4192 7.11838C13.3962 6.1414 13.3962 4.55738 12.4192 3.5804C11.4422 2.60341 9.85823 2.60341 8.88124 3.5804L7.63695 4.82469C7.34406 5.11758 6.86918 5.11758 6.57629 4.82469C6.2834 4.5318 6.2834 4.05692 6.57629 3.76403L7.82058 2.51974ZM4.80405 6.59693C5.09695 6.88982 5.09695 7.3647 4.80405 7.65759L3.5804 8.88124C2.60341 9.85823 2.60341 11.4422 3.5804 12.4192C4.55739 13.3962 6.1414 13.3962 7.11838 12.4192L8.34204 11.1956C8.63493 10.9027 9.10981 10.9027 9.4027 11.1956C9.69559 11.4885 9.69559 11.9633 9.4027 12.2562L8.17904 13.4799C6.61627 15.0427 4.08251 15.0427 2.51974 13.4799C0.956964 11.9171 0.956962 9.38336 2.51974 7.82058L3.74339 6.59693C4.03629 6.30404 4.51116 6.30403 4.80405 6.59693Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.6946 5.36582C10.9875 5.65871 10.9875 6.13358 10.6946 6.42648L6.12342 10.9976C5.83053 11.2905 5.35566 11.2905 5.06276 10.9976C4.76987 10.7047 4.76987 10.2299 5.06276 9.93698L9.63393 5.36582C9.92682 5.07292 10.4017 5.07292 10.6946 5.36582Z" fill="white"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Delete/trash icon for removing edits. */
|
||||
export const ICON_DELETE = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g transform="translate(1.85 1.085)">
|
||||
<path d="M5.55 5.25C5.55 4.83579 5.21421 4.5 4.8 4.5C4.38579 4.5 4.05 4.83579 4.05 5.25V10.2C4.05 10.6142 4.38579 10.95 4.8 10.95C5.21421 10.95 5.55 10.6142 5.55 10.2V5.25Z" fill="white"/>
|
||||
<path d="M7.5 4.5C7.91421 4.5 8.25 4.83579 8.25 5.25V10.2C8.25 10.6142 7.91421 10.95 7.5 10.95C7.08579 10.95 6.75 10.6142 6.75 10.2V5.25C6.75 4.83579 7.08579 4.5 7.5 4.5Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.35623 0C4.73126 0 4.15992 0.353103 3.88043 0.912097L3.39085 1.89125L0.750363 1.88998C0.336149 1.88978 0.000200302 2.2254 8.95287e-08 2.63962C-0.000200122 3.05383 0.335424 3.38978 0.749637 3.38998L1.20364 3.3902L1.74954 12.2812C1.80298 13.1515 2.52443 13.83 3.39644 13.83H8.90357C9.77557 13.83 10.497 13.1515 10.5505 12.2812L11.0962 3.39498L11.5496 3.3952C11.9639 3.3954 12.2998 3.05977 12.3 2.64556C12.3002 2.23135 11.9646 1.8954 11.5504 1.8952L8.91049 1.89392L8.41957 0.912098C8.14008 0.353104 7.56874 0 6.94377 0H5.35623ZM5.22207 1.58292C5.24748 1.5321 5.29941 1.5 5.35623 1.5H6.94377C7.00059 1.5 7.05253 1.5321 7.07793 1.58292L7.18647 1.8H5.11353L5.22207 1.58292ZM2.70651 3.39092L9.59338 3.39425L9.05329 12.1892C9.04843 12.2684 8.98284 12.33 8.90357 12.33H3.39644C3.31716 12.33 3.25158 12.2684 3.24672 12.1892L2.70651 3.39092Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Close/dismiss icon for panels and popovers. */
|
||||
export const ICON_CLOSE = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path transform="translate(3.75 3.75)" d="M0.219671 1.28033C-0.0732222 0.987437 -0.0732222 0.512563 0.219671 0.21967C0.512564 -0.0732233 0.987438 -0.0732233 1.28033 0.21967L4.25 3.18934L7.21967 0.21967C7.51256 -0.0732232 7.98744 -0.0732232 8.28033 0.21967C8.57322 0.512563 8.57322 0.987437 8.28033 1.28033L5.31066 4.25L8.28033 7.21967C8.57322 7.51256 8.57322 7.98744 8.28033 8.28033C7.98744 8.57322 7.51256 8.57322 7.21967 8.28033L4.25 5.31066L1.28033 8.28033C0.987437 8.57322 0.512563 8.57322 0.21967 8.28033C-0.0732233 7.98744 -0.0732233 7.51256 0.21967 7.21967L3.18934 4.25L0.219671 1.28033Z" fill="white"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Image icon for attaching reference images to an annotation. Mirrors the chat prompt's `ic-image-16`. */
|
||||
export const ICON_IMAGE = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 2.5H12.5C13.0523 2.5 13.5 2.94772 13.5 3.5V12.0818L10.8974 8.39082C10.5672 7.92252 10.4021 7.68836 10.1967 7.60571C10.017 7.53339 9.81632 7.53339 9.63663 7.60571C9.43128 7.68836 9.26618 7.92252 8.93596 8.39082L6.83975 11.3636L6.02303 10.2054C5.73684 9.79951 5.59374 9.59658 5.41578 9.52495C5.26005 9.46227 5.08611 9.46227 4.93038 9.52495C4.75241 9.59658 4.60932 9.79951 4.32313 10.2054L2.5308 12.7472C2.51069 12.6682 2.5 12.5853 2.5 12.5V3.5C2.5 2.94772 2.94772 2.5 3.5 2.5ZM1 3.5C1 2.11929 2.11929 1 3.5 1H12.5C13.8807 1 15 2.11929 15 3.5V12.5C15 13.8807 13.8807 15 12.5 15H3.5C2.11929 15 1 13.8807 1 12.5V3.5ZM5.5 7C6.32843 7 7 6.32843 7 5.5C7 4.67157 6.32843 4 5.5 4C4.67157 4 4 4.67157 4 5.5C4 6.32843 4.67157 7 5.5 7Z" fill="white"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Cursor icon shown while actively editing text (used as a CSS `cursor: url(...)` value, must stay on one line). */
|
||||
export const ICON_TEXT_EDIT_CURSOR = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.6855 4.01657C15.1185 3.98715 15.5827 4.0007 15.9206 4.02768L16.0189 6.02799C15.4981 6.00166 15.1549 5.99184 14.856 6.00975C14.2838 6.13443 14.022 6.27355 13.7212 6.48238C13.5144 6.6249 13.1758 7.08781 13.0328 7.40688V11.9466H14.0308V13.9306H13.0328V17.5075C13.1757 17.8261 13.5144 18.2881 13.7256 18.4331C14.0074 18.6295 14.253 18.7705 14.4705 18.8452C15.3967 18.9569 15.3319 18.8892 15.8593 18.906L16.1008 20.8832C15.4621 20.9149 15.0617 20.9214 14.6848 20.8955C14.3728 20.8741 14.1113 20.8311 13.9107 20.7663C13.4939 20.6366 13.0581 20.4058 12.5878 20.0778C12.4192 19.9621 12.2307 19.7872 12.0449 19.5797C11.8745 19.7815 11.7053 19.9522 11.561 20.0652C11.1569 20.3791 10.6947 20.6094 10.2013 20.7643C9.98962 20.8325 9.72877 20.8751 9.41772 20.8965C8.98322 20.9264 8.5177 20.9128 8.17962 20.8857L8.0827 18.8854C8.60041 18.9118 8.94438 18.9217 9.2438 18.9036C9.86247 18.7641 10.1206 18.6516 10.3311 18.4881C10.5282 18.3338 10.9156 17.8079 11.0328 17.5043L11.0451 13.9306H10.0448V11.9466H11.0468V7.41344C10.9148 7.10644 10.5303 6.57954 10.3355 6.42776C10.1342 6.27198 9.89676 6.15407 9.63388 6.06667C8.70613 5.95562 8.77053 6.02289 8.24302 6.00632L8 4.02903C8.64058 3.99743 9.04059 3.99092 9.41699 4.01654C9.72766 4.03768 9.98817 4.07993 10.1966 4.1466C10.6967 4.30508 11.1568 4.53443 11.5622 4.84814C11.7056 4.95991 11.8748 5.13053 12.0455 5.33267C12.23 5.12617 12.417 4.95226 12.5834 4.83756C13.0581 4.508 13.4916 4.27789 13.9024 4.14775C14.1137 4.07998 14.3744 4.0377 14.6855 4.01657Z" fill="black" stroke="white" stroke-linejoin="round"/></svg>`;
|
||||
|
||||
/** Sparkles icon (`ic-sparkles`) for AI edit affordances: the toolbar button and annotation markers. 12x13 glyph in a 16x16 box. */
|
||||
export const ICON_SPARKLES = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<g transform="translate(2 1)">
|
||||
<path d="M3.89349 3.7678C3.75267 3.41073 3.24733 3.41073 3.10651 3.7678L2.371 5.6327C2.32801 5.74171 2.24171 5.82801 2.1327 5.871L0.2678 6.60651C-0.0892668 6.74733 -0.0892668 7.25267 0.2678 7.39349L2.1327 8.129C2.24171 8.17199 2.32801 8.25829 2.371 8.3673L3.1065 10.2322C3.24733 10.5893 3.75267 10.5893 3.89349 10.2322L4.629 8.3673C4.67199 8.25829 4.75829 8.17199 4.8673 8.129L6.7322 7.39349C7.08927 7.25267 7.08927 6.74733 6.7322 6.60651L4.8673 5.871C4.75829 5.82801 4.67199 5.74171 4.629 5.6327L3.89349 3.7678Z" fill="white"/>
|
||||
<path d="M9.78107 0.191286C9.68048 -0.0637618 9.31952 -0.0637621 9.21893 0.191286L8.69357 1.52336C8.66286 1.60122 8.60122 1.66286 8.52336 1.69357L7.19129 2.21893C6.93624 2.31952 6.93624 2.68048 7.19129 2.78107L8.52336 3.30643C8.60122 3.33714 8.66286 3.39878 8.69357 3.47664L9.21893 4.80871C9.31952 5.06376 9.68048 5.06376 9.78107 4.80872L10.3064 3.47664C10.3371 3.39878 10.3988 3.33714 10.4766 3.30643L11.8087 2.78107C12.0638 2.68048 12.0638 2.31952 11.8087 2.21893L10.4766 1.69357C10.3988 1.66286 10.3371 1.60122 10.3064 1.52336L9.78107 0.191286Z" fill="white"/>
|
||||
<path d="M9.66864 10.1148C9.60829 9.96174 9.39171 9.96174 9.33136 10.1148L9.01614 10.914C8.99772 10.9607 8.96073 10.9977 8.91401 11.0161L8.11477 11.3314C7.96174 11.3917 7.96174 11.6083 8.11477 11.6686L8.91401 11.9839C8.96073 12.0023 8.99772 12.0393 9.01614 12.086L9.33136 12.8852C9.39171 13.0383 9.60829 13.0383 9.66864 12.8852L9.98386 12.086C10.0023 12.0393 10.0393 12.0023 10.086 11.9839L10.8852 11.6686C11.0383 11.6083 11.0383 11.3917 10.8852 11.3314L10.086 11.0161C10.0393 10.9977 10.0023 10.9607 9.98386 10.914L9.66864 10.1148Z" fill="white"/>
|
||||
</g>
|
||||
</svg>
|
||||
`.trim();
|
||||
|
||||
/** Label icon shown on elements with assisted (AI) edits. */
|
||||
export const ASSISTED_EDIT_LABEL = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.9707 8.64551C13.1721 8.57718 13.3909 8.5738 13.5938 8.6377C13.8245 8.71052 14.0256 8.91063 14.4277 9.30859L14.8369 9.71387C15.1919 10.0638 15.3707 10.2388 15.4482 10.4375C15.537 10.6652 15.5388 10.9183 15.4541 11.1475C15.38 11.3472 15.2046 11.5261 14.8564 11.8818L11.5137 15.2969C11.4057 15.4072 11.3511 15.463 11.29 15.5088C11.2359 15.5494 11.1774 15.5854 11.1162 15.6143L11 15.6582C10.9543 15.6732 10.8984 15.691 10.8242 15.7129L9.78418 16.0195L9.42383 16.125C9.10857 16.2124 8.91655 16.2452 8.76367 16.1973L8.63574 16.1426C8.51494 16.0759 8.41463 15.9741 8.35059 15.8496C8.27735 15.7071 8.27841 15.5121 8.31152 15.1865L8.35449 14.8145L8.50781 13.5195C8.5317 13.3189 8.54362 13.217 8.57422 13.123C8.58781 13.0815 8.60516 13.0403 8.62402 13.001L8.68848 12.8857C8.71549 12.8446 8.74705 12.8049 8.78906 12.7588L8.95605 12.5869L11.6582 9.84277L12.1504 9.33301C12.5436 8.92657 12.7417 8.72344 12.9707 8.64551ZM13.2295 10.376L12.7363 10.8857L12.7275 10.8945L10.0254 13.6377C10.0103 13.6531 10.0009 13.6736 9.99821 13.6949C9.99815 13.6954 9.9981 13.6958 9.99805 13.6963L9.95695 14.0409C9.93571 14.2191 10.1034 14.3609 10.2755 14.3104L10.3984 14.2744C10.4146 14.2696 10.4292 14.2605 10.441 14.2485C10.4411 14.2483 10.4413 14.2482 10.4414 14.248L13.7842 10.833C13.7981 10.8188 13.7972 10.7954 13.7831 10.7814L13.3719 10.3742C13.3322 10.3349 13.2684 10.3358 13.2295 10.376Z" fill="#18181A"/>
|
||||
<path d="M7 0C7.41421 0 7.75 0.335786 7.75 0.75V2.40039C7.75 2.4554 7.7946 2.5 7.84961 2.5C8.95782 2.5 9.84974 2.49916 10.5605 2.57617C11.2852 2.6547 11.9107 2.82112 12.4541 3.21582C12.7725 3.44712 13.0529 3.72754 13.2842 4.0459C13.6789 4.58927 13.8453 5.21477 13.9238 5.93945C13.9538 6.21579 13.9716 6.5195 13.9825 6.85264C13.988 7.02115 13.8262 7.14461 13.6595 7.11978C13.3798 7.07813 13.0964 7.08423 12.8213 7.13676C12.6552 7.16849 12.4881 7.05113 12.4826 6.88211C12.4729 6.58977 12.4576 6.33246 12.4326 6.10156C12.3679 5.50448 12.2483 5.17286 12.0703 4.92773C11.9315 4.73672 11.7633 4.56847 11.5723 4.42969C11.3271 4.25165 10.9955 4.13207 10.3984 4.06738C9.78723 4.0012 8.99098 4 7.84961 4H6.15039C5.00902 4 4.21277 4.0012 3.60156 4.06738C3.00448 4.13207 2.67286 4.25165 2.42773 4.42969C2.23672 4.56847 2.06847 4.73672 1.92969 4.92773C1.75165 5.17286 1.63207 5.50448 1.56738 6.10156C1.5012 6.71277 1.5 7.50902 1.5 8.65039V8.84961C1.5 9.99098 1.5012 10.7872 1.56738 11.3984C1.63207 11.9955 1.75166 12.3271 1.92969 12.5723C2.06847 12.7633 2.23672 12.9315 2.42773 13.0703C2.67286 13.2483 3.00449 13.3679 3.60156 13.4326C4.21277 13.4988 5.00902 13.5 6.15039 13.5H6.71864C6.86837 13.5 6.98452 13.6307 6.9669 13.7794L6.86523 14.6377L6.86426 14.6426L6.84866 14.7785C6.83418 14.9047 6.72733 15 6.60029 15H6.15039C5.04218 15 4.15026 15.0008 3.43945 14.9238C2.71477 14.8453 2.08927 14.6789 1.5459 14.2842C1.22754 14.0529 0.94712 13.7725 0.715821 13.4541C0.321123 12.9107 0.154699 12.2852 0.0761723 11.5605C-0.000839256 10.8497 3.82375e-07 9.95782 3.82375e-07 8.84961V8.65039C3.82375e-07 7.54218 -0.000839256 6.65026 0.0761723 5.93945C0.154699 5.21477 0.321123 4.58927 0.715821 4.0459C0.947119 3.72754 1.22754 3.44712 1.5459 3.21582C2.08927 2.82112 2.71477 2.6547 3.43945 2.57617C4.15026 2.49916 5.04218 2.5 6.15039 2.5C6.2054 2.5 6.25 2.4554 6.25 2.40039V0.75C6.25 0.335787 6.58579 2.89917e-07 7 0Z" fill="#18181A"/>
|
||||
<path d="M4.5 7.5C5.05229 7.5 5.5 7.94772 5.5 8.5C5.5 9.05229 5.05229 9.5 4.5 9.5C3.94772 9.5 3.5 9.05229 3.5 8.5C3.5 7.94772 3.94772 7.5 4.5 7.5Z" fill="#18181A"/>
|
||||
<path d="M9.5 7.5C10.0523 7.5 10.5 7.94772 10.5 8.5C10.5 9.05229 10.0523 9.5 9.5 9.5C8.94771 9.5 8.5 9.05229 8.5 8.5C8.5 7.94772 8.94771 7.5 9.5 7.5Z" fill="#18181A"/>
|
||||
</svg>
|
||||
`.trim();
|
||||
@@ -0,0 +1,26 @@
|
||||
/** Numeric spacing and sizing values (px) used to position editor UI. */
|
||||
|
||||
/** Gap (px) between a panel and its anchor element. */
|
||||
export const PANEL_GAP = 4;
|
||||
/** Minimum margin (px) from the viewport edge when positioning panels. */
|
||||
export const PANEL_MARGIN = 8;
|
||||
/** Reserved height (px) of the parent Horizons toolbar when positioning iframe UI. */
|
||||
export const PARENT_TOOLBAR_HEIGHT = 68;
|
||||
/** Offset (px) from the cursor to the type tooltip's top-left corner. */
|
||||
export const TYPE_TOOLTIP_CURSOR_OFFSET = 12;
|
||||
/** Minimum margin (px) from the viewport edge when clamping the type tooltip. */
|
||||
export const TOOLTIP_VIEWPORT_MARGIN = 4;
|
||||
/** Border width (px) of the dashed hover outline drawn around the hovered element. */
|
||||
export const HOVER_OUTLINE_STROKE_WIDTH = 1;
|
||||
/** Width (px) of the annotation panel. */
|
||||
export const ANNOTATION_PANEL_WIDTH = 320;
|
||||
/** Estimated height (px) of the annotation panel for positioning calculations. */
|
||||
export const ANNOTATION_PANEL_ESTIMATED_HEIGHT = 184;
|
||||
/** Pointer travel (px) from mousedown before a click becomes a drag-select. */
|
||||
export const DRAG_SELECT_THRESHOLD = 4;
|
||||
/** Smallest side (px) an element may have to be worth drag-selecting. */
|
||||
export const MIN_SELECTION_CANDIDATE_SIZE = 8;
|
||||
/** Elements one annotation may target. Matches the backend's cap so a drag can never build a payload the API rejects.*/
|
||||
export const MAX_SELECTED_ELEMENTS = 20;
|
||||
/** Reference images across all annotations in one visual-editor turn. */
|
||||
export const MAX_ANNOTATION_ATTACHMENTS = 10;
|
||||
@@ -0,0 +1,35 @@
|
||||
/** Message types sent from the iframe to the parent Horizons frame. */
|
||||
export const ParentMessage = {
|
||||
EDIT_APPLIED: 'editApplied',
|
||||
EDIT_CANCEL: 'editCancel',
|
||||
EDIT_ENTER: 'editEnter',
|
||||
IMAGE_EDIT_ENTER: 'imageEditEnter',
|
||||
EDIT_STATE_CHANGED: 'editStateChanged',
|
||||
EDIT_UNDO_APPLIED: 'undoApplied',
|
||||
EDIT_REDO_APPLIED: 'redoApplied',
|
||||
DRAFT_SAVE_STARTED: 'draftSaveStarted',
|
||||
DRAFT_APPLIED: 'draftApplied',
|
||||
DRAFT_SAVE_FAILED: 'draftSaveFailed',
|
||||
DRAFT_SAVE_FINISHED: 'draftSaveFinished',
|
||||
DRAFT_DISCARDED: 'draftDiscarded',
|
||||
ANNOTATION_PANEL_OPENED: 'annotationPanelOpened',
|
||||
ANNOTATION_IMAGE_ATTACH_REQUESTED: 'annotationImageAttachRequested',
|
||||
ANNOTATION_IMAGE_REMOVED: 'annotationImageRemoved',
|
||||
ANNOTATION_IMAGE_PREVIEW_REQUESTED: 'annotationImagePreviewRequested',
|
||||
}
|
||||
|
||||
/** Message types sent from the parent Horizons frame to the iframe. */
|
||||
export const ChildMessage = {
|
||||
ENABLE_EDIT_MODE: 'enable-edit-mode',
|
||||
DISABLE_EDIT_MODE: 'disable-edit-mode',
|
||||
EDIT_SAVE: 'edit-save',
|
||||
DRAFT_SAVE: 'draft-save',
|
||||
DRAFT_DISCARD: 'draft-discard',
|
||||
DRAFT_RESTORE: 'draft-restore',
|
||||
DRAFT_SNAPSHOT_REQUEST: 'draft-snapshot-request',
|
||||
EDIT_UNDO: 'edit-undo',
|
||||
EDIT_REDO: 'edit-redo',
|
||||
EDIT_CANCEL: 'edit-cancel',
|
||||
ANNOTATION_OPEN_FOR_IMAGE: 'annotation-open-for-image',
|
||||
ANNOTATION_IMAGE_ATTACHED: 'annotation-image-attached',
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/** CSS properties captured in DOM context payloads sent to the parent frame. */
|
||||
export const IMPORTANT_STYLES = [
|
||||
'display',
|
||||
'position',
|
||||
'flex-direction',
|
||||
'justify-content',
|
||||
'align-items',
|
||||
'width',
|
||||
'height',
|
||||
'padding',
|
||||
'margin',
|
||||
'border',
|
||||
'background-color',
|
||||
'color',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'font-family',
|
||||
'border-radius',
|
||||
'box-shadow',
|
||||
'gap',
|
||||
'grid-template-columns',
|
||||
];
|
||||
|
||||
/** Maps logical element types to tag names (lowercase HTML and PascalCase components). */
|
||||
export const ELEMENT_TYPE_MAP = {
|
||||
text: ['a', 'Link', 'p', 'span', 'time', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'Label', 'strong', 'em', 'b', 'i', 'u'],
|
||||
button: ['button', 'Button'],
|
||||
image: ['img'],
|
||||
};
|
||||
|
||||
/** Hover type tooltip label maps to its translation key and default English label. */
|
||||
export const ELEMENT_TYPE_TRANSLATIONS = {
|
||||
text: { key: 'elementTypeText', defaultLabel: 'Text' },
|
||||
button: { key: 'elementTypeButton', defaultLabel: 'Button' },
|
||||
image: { key: 'elementTypeImage', defaultLabel: 'Image' },
|
||||
block: { key: 'elementTypeBlock', defaultLabel: 'Block' },
|
||||
};
|
||||
|
||||
/** Selector for ancestors that use fixed/sticky positioning (affects overlay coordinates). */
|
||||
export const FIXED_CONTEXT_SELECTOR = 'nav, header, [style*="position: fixed"], [style*="position:fixed"], [style*="position: sticky"], [style*="position:sticky"]';
|
||||
|
||||
/** CSS selector matching the floating text-format toolbar and its action panels. */
|
||||
export const TOOLBAR_UI_SELECTOR = '#text-format-toolbar, #text-format-link-action, #text-format-size-action, #text-format-font-action, #text-format-color-action';
|
||||
|
||||
/** DOM id of the drag-select band drawn while rubber-band selecting. */
|
||||
export const SELECTION_RECT_ID = 'selection-mode-select-rect';
|
||||
|
||||
/** CSS selector matching the annotation panel, its pin markers and the drag-select band. */
|
||||
export const ANNOTATION_UI_SELECTOR = `#selection-mode-annotation-panel, .selection-mode-annotation-marker, #${SELECTION_RECT_ID}`;
|
||||
|
||||
/** All floating editor UI (toolbar, annotation panel, pin markers). */
|
||||
export const EDITOR_UI_SELECTOR = `${TOOLBAR_UI_SELECTOR}, ${ANNOTATION_UI_SELECTOR}`;
|
||||
|
||||
/** CSS selector matching any editable target (static or assisted). */
|
||||
export const EDIT_TARGET_SELECTOR = '[data-edit-id], [data-edit-assisted-id]';
|
||||
|
||||
/**
|
||||
* Returns whichever edit-id attribute is present on the element.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getEditId(element) {
|
||||
return element?.getAttribute('data-edit-id') || element?.getAttribute('data-edit-assisted-id') || null;
|
||||
}
|
||||
|
||||
/** Selector for actionable elements (pointer-events disabled in edit mode, see `EDIT_MODE_STYLES`). */
|
||||
export const ACTIONABLE_SELECTOR = 'a[href], button, [role="button"], [type="submit"]';
|
||||
|
||||
/**
|
||||
* True while edit mode is active on the app root.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEditModeEnabled() {
|
||||
return document.getElementById('root')?.getAttribute('data-edit-mode-enabled') === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the event target sits inside any floating editor UI (toolbar or action panels).
|
||||
* @param {EventTarget|null} target
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isInsideEditorUi(target) {
|
||||
return !!target?.closest?.(EDITOR_UI_SELECTOR);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/** Visual design tokens (colors, z-index, shadows) for the editor UI. */
|
||||
|
||||
/** Background color for floating panels and toolbars. */
|
||||
export const PANEL_BG = '#1D1E20';
|
||||
/** Background color for inset input surfaces inside panels (textareas, fields). */
|
||||
export const PANEL_INPUT_BG = '#262831';
|
||||
/** Border color for panel borders and dividers. */
|
||||
export const BORDER_COLOR = '#3B3D4A';
|
||||
/** Translucent white border (#fff at 20% opacity) for subtle on-dark outlines. */
|
||||
export const BORDER_COLOR_TRANSLUCENT = '#ffffff33';
|
||||
/** Border color for focused inputs: a touch brighter than the default, not white. */
|
||||
export const BORDER_COLOR_FOCUS = '#6B6E7E';
|
||||
/** Subtle hover background for interactive panel controls. */
|
||||
export const HOVER_BG = '#ffffff0d';
|
||||
/** Active/pressed background for panel controls. */
|
||||
export const ACTIVE_BG = '#2a2b2f';
|
||||
/** Default foreground color on dark panels. */
|
||||
export const COLOR_WHITE = '#ffffff';
|
||||
/** Secondary foreground on dark panels (#fff at 50% opacity) for labels and placeholders. */
|
||||
export const TEXT_MUTED = '#ffffff80';
|
||||
/** Foreground color for light (white-filled) buttons. */
|
||||
export const BUTTON_LIGHT_TEXT = '#161718';
|
||||
/** Hover background for light (white-filled) buttons. */
|
||||
export const BUTTON_LIGHT_BG_HOVER = '#E5E5E7';
|
||||
/** Outline color for editable targets in edit mode. */
|
||||
export const EDIT_BORDER_COLOR = '#673DE6';
|
||||
/** Hover background for editable targets in edit mode. */
|
||||
export const EDIT_BACKGROUND_COLOR = '#673DE61A';
|
||||
/** Accent color for annotation panel buttons and markers. */
|
||||
export const ANNOTATION_COLOR = '#673DE6';
|
||||
/** Hover state for annotation buttons and markers. */
|
||||
export const ANNOTATION_COLOR_HOVER = '#5025D1';
|
||||
/** Placeholder text color in panel inputs. */
|
||||
export const PLACEHOLDER_COLOR = '#D1D5DC';
|
||||
/** Scrollbar thumb on dark panel surfaces. Matches the chat's `--scrollbar-thumb`. */
|
||||
export const SCROLLBAR_THUMB = 'rgba(255, 255, 255, 0.28)';
|
||||
/** Hovered scrollbar thumb. Matches the chat's `--scrollbar-thumb-hover`. */
|
||||
export const SCROLLBAR_THUMB_HOVER = 'rgba(255, 255, 255, 0.42)';
|
||||
|
||||
/** Browser int32 max; toolbar, dropdowns, and annotation panel. */
|
||||
export const Z_INDEX_EDITOR_PANEL = 2147483647;
|
||||
/** Overlays: element-type tooltip and drag-select band. */
|
||||
export const Z_INDEX_EDITOR_OVERLAY = Z_INDEX_EDITOR_PANEL - 1;
|
||||
/** Annotation markers. */
|
||||
export const Z_INDEX_EDITOR_MARKER = Z_INDEX_EDITOR_PANEL - 2;
|
||||
|
||||
/** Box shadow for dropdown panels. */
|
||||
export const BOX_SHADOW_DROPDOWN = '0 4px 16px 0 rgba(0, 0, 0, 0.30)';
|
||||
/** Box shadow for floating editor surfaces: inline toolbars and panels. */
|
||||
export const BOX_SHADOW_TOOLBAR = '0 1px 4px 0 rgba(0, 0, 0, 0.10)';
|
||||
@@ -0,0 +1,812 @@
|
||||
import { startInlineEdit, commitCurrentEdit, placeCursorAtPoint } from "./ui/inline-edit/edit-action.js";
|
||||
import { getEditing } from "./state/editing-state.js";
|
||||
import { getElementType, isInFixedContext, isolateEditorUiEvents, getOpenModals } from "./utils/dom-utils.js";
|
||||
import { postToParent, ALLOWED_PARENT_ORIGINS } from "./utils/parent-frame.js";
|
||||
import { ParentMessage, ChildMessage } from "./constants/messages.js";
|
||||
import { isEditModeEnabled, isInsideEditorUi, ACTIONABLE_SELECTOR, EDIT_TARGET_SELECTOR, getEditId } from "./constants/selectors.js";
|
||||
import { recordEdit, reapplyCommittedEdits, getEditState } from "./state/history-state.js";
|
||||
import { applySessionUndo, applySessionRedo } from "./utils/session-history-command.js";
|
||||
import { handleDraftSave, handleDraftDiscard, discardAllEdits } from "./api/draft.js";
|
||||
import { applyDraftSnapshot, notifyDraftStateChanged } from "./api/draft-snapshot.js";
|
||||
import { patchReactDomMutations } from "./utils/html-utils.js";
|
||||
import { showTypeTooltip, hideTypeTooltip } from "./ui/overlays/type-tooltip.js";
|
||||
import { openOrCreateAnnotation, getAnnotationPanelEl, hideAnnotationPanel, openPanelForEdit, addPendingAttachments } from "./ui/annotation-panel/panel.js";
|
||||
import { clearAllAnnotationMarkers, refreshAnnotationMarkers } from "./ui/annotation-panel/annotation-markers.js";
|
||||
import { setEditorTranslations, setComments, getPendingElements } from "./state/annotation-state.js";
|
||||
import { captureElementMetadata } from "./utils/selection-mode-metadata.js";
|
||||
import { hideTextFormatToolbar } from "./ui/text-format/toolbar/toolbar.js";
|
||||
import { hideAllPanels } from "./state/panel-state.js";
|
||||
import { lockHoverOutline, unlockHoverOutline } from "./ui/overlays/hover-outline.js";
|
||||
import {
|
||||
DRAG_SELECT_THRESHOLD, MAX_SELECTED_ELEMENTS,
|
||||
ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT, PANEL_GAP,
|
||||
} from "./constants/layout.js";
|
||||
import {
|
||||
getSelectedElements, setSelectedElements, clearSelectedElements,
|
||||
getDragStartX, getDragStartY, setDragStart, clearDragStart,
|
||||
getIsDragging, setIsDragging,
|
||||
getHadDrag, setHadDrag,
|
||||
isDragPending, resetMultiSelectState, toggleSelectedElement, orderSelection,
|
||||
pruneDetachedSelection,
|
||||
} from "./state/multi-select-state.js";
|
||||
import {
|
||||
getSelectionRect, collectSelection, NO_TEXT_SELECT_CLASS,
|
||||
showSelectionRect, hideSelectionRect, destroySelectionRect,
|
||||
} from "./ui/overlays/selection-rect.js";
|
||||
|
||||
let globalClickHandler = null;
|
||||
|
||||
/** Tracks the element behind the parent image picker (back-compat `edit-save` flow). */
|
||||
let currentImageEdit = null;
|
||||
|
||||
let lastCursorClientX = 0;
|
||||
let lastCursorClientY = 0;
|
||||
let typeTooltipAnimationFrameId = null;
|
||||
/** True right after a mousedown until the next genuine mousemove, so a drag doesn't drag the tooltip too. */
|
||||
let suppressTypeTooltipUntilMove = false;
|
||||
/** Caches the last raw `elementFromPoint` hit to skip the pointer-events workaround below when unchanged. */
|
||||
let lastRawHitTestElement = null;
|
||||
let lastResolvedTypeTooltipTarget = null;
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Editable target lookup
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function findEditableElementAtPoint(event) {
|
||||
const direct = event.target.closest(EDIT_TARGET_SELECTOR);
|
||||
if (direct) return direct;
|
||||
|
||||
if (isInFixedContext(event.target)) return null;
|
||||
|
||||
const elementsAtPoint = document.elementsFromPoint(event.clientX, event.clientY);
|
||||
const found = elementsAtPoint.find(
|
||||
(element) => element !== event.target && (element.hasAttribute("data-edit-id") || element.hasAttribute("data-edit-assisted-id"))
|
||||
);
|
||||
return found || null;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Image edit (parent picker) — kept as the existing message contract
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function startImageEdit(targetElement, editId) {
|
||||
currentImageEdit = { editId, targetElement };
|
||||
lockHoverOutline(targetElement);
|
||||
postToParent(ParentMessage.IMAGE_EDIT_ENTER, {
|
||||
currentText: targetElement.getAttribute('src') || '',
|
||||
elementType: 'img',
|
||||
isDirectlyEditable: targetElement.hasAttribute('data-edit-id'),
|
||||
});
|
||||
}
|
||||
|
||||
/** Clears the in-progress image edit and drops its locked outline. */
|
||||
function clearImageEdit() {
|
||||
if (!currentImageEdit) return;
|
||||
currentImageEdit = null;
|
||||
unlockHoverOutline();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses an active image selection and tells the parent to close the image
|
||||
* picker popup (e.g. when the user clicks away to another element or empty space).
|
||||
*/
|
||||
function cancelImageEdit() {
|
||||
if (!currentImageEdit) return;
|
||||
clearImageEdit();
|
||||
postToParent(ParentMessage.EDIT_CANCEL, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent image picker drives image replacement via `edit-save`. The change
|
||||
* is applied to the live DOM and recorded in history; it is written to source
|
||||
* only on an explicit draft save.
|
||||
*/
|
||||
function handleImageEditSave(newText) {
|
||||
if (!currentImageEdit) return;
|
||||
const { editId, targetElement } = currentImageEdit;
|
||||
const beforeContent = targetElement.getAttribute('src');
|
||||
const afterContent = newText ?? "";
|
||||
const isAssisted = targetElement.hasAttribute('data-edit-assisted-id');
|
||||
const selectionMode = captureElementMetadata(targetElement);
|
||||
targetElement.setAttribute('src', afterContent);
|
||||
recordEdit(editId, { beforeContent, afterContent }, {
|
||||
attribute: 'src',
|
||||
element: targetElement,
|
||||
isAssisted,
|
||||
selectionMode,
|
||||
});
|
||||
clearImageEdit();
|
||||
notifyDraftStateChanged();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Group selection (drag band + shift+click)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
let dragPreviewFrameId = null;
|
||||
|
||||
/** Drops the group and the outlines marking it. */
|
||||
function clearMultiSelection() {
|
||||
clearSelectedElements();
|
||||
unlockHoverOutline();
|
||||
}
|
||||
|
||||
/** Element under the cursor that a group may target, or null for empty space. */
|
||||
function resolveGroupTarget(event) {
|
||||
const rawTarget = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (!rawTarget || isPageRoot(rawTarget) || isInsideEditorUi(rawTarget)) return null;
|
||||
|
||||
const resolved = resolveHoverTarget(rawTarget, event.clientX, event.clientY);
|
||||
return resolved && !isPageRoot(resolved) ? resolved : null;
|
||||
}
|
||||
|
||||
/** True when the click landed on a member of the group (or inside one). */
|
||||
function isGroupMember(group, target) {
|
||||
return !!target && group.some(member => member === target || member.contains(target));
|
||||
}
|
||||
|
||||
/**
|
||||
* A drag takes over the whole surface, so every other floating affordance —
|
||||
* toolbar dropdowns, the text toolbar, an open annotation panel, the image
|
||||
* picker — gets out of the way before the band is drawn.
|
||||
*/
|
||||
function beginDragSelect(event) {
|
||||
setIsDragging(true);
|
||||
hideAllPanels();
|
||||
hideTextFormatToolbar();
|
||||
// A shift+drag extends the group, so the panel must not take it down with it.
|
||||
hideAnnotationPanel({ restoreFocus: false, keepSelection: event.shiftKey });
|
||||
if (getEditing()) commitCurrentEdit();
|
||||
hideTypeTooltipImmediately();
|
||||
cancelImageEdit();
|
||||
if (!event.shiftKey) clearMultiSelection();
|
||||
}
|
||||
|
||||
/** Ends the gesture and tears the band down, whether it completed or was abandoned. */
|
||||
function endDragSelect() {
|
||||
clearDragStart();
|
||||
setIsDragging(false);
|
||||
if (dragPreviewFrameId !== null) {
|
||||
cancelAnimationFrame(dragPreviewFrameId);
|
||||
dragPreviewFrameId = null;
|
||||
}
|
||||
hideSelectionRect();
|
||||
document.body.classList.remove(NO_TEXT_SELECT_CLASS);
|
||||
}
|
||||
|
||||
function cancelDragSelect() {
|
||||
if (!isDragPending()) return;
|
||||
endDragSelect();
|
||||
clearMultiSelection();
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === "hidden" && getEditing()) {
|
||||
commitCurrentEdit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelectMouseDown(event) {
|
||||
setHadDrag(false);
|
||||
if (!isEditModeEnabled()) return;
|
||||
if (event.button !== 0 || isInsideEditorUi(event.target)) return;
|
||||
// Text selection inside the active inline edit stays native.
|
||||
if (getEditing()?.targetElement?.contains(event.target)) return;
|
||||
|
||||
// Suppress all native pointerdown side-effects (focus, text selection, form
|
||||
// validation) so the host app never reacts to clicks during edit mode.
|
||||
event.preventDefault();
|
||||
|
||||
document.body.classList.add(NO_TEXT_SELECT_CLASS);
|
||||
if (event.shiftKey) {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
}
|
||||
|
||||
setDragStart(event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recomputes the previewed group at most once per frame — `collectSelection`
|
||||
* walks the page and measures every candidate, which is far too heavy to run on
|
||||
* every mousemove of a drag.
|
||||
*/
|
||||
function scheduleDragPreview(clientX, clientY, additive) {
|
||||
if (dragPreviewFrameId !== null) return;
|
||||
dragPreviewFrameId = requestAnimationFrame(() => {
|
||||
dragPreviewFrameId = null;
|
||||
if (!getIsDragging()) return;
|
||||
|
||||
const preview = collectSelection(getSelectionRect(getDragStartX(), getDragStartY(), clientX, clientY));
|
||||
lockHoverOutline(orderSelection(additive ? [...getSelectedElements(), ...preview] : preview));
|
||||
});
|
||||
}
|
||||
|
||||
function handleSelectMouseMove(event) {
|
||||
if (!isDragPending()) return;
|
||||
|
||||
const startX = getDragStartX();
|
||||
const startY = getDragStartY();
|
||||
|
||||
if (!getIsDragging()) {
|
||||
const movedFarEnough = Math.abs(event.clientX - startX) > DRAG_SELECT_THRESHOLD
|
||||
|| Math.abs(event.clientY - startY) > DRAG_SELECT_THRESHOLD;
|
||||
if (!movedFarEnough) return;
|
||||
beginDragSelect(event);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
showSelectionRect(startX, startY, event.clientX, event.clientY);
|
||||
scheduleDragPreview(event.clientX, event.clientY, event.shiftKey);
|
||||
}
|
||||
|
||||
function handleSelectMouseUp(event) {
|
||||
if (!isDragPending()) return;
|
||||
|
||||
const startX = getDragStartX();
|
||||
const startY = getDragStartY();
|
||||
const wasDragging = getIsDragging();
|
||||
endDragSelect();
|
||||
if (!wasDragging) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
// The browser still delivers a click for this gesture; handleClick drops it.
|
||||
setHadDrag(true);
|
||||
|
||||
const picked = collectSelection(getSelectionRect(startX, startY, event.clientX, event.clientY));
|
||||
const group = orderSelection(event.shiftKey ? [...getSelectedElements(), ...picked] : picked)
|
||||
.slice(0, MAX_SELECTED_ELEMENTS);
|
||||
setSelectedElements(group);
|
||||
|
||||
if (!group.length) {
|
||||
clearMultiSelection();
|
||||
return;
|
||||
}
|
||||
openOrCreateAnnotation(group, event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
/** Escape drops a group that no panel is holding; an open panel owns the key itself. */
|
||||
function handleSelectionKeydown(event) {
|
||||
if (event.key !== 'Escape' || !getSelectedElements().length) return;
|
||||
if (getAnnotationPanelEl()?.classList.contains('active')) return;
|
||||
clearMultiSelection();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Click routing
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function handleClick(event) {
|
||||
if (!isEditModeEnabled()) return;
|
||||
|
||||
if (event.target.closest?.("a[href]")) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (isInsideEditorUi(event.target)) {
|
||||
cancelImageEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: drop the click a completed drag leaves behind.
|
||||
if (getHadDrag()) {
|
||||
setHadDrag(false);
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: shift+click adds or removes one element from the group. The panel
|
||||
// stays shut — it opens on the following plain click on a member.
|
||||
if (event.shiftKey) {
|
||||
const target = resolveGroupTarget(event);
|
||||
if (!target) return;
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
hideTypeTooltipImmediately();
|
||||
cancelImageEdit();
|
||||
if (getEditing()) {
|
||||
hideTextFormatToolbar();
|
||||
commitCurrentEdit();
|
||||
}
|
||||
hideAnnotationPanel({ restoreFocus: false, keepSelection: true });
|
||||
|
||||
// At the cap, removing still works but adding is ignored — silently
|
||||
// dropping some other member to make room would be worse.
|
||||
const selected = getSelectedElements();
|
||||
if (selected.includes(target) || selected.length < MAX_SELECTED_ELEMENTS) {
|
||||
toggleSelectedElement(target);
|
||||
}
|
||||
lockHoverOutline(getSelectedElements());
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: open the group's panel when the click lands on one of its members;
|
||||
// anything else drops the group and falls through to single-element routing.
|
||||
const group = getSelectedElements();
|
||||
if (group.length) {
|
||||
if (isGroupMember(group, resolveGroupTarget(event))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
hideTypeTooltipImmediately();
|
||||
cancelImageEdit();
|
||||
if (getEditing()) commitCurrentEdit();
|
||||
hideAnnotationPanel({ restoreFocus: false, keepSelection: true });
|
||||
|
||||
openOrCreateAnnotation(group, event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
clearMultiSelection();
|
||||
}
|
||||
|
||||
// Action: dismiss annotation panel on outside click.
|
||||
if (getAnnotationPanelEl()?.classList.contains('active')) {
|
||||
hideAnnotationPanel();
|
||||
hideTextFormatToolbar();
|
||||
if (getEditing()) {
|
||||
commitCurrentEdit();
|
||||
}
|
||||
if (!findEditableElementAtPoint(event)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const editing = getEditing();
|
||||
|
||||
// Action: place cursor inside the active contenteditable on click.
|
||||
if (editing?.targetElement?.hasAttribute("contenteditable") && editing.targetElement.contains(event.target)) {
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
const link = event.target.closest?.("a");
|
||||
if (link && editing.targetElement.contains(link)) {
|
||||
event.preventDefault();
|
||||
if (event.detail <= 1) {
|
||||
placeCursorAtPoint(event.clientX, event.clientY, editing.targetElement);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: start inline edit or image edit on an editable element.
|
||||
const editable = findEditableElementAtPoint(event);
|
||||
|
||||
if (editable) {
|
||||
if (editing && editing.targetElement === editable) {
|
||||
commitCurrentEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
const editId = getEditId(editable);
|
||||
if (!editId) return;
|
||||
|
||||
hideTypeTooltipImmediately();
|
||||
|
||||
if (editable?.tagName?.toLowerCase() === 'img') {
|
||||
if (editing) commitCurrentEdit();
|
||||
startImageEdit(editable, editId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
commitCurrentEdit();
|
||||
}
|
||||
clearImageEdit();
|
||||
startInlineEdit(editable, editId, editable.innerHTML || "");
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: commit any active inline edit and stop.
|
||||
if (editing) {
|
||||
commitCurrentEdit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Action: open annotation panel for non-editable elements (blocks, icon-only buttons/links, etc.).
|
||||
const rawTarget = document.elementFromPoint(event.clientX, event.clientY);
|
||||
if (rawTarget && !isPageRoot(rawTarget)) {
|
||||
const hoverTarget = resolveHoverTarget(rawTarget, event.clientX, event.clientY);
|
||||
const hoverType = hoverTarget && getElementType(hoverTarget);
|
||||
if (hoverTarget && !isPageRoot(hoverTarget) && (hoverType === 'block' || hoverType === 'button')) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
hideTypeTooltipImmediately();
|
||||
cancelImageEdit();
|
||||
|
||||
postToParent(ParentMessage.ANNOTATION_PANEL_OPENED, { elementType: hoverTarget.tagName.toLowerCase() });
|
||||
openOrCreateAnnotation(hoverTarget, event.clientX, event.clientY);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Action: close the image picker when clicking empty space.
|
||||
cancelImageEdit();
|
||||
|
||||
if (event.target.closest("a[href], button, [role='button'], [type='submit']")) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Hover type tooltip (element type near the cursor)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** Finds the deepest actionable descendant of `container` still under `(clientX, clientY)`, or `container` itself. */
|
||||
function resolveActionableDescendantAtPoint(container, clientX, clientY) {
|
||||
let deepest = container;
|
||||
for (const candidate of container.querySelectorAll(ACTIONABLE_SELECTOR)) {
|
||||
const bounds = candidate.getBoundingClientRect();
|
||||
if (clientX >= bounds.left && clientX <= bounds.right && clientY >= bounds.top && clientY <= bounds.bottom) {
|
||||
deepest = candidate;
|
||||
}
|
||||
}
|
||||
return deepest;
|
||||
}
|
||||
|
||||
/** Hides the type tooltip and drops any frame scheduled to redisplay it. */
|
||||
function hideTypeTooltipImmediately() {
|
||||
if (typeTooltipAnimationFrameId !== null) {
|
||||
cancelAnimationFrame(typeTooltipAnimationFrameId);
|
||||
typeTooltipAnimationFrameId = null;
|
||||
}
|
||||
hideTypeTooltip();
|
||||
}
|
||||
|
||||
/** Resolves the element under the cursor to highlight:
|
||||
* an editable or `<svg>` ancestor wins, else the raw target. */
|
||||
function resolveHoverTarget(rawTarget, clientX, clientY) {
|
||||
const editableAncestor = rawTarget.closest(EDIT_TARGET_SELECTOR);
|
||||
if (editableAncestor) return resolveActionableDescendantAtPoint(editableAncestor, clientX, clientY);
|
||||
|
||||
const svgAncestor = rawTarget.closest('svg');
|
||||
if (svgAncestor) return svgAncestor;
|
||||
|
||||
return resolveActionableDescendantAtPoint(rawTarget, clientX, clientY);
|
||||
}
|
||||
|
||||
/** True for the page's own root containers, which are too broad to be a meaningful hover target. */
|
||||
function isPageRoot(element) {
|
||||
return element === document.body || element === document.documentElement || element.id === 'root';
|
||||
}
|
||||
|
||||
function updateTypeTooltip() {
|
||||
// A drag keeps firing mousemove, which would otherwise clear the
|
||||
// suppress-until-move flag and flash the tooltip over the band.
|
||||
if (!isEditModeEnabled() || suppressTypeTooltipUntilMove || getIsDragging()) {
|
||||
hideTypeTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const rawTarget = document.elementFromPoint(lastCursorClientX, lastCursorClientY);
|
||||
if (!rawTarget || isInsideEditorUi(rawTarget) || isPageRoot(rawTarget)) {
|
||||
hideTypeTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
const editing = getEditing();
|
||||
if (editing?.targetElement && (editing.targetElement === rawTarget || editing.targetElement.contains(rawTarget))) {
|
||||
hideTypeTooltip();
|
||||
return;
|
||||
}
|
||||
|
||||
if (rawTarget !== lastRawHitTestElement) {
|
||||
lastRawHitTestElement = rawTarget;
|
||||
lastResolvedTypeTooltipTarget = resolveHoverTarget(rawTarget, lastCursorClientX, lastCursorClientY);
|
||||
}
|
||||
|
||||
showTypeTooltip(lastResolvedTypeTooltipTarget, lastCursorClientX, lastCursorClientY);
|
||||
}
|
||||
|
||||
/** Throttles type-tooltip recomputation to at most once per animation frame. */
|
||||
function scheduleTypeTooltipUpdate() {
|
||||
if (typeTooltipAnimationFrameId !== null) return;
|
||||
typeTooltipAnimationFrameId = requestAnimationFrame(() => {
|
||||
typeTooltipAnimationFrameId = null;
|
||||
updateTypeTooltip();
|
||||
});
|
||||
}
|
||||
|
||||
function handleTypeTooltipMouseMove(event) {
|
||||
lastCursorClientX = event.clientX;
|
||||
lastCursorClientY = event.clientY;
|
||||
suppressTypeTooltipUntilMove = false;
|
||||
scheduleTypeTooltipUpdate();
|
||||
}
|
||||
|
||||
/** Wheel-scroll fires no `mousemove`, so recompute from the last known cursor position. */
|
||||
function handleTypeTooltipScroll() {
|
||||
if (!isEditModeEnabled()) return;
|
||||
scheduleTypeTooltipUpdate();
|
||||
}
|
||||
|
||||
/** Suppresses the tooltip for the duration of a text-selection or element drag. */
|
||||
function handleTypeTooltipMouseDown() {
|
||||
suppressTypeTooltipUntilMove = true;
|
||||
hideTypeTooltipImmediately();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Annotation ↔ DOM sync (client-side route changes, re-renders)
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
let annotationDomObserver = null;
|
||||
let annotationDomSyncFrameId = null;
|
||||
|
||||
/**
|
||||
* Re-anchors markers to their (re-mounted) elements and hides the ones whose
|
||||
* element left the page, so a comment made on one route stays on that route.
|
||||
* Also re-applies unsaved edits to any element a route change or React
|
||||
* re-render replaced, so they don't visually revert to the unedited source.
|
||||
*/
|
||||
function syncAnnotationsWithDom() {
|
||||
annotationDomSyncFrameId = null;
|
||||
reapplyCommittedEdits();
|
||||
refreshAnnotationMarkers();
|
||||
|
||||
// A group only ever spans one page: navigating away unmounts its members, and
|
||||
// an outline can't be drawn for an element that is no longer there.
|
||||
if (pruneDetachedSelection()) {
|
||||
const group = getSelectedElements();
|
||||
if (group.length) lockHoverOutline(group);
|
||||
else clearMultiSelection();
|
||||
}
|
||||
|
||||
const pendingElements = getPendingElements();
|
||||
if (pendingElements.length
|
||||
&& pendingElements.every(element => !element.isConnected)
|
||||
&& getAnnotationPanelEl()?.classList.contains('active')) {
|
||||
hideAnnotationPanel({ restoreFocus: false });
|
||||
hideTextFormatToolbar();
|
||||
}
|
||||
}
|
||||
|
||||
/** Throttles the annotation sync to at most once per animation frame. */
|
||||
function scheduleAnnotationDomSync() {
|
||||
if (annotationDomSyncFrameId !== null) return;
|
||||
annotationDomSyncFrameId = requestAnimationFrame(syncAnnotationsWithDom);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Undo / redo keyboard shortcuts
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/** True for native text-editing surfaces where the browser should own undo. */
|
||||
function isNativeEditingContext(target) {
|
||||
if (!target) return false;
|
||||
const tag = target.tagName?.toLowerCase();
|
||||
return tag === "input" || tag === "textarea" || !!target.isContentEditable;
|
||||
}
|
||||
|
||||
function handleHistoryKeydown(event) {
|
||||
if (!isEditModeEnabled()) return;
|
||||
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z") return;
|
||||
|
||||
// While an inline edit is active, let the browser handle native text undo
|
||||
// inside the contenteditable region instead of stepping the edit history.
|
||||
if (getEditing()?.targetElement?.hasAttribute("contenteditable") || isNativeEditingContext(event.target)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
applySessionRedo();
|
||||
} else {
|
||||
applySessionUndo();
|
||||
}
|
||||
}
|
||||
|
||||
/* Keeps host-page modals open and host-page key handlers quiet in edit mode. */
|
||||
|
||||
function handleModalPointerDownGuard(event) {
|
||||
if (isInsideEditorUi(event.target)) return;
|
||||
|
||||
const openModals = getOpenModals();
|
||||
if (!openModals.length) return;
|
||||
if (openModals.some((modal) => modal.contains(event.target))) return;
|
||||
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered on window capture after the editor's own key handlers: `stopPropagation`
|
||||
* still lets those sibling window listeners run, but nothing on document or below
|
||||
* (host shortcuts, dismiss-on-Escape) ever sees the key.
|
||||
*/
|
||||
function handleHostKeyGuard(event) {
|
||||
if (isInsideEditorUi(event.target) || getEditing()?.targetElement?.contains(event.target)) {
|
||||
// Dismissable layers honour `defaultPrevented`, so an Escape aimed at the editor stays with it.
|
||||
if (event.key === "Escape") event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
function handleEditorFocusGuard(event) {
|
||||
if (isInsideEditorUi(event.target) || isInsideEditorUi(event.relatedTarget)) {
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Enable / disable edit mode
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
function enableEditMode() {
|
||||
// Guard React reconciliation before any inline edit rewrites DOM subtrees.
|
||||
patchReactDomMutations();
|
||||
|
||||
document.getElementById("root")?.setAttribute("data-edit-mode-enabled", "true");
|
||||
|
||||
if (!globalClickHandler) {
|
||||
globalClickHandler = handleClick;
|
||||
document.addEventListener("click", globalClickHandler, true);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleHistoryKeydown, true);
|
||||
window.addEventListener("keydown", handleSelectionKeydown, true);
|
||||
window.addEventListener("keydown", handleHostKeyGuard, true);
|
||||
window.addEventListener("keyup", handleHostKeyGuard, true);
|
||||
|
||||
document.addEventListener("mousemove", handleTypeTooltipMouseMove, true);
|
||||
document.addEventListener("mousedown", handleTypeTooltipMouseDown, true);
|
||||
document.addEventListener("dragstart", hideTypeTooltipImmediately, true);
|
||||
document.addEventListener("mouseleave", hideTypeTooltipImmediately);
|
||||
document.addEventListener("scroll", handleTypeTooltipScroll, true);
|
||||
window.addEventListener("blur", hideTypeTooltipImmediately);
|
||||
|
||||
document.addEventListener("mousedown", handleSelectMouseDown, true);
|
||||
document.addEventListener("mousemove", handleSelectMouseMove, true);
|
||||
document.addEventListener("mouseup", handleSelectMouseUp, true);
|
||||
// A mouseup outside the iframe never reaches us, so the band would stay up.
|
||||
window.addEventListener("blur", cancelDragSelect);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
window.addEventListener("pointerdown", handleModalPointerDownGuard, true);
|
||||
window.addEventListener("pointerup", handleModalPointerDownGuard, true);
|
||||
window.addEventListener("focusin", handleEditorFocusGuard, true);
|
||||
window.addEventListener("focusout", handleEditorFocusGuard, true);
|
||||
|
||||
if (!annotationDomObserver && typeof MutationObserver !== "undefined") {
|
||||
annotationDomObserver = new MutationObserver(scheduleAnnotationDomSync);
|
||||
annotationDomObserver.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState() });
|
||||
}
|
||||
|
||||
function disableEditMode() {
|
||||
const editing = getEditing();
|
||||
if (editing?.targetElement?.hasAttribute("contenteditable")) {
|
||||
commitCurrentEdit();
|
||||
}
|
||||
|
||||
document.getElementById("root")?.removeAttribute("data-edit-mode-enabled");
|
||||
|
||||
currentImageEdit = null;
|
||||
hideAnnotationPanel();
|
||||
annotationDomObserver?.disconnect();
|
||||
annotationDomObserver = null;
|
||||
if (annotationDomSyncFrameId !== null) {
|
||||
cancelAnimationFrame(annotationDomSyncFrameId);
|
||||
annotationDomSyncFrameId = null;
|
||||
}
|
||||
clearAllAnnotationMarkers();
|
||||
setComments([]);
|
||||
|
||||
if (globalClickHandler) {
|
||||
document.removeEventListener("click", globalClickHandler, true);
|
||||
globalClickHandler = null;
|
||||
}
|
||||
|
||||
window.removeEventListener("keydown", handleHistoryKeydown, true);
|
||||
window.removeEventListener("keydown", handleSelectionKeydown, true);
|
||||
window.removeEventListener("keydown", handleHostKeyGuard, true);
|
||||
window.removeEventListener("keyup", handleHostKeyGuard, true);
|
||||
|
||||
document.removeEventListener("mousemove", handleTypeTooltipMouseMove, true);
|
||||
document.removeEventListener("mousedown", handleTypeTooltipMouseDown, true);
|
||||
document.removeEventListener("dragstart", hideTypeTooltipImmediately, true);
|
||||
document.removeEventListener("mouseleave", hideTypeTooltipImmediately);
|
||||
document.removeEventListener("scroll", handleTypeTooltipScroll, true);
|
||||
window.removeEventListener("blur", hideTypeTooltipImmediately);
|
||||
|
||||
document.removeEventListener("mousedown", handleSelectMouseDown, true);
|
||||
document.removeEventListener("mousemove", handleSelectMouseMove, true);
|
||||
document.removeEventListener("mouseup", handleSelectMouseUp, true);
|
||||
window.removeEventListener("blur", cancelDragSelect);
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("pointerdown", handleModalPointerDownGuard, true);
|
||||
window.removeEventListener("pointerup", handleModalPointerDownGuard, true);
|
||||
window.removeEventListener("focusin", handleEditorFocusGuard, true);
|
||||
window.removeEventListener("focusout", handleEditorFocusGuard, true);
|
||||
endDragSelect();
|
||||
destroySelectionRect();
|
||||
resetMultiSelectState();
|
||||
unlockHoverOutline();
|
||||
hideTypeTooltipImmediately();
|
||||
suppressTypeTooltipUntilMove = false;
|
||||
lastRawHitTestElement = null;
|
||||
lastResolvedTypeTooltipTarget = null;
|
||||
|
||||
discardAllEdits();
|
||||
postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState() });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Parent <-> iframe messaging
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
window.addEventListener("message", function (event) {
|
||||
if (!ALLOWED_PARENT_ORIGINS.includes(event.origin)) return;
|
||||
|
||||
if (event.data?.type === ChildMessage.ENABLE_EDIT_MODE) {
|
||||
if (event.data?.translations) {
|
||||
setEditorTranslations(event.data.translations);
|
||||
}
|
||||
enableEditMode();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.DISABLE_EDIT_MODE) {
|
||||
disableEditMode();
|
||||
}
|
||||
|
||||
// Text editing now happens in-iframe via contenteditable; `edit-save` only
|
||||
// remains to keep the existing parent-driven image picker working.
|
||||
if (event.data?.type === ChildMessage.EDIT_SAVE) {
|
||||
handleImageEditSave(event.data?.payload?.newText);
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.EDIT_CANCEL) {
|
||||
clearImageEdit();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.DRAFT_SAVE) {
|
||||
handleDraftSave();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.DRAFT_DISCARD) {
|
||||
handleDraftDiscard();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.DRAFT_RESTORE) {
|
||||
applyDraftSnapshot(event.data?.payload?.snapshot, { onMarkerClick: openPanelForEdit });
|
||||
postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState(), isDraftRestored: true });
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.DRAFT_SNAPSHOT_REQUEST) {
|
||||
notifyDraftStateChanged();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.EDIT_UNDO) {
|
||||
applySessionUndo();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.EDIT_REDO) {
|
||||
applySessionRedo();
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.ANNOTATION_IMAGE_ATTACHED) {
|
||||
addPendingAttachments(event.data?.payload?.attachments);
|
||||
}
|
||||
|
||||
if (event.data?.type === ChildMessage.ANNOTATION_OPEN_FOR_IMAGE) {
|
||||
if (!currentImageEdit) return;
|
||||
const { targetElement } = currentImageEdit;
|
||||
clearImageEdit();
|
||||
|
||||
const clickX = (window.innerWidth - ANNOTATION_PANEL_WIDTH) / 2;
|
||||
const clickY = (window.innerHeight - ANNOTATION_PANEL_ESTIMATED_HEIGHT) / 2 - PANEL_GAP;
|
||||
openOrCreateAnnotation(targetElement, clickX, clickY);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import path from 'path';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import * as t from '@babel/types';
|
||||
import {
|
||||
validateFilePath,
|
||||
findJSXElementAtPosition,
|
||||
generateSourceWithMap,
|
||||
VITE_PROJECT_ROOT,
|
||||
} from '../../utils/ast-utils.js';
|
||||
import { decodeHtmlEntities, cssPropToCamel, toJsxAttributeName } from '../utils/html-utils.js';
|
||||
|
||||
const WRITABLE_ATTRIBUTE_TARGETS = new Set(['src', 'href', 'to']);
|
||||
const JSX_STYLE_PROPERTY_PATTERN = /^[A-Za-z_$][\w$]*$/;
|
||||
|
||||
/**
|
||||
* Parses `filePath:line:column` edit IDs produced by the transform plugin.
|
||||
*
|
||||
* @param {string} editId
|
||||
* @returns {{ filePath: string, line: number, column: number }|null}
|
||||
*/
|
||||
function parseEditId(editId) {
|
||||
const segments = editId.split(':');
|
||||
if (segments.length < 3) return null;
|
||||
const column = parseInt(segments.at(-1), 10);
|
||||
const line = parseInt(segments.at(-2), 10);
|
||||
const filePath = segments.slice(0, -2).join(':');
|
||||
if (!filePath || isNaN(line) || isNaN(column)) return null;
|
||||
return { filePath, line, column };
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an HTML `style="..."` string into a Babel JSX style object expression.
|
||||
*
|
||||
* @param {string} styleString
|
||||
* @returns {import('@babel/types').JSXExpressionContainer}
|
||||
*/
|
||||
function parseStyleString(styleString) {
|
||||
const decoded = decodeHtmlEntities(styleString);
|
||||
const properties = [];
|
||||
for (const declaration of decoded.split(';')) {
|
||||
const colonIndex = declaration.indexOf(':');
|
||||
if (colonIndex < 0) continue;
|
||||
const key = declaration.slice(0, colonIndex).trim();
|
||||
const value = declaration.slice(colonIndex + 1).trim();
|
||||
if (!key || !value) continue;
|
||||
properties.push(
|
||||
t.objectProperty(t.identifier(cssPropToCamel(key)), t.stringLiteral(value))
|
||||
);
|
||||
}
|
||||
return t.jsxExpressionContainer(t.objectExpression(properties));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses HTML attribute string from saved innerHTML into JSX attribute nodes.
|
||||
* Skips `data-edit-*` attributes; maps names via {@link toJsxAttributeName}.
|
||||
*
|
||||
* @param {string} rawAttributes
|
||||
* @returns {import('@babel/types').JSXAttribute[]}
|
||||
*/
|
||||
function parseHtmlAttributesToJsx(rawAttributes) {
|
||||
if (!rawAttributes || !rawAttributes.trim()) return [];
|
||||
const attributes = [];
|
||||
const ATTRIBUTE_REGEX = /([\w-]+)\s*=\s*"([^"]*)"/g;
|
||||
let match;
|
||||
while ((match = ATTRIBUTE_REGEX.exec(rawAttributes)) !== null) {
|
||||
const [, rawName, value] = match;
|
||||
// data-edit-* are injected by the visual-editor transform; never write them to source
|
||||
if (rawName.startsWith('data-edit-')) continue;
|
||||
const name = toJsxAttributeName(rawName);
|
||||
if (name === 'style') {
|
||||
attributes.push(t.jsxAttribute(t.jsxIdentifier(name), parseStyleString(value)));
|
||||
} else {
|
||||
attributes.push(t.jsxAttribute(t.jsxIdentifier(name), t.stringLiteral(decodeHtmlEntities(value))));
|
||||
}
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds JSX children from saved innerHTML, reconstructing every tag structurally.
|
||||
* Rendered `<svg>` blocks are matched back, in order, to `iconQueue` (original icon nodes).
|
||||
*
|
||||
* @param {string} html
|
||||
* @param {import('@babel/types').JSXElement[]} iconQueue - consumed as rendered `<svg>` blocks are matched.
|
||||
* @returns {import('@babel/types').JSXChild[]}
|
||||
*/
|
||||
function buildChildrenFromText(html, iconQueue) {
|
||||
if (!html || html.trim() === '') return [];
|
||||
|
||||
// Decode </> back to literal tags (not ", which TAG_REGEX relies on as a delimiter).
|
||||
html = html.replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const TAG_REGEX = /<(\/?)(\w+)((?:\s+[\w-]+(?:\s*=\s*"[^"]*")?)*)\s*(\/?)>/g;
|
||||
|
||||
const result = [];
|
||||
const stack = [{ children: result }];
|
||||
let lastIndex = 0;
|
||||
/** Depth inside a rendered `<svg>` block being skipped because it was matched to a preserved icon. */
|
||||
let skippedSvgDepth = 0;
|
||||
let match;
|
||||
|
||||
while ((match = TAG_REGEX.exec(html)) !== null) {
|
||||
const [full, closing, tagName, rawAttributes, selfClose] = match;
|
||||
const lower = tagName.toLowerCase();
|
||||
|
||||
if (skippedSvgDepth > 0) {
|
||||
if (lower === 'svg' && selfClose !== '/') {
|
||||
skippedSvgDepth += closing === '/' ? -1 : 1;
|
||||
}
|
||||
lastIndex = match.index + full.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.index > lastIndex) {
|
||||
const text = html.slice(lastIndex, match.index);
|
||||
if (text) stack[stack.length - 1].children.push(t.jsxText(text));
|
||||
}
|
||||
lastIndex = match.index + full.length;
|
||||
|
||||
if (closing === '/') {
|
||||
if (stack.length > 1 && stack[stack.length - 1].tag === lower) {
|
||||
const frame = stack.pop();
|
||||
const attributes = parseHtmlAttributesToJsx(frame.rawAttrs);
|
||||
stack[stack.length - 1].children.push(
|
||||
t.jsxElement(
|
||||
t.jsxOpeningElement(t.jsxIdentifier(frame.tag), attributes),
|
||||
t.jsxClosingElement(t.jsxIdentifier(frame.tag)),
|
||||
frame.children
|
||||
)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lower === 'svg' && selfClose !== '/' && iconQueue.length > 0) {
|
||||
stack[stack.length - 1].children.push(iconQueue.shift());
|
||||
skippedSvgDepth = 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Void elements (img, br, ...) never get a closing tag in serialized HTML; others always do.
|
||||
const hasClosingTag = new RegExp(`</${lower}\\s*>`, 'i').test(html.slice(lastIndex));
|
||||
if (selfClose === '/' || !hasClosingTag) {
|
||||
const attributes = parseHtmlAttributesToJsx(rawAttributes);
|
||||
stack[stack.length - 1].children.push(
|
||||
t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(lower), attributes, true), null, [], true)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
stack.push({ tag: lower, children: [], rawAttrs: rawAttributes || '' });
|
||||
}
|
||||
|
||||
if (lastIndex < html.length) {
|
||||
const text = html.slice(lastIndex);
|
||||
if (text) stack[stack.length - 1].children.push(t.jsxText(text));
|
||||
}
|
||||
|
||||
// Auto-close any unclosed tags (defensive: malformed/truncated captured HTML).
|
||||
while (stack.length > 1) {
|
||||
const frame = stack.pop();
|
||||
const attributes = parseHtmlAttributesToJsx(frame.rawAttrs);
|
||||
stack[stack.length - 1].children.push(
|
||||
t.jsxElement(
|
||||
t.jsxOpeningElement(t.jsxIdentifier(frame.tag), attributes),
|
||||
t.jsxClosingElement(t.jsxIdentifier(frame.tag)),
|
||||
frame.children
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges or creates a JSX `style={{ ... }}` object on the opening element.
|
||||
* An empty-string value means the editor removed the inline property, so the
|
||||
* matching JSX style entry is dropped instead of written as `prop: ""`.
|
||||
*
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingElement
|
||||
* @param {Record<string, string>} styleObject
|
||||
* @returns {boolean} Whether the style shape was safe to update.
|
||||
*/
|
||||
function setJsxStyleProperty(openingElement, styleObject) {
|
||||
const existing = openingElement.attributes.find(attribute =>
|
||||
t.isJSXAttribute(attribute) && attribute.name?.name === 'style'
|
||||
);
|
||||
if (existing && t.isJSXExpressionContainer(existing.value) && t.isObjectExpression(existing.value.expression)) {
|
||||
const properties = existing.value.expression.properties;
|
||||
for (const [key, value] of Object.entries(styleObject)) {
|
||||
const propertyIndex = properties.findIndex(property =>
|
||||
t.isObjectProperty(property) && t.isIdentifier(property.key) && property.key.name === key
|
||||
);
|
||||
if (!value) {
|
||||
if (propertyIndex !== -1) properties.splice(propertyIndex, 1);
|
||||
} else if (propertyIndex !== -1) {
|
||||
properties[propertyIndex].value = t.stringLiteral(value);
|
||||
} else {
|
||||
properties.push(t.objectProperty(t.identifier(key), t.stringLiteral(value)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} else if (!existing) {
|
||||
const properties = Object.entries(styleObject)
|
||||
.filter(([, value]) => !!value)
|
||||
.map(([key, value]) => t.objectProperty(t.identifier(key), t.stringLiteral(value)));
|
||||
if (properties.length === 0) return true;
|
||||
openingElement.attributes.push(
|
||||
t.jsxAttribute(t.jsxIdentifier('style'), t.jsxExpressionContainer(t.objectExpression(properties)))
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets or removes a static JSX attribute.
|
||||
*
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingElement
|
||||
* @param {string} attributeName
|
||||
* @param {string|null} value - `null` removes the attribute.
|
||||
* @returns {{ modified: true }|{ modified: false, error: string }}
|
||||
*/
|
||||
function setJsxAttribute(openingElement, attributeName, value) {
|
||||
const attributeIndex = openingElement.attributes.findIndex(attribute =>
|
||||
t.isJSXAttribute(attribute) && attribute.name?.name === attributeName
|
||||
);
|
||||
const existingAttribute = openingElement.attributes[attributeIndex];
|
||||
|
||||
if (value === null) {
|
||||
if (attributeIndex !== -1) openingElement.attributes.splice(attributeIndex, 1);
|
||||
return { modified: true };
|
||||
}
|
||||
|
||||
const newLiteral = t.stringLiteral(value);
|
||||
if (!existingAttribute) {
|
||||
openingElement.attributes.push(t.jsxAttribute(t.jsxIdentifier(attributeName), newLiteral));
|
||||
return { modified: true };
|
||||
}
|
||||
if (t.isStringLiteral(existingAttribute.value)) {
|
||||
existingAttribute.value = newLiteral;
|
||||
return { modified: true };
|
||||
}
|
||||
if (t.isJSXExpressionContainer(existingAttribute.value) && t.isStringLiteral(existingAttribute.value.expression)) {
|
||||
existingAttribute.value = newLiteral;
|
||||
return { modified: true };
|
||||
}
|
||||
return { modified: false, error: `Cannot write '${attributeName}' — source value is not a static string literal` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse + validate every draftKey; group valid edits by absolute file path;
|
||||
* push error entries for invalid ones.
|
||||
* @param {Record<string, object>} draft
|
||||
* @returns {{ editsByFile: Map<string, object[]>, results: object[] }}
|
||||
*/
|
||||
export function groupEditsByFile(draft) {
|
||||
const results = [];
|
||||
const editsByFile = new Map();
|
||||
|
||||
for (const [draftKey, edit] of Object.entries(draft)) {
|
||||
let editId = draftKey;
|
||||
const atIndex = editId.indexOf('@');
|
||||
if (atIndex >= 0) editId = editId.slice(0, atIndex);
|
||||
|
||||
const parsedId = parseEditId(editId);
|
||||
if (!parsedId) {
|
||||
results.push({ draftKey, success: false, error: 'Invalid editId format' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const validation = validateFilePath(parsedId.filePath);
|
||||
if (!validation.isValid) {
|
||||
results.push({ draftKey, success: false, error: validation.error });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!editsByFile.has(validation.absolutePath)) {
|
||||
editsByFile.set(validation.absolutePath, []);
|
||||
}
|
||||
editsByFile.get(validation.absolutePath).push({ draftKey, editId, edit, parsedId });
|
||||
}
|
||||
|
||||
return { editsByFile, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the JSX opening element and apply its atomic content, attribute, and style changes.
|
||||
* @param {object} babelAst
|
||||
* @param {object} edit
|
||||
* @param {{ filePath: string, line: number, column: number }} parsedId
|
||||
* @returns {{ modified: true }|{ modified: false, error: string }}
|
||||
*/
|
||||
export function applyElementEdit(babelAst, edit, parsedId) {
|
||||
const targetNodePath = findJSXElementAtPosition(babelAst, parsedId.line, parsedId.column + 1);
|
||||
if (!targetNodePath) return { modified: false, error: 'Target node not found' };
|
||||
|
||||
const targetOpeningElement = targetNodePath.node;
|
||||
const parentElementNode = targetNodePath.parentPath?.node;
|
||||
const changes = edit.instruction?.changes;
|
||||
if (!Array.isArray(changes) || changes.length === 0) {
|
||||
return { modified: false, error: 'Edit has no changes' };
|
||||
}
|
||||
|
||||
const contentChanges = changes.filter(change => change.kind === 'content');
|
||||
const attributeChanges = changes.filter(change => change.kind === 'attribute');
|
||||
const styleChanges = changes.filter(change => change.kind === 'style');
|
||||
if (contentChanges.length + attributeChanges.length + styleChanges.length !== changes.length) {
|
||||
return { modified: false, error: 'Unsupported visual edit change kind' };
|
||||
}
|
||||
if (contentChanges.length > 1) {
|
||||
return { modified: false, error: 'Edit contains multiple content changes' };
|
||||
}
|
||||
if (changes.some(change =>
|
||||
(change.before !== null && typeof change.before !== 'string')
|
||||
|| (change.after !== null && typeof change.after !== 'string')
|
||||
)) {
|
||||
return { modified: false, error: 'Visual edit change values must be strings or null' };
|
||||
}
|
||||
if (contentChanges.some(change => change.target !== null)) {
|
||||
return { modified: false, error: 'Content changes must have a null target' };
|
||||
}
|
||||
if (contentChanges.length && (!parentElementNode || !t.isJSXElement(parentElementNode))) {
|
||||
return { modified: false, error: 'Could not apply content change to AST' };
|
||||
}
|
||||
if ([...attributeChanges, ...styleChanges].some(change => typeof change.target !== 'string' || !change.target)) {
|
||||
return { modified: false, error: 'Attribute and style changes require a target' };
|
||||
}
|
||||
if (attributeChanges.some(change => !WRITABLE_ATTRIBUTE_TARGETS.has(change.target))) {
|
||||
return { modified: false, error: 'Unsupported visual edit attribute target' };
|
||||
}
|
||||
if (styleChanges.some(change => !JSX_STYLE_PROPERTY_PATTERN.test(change.target))) {
|
||||
return { modified: false, error: 'Unsupported visual edit style target' };
|
||||
}
|
||||
if (styleChanges.length) {
|
||||
const styleAttribute = targetOpeningElement.attributes.find(attribute =>
|
||||
t.isJSXAttribute(attribute) && attribute.name?.name === 'style'
|
||||
);
|
||||
if (
|
||||
styleAttribute
|
||||
&& !(
|
||||
t.isJSXExpressionContainer(styleAttribute.value)
|
||||
&& t.isObjectExpression(styleAttribute.value.expression)
|
||||
)
|
||||
) {
|
||||
return { modified: false, error: 'Cannot write style — source value is not a static object literal' };
|
||||
}
|
||||
}
|
||||
|
||||
// Validate every attribute before mutating the AST so a later unsupported
|
||||
// dynamic value cannot leave an earlier change partially applied.
|
||||
for (const change of attributeChanges) {
|
||||
const existingAttribute = targetOpeningElement.attributes.find(attribute =>
|
||||
t.isJSXAttribute(attribute) && attribute.name?.name === change.target
|
||||
);
|
||||
const isWritable = !existingAttribute
|
||||
|| t.isStringLiteral(existingAttribute.value)
|
||||
|| (
|
||||
t.isJSXExpressionContainer(existingAttribute.value)
|
||||
&& t.isStringLiteral(existingAttribute.value.expression)
|
||||
);
|
||||
if (!isWritable) {
|
||||
return {
|
||||
modified: false,
|
||||
error: `Cannot write '${change.target}' — source value is not a static string literal`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const change of attributeChanges) {
|
||||
const result = setJsxAttribute(targetOpeningElement, change.target, change.after);
|
||||
if (!result.modified) return result;
|
||||
}
|
||||
|
||||
if (contentChanges.length) {
|
||||
// Icon components can't be rebuilt from rendered HTML, so their original nodes pass through.
|
||||
const preservedIcons = parentElementNode.children.filter(child =>
|
||||
t.isJSXElement(child)
|
||||
&& child.openingElement.selfClosing
|
||||
&& child.openingElement.name?.name
|
||||
&& /^[A-Z]/.test(child.openingElement.name.name)
|
||||
);
|
||||
parentElementNode.children = buildChildrenFromText(contentChanges[0].after ?? '', preservedIcons);
|
||||
}
|
||||
|
||||
if (styleChanges.length) {
|
||||
const style = Object.fromEntries(styleChanges.map(change => [change.target, change.after ?? '']));
|
||||
if (!setJsxStyleProperty(targetOpeningElement, style)) {
|
||||
return { modified: false, error: 'Cannot write style — source value is not a static object literal' };
|
||||
}
|
||||
}
|
||||
|
||||
return { modified: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip any lingering data-edit-* attributes from the AST and generate the new
|
||||
* source, returning the result entry for the API response.
|
||||
*
|
||||
* Must never write to disk: `/api/draft-save` is reachable unauthenticated from
|
||||
* the public preview origin, so persisting is the authenticated backend's job
|
||||
* via `POST /api/websites/:id/visual-edits`.
|
||||
*
|
||||
* @param {object} babelAst
|
||||
* @param {string} absoluteFilePath
|
||||
* @param {string} originalContent
|
||||
* @returns {{ filePath: string, newFileContent: string }}
|
||||
*/
|
||||
export function renderEditedSource(babelAst, absoluteFilePath, originalContent) {
|
||||
// Strip any data-edit-* attributes before generating source.
|
||||
const traverseFunction = traverseBabel.default || traverseBabel;
|
||||
traverseFunction(babelAst, {
|
||||
JSXOpeningElement(openingElementPath) {
|
||||
openingElementPath.node.attributes = openingElementPath.node.attributes.filter(attribute =>
|
||||
!(t.isJSXAttribute(attribute) && typeof attribute.name?.name === 'string'
|
||||
&& attribute.name.name.startsWith('data-edit-'))
|
||||
);
|
||||
},
|
||||
});
|
||||
const webRelativeFilePath = path.relative(VITE_PROJECT_ROOT, absoluteFilePath).split(path.sep).join('/');
|
||||
const output = generateSourceWithMap(babelAst, webRelativeFilePath, originalContent);
|
||||
return { filePath: webRelativeFilePath, newFileContent: output.code };
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* A reference attachment the user added to a comment.
|
||||
* @typedef {{ url: string, fileName: string, size: number, type: 'image' }} AnnotationAttachment
|
||||
*/
|
||||
|
||||
/**
|
||||
* A comment always targets a group: `elements` and `selections` are parallel,
|
||||
* one entry per annotated element. A single annotation is just a group of one.
|
||||
* @typedef {{
|
||||
* elements: HTMLElement[],
|
||||
* selections: object[],
|
||||
* text: string,
|
||||
* attachments: AnnotationAttachment[],
|
||||
* clickX: number,
|
||||
* clickY: number,
|
||||
* fixed: boolean,
|
||||
* marker?: HTMLElement,
|
||||
* }} AnnotationComment
|
||||
*/
|
||||
|
||||
let _editorTranslations = {};
|
||||
/** @type {AnnotationComment[]} */
|
||||
let _comments = [];
|
||||
let _panelMode = 'create';
|
||||
let _editingComment = null;
|
||||
/** @type {HTMLElement[]} */
|
||||
let _pendingElements = [];
|
||||
/** @type {object[]} */
|
||||
let _pendingSelections = [];
|
||||
/** @type {AnnotationAttachment[]} */
|
||||
let _pendingAttachments = [];
|
||||
let _annotationClickX = 0;
|
||||
let _annotationClickY = 0;
|
||||
|
||||
export function getComments() { return _comments; }
|
||||
export function setComments(value) { _comments = value; }
|
||||
|
||||
export function getPanelMode() { return _panelMode; }
|
||||
export function setPanelMode(value) { _panelMode = value; }
|
||||
|
||||
export function getEditingComment() { return _editingComment; }
|
||||
export function setEditingComment(value) { _editingComment = value; }
|
||||
|
||||
export function getPendingElements() { return _pendingElements; }
|
||||
export function setPendingElements(value) { _pendingElements = value ?? []; }
|
||||
|
||||
export function getPendingSelections() { return _pendingSelections; }
|
||||
export function setPendingSelections(value) { _pendingSelections = value ?? []; }
|
||||
|
||||
export function getPendingAttachments() { return _pendingAttachments; }
|
||||
export function setPendingAttachments(value) { _pendingAttachments = value ?? []; }
|
||||
|
||||
export function getAnnotationClickX() { return _annotationClickX; }
|
||||
export function setAnnotationClickX(value) { _annotationClickX = value; }
|
||||
|
||||
export function getAnnotationClickY() { return _annotationClickY; }
|
||||
export function setAnnotationClickY(value) { _annotationClickY = value; }
|
||||
|
||||
export function getEditorTranslations() { return _editorTranslations; }
|
||||
export function setEditorTranslations(value) { _editorTranslations = value; }
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Active inline edit session; `null` when no element is being edited. */
|
||||
let current = null;
|
||||
|
||||
/**
|
||||
* @returns {{ editId: string, targetElement: HTMLElement, sessionId: number, originalContent: string,
|
||||
* originalTextAlign: string, originalInlineStyles: object,
|
||||
* savedChildren: Array, hasSoftSaved?: boolean }|null}
|
||||
*/
|
||||
export function getEditing() {
|
||||
return current;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ editId: string, targetElement: HTMLElement, originalContent: string,
|
||||
* originalTextAlign: string, originalInlineStyles: object,
|
||||
* savedChildren: Array }} info
|
||||
*/
|
||||
export function setEditing(info) {
|
||||
current = info;
|
||||
}
|
||||
|
||||
/** Clears the active edit session (called on commit or discard). */
|
||||
export function clearEditing() {
|
||||
current = null;
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
import { sanitizeText, patchRemoveChild } from "../utils/html-utils.js";
|
||||
import { getComments } from "./annotation-state.js";
|
||||
import { getEditId } from "../constants/selectors.js";
|
||||
|
||||
/** Maximum undo/redo entries; oldest entry is dropped when the log exceeds this. */
|
||||
const MAX_HISTORY = 25;
|
||||
|
||||
/** Committed and redoable edit actions for the current session. */
|
||||
const actionLog = [];
|
||||
|
||||
/** Index of the last committed entry in `actionLog`; -1 when nothing is committed. */
|
||||
let actionPointer = -1;
|
||||
|
||||
/** Monotonic id assigned at the start of each inline edit session. */
|
||||
let nextEditSessionId = 1;
|
||||
|
||||
/** @returns {number} A new session id for the next inline edit burst. */
|
||||
export function beginEditSession() {
|
||||
return nextEditSessionId++;
|
||||
}
|
||||
|
||||
/** @returns {number} index of the last committed entry; -1 if nothing committed */
|
||||
export function getActionPointer() {
|
||||
return actionPointer;
|
||||
}
|
||||
|
||||
/** @returns {number} total entries in the log, including undone future entries */
|
||||
export function getActionLogLength() {
|
||||
return actionLog.length;
|
||||
}
|
||||
|
||||
/** Wipes all undo/redo history (e.g. on discard or new session). */
|
||||
export function clearHistory() {
|
||||
actionLog.length = 0;
|
||||
actionPointer = -1;
|
||||
nextEditSessionId = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable deduplication key for an action.
|
||||
* Direct edits target one source location (AST writer), so they key by editId only.
|
||||
* Assisted edits can target separate rendered instances of the same JSX expression,
|
||||
* so they also include the captured selector.
|
||||
* @param {{ editId: string, attribute?: string|null, isAssisted?: boolean, selectionMode?: object|null }} action
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getActionKey(action) {
|
||||
const attributeSuffix = action.attribute ? `@${action.attribute}` : '';
|
||||
const selector = action.isAssisted ? action.selectionMode.selector : null;
|
||||
const instanceSuffix = selector ? `@selector:${selector}` : '';
|
||||
return `${action.editId}${attributeSuffix}${instanceSuffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain text from a stored HTML snapshot (beforeContent), for matching mapped
|
||||
* siblings that share one edit id.
|
||||
* @param {string|null|undefined} html
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function plainTextFromStoredHtml(html) {
|
||||
if (typeof html !== "string" || !html) return null;
|
||||
const text = html.replace(/<[^>]*>/g, "").trim();
|
||||
return text || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the DOM element for an action. Prefers the stored element reference
|
||||
* (survives HMR position shifts). When several nodes share one JSX edit id
|
||||
* (e.g. letter spans from `.map()`), disambiguates with selectionMode text /
|
||||
* beforeContent — same approach as annotation `resolveByEditId`. Never falls
|
||||
* back to the first querySelector match when duplicates exist.
|
||||
* @param {object} action
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function resolveElement(action) {
|
||||
if (action.element?.isConnected) return action.element;
|
||||
|
||||
const matches = Array.from(document.querySelectorAll(
|
||||
`[data-edit-id="${action.editId}"], [data-edit-assisted-id="${action.editId}"]`,
|
||||
));
|
||||
if (matches.length <= 1) return matches[0] ?? null;
|
||||
|
||||
const text = action.selectionMode?.textContent?.trim()
|
||||
|| plainTextFromStoredHtml(action.instruction?.beforeContent);
|
||||
if (!text) return null;
|
||||
|
||||
return matches.find((match) => match.textContent?.trim() === text) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the DOM element for an edit and writes a value to it (used by undo/redo).
|
||||
* @param {object} action - The history action entry (has element ref + editId).
|
||||
* @param {string} value
|
||||
*/
|
||||
function applyValueToElement(action, value) {
|
||||
const element = resolveElement(action);
|
||||
if (!element) return;
|
||||
|
||||
if (action.attribute) {
|
||||
const domAttribute = action.attribute === 'to' ? 'href' : action.attribute;
|
||||
if (value === null) {
|
||||
element.removeAttribute(domAttribute);
|
||||
} else {
|
||||
element.setAttribute(domAttribute, value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (element.tagName.toLowerCase() === "img") {
|
||||
element.setAttribute("src", value);
|
||||
} else {
|
||||
patchRemoveChild(element);
|
||||
element.innerHTML = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies element-level inline style props for undo/redo. Setting a prop to ''
|
||||
* removes the inline value so CSS-class styling takes over again.
|
||||
* @param {object} action - The history action entry.
|
||||
* @param {object|null} styleProperties - camelCase property → value (or '' to clear)
|
||||
*/
|
||||
function applyStyleToElement(action, styleProperties) {
|
||||
if (!styleProperties) return;
|
||||
const element = resolveElement(action);
|
||||
if (!element) return;
|
||||
for (const [key, value] of Object.entries(styleProperties)) {
|
||||
element.style[key] = value || '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records an edit, coalescing same-session changes to the same element into one
|
||||
* undo/redo step. Attribute edits (e.g. href) are keyed separately via {@link getActionKey}.
|
||||
*
|
||||
* @param {string} editId
|
||||
* @param {{ beforeContent: string|null, afterContent: string|null }} instruction - Internal undo/redo snapshot.
|
||||
* @param {object} [options]
|
||||
* @param {object|null} [options.style] - Net element-level style props applied.
|
||||
* @param {object|null} [options.oldStyle] - Previous inline values of those props ('' = unset).
|
||||
* @param {string|null} [options.attribute]
|
||||
* @param {HTMLElement|null} [options.element]
|
||||
* @param {number|null} [options.sessionId] - Inline edit session; coalesces only when equal.
|
||||
* @param {boolean} [options.isAssisted] - True when the source-writer can't safely apply this edit and the AI must.
|
||||
* @param {object|null} [options.selectionMode] - Rich DOM context captured before mutation (see `captureElementMetadata`).
|
||||
*/
|
||||
export function recordEdit(editId, instruction, { style = null, oldStyle = null, attribute = null, element = null, sessionId = null, isAssisted = false, selectionMode = null } = {}) {
|
||||
const composite = getActionKey({ editId, attribute, isAssisted, selectionMode });
|
||||
const lastForEdit = [...actionLog].slice(0, actionPointer + 1).findLast(action => getActionKey(action) === composite);
|
||||
if (lastForEdit && lastForEdit.instruction.afterContent === instruction.afterContent && !style) return;
|
||||
|
||||
if (lastForEdit
|
||||
&& actionLog.lastIndexOf(lastForEdit) === actionPointer
|
||||
&& lastForEdit.sessionId === sessionId) {
|
||||
lastForEdit.instruction = { ...lastForEdit.instruction, afterContent: instruction.afterContent };
|
||||
if (style) {
|
||||
lastForEdit.style = { ...lastForEdit.style, ...style };
|
||||
lastForEdit.oldStyle = { ...oldStyle, ...lastForEdit.oldStyle };
|
||||
}
|
||||
if (element) lastForEdit.element = element;
|
||||
return;
|
||||
}
|
||||
|
||||
actionLog.length = actionPointer + 1;
|
||||
actionLog.push({ editId, instruction, style, oldStyle, attribute, element, sessionId, isAssisted, selectionMode });
|
||||
|
||||
if (actionLog.length > MAX_HISTORY) {
|
||||
actionLog.shift();
|
||||
}
|
||||
|
||||
actionPointer = actionLog.length - 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-applies every committed edit's content/attribute/style to its element,
|
||||
* for entries whose stored element reference went stale (a route change or
|
||||
* React re-render replaced it with a freshly-mounted, un-edited node).
|
||||
*/
|
||||
export function reapplyCommittedEdits() {
|
||||
for (let index = 0; index <= actionPointer; index++) {
|
||||
const action = actionLog[index];
|
||||
if (action.element?.isConnected) continue;
|
||||
|
||||
const element = resolveElement(action);
|
||||
if (!element) continue;
|
||||
|
||||
action.element = element;
|
||||
applyValueToElement(action, action.instruction.afterContent);
|
||||
if (action.style) applyStyleToElement(action, action.style);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts the most recent committed edit (content and element-level styles)
|
||||
* and moves the pointer back.
|
||||
* @returns {{ editId: string, instruction: { beforeContent: string, afterContent: string } }|null}
|
||||
*/
|
||||
export function undo() {
|
||||
if (actionPointer < 0) return null;
|
||||
const action = actionLog[actionPointer];
|
||||
applyValueToElement(action, action.instruction.beforeContent);
|
||||
if (action.style) {
|
||||
const revert = {};
|
||||
for (const key of Object.keys(action.style)) {
|
||||
revert[key] = action.oldStyle?.[key] ?? '';
|
||||
}
|
||||
applyStyleToElement(action, revert);
|
||||
}
|
||||
actionPointer--;
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-applies the next undone edit (content and element-level styles) and
|
||||
* advances the pointer.
|
||||
* @returns {{ editId: string, instruction: { beforeContent: string, afterContent: string } }|null}
|
||||
*/
|
||||
export function redo() {
|
||||
if (actionPointer >= actionLog.length - 1) return null;
|
||||
actionPointer++;
|
||||
const action = actionLog[actionPointer];
|
||||
applyValueToElement(action, action.instruction.afterContent);
|
||||
applyStyleToElement(action, action.style);
|
||||
return action;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates the committed log into one draft per target: the first entry for a key
|
||||
* captures the original instruction/style, later entries update `afterContent`/style.
|
||||
* @returns {Map<string, { editId: string, attribute: string|null, instruction: { beforeContent: string, afterContent: string }, originalStyle: Record<string, string>, currentStyle: Record<string, string>, isAssisted: boolean, selectionMode: object|null }>}
|
||||
*/
|
||||
function collectEditDrafts() {
|
||||
const draftsByKey = new Map();
|
||||
for (let index = 0; index <= actionPointer; index++) {
|
||||
const action = actionLog[index];
|
||||
const key = getActionKey(action);
|
||||
let draft = draftsByKey.get(key);
|
||||
if (!draft) {
|
||||
draft = {
|
||||
editId: action.editId,
|
||||
attribute: action.attribute ?? null,
|
||||
instruction: {
|
||||
beforeContent: action.instruction.beforeContent,
|
||||
afterContent: action.instruction.afterContent,
|
||||
},
|
||||
originalStyle: {},
|
||||
currentStyle: {},
|
||||
isAssisted: action.isAssisted ?? false,
|
||||
selectionMode: action.selectionMode ?? null,
|
||||
};
|
||||
draftsByKey.set(key, draft);
|
||||
}
|
||||
draft.instruction.afterContent = action.instruction.afterContent;
|
||||
for (const [property, value] of Object.entries(action.style ?? {})) {
|
||||
if (!(property in draft.originalStyle)) {
|
||||
draft.originalStyle[property] = action.oldStyle?.[property] ?? '';
|
||||
}
|
||||
draft.currentStyle[property] = value;
|
||||
}
|
||||
}
|
||||
return draftsByKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapses the committed log into one net edit per target, shaped like the wire
|
||||
* `VisualEditItem`, keyed separately for source persistence and history aggregation.
|
||||
* Content, attribute, and individual style-property changes are emitted atomically
|
||||
* so downstream consumers do not have to infer what a before/after pair represents.
|
||||
* @returns {Record<string, { editId: string, instruction: { changes: Array<{ kind: string, target: string|null, before: string|null, after: string|null }> }, isAssisted: boolean, selectionMode: object|null }>}
|
||||
*/
|
||||
export function getCurrentEdits() {
|
||||
const edits = {};
|
||||
for (const [key, draft] of collectEditDrafts()) {
|
||||
const changes = [];
|
||||
const beforeContent = draft.attribute
|
||||
? draft.instruction.beforeContent ?? null
|
||||
: sanitizeText(draft.instruction.beforeContent);
|
||||
const afterContent = draft.attribute
|
||||
? draft.instruction.afterContent ?? null
|
||||
: sanitizeText(draft.instruction.afterContent);
|
||||
|
||||
if (beforeContent !== afterContent) {
|
||||
changes.push({
|
||||
kind: draft.attribute ? 'attribute' : 'content',
|
||||
target: draft.attribute,
|
||||
before: beforeContent,
|
||||
after: afterContent,
|
||||
});
|
||||
}
|
||||
|
||||
for (const property of Object.keys(draft.currentStyle)) {
|
||||
if ((draft.currentStyle[property] ?? '') !== (draft.originalStyle[property] ?? '')) {
|
||||
changes.push({
|
||||
kind: 'style',
|
||||
target: property,
|
||||
before: draft.originalStyle[property] || null,
|
||||
after: draft.currentStyle[property] || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!changes.length) continue;
|
||||
|
||||
const edit = {
|
||||
editId: draft.editId,
|
||||
instruction: { changes },
|
||||
isAssisted: !!draft.isAssisted,
|
||||
selectionMode: draft.selectionMode,
|
||||
};
|
||||
edits[key] = edit;
|
||||
}
|
||||
return edits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorizes the current, uncommitted edit set — used to report which kinds of
|
||||
* changes a save actually carries, as opposed to which edit surfaces were merely
|
||||
* opened during the session (a text/image edit entered and then reverted leaves
|
||||
* no trace here).
|
||||
* @param {ReturnType<typeof getCurrentEdits>} edits
|
||||
* @param {ReturnType<typeof getComments>} annotations
|
||||
* @returns {string[]} distinct categories: "text" | "image" | "annotation"
|
||||
*/
|
||||
function getEditTypes(edits, annotations) {
|
||||
const editTypes = new Set();
|
||||
for (const edit of Object.values(edits)) {
|
||||
const isImageEdit = edit.instruction.changes.some(change => change.kind === 'attribute' && change.target === 'src');
|
||||
editTypes.add(isImageEdit ? 'image' : 'text');
|
||||
}
|
||||
if (annotations.length) editTypes.add('annotation');
|
||||
return [...editTypes];
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo/redo availability plus the count of distinct elements that differ from their original,
|
||||
* including elements that only carry an annotation (no history edit).
|
||||
* @returns {{ canUndo: boolean, canRedo: boolean, editedElementsCount: number, editTypes: string[] }}
|
||||
*/
|
||||
export function getEditState() {
|
||||
const edits = getCurrentEdits();
|
||||
const annotations = getComments();
|
||||
|
||||
const changedElementIds = new Set(
|
||||
Object.values(edits).map(edit => getActionKey({ ...edit, attribute: null })),
|
||||
);
|
||||
|
||||
annotations.forEach((annotation, annotationIndex) => {
|
||||
if (!annotation.elements.length) {
|
||||
changedElementIds.add(`annotation-${annotationIndex}`);
|
||||
return;
|
||||
}
|
||||
|
||||
annotation.elements.forEach((element, elementIndex) => {
|
||||
changedElementIds.add(getEditId(element) || `annotation-${annotationIndex}-${elementIndex}`);
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
canUndo: actionPointer >= 0,
|
||||
canRedo: actionPointer < actionLog.length - 1,
|
||||
editedElementsCount: changedElementIds.size,
|
||||
editTypes: getEditTypes(edits, annotations),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the full undo/redo stack for persistence across reloads.
|
||||
* Strips non-serializable DOM element refs.
|
||||
* @returns {{ actionLog: object[], actionPointer: number, nextEditSessionId: number }}
|
||||
*/
|
||||
export function exportDraftSnapshot() {
|
||||
return {
|
||||
actionLog: actionLog.map(({ element: _element, ...action }) => ({
|
||||
...action,
|
||||
instruction: { ...action.instruction },
|
||||
style: action.style ? { ...action.style } : null,
|
||||
oldStyle: action.oldStyle ? { ...action.oldStyle } : null,
|
||||
selectionMode: action.selectionMode ? structuredClone(action.selectionMode) : null,
|
||||
})),
|
||||
actionPointer,
|
||||
nextEditSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the undo/redo stack from a persisted snapshot and re-applies committed
|
||||
* edits to the current DOM. Skips actions whose elements cannot be resolved.
|
||||
* @param {{ actionLog?: object[], actionPointer?: number, nextEditSessionId?: number }|null|undefined} snapshot
|
||||
* @returns {{ restoredCount: number, skippedCount: number }}
|
||||
*/
|
||||
export function importDraftSnapshot(snapshot) {
|
||||
clearHistory();
|
||||
|
||||
if (!snapshot || !Array.isArray(snapshot.actionLog)) {
|
||||
return { restoredCount: 0, skippedCount: 0 };
|
||||
}
|
||||
|
||||
let restoredCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const action of snapshot.actionLog) {
|
||||
if (!action?.editId || !action?.instruction) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
actionLog.push({
|
||||
editId: action.editId,
|
||||
instruction: {
|
||||
beforeContent: action.instruction.beforeContent ?? null,
|
||||
afterContent: action.instruction.afterContent ?? null,
|
||||
},
|
||||
style: action.style ?? null,
|
||||
oldStyle: action.oldStyle ?? null,
|
||||
attribute: action.attribute ?? null,
|
||||
element: null,
|
||||
sessionId: action.sessionId ?? null,
|
||||
isAssisted: !!action.isAssisted,
|
||||
selectionMode: action.selectionMode ?? null,
|
||||
});
|
||||
restoredCount++;
|
||||
}
|
||||
|
||||
const maxPointer = actionLog.length - 1;
|
||||
const requestedPointer = typeof snapshot.actionPointer === 'number' ? snapshot.actionPointer : maxPointer;
|
||||
actionPointer = Math.min(Math.max(requestedPointer, -1), maxPointer);
|
||||
nextEditSessionId = typeof snapshot.nextEditSessionId === 'number' && snapshot.nextEditSessionId > 0
|
||||
? snapshot.nextEditSessionId
|
||||
: 1;
|
||||
|
||||
reapplyCommittedEdits();
|
||||
|
||||
for (let index = 0; index <= actionPointer; index++) {
|
||||
if (!actionLog[index].element) {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return { restoredCount, skippedCount };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Group selection built by a rubber-band drag or shift+click. The committed
|
||||
* group is document-ordered and never nested; `_drag*` lives for one gesture.
|
||||
*/
|
||||
|
||||
/** @type {HTMLElement[]} */
|
||||
let _selectedElements = [];
|
||||
let _dragStartX = null;
|
||||
let _dragStartY = null;
|
||||
let _isDragging = false;
|
||||
/** Set on mouseup so the synthetic click a drag leaves behind is swallowed. */
|
||||
let _hadDrag = false;
|
||||
|
||||
export function getSelectedElements() { return _selectedElements; }
|
||||
export function setSelectedElements(value) { _selectedElements = value; }
|
||||
|
||||
export function getDragStartX() { return _dragStartX; }
|
||||
export function getDragStartY() { return _dragStartY; }
|
||||
export function setDragStart(x, y) { _dragStartX = x; _dragStartY = y; }
|
||||
export function clearDragStart() { _dragStartX = null; _dragStartY = null; }
|
||||
|
||||
export function getIsDragging() { return _isDragging; }
|
||||
export function setIsDragging(value) { _isDragging = value; }
|
||||
|
||||
export function getHadDrag() { return _hadDrag; }
|
||||
export function setHadDrag(value) { _hadDrag = value; }
|
||||
|
||||
/** True when a mousedown is pending and could still become a drag. */
|
||||
export function isDragPending() { return _dragStartX !== null; }
|
||||
|
||||
/** Drops the current group without touching an in-flight gesture. */
|
||||
export function clearSelectedElements() { _selectedElements = []; }
|
||||
|
||||
/**
|
||||
* Drops members that have left the page, so a group never outlives the route it
|
||||
* was made on or holds nodes a re-render replaced.
|
||||
* @returns {boolean} True when the group changed.
|
||||
*/
|
||||
export function pruneDetachedSelection() {
|
||||
const connected = _selectedElements.filter(element => element.isConnected);
|
||||
if (connected.length === _selectedElements.length) return false;
|
||||
|
||||
_selectedElements = connected;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Resets everything, including any gesture in progress. */
|
||||
export function resetMultiSelectState() {
|
||||
_selectedElements = [];
|
||||
_dragStartX = null;
|
||||
_dragStartY = null;
|
||||
_isDragging = false;
|
||||
_hadDrag = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupes and puts the group in document order, which fixes the marker's anchor
|
||||
* element and the order selections reach the AI.
|
||||
* @param {HTMLElement[]} elements
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
export function orderSelection(elements) {
|
||||
return [...new Set(elements)]
|
||||
.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the element to the group, or removes it when already a member. Nesting is
|
||||
* allowed: picking a parent on top of its children is an explicit choice.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {HTMLElement[]} The new group.
|
||||
*/
|
||||
export function toggleSelectedElement(element) {
|
||||
_selectedElements = _selectedElements.includes(element)
|
||||
? _selectedElements.filter(member => member !== element)
|
||||
: orderSelection([..._selectedElements, element]);
|
||||
return _selectedElements;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/** @enum {string} Unique identifier for each action panel. */
|
||||
export const PanelId = Object.freeze({
|
||||
COLOR: 'color',
|
||||
FONT: 'font',
|
||||
SIZE: 'size',
|
||||
LINK: 'link',
|
||||
});
|
||||
|
||||
/**
|
||||
* Tracks whether each panel is currently open.
|
||||
* @type {Map<string, boolean>}
|
||||
*/
|
||||
const visibility = new Map();
|
||||
|
||||
/**
|
||||
* The hide function supplied by each panel at registration time.
|
||||
* @type {Map<string, () => void>}
|
||||
*/
|
||||
const hideFunctions = new Map();
|
||||
|
||||
/**
|
||||
* Register an action panel so it participates in bulk show/hide operations.
|
||||
* Called once at module load time by each panel.
|
||||
* @param {{ id: string, hide: () => void }} panel
|
||||
*/
|
||||
export function registerPanel({ id, hide }) {
|
||||
hideFunctions.set(id, hide);
|
||||
visibility.set(id, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the visibility state for a panel.
|
||||
* Called by the panel's own show/hide functions.
|
||||
* @param {string} id - A {@link PanelId} value.
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
export function setPanelVisible(id, visible) {
|
||||
visibility.set(id, visible);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} id - A {@link PanelId} value.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isPanelVisible(id) {
|
||||
return visibility.get(id) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide a single panel by ID.
|
||||
* @param {string} id - A {@link PanelId} value.
|
||||
*/
|
||||
export function hidePanelById(id) {
|
||||
hideFunctions.get(id)?.();
|
||||
}
|
||||
|
||||
/** Hide every registered panel. */
|
||||
export function hideAllPanels() {
|
||||
for (const id of hideFunctions.keys()) hidePanelById(id);
|
||||
}
|
||||
|
||||
/** @returns {boolean} True if at least one panel is currently visible. */
|
||||
export function anyPanelVisible() {
|
||||
return [...visibility.values()].some(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import * as t from '@babel/types';
|
||||
import { ELEMENT_TYPE_MAP } from '../constants/selectors.js';
|
||||
|
||||
export const EDITABLE_HTML_TAGS = ["a", "Link", "button", "Button", "p", "span", "time", "h1", "h2", "h3", "h4", "h5", "h6", "label", "Label", "img"];
|
||||
|
||||
export const INLINE_EDIT_HTML_TAGS = ["br", "strong", "em", "b", "i", "u", "span", "a"];
|
||||
|
||||
/**
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingElementNode
|
||||
* @param {string[]} editableTagsList
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function checkTagNameEditable(openingElementNode, editableTagsList) {
|
||||
if (!openingElementNode || !openingElementNode.name) return false;
|
||||
const nameNode = openingElementNode.name;
|
||||
|
||||
if (nameNode.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (nameNode.type === 'JSXMemberExpression' && nameNode.property && nameNode.property.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.property.name)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||
* @returns {{ isValid: boolean, reason: string|null }}
|
||||
*/
|
||||
export function validateImageSrc(openingNode) {
|
||||
if (!openingNode || !openingNode.name || (openingNode.name.name !== 'img' && openingNode.name.property?.name !== 'img')) {
|
||||
return { isValid: true, reason: null };
|
||||
}
|
||||
|
||||
const hasPropsSpread = openingNode.attributes.some(attribute =>
|
||||
t.isJSXSpreadAttribute(attribute) &&
|
||||
attribute.argument &&
|
||||
t.isIdentifier(attribute.argument) &&
|
||||
attribute.argument.name === 'props'
|
||||
);
|
||||
|
||||
if (hasPropsSpread) return { isValid: false, reason: 'props-spread' };
|
||||
|
||||
const sourceAttribute = openingNode.attributes.find(attribute =>
|
||||
t.isJSXAttribute(attribute) && attribute.name && attribute.name.name === 'src'
|
||||
);
|
||||
|
||||
if (!sourceAttribute) return { isValid: false, reason: 'missing-src' };
|
||||
if (!t.isStringLiteral(sourceAttribute.value)) return { isValid: false, reason: 'dynamic-src' };
|
||||
if (!sourceAttribute.value.value || sourceAttribute.value.value.trim() === '') return { isValid: false, reason: 'empty-src' };
|
||||
|
||||
return { isValid: true, reason: null };
|
||||
}
|
||||
|
||||
// `children` and capitalised identifiers hold rendered nodes, not text, so an
|
||||
// element whose only content is one of those is a layout slot rather than a target.
|
||||
function isNodeSlotExpression(expression) {
|
||||
if (t.isIdentifier(expression)) return expression.name === 'children' || /^[A-Z]/.test(expression.name);
|
||||
return t.isMemberExpression(expression) && expression.property?.name === 'children';
|
||||
}
|
||||
|
||||
function isLikelyTextExpression(expression) {
|
||||
if (isNodeSlotExpression(expression)) return false;
|
||||
// A ternary or `&&` still renders text through whichever branch wins. Without
|
||||
// this, `{ready ? `${n} entries` : 'Loading'}` reads as an empty element and
|
||||
// the span is skipped entirely instead of being marked assisted.
|
||||
if (t.isConditionalExpression(expression)) {
|
||||
return isLikelyTextExpression(expression.consequent) || isLikelyTextExpression(expression.alternate);
|
||||
}
|
||||
if (t.isLogicalExpression(expression)) {
|
||||
return isLikelyTextExpression(expression.right);
|
||||
}
|
||||
return t.isIdentifier(expression)
|
||||
|| t.isMemberExpression(expression)
|
||||
|| t.isStringLiteral(expression)
|
||||
|| t.isNumericLiteral(expression)
|
||||
|| t.isTemplateLiteral(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when an element has direct text children worth editing
|
||||
* @param {import('@babel/types').JSXElement} elementNode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasMeaningfulTextContent(elementNode) {
|
||||
if (!t.isJSXElement(elementNode) || !elementNode.children?.length) return false;
|
||||
for (const child of elementNode.children) {
|
||||
if (t.isJSXText(child) && child.value.trim().length > 0) return true;
|
||||
if (t.isJSXExpressionContainer(child)
|
||||
&& !t.isJSXEmptyExpression(child.expression)
|
||||
&& isLikelyTextExpression(child.expression)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link hasMeaningfulTextContent} but walks the full subtree.
|
||||
* Used to distinguish decorative inline children (color swatches, icons)
|
||||
* from ones that carry actual text.
|
||||
*/
|
||||
function subtreeHasText(elementNode) {
|
||||
if (!t.isJSXElement(elementNode)) return false;
|
||||
for (const child of elementNode.children ?? []) {
|
||||
if (t.isJSXText(child) && child.value.trim().length > 0) return true;
|
||||
if (t.isJSXExpressionContainer(child)
|
||||
&& !t.isJSXEmptyExpression(child.expression)
|
||||
&& isLikelyTextExpression(child.expression)) return true;
|
||||
if (t.isJSXElement(child) && subtreeHasText(child)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the element should receive an assisted-edit id due to spread
|
||||
* props or dynamic children that contain text. Icon-only buttons are excluded.
|
||||
* @param {import('@babel/types').JSXElement} elementNode
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function shouldDisableDueToChildren(elementNode, openingNode) {
|
||||
if (!t.isJSXElement(elementNode) || !elementNode.children) return false;
|
||||
|
||||
const hasPropsSpread = openingNode.attributes.some(attribute =>
|
||||
t.isJSXSpreadAttribute(attribute)
|
||||
&& attribute.argument
|
||||
&& t.isIdentifier(attribute.argument)
|
||||
&& attribute.argument.name === 'props'
|
||||
);
|
||||
if (hasPropsSpread) return true;
|
||||
|
||||
const hasDynamicChild = elementNode.children.some(child => t.isJSXExpressionContainer(child));
|
||||
if (hasDynamicChild) {
|
||||
const hasStaticText = elementNode.children.some(
|
||||
child => t.isJSXText(child) && child.value.trim().length > 0
|
||||
);
|
||||
if (hasStaticText) return true;
|
||||
|
||||
const hasTextLikeExpression = elementNode.children.some(child =>
|
||||
t.isJSXExpressionContainer(child)
|
||||
&& !t.isJSXEmptyExpression(child.expression)
|
||||
&& isLikelyTextExpression(child.expression)
|
||||
);
|
||||
if (hasTextLikeExpression) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyses child composition to decide whether the element can be edited.
|
||||
* @param {import('@babel/types').JSXElement} elementNode
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||
* @returns {'skip'|'disabled'|'allow'}
|
||||
*/
|
||||
export function evalChildComposition(elementNode, openingNode) {
|
||||
if (!t.isJSXElement(elementNode)) return 'allow';
|
||||
if (!elementNode.children?.length) {
|
||||
const name = openingNode?.name?.name || openingNode?.name?.property?.name || '';
|
||||
return name === 'img' ? 'allow' : 'skip';
|
||||
}
|
||||
|
||||
let hasTextContent = false;
|
||||
let hasNonEditableJsxChild = false;
|
||||
let hasNonSelfClosingChild = false;
|
||||
let hasInlineTagChild = false;
|
||||
let hasIdIneligibleInlineChild = false;
|
||||
|
||||
for (const child of elementNode.children) {
|
||||
if (t.isJSXText(child)) {
|
||||
if (child.value.trim().length > 0) hasTextContent = true;
|
||||
continue;
|
||||
}
|
||||
if (t.isJSXExpressionContainer(child)) {
|
||||
if (!t.isJSXEmptyExpression(child.expression) && isLikelyTextExpression(child.expression)) {
|
||||
hasTextContent = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (t.isJSXElement(child)) {
|
||||
const childNode = child.openingElement;
|
||||
if (childNode.selfClosing) {
|
||||
const childName = childNode.name?.name || '';
|
||||
if (!/^[A-Z]/.test(childName) && !checkTagNameEditable(childNode, EDITABLE_HTML_TAGS) && !INLINE_EDIT_HTML_TAGS.includes(childName)) {
|
||||
hasNonEditableJsxChild = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const childTagName = child.openingElement.name?.name;
|
||||
if (INLINE_EDIT_HTML_TAGS.includes(childTagName)) {
|
||||
if (!subtreeHasText(child)) {
|
||||
hasNonEditableJsxChild = true;
|
||||
continue;
|
||||
}
|
||||
hasInlineTagChild = true;
|
||||
// `strong`/`em`/… are inline-editable inside a parent but cannot
|
||||
// receive their own edit id (Condition 2 returns early). Only
|
||||
// skip the parent when every inline child could own an id.
|
||||
if (!EDITABLE_HTML_TAGS.includes(childTagName)) {
|
||||
hasIdIneligibleInlineChild = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
hasNonSelfClosingChild = true;
|
||||
if (!checkTagNameEditable(childNode, EDITABLE_HTML_TAGS)) {
|
||||
hasNonEditableJsxChild = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTextContent && !hasNonSelfClosingChild && !hasInlineTagChild) return 'skip';
|
||||
if (hasNonEditableJsxChild) {
|
||||
if (!hasTextContent && !hasInlineTagChild) return 'skip';
|
||||
return 'disabled';
|
||||
}
|
||||
if (hasNonSelfClosingChild && !hasTextContent && !hasInlineTagChild) {
|
||||
if (openingNode && checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS)) return 'allow';
|
||||
return 'skip';
|
||||
}
|
||||
// Layout wrappers that only nest id-eligible inline tags (span/a) skip so
|
||||
// the child receives the id. Keep 'allow' when an inline child cannot own
|
||||
// an id (`<p><strong>text</strong></p>`), or when the parent is itself an
|
||||
// editable tag (h1, p, a, button, …) that should own the edit target.
|
||||
if (!hasTextContent && hasInlineTagChild && !hasIdIneligibleInlineChild) {
|
||||
const parentName = openingNode?.name?.name || openingNode?.name?.property?.name || '';
|
||||
const isFormattingWrapper = INLINE_EDIT_HTML_TAGS.includes(parentName) && parentName !== 'a';
|
||||
if (openingNode && checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS)
|
||||
&& !isFormattingWrapper) return 'allow';
|
||||
return 'skip';
|
||||
}
|
||||
return 'allow';
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively checks whether an element or any of its descendants contain
|
||||
* dynamic content, spread props, or conditional expressions that a direct
|
||||
* inline edit would silently overwrite. When true the element must be routed
|
||||
* through AI-assisted editing instead.
|
||||
* @param {import('@babel/types').JSXElement} elementNode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function subtreeRequiresAssistance(elementNode) {
|
||||
if (!t.isJSXElement(elementNode)) return false;
|
||||
|
||||
const opening = elementNode.openingElement;
|
||||
if (opening.attributes.some(attribute => t.isJSXSpreadAttribute(attribute))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const child of elementNode.children ?? []) {
|
||||
if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (t.isJSXElement(child) && subtreeRequiresAssistance(child)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if an ancestor JSXElement already received a target attribute.
|
||||
* @param {import('@babel/traverse').NodePath<import('@babel/types').JSXOpeningElement>} openingPath
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasEditableAncestor(openingPath) {
|
||||
const selfElementPath = openingPath.parentPath;
|
||||
if (!selfElementPath?.isJSXElement()) return false;
|
||||
|
||||
let ancestorJsxElementPath = selfElementPath.findParent(path => path.isJSXElement());
|
||||
while (ancestorJsxElementPath) {
|
||||
const ancestorOpening = ancestorJsxElementPath.node.openingElement;
|
||||
if (ancestorOpening.attributes.some(attribute =>
|
||||
t.isJSXAttribute(attribute) && (attribute.name?.name === 'data-edit-id' || attribute.name?.name === 'data-edit-assisted-id')
|
||||
)) {
|
||||
return true;
|
||||
}
|
||||
ancestorJsxElementPath = ancestorJsxElementPath.findParent(path => path.isJSXElement());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a JSX opening element tag name to a semantic edit type.
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getElementTypeFromNode(openingNode) {
|
||||
if (!openingNode || !openingNode.name) return null;
|
||||
const name = openingNode.name?.name || openingNode.name?.property?.name || '';
|
||||
if (!name) return null;
|
||||
|
||||
for (const [type, tags] of Object.entries(ELEMENT_TYPE_MAP)) {
|
||||
if (tags.includes(name)) return type;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as t from '@babel/types';
|
||||
|
||||
/**
|
||||
* For an editable `<a>` / `<Link>` / `<NavLink>`, returns the name of the URL
|
||||
* attribute (`href` or `to`) when it holds a static string literal.
|
||||
* Returns `null` for non-link tags or dynamic/expression URLs.
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function detectUrlAttribute(openingNode) {
|
||||
const tagName = openingNode.name?.name || openingNode.name?.property?.name || '';
|
||||
let attributeName = null;
|
||||
if (tagName === 'a') attributeName = 'href';
|
||||
else if (tagName === 'Link' || tagName === 'NavLink') attributeName = 'to';
|
||||
if (!attributeName) return null;
|
||||
|
||||
const matchedAttribute = openingNode.attributes.find(attribute =>
|
||||
t.isJSXAttribute(attribute) && attribute.name?.name === attributeName
|
||||
);
|
||||
if (!matchedAttribute || !matchedAttribute.value) return null;
|
||||
|
||||
if (t.isStringLiteral(matchedAttribute.value)) return attributeName;
|
||||
if (t.isJSXExpressionContainer(matchedAttribute.value) && t.isStringLiteral(matchedAttribute.value.expression)) return attributeName;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes `data-edit-id` (and optionally `data-edit-url-attr`) onto the element.
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||
* @param {string} editId
|
||||
* @param {string|null} urlAttributeName
|
||||
*/
|
||||
export function attachEditAttributes(openingNode, editId, urlAttributeName) {
|
||||
openingNode.attributes.push(
|
||||
t.jsxAttribute(t.jsxIdentifier('data-edit-id'), t.stringLiteral(editId))
|
||||
);
|
||||
if (urlAttributeName) {
|
||||
openingNode.attributes.push(
|
||||
t.jsxAttribute(t.jsxIdentifier('data-edit-url-attr'), t.stringLiteral(urlAttributeName))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pushes only `data-edit-assisted-id` onto the element.
|
||||
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||
* @param {string} editId
|
||||
*/
|
||||
export function attachAssistedEditId(openingNode, editId) {
|
||||
openingNode.attributes.push(
|
||||
t.jsxAttribute(t.jsxIdentifier('data-edit-assisted-id'), t.stringLiteral(editId))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
import { ICON_SPARKLES } from '../../constants/icons.js';
|
||||
import { ANNOTATION_MARKER_STYLES } from './styles.js';
|
||||
import { getEditId } from '../../constants/selectors.js';
|
||||
import { setComments } from '../../state/annotation-state.js';
|
||||
import { getAppRoot, isolateEditorUiEvents } from '../../utils/dom-utils.js';
|
||||
import {
|
||||
showHoverOutline, holdHoverOutline, releaseHoverOutline, lockHoverOutline,
|
||||
HOVER_OUTLINE_CLASS, LOCKED_OUTLINE_CLASS,
|
||||
} from '../overlays/hover-outline.js';
|
||||
|
||||
/**
|
||||
* `class` attribute with our own hover/locked outline classes removed, so an
|
||||
* anchor captured while the panel had the element locked never bakes in a
|
||||
* class the element won't carry once re-mounted on a route round-trip.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string}
|
||||
*/
|
||||
function stableClassName(element) {
|
||||
return Array.from(element.classList)
|
||||
.filter(className => className !== HOVER_OUTLINE_CLASS && className !== LOCKED_OUTLINE_CLASS)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
const _markers = [];
|
||||
let _scrollListenerActive = false;
|
||||
let _stylesInjected = false;
|
||||
|
||||
function ensureStyles() {
|
||||
if (_stylesInjected) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'annotation-marker-styles';
|
||||
style.textContent = ANNOTATION_MARKER_STYLES;
|
||||
document.head.appendChild(style);
|
||||
_stylesInjected = true;
|
||||
}
|
||||
|
||||
/** Route an annotation belongs to; it is never re-anchored on any other one. */
|
||||
function currentRoute() {
|
||||
return location.pathname + location.hash;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structural anchor: the element's child-index path from the app root plus the
|
||||
* tag and classes expected there. Its captured `selector` is unusable for
|
||||
* re-querying — Tailwind variants (`md:py-32`) make it invalid CSS.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {{path: number[], tagName: string, className: string}|null}
|
||||
*/
|
||||
function captureAnchor(element) {
|
||||
const root = getAppRoot();
|
||||
const path = [];
|
||||
let node = element;
|
||||
|
||||
while (node && node !== root) {
|
||||
const parent = node.parentElement;
|
||||
if (!parent) return null;
|
||||
path.unshift(Array.prototype.indexOf.call(parent.children, node));
|
||||
node = parent;
|
||||
}
|
||||
|
||||
const anchor = { path, tagName: element.tagName, className: stableClassName(element) };
|
||||
|
||||
return anchor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Corrects for a reordered list: items rendered from one `.map()` are identical
|
||||
* in tag and classes, so only their text tells them apart. When no sibling
|
||||
* matches either, the text was edited since and the position stands.
|
||||
* @param {HTMLElement} candidate
|
||||
* @param {object|null} selection - `captureElementMetadata` output.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function disambiguateSiblings(candidate, selection) {
|
||||
const text = selection?.textContent;
|
||||
if (!text || candidate.textContent?.trim() === text) return candidate;
|
||||
|
||||
const siblings = Array.from(candidate.parentElement?.children ?? []);
|
||||
return siblings.find(sibling =>
|
||||
sibling.tagName === candidate.tagName
|
||||
&& stableClassName(sibling) === stableClassName(candidate)
|
||||
&& sibling.textContent?.trim() === text) ?? candidate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Node currently sitting at the anchor's tree position, or null when the
|
||||
* position is empty or now holds a different element.
|
||||
* @param {{path: number[], tagName: string, className: string}|null} anchor
|
||||
* @param {object|null} selection - `captureElementMetadata` output.
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function resolveByAnchor(anchor, selection) {
|
||||
if (!anchor) return null;
|
||||
|
||||
let candidate = getAppRoot();
|
||||
for (const index of anchor.path) {
|
||||
candidate = candidate?.children[index];
|
||||
}
|
||||
if (!candidate) return null;
|
||||
|
||||
const actualClassName = stableClassName(candidate);
|
||||
const sameElement = candidate.tagName === anchor.tagName && actualClassName === anchor.className;
|
||||
|
||||
return sameElement ? disambiguateSiblings(candidate, selection) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* An edit id is a JSX source location, so every node rendered from one `.map()`
|
||||
* call site carries the same one; the anchor picks the annotated instance.
|
||||
* @param {{editId: string, anchor: object|null, selection: object|null}} target
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function resolveByEditId(target) {
|
||||
const matches = Array.from(document.querySelectorAll(`[data-edit-id="${target.editId}"], [data-edit-assisted-id="${target.editId}"]`));
|
||||
if (matches.length <= 1) return matches[0] ?? null;
|
||||
|
||||
const anchored = resolveByAnchor(target.anchor, target.selection);
|
||||
if (anchored && matches.includes(anchored)) return anchored;
|
||||
|
||||
const text = target.selection?.textContent;
|
||||
return (text && matches.find(match => match.textContent?.trim() === text)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Element one annotated target currently points at, re-resolved when the stored
|
||||
* node was unmounted (re-render, route change); null when it is gone.
|
||||
* @param {object} target
|
||||
* @param {string} route - Route the annotation was made on.
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function resolveTarget(target, route) {
|
||||
if (target.element?.isConnected) return target.element;
|
||||
if (currentRoute() !== route) return null;
|
||||
|
||||
const element = target.editId ? resolveByEditId(target) : resolveByAnchor(target.anchor, target.selection);
|
||||
if (element) target.element = element;
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Elements a marker's annotation still points at. A group keeps its pin as long
|
||||
* as at least one member survives, so a partial re-render doesn't drop it.
|
||||
* @param {object} entry
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
function resolveMarkerElements(entry) {
|
||||
const elements = entry.targets
|
||||
.map(target => resolveTarget(target, entry.route))
|
||||
.filter(Boolean);
|
||||
|
||||
entry.elements = elements;
|
||||
if (elements.length) entry.comment.elements = elements;
|
||||
return elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places one marker over its annotation's current elements.
|
||||
* @param {object} entry
|
||||
* @param {HTMLElement[]} elements - `resolveMarkerElements` output, never empty.
|
||||
*/
|
||||
function positionMarker(entry, elements) {
|
||||
const anchorElement = elements[0];
|
||||
const rect = anchorElement.getBoundingClientRect();
|
||||
const fixed = entry.fixed;
|
||||
|
||||
const anchored = elements.length === 1
|
||||
&& Array.from(anchorElement.attributes || []).some(attr => attr.name.startsWith('data-edit-'));
|
||||
const relativeX = anchored ? 0 : entry.relativeX;
|
||||
const relativeY = anchored ? rect.height / 2 : entry.relativeY;
|
||||
|
||||
const scrollX = fixed ? 0 : window.scrollX;
|
||||
const scrollY = fixed ? 0 : window.scrollY;
|
||||
entry.marker.style.left = `${rect.left + relativeX + scrollX}px`;
|
||||
entry.marker.style.top = `${rect.top + relativeY + scrollY}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repositions every marker over its annotation, hiding the ones whose elements
|
||||
* are no longer on the page so annotations never bleed onto another route. This
|
||||
* is also what anchors a restored annotation, once its route renders.
|
||||
*/
|
||||
export function refreshAnnotationMarkers() {
|
||||
for (const entry of _markers) {
|
||||
const elements = resolveMarkerElements(entry);
|
||||
if (!elements.length) {
|
||||
entry.marker.style.display = 'none';
|
||||
continue;
|
||||
}
|
||||
entry.marker.style.display = '';
|
||||
positionMarker(entry, elements);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureScrollListener() {
|
||||
if (_scrollListenerActive) return;
|
||||
_scrollListenerActive = true;
|
||||
window.addEventListener('scroll', refreshAnnotationMarkers, true);
|
||||
window.addEventListener('resize', refreshAnnotationMarkers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and registers the pin of one annotation. `targets` may describe elements
|
||||
* that are not on the page: the pin stays hidden until a refresh resolves them.
|
||||
* @param {{
|
||||
* comment: import('../../state/annotation-state.js').AnnotationComment,
|
||||
* targets: Array<{element: HTMLElement|null, editId: string|null, anchor: object|null, selection: object|null}>,
|
||||
* relativeX: number,
|
||||
* relativeY: number,
|
||||
* fixed: boolean,
|
||||
* route: string,
|
||||
* onMarkerClick: (comment: object, clientX: number, clientY: number) => void,
|
||||
* }} options
|
||||
* @returns {object} The marker entry.
|
||||
*/
|
||||
function createMarkerEntry({ comment, targets, relativeX, relativeY, fixed, route, onMarkerClick }) {
|
||||
ensureStyles();
|
||||
ensureScrollListener();
|
||||
|
||||
const marker = document.createElement('div');
|
||||
marker.className = 'selection-mode-annotation-marker';
|
||||
marker.style.position = fixed ? 'fixed' : 'absolute';
|
||||
marker.style.display = 'none';
|
||||
marker.innerHTML = ICON_SPARKLES;
|
||||
|
||||
// The outline and panel target `entry.elements`, not the captured ones:
|
||||
// re-resolution after a re-render swaps in the freshly mounted nodes.
|
||||
const entry = {
|
||||
marker,
|
||||
elements: targets.map(target => target.element).filter(Boolean),
|
||||
relativeX,
|
||||
relativeY,
|
||||
fixed,
|
||||
comment,
|
||||
targets,
|
||||
route,
|
||||
};
|
||||
|
||||
marker.addEventListener('mouseenter', () => {
|
||||
holdHoverOutline();
|
||||
showHoverOutline(entry.elements);
|
||||
});
|
||||
|
||||
marker.addEventListener('mouseleave', () => {
|
||||
releaseHoverOutline();
|
||||
});
|
||||
|
||||
marker.addEventListener('click', (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
lockHoverOutline(entry.elements);
|
||||
onMarkerClick(comment, event.clientX, event.clientY);
|
||||
});
|
||||
|
||||
isolateEditorUiEvents(marker);
|
||||
document.body.appendChild(marker);
|
||||
|
||||
_markers.push(entry);
|
||||
comment.marker = marker;
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the single pin that represents one annotation, tracked against the first of its elements
|
||||
* @param {import('../../state/annotation-state.js').AnnotationComment} comment
|
||||
* @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick
|
||||
*/
|
||||
export function addAnnotationMarker(comment, onMarkerClick) {
|
||||
const { elements, selections, clickX, clickY, fixed } = comment;
|
||||
const anchorElement = elements[0];
|
||||
const rect = anchorElement.getBoundingClientRect();
|
||||
|
||||
const anchorToLeftBorder = elements.length === 1
|
||||
&& (anchorElement.hasAttribute('data-edit-id') || anchorElement.hasAttribute('data-edit-assisted-id'));
|
||||
|
||||
const entry = createMarkerEntry({
|
||||
comment,
|
||||
targets: elements.map((element, index) => ({
|
||||
element,
|
||||
editId: getEditId(element),
|
||||
anchor: captureAnchor(element),
|
||||
selection: selections[index] ?? null,
|
||||
})),
|
||||
relativeX: anchorToLeftBorder ? 0 : clickX - rect.left,
|
||||
relativeY: anchorToLeftBorder ? rect.height / 2 : clickY - rect.top,
|
||||
fixed,
|
||||
route: currentRoute(),
|
||||
onMarkerClick,
|
||||
});
|
||||
|
||||
entry.marker.style.display = '';
|
||||
positionMarker(entry, elements);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recreates the pin of a persisted annotation
|
||||
* @param {import('../../state/annotation-state.js').AnnotationComment} comment
|
||||
* @param {ReturnType<typeof exportAnnotationMarkers>[number]} serialized
|
||||
* @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick
|
||||
*/
|
||||
function addRestoredAnnotationMarker(comment, serialized, onMarkerClick) {
|
||||
createMarkerEntry({
|
||||
comment,
|
||||
targets: (serialized.targets ?? []).map(target => ({
|
||||
element: null,
|
||||
editId: target.editId ?? null,
|
||||
anchor: target.anchor ?? null,
|
||||
selection: target.selection ?? null,
|
||||
})),
|
||||
relativeX: serialized.relativeX ?? 0,
|
||||
relativeY: serialized.relativeY ?? 0,
|
||||
fixed: !!serialized.fixed,
|
||||
route: serialized.route ?? currentRoute(),
|
||||
onMarkerClick,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds annotation comments and their pins from a snapshot. Nothing is
|
||||
* resolved here: the refresh pass anchors each annotation as its route renders
|
||||
* @param {ReturnType<typeof exportAnnotationMarkers>|null|undefined} serialized
|
||||
* @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick
|
||||
*/
|
||||
export function importAnnotationMarkers(serialized, onMarkerClick) {
|
||||
clearAllAnnotationMarkers();
|
||||
setComments([]);
|
||||
|
||||
if (!Array.isArray(serialized)) return;
|
||||
|
||||
// Without targets an annotation can never be anchored again, so it would sit
|
||||
// in the draft as a change the user cannot see, reach or remove.
|
||||
const annotations = serialized.filter(annotation => annotation?.targets?.length);
|
||||
if (!annotations.length) return;
|
||||
|
||||
const comments = annotations.map(annotation => ({
|
||||
elements: [],
|
||||
selections: annotation.targets.map(target => target.selection).filter(Boolean),
|
||||
text: annotation.text ?? '',
|
||||
attachments: Array.isArray(annotation.attachments) ? annotation.attachments : [],
|
||||
clickX: annotation.clickX ?? 0,
|
||||
clickY: annotation.clickY ?? 0,
|
||||
fixed: !!annotation.fixed,
|
||||
}));
|
||||
|
||||
setComments(comments);
|
||||
comments.forEach((comment, index) => addRestoredAnnotationMarker(comment, annotations[index], onMarkerClick));
|
||||
refreshAnnotationMarkers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes every annotation for persistence
|
||||
* @returns {Array<{
|
||||
* targets: Array<{editId: string|null, anchor: object|null, selection: object|null}>,
|
||||
* text: string, clickX: number, clickY: number,
|
||||
* relativeX: number, relativeY: number, fixed: boolean, route: string,
|
||||
* attachments: Array<{url: string, fileName: string, size: number}>,
|
||||
* }>}
|
||||
*/
|
||||
export function exportAnnotationMarkers() {
|
||||
return _markers.map(entry => ({
|
||||
targets: entry.targets.map(({ editId, anchor, selection }) => ({ editId, anchor, selection })),
|
||||
text: entry.comment.text ?? '',
|
||||
clickX: entry.comment.clickX ?? 0,
|
||||
clickY: entry.comment.clickY ?? 0,
|
||||
relativeX: entry.relativeX,
|
||||
relativeY: entry.relativeY,
|
||||
fixed: entry.fixed,
|
||||
route: entry.route,
|
||||
attachments: entry.comment.attachments ?? [],
|
||||
}));
|
||||
}
|
||||
|
||||
export function removeMarker(comment) {
|
||||
const index = _markers.findIndex(e => e.comment === comment);
|
||||
if (index === -1) return;
|
||||
const entry = _markers[index];
|
||||
entry.marker.remove();
|
||||
_markers.splice(index, 1);
|
||||
}
|
||||
|
||||
/** Highlights the pin of whichever annotation covers the element, group or not. */
|
||||
export function highlightMarkerForElement(element) {
|
||||
for (const entry of _markers) {
|
||||
if (entry.elements.includes(element)) {
|
||||
entry.marker.classList.add('highlighted');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function unhighlightAllMarkers() {
|
||||
for (const entry of _markers) {
|
||||
entry.marker.classList.remove('highlighted');
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllAnnotationMarkers() {
|
||||
for (const entry of _markers) {
|
||||
entry.marker.remove();
|
||||
}
|
||||
_markers.length = 0;
|
||||
window.removeEventListener('scroll', refreshAnnotationMarkers, true);
|
||||
window.removeEventListener('resize', refreshAnnotationMarkers);
|
||||
_scrollListenerActive = false;
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
import { ANNOTATION_PANEL_HTML } from './template.js';
|
||||
import { ANNOTATION_PANEL_STYLES } from './styles.js';
|
||||
import { addAnnotationMarker, removeMarker, unhighlightAllMarkers } from './annotation-markers.js';
|
||||
import { lockHoverOutline, unlockHoverOutline } from '../overlays/hover-outline.js';
|
||||
import { isInFixedContext, getElementType, isolateEditorUiEvents } from '../../utils/dom-utils.js';
|
||||
import { captureElementMetadata } from '../../utils/selection-mode-metadata.js';
|
||||
import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT, ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT, MAX_ANNOTATION_ATTACHMENTS, TOOLTIP_VIEWPORT_MARGIN } from '../../constants/layout.js';
|
||||
import {
|
||||
getComments, setComments,
|
||||
getPanelMode, setPanelMode,
|
||||
getEditingComment, setEditingComment,
|
||||
getPendingElements, setPendingElements,
|
||||
getPendingSelections, setPendingSelections,
|
||||
getPendingAttachments, setPendingAttachments,
|
||||
getAnnotationClickX, setAnnotationClickX,
|
||||
getAnnotationClickY, setAnnotationClickY,
|
||||
getEditorTranslations,
|
||||
} from '../../state/annotation-state.js';
|
||||
import { ICON_DELETE, ICON_IMAGE } from '../../constants/icons.js';
|
||||
import { ParentMessage } from '../../constants/messages.js';
|
||||
import { postToParent } from '../../utils/parent-frame.js';
|
||||
import { clearSelectedElements } from '../../state/multi-select-state.js';
|
||||
import { notifyDraftStateChanged } from '../../api/draft-snapshot.js';
|
||||
import { getEditing } from '../../state/editing-state.js';
|
||||
import { startInlineEdit, commitCurrentEdit } from '../inline-edit/edit-action.js';
|
||||
import { showTextFormatToolbar, hideTextFormatToolbar, getToolbarEl } from '../text-format/toolbar/toolbar.js';
|
||||
|
||||
const DEFAULT_LABELS = {
|
||||
annotationAddComment: 'Add comment',
|
||||
annotationSaveComment: 'Save comment',
|
||||
annotationPlaceholder: 'Tell AI what to change',
|
||||
annotationElementSelected: '{count} element selected',
|
||||
annotationElementsSelected: '{count} elements selected',
|
||||
annotationAttachImage: 'Add images',
|
||||
annotationRemoveImage: 'Remove image',
|
||||
annotationDeleteComment: 'Delete comment',
|
||||
};
|
||||
|
||||
/** Gap (px) between a panel button and the tooltip floating above it. */
|
||||
const BUTTON_TOOLTIP_GAP = 8;
|
||||
|
||||
function label(key, replacements = {}) {
|
||||
const raw = getEditorTranslations()[key] || DEFAULT_LABELS[key] || key;
|
||||
return Object.entries(replacements).reduce(
|
||||
(text, [token, value]) => text.replace(`{${token}}`, value), raw
|
||||
);
|
||||
}
|
||||
|
||||
let _annotationPanelEl = null;
|
||||
let _buttonTooltipEl = null;
|
||||
let _stylesInjected = false;
|
||||
let _panelKeydownHandler = null;
|
||||
let _isAttachmentUploadInProgress = false;
|
||||
// Attachments already stored on the comment when the panel opened, so cancelling
|
||||
// can tell apart images added this session from ones the user saved earlier.
|
||||
let _savedAttachments = [];
|
||||
|
||||
function selectionLabel(elements) {
|
||||
const count = elements.length;
|
||||
const key = count === 1 ? 'annotationElementSelected' : 'annotationElementsSelected';
|
||||
return label(key, { count });
|
||||
}
|
||||
|
||||
function ensureStyles() {
|
||||
if (_stylesInjected) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'annotation-panel-styles';
|
||||
style.textContent = ANNOTATION_PANEL_STYLES;
|
||||
document.head.appendChild(style);
|
||||
_stylesInjected = true;
|
||||
}
|
||||
|
||||
function ensurePanelElement() {
|
||||
if (_annotationPanelEl) return _annotationPanelEl;
|
||||
ensureStyles();
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = ANNOTATION_PANEL_HTML;
|
||||
_annotationPanelEl = wrapper.firstElementChild;
|
||||
document.body.appendChild(_annotationPanelEl);
|
||||
|
||||
const textarea = _annotationPanelEl.querySelector('#selection-mode-annotation-textarea');
|
||||
const commentBtn = _annotationPanelEl.querySelector('#selection-mode-comment-btn');
|
||||
const discardBtn = _annotationPanelEl.querySelector('#selection-mode-discard-btn');
|
||||
const deleteBtn = _annotationPanelEl.querySelector('#selection-mode-delete-btn');
|
||||
const attachImageBtn = _annotationPanelEl.querySelector('#selection-mode-attach-image-btn');
|
||||
|
||||
textarea.placeholder = label('annotationPlaceholder');
|
||||
commentBtn.textContent = label('annotationAddComment');
|
||||
attachImageBtn.setAttribute('aria-label', label('annotationAttachImage'));
|
||||
deleteBtn.setAttribute('aria-label', label('annotationDeleteComment'));
|
||||
bindButtonTooltip(attachImageBtn);
|
||||
bindButtonTooltip(deleteBtn);
|
||||
|
||||
textarea.addEventListener('input', () => {
|
||||
commentBtn.disabled = !textarea.value.trim();
|
||||
});
|
||||
|
||||
commentBtn.addEventListener('click', handleCommentClick);
|
||||
discardBtn.addEventListener('click', handleDiscardClick);
|
||||
deleteBtn.addEventListener('click', handleDeleteClick);
|
||||
attachImageBtn.addEventListener('click', () => {
|
||||
if (attachImageBtn.disabled) return;
|
||||
setAttachmentUploadInProgress(true);
|
||||
postToParent(ParentMessage.ANNOTATION_IMAGE_ATTACH_REQUESTED);
|
||||
});
|
||||
|
||||
isolateEditorUiEvents(_annotationPanelEl);
|
||||
|
||||
return _annotationPanelEl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the editor's hover tooltip centred above a panel button, reusing the
|
||||
* `element-type-tooltip` look. The tooltip is a child of the panel: the panel
|
||||
* already carries the highest z-index the editor has, so a tooltip anywhere
|
||||
* else on the page could never paint over it. Offsets are therefore measured
|
||||
* against the panel's own box rather than the viewport.
|
||||
* @param {HTMLButtonElement} button
|
||||
*/
|
||||
function showButtonTooltip(button) {
|
||||
const text = button.getAttribute('aria-label');
|
||||
if (!text || !_annotationPanelEl) return;
|
||||
|
||||
if (!_buttonTooltipEl) {
|
||||
_buttonTooltipEl = document.createElement('div');
|
||||
_buttonTooltipEl.className = 'element-type-tooltip selection-mode-button-tooltip';
|
||||
_annotationPanelEl.appendChild(_buttonTooltipEl);
|
||||
}
|
||||
|
||||
_buttonTooltipEl.textContent = text;
|
||||
_buttonTooltipEl.classList.add('active');
|
||||
|
||||
const panelRect = _annotationPanelEl.getBoundingClientRect();
|
||||
const buttonRect = button.getBoundingClientRect();
|
||||
const { width, height } = _buttonTooltipEl.getBoundingClientRect();
|
||||
const centeredLeft = buttonRect.left + (buttonRect.width - width) / 2;
|
||||
const clampedLeft = Math.min(
|
||||
Math.max(centeredLeft, TOOLTIP_VIEWPORT_MARGIN),
|
||||
Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerWidth - width - TOOLTIP_VIEWPORT_MARGIN)
|
||||
);
|
||||
|
||||
_buttonTooltipEl.style.left = `${clampedLeft - panelRect.left}px`;
|
||||
_buttonTooltipEl.style.top = `${buttonRect.top - panelRect.top - height - BUTTON_TOOLTIP_GAP}px`;
|
||||
}
|
||||
|
||||
function hideButtonTooltip() {
|
||||
_buttonTooltipEl?.classList.remove('active');
|
||||
}
|
||||
|
||||
function bindButtonTooltip(button) {
|
||||
button.addEventListener('mouseenter', () => {
|
||||
if (button.disabled) return;
|
||||
showButtonTooltip(button);
|
||||
});
|
||||
button.addEventListener('mouseleave', hideButtonTooltip);
|
||||
button.addEventListener('blur', hideButtonTooltip);
|
||||
button.addEventListener('click', hideButtonTooltip);
|
||||
}
|
||||
|
||||
function setAttachmentUploadInProgress(isUploading) {
|
||||
_isAttachmentUploadInProgress = Boolean(isUploading);
|
||||
updateAttachImageButtonState();
|
||||
}
|
||||
|
||||
function releaseAttachments(attachments) {
|
||||
attachments.forEach(({ url }) => postToParent(ParentMessage.ANNOTATION_IMAGE_REMOVED, { url }));
|
||||
}
|
||||
|
||||
function attachmentsNotIn(attachments, others) {
|
||||
const urls = new Set(others.map(({ url }) => url));
|
||||
return attachments.filter(({ url }) => !urls.has(url));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds attachments the parent frame finished uploading to the open panel.
|
||||
* Clears the paperclip loading state started when attach was requested.
|
||||
* @param {import('../../state/annotation-state.js').AnnotationAttachment[]} attachments
|
||||
*/
|
||||
export function addPendingAttachments(attachments) {
|
||||
setAttachmentUploadInProgress(false);
|
||||
|
||||
if (!attachments?.length) return;
|
||||
|
||||
if (!_annotationPanelEl?.classList.contains('active')) {
|
||||
releaseAttachments(attachments);
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingAttachments([...getPendingAttachments(), ...attachments]);
|
||||
renderAttachments();
|
||||
}
|
||||
|
||||
function removePendingAttachment(url) {
|
||||
const removed = getPendingAttachments().filter(attachment => attachment.url === url);
|
||||
setPendingAttachments(getPendingAttachments().filter(attachment => attachment.url !== url));
|
||||
renderAttachments();
|
||||
|
||||
// An image already stored on the comment is only released once Save confirms
|
||||
// the removal, so cancelling the panel can bring it back.
|
||||
if (!_savedAttachments.some(attachment => attachment.url === url)) {
|
||||
releaseAttachments(removed);
|
||||
}
|
||||
}
|
||||
|
||||
function getVisualEditorAttachmentCount() {
|
||||
const editing = getEditingComment();
|
||||
const savedAttachmentsCount = getComments().reduce((total, comment) => {
|
||||
if (comment === editing) {
|
||||
return total;
|
||||
}
|
||||
|
||||
return total + (comment.attachments?.length ?? 0);
|
||||
}, 0);
|
||||
|
||||
return savedAttachmentsCount + getPendingAttachments().length;
|
||||
}
|
||||
|
||||
function updateAttachImageButtonState() {
|
||||
const attachImageBtn = _annotationPanelEl?.querySelector('#selection-mode-attach-image-btn');
|
||||
if (!attachImageBtn) return;
|
||||
|
||||
const isAtLimit = getVisualEditorAttachmentCount() >= MAX_ANNOTATION_ATTACHMENTS;
|
||||
const isUploading = _isAttachmentUploadInProgress;
|
||||
|
||||
attachImageBtn.disabled = isAtLimit || isUploading;
|
||||
attachImageBtn.classList.toggle('is-loading', isUploading);
|
||||
attachImageBtn.innerHTML = isUploading
|
||||
? '<span class="selection-mode-attach-spinner" aria-hidden="true"></span>'
|
||||
: ICON_IMAGE;
|
||||
if (attachImageBtn.disabled) hideButtonTooltip();
|
||||
}
|
||||
|
||||
function renderAttachments() {
|
||||
const container = _annotationPanelEl?.querySelector('#selection-mode-annotation-attachments');
|
||||
if (!container) return;
|
||||
|
||||
const attachments = getPendingAttachments();
|
||||
container.textContent = '';
|
||||
container.classList.toggle('active', attachments.length > 0);
|
||||
|
||||
attachments.forEach(({ url, fileName }) => {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'selection-mode-annotation-attachment';
|
||||
|
||||
const thumbnail = document.createElement('img');
|
||||
thumbnail.className = 'selection-mode-annotation-attachment-thumb';
|
||||
thumbnail.src = url;
|
||||
thumbnail.alt = fileName;
|
||||
thumbnail.title = fileName;
|
||||
thumbnail.loading = 'lazy';
|
||||
thumbnail.decoding = 'async';
|
||||
thumbnail.addEventListener('click', () => {
|
||||
const allAttachments = getPendingAttachments().map(({ url: attachmentUrl, fileName: attachmentName }) => ({
|
||||
url: attachmentUrl,
|
||||
fileName: attachmentName,
|
||||
}));
|
||||
postToParent(ParentMessage.ANNOTATION_IMAGE_PREVIEW_REQUESTED, { url, attachments: allAttachments });
|
||||
});
|
||||
|
||||
const removeBtn = document.createElement('button');
|
||||
removeBtn.className = 'selection-mode-annotation-attachment-remove';
|
||||
removeBtn.title = label('annotationRemoveImage');
|
||||
removeBtn.innerHTML = ICON_DELETE;
|
||||
removeBtn.addEventListener('click', () => removePendingAttachment(url));
|
||||
|
||||
chip.append(thumbnail, removeBtn);
|
||||
container.appendChild(chip);
|
||||
});
|
||||
|
||||
updateAttachImageButtonState();
|
||||
}
|
||||
|
||||
function handleCommentClick() {
|
||||
const textarea = _annotationPanelEl.querySelector('#selection-mode-annotation-textarea');
|
||||
const text = textarea.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
if (getPanelMode() === 'edit') {
|
||||
const editing = getEditingComment();
|
||||
if (editing) {
|
||||
editing.text = text;
|
||||
editing.attachments = getPendingAttachments();
|
||||
notifyDraftStateChanged();
|
||||
}
|
||||
hideAnnotationPanel({ commitPendingAttachments: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = getPendingElements();
|
||||
const selections = getPendingSelections();
|
||||
const clickX = getAnnotationClickX();
|
||||
const clickY = getAnnotationClickY();
|
||||
|
||||
const comment = {
|
||||
elements,
|
||||
selections,
|
||||
text,
|
||||
attachments: getPendingAttachments(),
|
||||
clickX,
|
||||
clickY,
|
||||
fixed: elements.length ? isInFixedContext(elements[0]) : false,
|
||||
};
|
||||
setComments([...getComments(), comment]);
|
||||
addAnnotationMarker(comment, openPanelForEdit);
|
||||
notifyDraftStateChanged();
|
||||
hideAnnotationPanel({ commitPendingAttachments: true });
|
||||
}
|
||||
|
||||
function handleDiscardClick() {
|
||||
hideAnnotationPanel();
|
||||
}
|
||||
|
||||
function handleDeleteClick() {
|
||||
const editing = getEditingComment();
|
||||
if (!editing) return;
|
||||
|
||||
releaseAttachments(editing.attachments ?? []);
|
||||
|
||||
removeMarker(editing);
|
||||
setComments(getComments().filter(c => c !== editing));
|
||||
notifyDraftStateChanged();
|
||||
hideAnnotationPanel();
|
||||
}
|
||||
|
||||
function positionPanel(clickX, clickY, elements, anchorTop = clickY) {
|
||||
const panel = _annotationPanelEl;
|
||||
const fixed = elements.length ? isInFixedContext(elements[0]) : false;
|
||||
panel.style.position = fixed ? 'fixed' : 'absolute';
|
||||
|
||||
const scrollX = fixed ? 0 : window.scrollX;
|
||||
const scrollY = fixed ? 0 : window.scrollY;
|
||||
|
||||
let left = clickX;
|
||||
let top = clickY + PANEL_GAP;
|
||||
|
||||
if (left + ANNOTATION_PANEL_WIDTH > window.innerWidth - PANEL_MARGIN) {
|
||||
left = window.innerWidth - ANNOTATION_PANEL_WIDTH - PANEL_MARGIN;
|
||||
}
|
||||
if (left < PANEL_MARGIN) left = PANEL_MARGIN;
|
||||
|
||||
if (top + ANNOTATION_PANEL_ESTIMATED_HEIGHT > window.innerHeight - PARENT_TOOLBAR_HEIGHT - PANEL_MARGIN) {
|
||||
// anchorTop is the anchor's top edge (equals clickY for a plain click
|
||||
// point, but the toolbar's top when anchored to it) so the panel
|
||||
// flips fully above the anchor instead of overlapping its bottom half.
|
||||
top = anchorTop - ANNOTATION_PANEL_ESTIMATED_HEIGHT - PANEL_GAP;
|
||||
}
|
||||
if (top < PANEL_MARGIN) top = PANEL_MARGIN;
|
||||
|
||||
panel.style.left = `${left + scrollX}px`;
|
||||
panel.style.top = `${top + scrollY}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the panel in create mode for a group of one or more elements.
|
||||
* @param {HTMLElement[]} elements
|
||||
* @param {object[]} selections - `captureElementMetadata` output, one per element.
|
||||
*/
|
||||
export function showAnnotationPanel(elements, selections, clickX, clickY, anchorTop = clickY) {
|
||||
const panel = ensurePanelElement();
|
||||
|
||||
setPendingElements(elements);
|
||||
setPendingSelections(selections);
|
||||
setPendingAttachments([]);
|
||||
_savedAttachments = [];
|
||||
setAnnotationClickX(clickX);
|
||||
setAnnotationClickY(clickY);
|
||||
setPanelMode('create');
|
||||
setEditingComment(null);
|
||||
|
||||
const textarea = panel.querySelector('#selection-mode-annotation-textarea');
|
||||
const commentBtn = panel.querySelector('#selection-mode-comment-btn');
|
||||
const deleteWrapper = panel.querySelector('#selection-mode-delete-wrapper');
|
||||
const countEl = panel.querySelector('#selection-mode-annotation-count');
|
||||
|
||||
textarea.value = '';
|
||||
commentBtn.disabled = true;
|
||||
commentBtn.textContent = label('annotationAddComment');
|
||||
deleteWrapper.style.display = 'none';
|
||||
countEl.textContent = selectionLabel(elements);
|
||||
renderAttachments();
|
||||
|
||||
lockHoverOutline(elements);
|
||||
positionPanel(clickX, clickY, elements, anchorTop);
|
||||
panel.classList.add('active');
|
||||
textarea.focus();
|
||||
|
||||
attachPanelKeydown();
|
||||
}
|
||||
|
||||
export function openPanelForEdit(comment, clickX, clickY) {
|
||||
const panel = ensurePanelElement();
|
||||
const elements = comment.elements;
|
||||
const single = elements.length === 1 ? elements[0] : null;
|
||||
|
||||
const currentEditing = getEditing();
|
||||
if (currentEditing && currentEditing.targetElement !== single) {
|
||||
hideTextFormatToolbar();
|
||||
commitCurrentEdit();
|
||||
}
|
||||
|
||||
const elementType = single ? getElementType(single) : null;
|
||||
const openToolbar = !!single
|
||||
&& (elementType === 'text' || elementType === 'button')
|
||||
&& (single.hasAttribute('data-edit-id') || single.hasAttribute('data-edit-assisted-id'));
|
||||
|
||||
setPanelMode('edit');
|
||||
setEditingComment(comment);
|
||||
setPendingElements(elements);
|
||||
setPendingSelections(comment.selections);
|
||||
setPendingAttachments(comment.attachments ? [...comment.attachments] : []);
|
||||
_savedAttachments = comment.attachments ? [...comment.attachments] : [];
|
||||
setAnnotationClickX(clickX);
|
||||
setAnnotationClickY(clickY);
|
||||
|
||||
const textarea = panel.querySelector('#selection-mode-annotation-textarea');
|
||||
const commentBtn = panel.querySelector('#selection-mode-comment-btn');
|
||||
const deleteWrapper = panel.querySelector('#selection-mode-delete-wrapper');
|
||||
const countEl = panel.querySelector('#selection-mode-annotation-count');
|
||||
|
||||
countEl.textContent = selectionLabel(elements);
|
||||
textarea.value = comment.text;
|
||||
commentBtn.disabled = !comment.text.trim();
|
||||
commentBtn.textContent = label('annotationSaveComment');
|
||||
deleteWrapper.style.display = 'flex';
|
||||
renderAttachments();
|
||||
|
||||
let anchorTop = clickY;
|
||||
if (openToolbar) {
|
||||
if (!getEditing() || getEditing().targetElement !== single) {
|
||||
const editId = single.getAttribute('data-edit-id') || single.getAttribute('data-edit-assisted-id');
|
||||
if (editId) {
|
||||
startInlineEdit(single, editId, single.innerHTML || "", { focusTarget: false });
|
||||
}
|
||||
}
|
||||
showTextFormatToolbar(single);
|
||||
const toolbarRect = getToolbarEl()?.getBoundingClientRect();
|
||||
if (toolbarRect) {
|
||||
clickX = toolbarRect.left;
|
||||
clickY = toolbarRect.bottom;
|
||||
anchorTop = toolbarRect.top;
|
||||
}
|
||||
}
|
||||
|
||||
panel.classList.add('active');
|
||||
lockHoverOutline(elements);
|
||||
unhighlightAllMarkers();
|
||||
if (comment.marker) comment.marker.classList.add('highlighted');
|
||||
positionPanel(clickX, clickY, elements, anchorTop);
|
||||
|
||||
textarea.focus();
|
||||
attachPanelKeydown();
|
||||
}
|
||||
|
||||
/**
|
||||
* `restoreFocus: false` skips re-focusing the inline edit, whose `selectionchange`
|
||||
* would close a panel the caller opens next; `keepSelection` keeps the group.
|
||||
* `commitPendingAttachments` keeps the images the caller just saved onto a comment
|
||||
* and releases the ones it dropped; without it the panel is cancelling, so images
|
||||
* added during this session are released and previously saved ones are kept.
|
||||
* @param {{ restoreFocus?: boolean, keepSelection?: boolean, commitPendingAttachments?: boolean }} [options]
|
||||
*/
|
||||
export function hideAnnotationPanel({ restoreFocus = true, keepSelection = false, commitPendingAttachments = false } = {}) {
|
||||
if (!_annotationPanelEl?.classList.contains('active')) return;
|
||||
|
||||
releaseAttachments(commitPendingAttachments
|
||||
? attachmentsNotIn(_savedAttachments, getPendingAttachments())
|
||||
: attachmentsNotIn(getPendingAttachments(), _savedAttachments));
|
||||
|
||||
_annotationPanelEl.classList.remove('active');
|
||||
hideButtonTooltip();
|
||||
setAttachmentUploadInProgress(false);
|
||||
|
||||
setPendingElements([]);
|
||||
setPendingSelections([]);
|
||||
setPendingAttachments([]);
|
||||
_savedAttachments = [];
|
||||
setEditingComment(null);
|
||||
setPanelMode('create');
|
||||
if (!keepSelection) clearSelectedElements();
|
||||
|
||||
unlockHoverOutline();
|
||||
unhighlightAllMarkers();
|
||||
|
||||
const editing = getEditing();
|
||||
if (restoreFocus && editing?.targetElement?.hasAttribute('contenteditable')) {
|
||||
editing.targetElement.focus({ preventScroll: true });
|
||||
const selection = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editing.targetElement);
|
||||
range.collapse(false);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
detachPanelKeydown();
|
||||
}
|
||||
|
||||
export function getAnnotationPanelEl() {
|
||||
return _annotationPanelEl;
|
||||
}
|
||||
|
||||
|
||||
function attachPanelKeydown() {
|
||||
detachPanelKeydown();
|
||||
_panelKeydownHandler = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
hideAnnotationPanel();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', _panelKeydownHandler, true);
|
||||
}
|
||||
|
||||
function detachPanelKeydown() {
|
||||
if (_panelKeydownHandler) {
|
||||
window.removeEventListener('keydown', _panelKeydownHandler, true);
|
||||
_panelKeydownHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the panel for one element or a group, resuming the existing comment when
|
||||
* the same target is already annotated and capturing fresh metadata otherwise.
|
||||
* @param {HTMLElement|HTMLElement[]} target
|
||||
* @param {number} clickX
|
||||
* @param {number} clickY
|
||||
* @param {number} [anchorTop] - Top edge of the anchor (e.g. a toolbar) used
|
||||
* when flipping the panel above; defaults to clickY for a plain click point.
|
||||
*/
|
||||
export function openOrCreateAnnotation(target, clickX, clickY, anchorTop = clickY) {
|
||||
const elements = (Array.isArray(target) ? target : [target]).filter(Boolean);
|
||||
if (!elements.length) return;
|
||||
|
||||
const existing = getComments().find(comment => comment.elements.length === elements.length
|
||||
&& comment.elements.every(element => elements.includes(element)));
|
||||
|
||||
if (existing) {
|
||||
openPanelForEdit(existing, existing.clickX, existing.clickY);
|
||||
} else {
|
||||
showAnnotationPanel(elements, elements.map(captureElementMetadata), clickX, clickY, anchorTop);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { ANNOTATION_COLOR, PANEL_BG, PANEL_INPUT_BG, BORDER_COLOR, HOVER_BG, TEXT_MUTED, COLOR_WHITE, BUTTON_LIGHT_TEXT, BUTTON_LIGHT_BG_HOVER, BOX_SHADOW_TOOLBAR, SCROLLBAR_THUMB, SCROLLBAR_THUMB_HOVER, Z_INDEX_EDITOR_PANEL, Z_INDEX_EDITOR_MARKER } from '../../constants/theme.js';
|
||||
|
||||
export const ANNOTATION_PANEL_STYLES = `
|
||||
#selection-mode-annotation-panel {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
position: fixed;
|
||||
background: ${PANEL_BG};
|
||||
border-radius: 16px;
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
padding: 8px;
|
||||
width: 320px;
|
||||
box-shadow: ${BOX_SHADOW_TOOLBAR};
|
||||
pointer-events: all;
|
||||
box-sizing: border-box;
|
||||
font-family: DM Sans, sans-serif;
|
||||
z-index: ${Z_INDEX_EDITOR_PANEL};
|
||||
|
||||
&.active {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
#selection-mode-annotation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
#selection-mode-annotation-count {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
color: ${TEXT_MUTED};
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 400;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#selection-mode-annotation-input-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${PANEL_INPUT_BG};
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
#selection-mode-annotation-textarea {
|
||||
width: 100%;
|
||||
min-height: 88px;
|
||||
max-height: 160px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0 4px;
|
||||
color: ${COLOR_WHITE};
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
resize: none;
|
||||
box-sizing: border-box;
|
||||
caret-color: ${COLOR_WHITE};
|
||||
line-height: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
#selection-mode-annotation-textarea::placeholder {
|
||||
color: ${TEXT_MUTED};
|
||||
}
|
||||
|
||||
#selection-mode-annotation-buttons {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Mirrors the chat file previews: chips scroll sideways instead of stacking into rows. */
|
||||
#selection-mode-annotation-attachments {
|
||||
display: none;
|
||||
overflow-x: auto;
|
||||
padding-top: 4px;
|
||||
margin-top: -4px;
|
||||
margin-bottom: 16px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: ${SCROLLBAR_THUMB} transparent;
|
||||
|
||||
&.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: ${SCROLLBAR_THUMB};
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: ${SCROLLBAR_THUMB_HOVER};
|
||||
}
|
||||
}
|
||||
|
||||
.selection-mode-annotation-attachment {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.selection-mode-annotation-attachment-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.selection-mode-annotation-attachment-remove {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: 8px;
|
||||
background: ${PANEL_BG};
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
color: ${COLOR_WHITE};
|
||||
|
||||
& svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${BORDER_COLOR};
|
||||
}
|
||||
}
|
||||
|
||||
#selection-mode-annotation-actions,
|
||||
#selection-mode-annotation-primary-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Overrides the hover tooltip's fixed positioning; the id keeps this winning
|
||||
no matter which of the two stylesheets is injected last. */
|
||||
#selection-mode-annotation-panel .selection-mode-button-tooltip {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
#selection-mode-discard-btn,
|
||||
#selection-mode-attach-image-btn,
|
||||
#selection-mode-delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#selection-mode-discard-btn:hover,
|
||||
#selection-mode-attach-image-btn:hover:not(:disabled),
|
||||
#selection-mode-delete-btn:hover {
|
||||
background: ${HOVER_BG};
|
||||
}
|
||||
|
||||
#selection-mode-attach-image-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
#selection-mode-attach-image-btn.is-loading:disabled {
|
||||
opacity: 1;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* Mirrors horizons-frontend Spinner.vue (w-4 border-1 border-light border-t-transparent, 2s spin). */
|
||||
.selection-mode-attach-spinner {
|
||||
box-sizing: border-box;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid ${COLOR_WHITE};
|
||||
border-top-color: transparent;
|
||||
animation: selection-mode-attach-spin 2s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes selection-mode-attach-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
#selection-mode-delete-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
#selection-mode-comment-btn {
|
||||
background: ${COLOR_WHITE};
|
||||
border: none;
|
||||
color: ${BUTTON_LIGHT_TEXT};
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#selection-mode-comment-btn:hover {
|
||||
background: ${BUTTON_LIGHT_BG_HOVER};
|
||||
}
|
||||
|
||||
#selection-mode-comment-btn:disabled {
|
||||
opacity: 0.2;
|
||||
cursor: not-allowed;
|
||||
background: ${COLOR_WHITE};
|
||||
}
|
||||
`;
|
||||
|
||||
export const ANNOTATION_MARKER_STYLES = `
|
||||
.selection-mode-annotation-marker > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.selection-mode-annotation-marker {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: ${ANNOTATION_COLOR};
|
||||
border: 1px solid ${COLOR_WHITE}25;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transform: translate(-50%, -50%);
|
||||
transition: transform 0.15s ease;
|
||||
z-index: ${Z_INDEX_EDITOR_MARKER};
|
||||
}
|
||||
|
||||
.selection-mode-annotation-marker:hover,
|
||||
.selection-mode-annotation-marker.highlighted {
|
||||
transform: translate(-50%, -50%) scale(1.1);
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ICON_DELETE, ICON_CLOSE, ICON_IMAGE } from '../../constants/icons.js';
|
||||
|
||||
export const ANNOTATION_PANEL_HTML = `
|
||||
<div id="selection-mode-annotation-panel">
|
||||
<div id="selection-mode-annotation-header">
|
||||
<span id="selection-mode-annotation-count"></span>
|
||||
<button id="selection-mode-discard-btn" title="Close">${ICON_CLOSE}</button>
|
||||
</div>
|
||||
<div id="selection-mode-annotation-input-wrapper">
|
||||
<div id="selection-mode-annotation-attachments"></div>
|
||||
<textarea id="selection-mode-annotation-textarea" placeholder="Tell AI what to change" rows="1"></textarea>
|
||||
</div>
|
||||
<div id="selection-mode-annotation-buttons">
|
||||
<div id="selection-mode-annotation-actions">
|
||||
<button id="selection-mode-attach-image-btn" aria-label="Add images">${ICON_IMAGE}</button>
|
||||
</div>
|
||||
<div id="selection-mode-annotation-primary-actions">
|
||||
<div id="selection-mode-delete-wrapper">
|
||||
<button id="selection-mode-delete-btn" aria-label="Delete comment">${ICON_DELETE}</button>
|
||||
</div>
|
||||
<button id="selection-mode-comment-btn" disabled>Add comment</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { getEditing } from "../../state/editing-state.js";
|
||||
import { stopFontSizeEdit, updateTextFormatToolbar, scheduleRepositionEditOverlays } from "../text-format/toolbar/toolbar.js";
|
||||
import { hideAllPanels } from "../../state/panel-state.js";
|
||||
import { resolveInlineStylesAtRange } from "../../utils/inline-style-state.js";
|
||||
import { EDITOR_UI_SELECTOR } from "../../constants/selectors.js";
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} element
|
||||
* @param {HTMLElement} container
|
||||
* @returns {{ value: string, isGradient: boolean }}
|
||||
*/
|
||||
function resolveTextColor(element, container) {
|
||||
const color = getComputedStyle(element).color;
|
||||
|
||||
if (color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent') {
|
||||
return { value: color, isGradient: false };
|
||||
}
|
||||
|
||||
let current = element;
|
||||
while (current && current !== container.parentElement) {
|
||||
const computedStyle = getComputedStyle(current);
|
||||
const backgroundImage = computedStyle.backgroundImage;
|
||||
const backgroundClip = computedStyle.backgroundClip || computedStyle['webkitBackgroundClip'];
|
||||
|
||||
if (backgroundImage && backgroundImage !== 'none' && backgroundImage.includes('gradient') && backgroundClip === 'text') {
|
||||
return { value: backgroundImage, isGradient: true };
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
|
||||
return { value: color, isGradient: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves caret/selection presentation state for the formatting toolbar.
|
||||
* Bold, italic, and underline are detected independently via ancestor walks
|
||||
* so nested markup (`<em><u><strong>…</strong></u></em>`) reports all active styles.
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function getStylesAtCursor() {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
let node = range.startContainer;
|
||||
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const child = node.childNodes[range.startOffset];
|
||||
if (child) node = child;
|
||||
}
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
node = node.parentElement;
|
||||
}
|
||||
|
||||
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
|
||||
node = getEditing()?.targetElement;
|
||||
}
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const container = getEditing()?.targetElement || node;
|
||||
const presentation = resolveInlineStylesAtRange(range, container);
|
||||
const styles = getComputedStyle(node);
|
||||
const containerStyles = getComputedStyle(container);
|
||||
const resolvedColor = resolveTextColor(node, container);
|
||||
|
||||
return {
|
||||
color: resolvedColor.value,
|
||||
isGradient: resolvedColor.isGradient,
|
||||
fontFamily: styles.fontFamily,
|
||||
fontSize: styles.fontSize,
|
||||
textAlign: containerStyles.textAlign,
|
||||
...presentation,
|
||||
};
|
||||
}
|
||||
|
||||
/** Updates toolbar and parent when the selection moves during inline edit. */
|
||||
export function onSelectionChange(force = false) {
|
||||
const editing = getEditing();
|
||||
if (!editing?.targetElement?.hasAttribute("contenteditable")) return;
|
||||
if (!force && document.activeElement?.closest(EDITOR_UI_SELECTOR)) return;
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.rangeCount > 0 && !editing.targetElement.contains(selection.getRangeAt(0).commonAncestorContainer)) {
|
||||
return;
|
||||
}
|
||||
stopFontSizeEdit();
|
||||
hideAllPanels();
|
||||
const styles = getStylesAtCursor();
|
||||
if (!styles) return;
|
||||
updateTextFormatToolbar(styles);
|
||||
scheduleRepositionEditOverlays();
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import { getEditing, setEditing, clearEditing } from "../../state/editing-state.js";
|
||||
import { normalizeContentEditableHtml, patchRemoveChild, serializeContentEditableHtml } from "../../utils/html-utils.js";
|
||||
import { postToParent } from "../../utils/parent-frame.js";
|
||||
import { ParentMessage } from "../../constants/messages.js";
|
||||
import { beginEditSession, recordEdit } from "../../state/history-state.js";
|
||||
import { notifyDraftStateChanged } from "../../api/draft-snapshot.js";
|
||||
import { showTextFormatToolbar, hideTextFormatToolbar, scheduleRepositionEditOverlays } from "../text-format/toolbar/toolbar.js";
|
||||
import { onSelectionChange } from "./cursor-styles.js";
|
||||
import { captureElementMetadata } from "../../utils/selection-mode-metadata.js";
|
||||
|
||||
|
||||
/**
|
||||
* Converts a CSS property name to its camelCase JS form
|
||||
* (e.g. `background-clip` → `backgroundClip`), matching the keys used by the
|
||||
* JSX style objects in the source write-back.
|
||||
*/
|
||||
function cssPropertyToCamelCase(cssProperty) {
|
||||
return cssProperty.replace(/^-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||
}
|
||||
|
||||
const TRANSPARENT_TEXT_CARET_COLOR = "#6B7280";
|
||||
|
||||
function isTransparentColor(color) {
|
||||
if (!color || color === "transparent") return true;
|
||||
const match = color.replace(/\s+/g, "").match(/^rgba?\(([^)]+)\)$/i);
|
||||
if (!match) return false;
|
||||
const segments = match[1].split(",");
|
||||
return segments.length === 4 && parseFloat(segments[3]) === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether the element or any descendant renders transparent text
|
||||
* (e.g. gradient text via `background-clip: text` + `color: transparent`),
|
||||
* where the caret would otherwise be invisible.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasTransparentText(element) {
|
||||
if (isTransparentColor(getComputedStyle(element).color)) return true;
|
||||
return Array.from(element.querySelectorAll("*")).some(
|
||||
descendant => isTransparentColor(getComputedStyle(descendant).color)
|
||||
);
|
||||
}
|
||||
|
||||
/** `caret-color: currentColor` is invisible on transparent/gradient text, so use a visible caret there. */
|
||||
function applyEditingCaretColor(element) {
|
||||
if (hasTransparentText(element)) {
|
||||
element.style.caretColor = TRANSPARENT_TEXT_CARET_COLOR;
|
||||
} else {
|
||||
element.style.removeProperty("caret-color");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots every style property set inline on the element itself.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {Record<string, string>} camelCase property → value
|
||||
*/
|
||||
function captureInlineStyles(element) {
|
||||
const styles = {};
|
||||
for (let index = 0; index < element.style.length; index++) {
|
||||
const cssProperty = element.style.item(index);
|
||||
styles[cssPropertyToCamelCase(cssProperty)] = element.style.getPropertyValue(cssProperty);
|
||||
}
|
||||
return styles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diffs the element's current inline styles against a snapshot, covering any
|
||||
* panel action that styles the element itself ('' marks a removed property).
|
||||
* `textAlign` is excluded here — it is compared against computed style instead,
|
||||
* so re-applying an alignment inherited from CSS is not logged as a change.
|
||||
* @param {HTMLElement} element
|
||||
* @param {Record<string, string>} original
|
||||
* @returns {{ changed: Record<string, string>, previous: Record<string, string> }|null}
|
||||
*/
|
||||
function getInlineStyleDiff(element, original) {
|
||||
const current = captureInlineStyles(element);
|
||||
const changed = {};
|
||||
const previous = {};
|
||||
for (const key of new Set([...Object.keys(current), ...Object.keys(original)])) {
|
||||
if (key === 'textAlign') continue;
|
||||
// Transient editing caret-color must never be written back to source.
|
||||
if (key === 'caretColor') continue;
|
||||
if ((current[key] ?? '') !== (original[key] ?? '')) {
|
||||
changed[key] = current[key] ?? '';
|
||||
previous[key] = original[key] ?? '';
|
||||
}
|
||||
}
|
||||
return Object.keys(changed).length > 0 ? { changed, previous } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes element-level style changes (alignment plus any other inline style
|
||||
* the panel set on the element) since the edit session's last snapshot.
|
||||
* @param {object} editing - Current editing state.
|
||||
* @returns {{ style: object|null }}
|
||||
*/
|
||||
function buildStyleDiff(editing) {
|
||||
const { targetElement, originalTextAlign, originalInlineTextAlign, originalInlineStyles } = editing;
|
||||
const alignChanged = getComputedStyle(targetElement).textAlign !== originalTextAlign;
|
||||
const inlineDiff = getInlineStyleDiff(targetElement, originalInlineStyles);
|
||||
if (!alignChanged && !inlineDiff) return { style: null, oldStyle: null };
|
||||
|
||||
const style = {};
|
||||
const oldStyle = {};
|
||||
if (alignChanged) {
|
||||
style.textAlign = targetElement.style.textAlign;
|
||||
// Keep previous inline value around so re-applying the inherited default still serializes.
|
||||
if (!style.textAlign) style.textAlign = originalInlineTextAlign ?? '';
|
||||
oldStyle.textAlign = originalInlineTextAlign ?? '';
|
||||
}
|
||||
if (inlineDiff) {
|
||||
Object.assign(style, inlineDiff.changed);
|
||||
Object.assign(oldStyle, inlineDiff.previous);
|
||||
}
|
||||
return { style, oldStyle };
|
||||
}
|
||||
|
||||
/** Re-snapshots the editing state's style baselines after a soft-save. */
|
||||
function refreshStyleSnapshots(editing) {
|
||||
const { targetElement } = editing;
|
||||
editing.originalTextAlign = getComputedStyle(targetElement).textAlign;
|
||||
editing.originalInlineTextAlign = targetElement.style.textAlign || '';
|
||||
editing.originalInlineStyles = captureInlineStyles(targetElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots child nodes so DOM mutations during edit can be restored if needed.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {Array<{ node: Node, value: string|null }>}
|
||||
*/
|
||||
export function saveChildNodes(element) {
|
||||
const saved = [];
|
||||
for (const child of element.childNodes) {
|
||||
saved.push({
|
||||
node: child,
|
||||
value: child.nodeType === Node.TEXT_NODE ? child.nodeValue : null,
|
||||
});
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places a collapsed caret at the end of the editable container's contents.
|
||||
* @param {HTMLElement} container
|
||||
*/
|
||||
export function placeCursorAtEnd(container) {
|
||||
if (!container.childNodes.length) {
|
||||
container.appendChild(document.createTextNode(''));
|
||||
}
|
||||
|
||||
let target = container;
|
||||
while (target.lastChild) {
|
||||
target = target.lastChild;
|
||||
}
|
||||
|
||||
const range = document.createRange();
|
||||
if (target.nodeType === Node.TEXT_NODE) {
|
||||
range.setStart(target, target.textContent.length);
|
||||
} else {
|
||||
range.selectNodeContents(target);
|
||||
}
|
||||
range.collapse(false);
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Places the text caret at viewport coordinates, clamped to the editable container.
|
||||
* @param {number} x
|
||||
* @param {number} y
|
||||
* @param {HTMLElement} [container]
|
||||
*/
|
||||
export function placeCursorAtPoint(x, y, container) {
|
||||
let range = null;
|
||||
if (document.caretPositionFromPoint) {
|
||||
const position = document.caretPositionFromPoint(x, y);
|
||||
if (position) {
|
||||
range = document.createRange();
|
||||
range.setStart(position.offsetNode, position.offset);
|
||||
range.collapse(true);
|
||||
}
|
||||
} else if (document.caretRangeFromPoint) {
|
||||
range = document.caretRangeFromPoint(x, y);
|
||||
}
|
||||
|
||||
// If the caret resolved outside our editable target (e.g. an absolute overlay
|
||||
// sat on top of the click point), fall back to placing it at the end of the
|
||||
// target so typing actually goes into the contenteditable.
|
||||
if (container && (!range || !container.contains(range.startContainer))) {
|
||||
range = document.createRange();
|
||||
range.selectNodeContents(container);
|
||||
range.collapse(false);
|
||||
}
|
||||
|
||||
if (!range) return;
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enables contenteditable on the target and wires edit listeners and toolbar.
|
||||
* @param {HTMLElement} targetElement
|
||||
* @param {string} editId
|
||||
* @param {string} currentContent - Initial HTML content.
|
||||
* @param {{ focusTarget?: boolean }} [options] - Pass `focusTarget: false` when the caller focuses its own UI instead.
|
||||
*/
|
||||
export function startInlineEdit(targetElement, editId, currentContent, { focusTarget = true } = {}) {
|
||||
if (getEditing()?.targetElement === targetElement) return;
|
||||
|
||||
const savedChildren = saveChildNodes(targetElement);
|
||||
const normalizedOriginal = normalizeContentEditableHtml(currentContent);
|
||||
const sessionId = beginEditSession();
|
||||
const selectionMode = captureElementMetadata(targetElement);
|
||||
setEditing({
|
||||
editId,
|
||||
targetElement,
|
||||
sessionId,
|
||||
originalContent: normalizedOriginal,
|
||||
originalTextAlign: getComputedStyle(targetElement).textAlign,
|
||||
originalInlineTextAlign: targetElement.style.textAlign || '',
|
||||
originalInlineStyles: captureInlineStyles(targetElement),
|
||||
savedChildren,
|
||||
selectionMode,
|
||||
});
|
||||
document.getElementById("root")?.setAttribute("data-inline-editing", "true");
|
||||
targetElement.setAttribute("data-editing", "true");
|
||||
patchRemoveChild(targetElement);
|
||||
|
||||
targetElement.setAttribute("contenteditable", "true");
|
||||
applyEditingCaretColor(targetElement);
|
||||
|
||||
if (focusTarget) {
|
||||
targetElement.focus();
|
||||
placeCursorAtEnd(targetElement);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (getEditing()?.targetElement !== targetElement) return;
|
||||
placeCursorAtEnd(targetElement);
|
||||
targetElement.focus();
|
||||
});
|
||||
}
|
||||
|
||||
postToParent(ParentMessage.EDIT_ENTER, {
|
||||
currentText: currentContent,
|
||||
elementType: targetElement.tagName.toLowerCase(),
|
||||
isDirectlyEditable: targetElement.hasAttribute('data-edit-id'),
|
||||
});
|
||||
showTextFormatToolbar(targetElement);
|
||||
targetElement.addEventListener("keydown", onInlineEditKeyDown);
|
||||
targetElement.addEventListener("beforeinput", onInlineEditBeforeInput);
|
||||
targetElement.addEventListener("input", onInlineEditInput);
|
||||
targetElement.addEventListener("tft-color-commit", saveCurrentEdit);
|
||||
document.addEventListener("selectionchange", onSelectionChange);
|
||||
setTimeout(onSelectionChange, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-saves the current edit without tearing down contenteditable (e.g. color commit),
|
||||
* recording it into the in-iframe edit history so it can be undone/redone and
|
||||
* later batch-persisted on draft save.
|
||||
*/
|
||||
export function saveCurrentEdit() {
|
||||
const editing = getEditing();
|
||||
if (!editing) return;
|
||||
|
||||
const { editId, targetElement, originalContent, sessionId, selectionMode } = editing;
|
||||
const newHTML = serializeContentEditableHtml(targetElement);
|
||||
const { style, oldStyle } = buildStyleDiff(editing);
|
||||
const isAssisted = targetElement.hasAttribute('data-edit-assisted-id');
|
||||
|
||||
if (newHTML !== originalContent || style) {
|
||||
recordEdit(editId, { beforeContent: originalContent, afterContent: newHTML }, { style, oldStyle, element: targetElement, sessionId, isAssisted, selectionMode });
|
||||
notifyDraftStateChanged();
|
||||
editing.originalContent = newHTML;
|
||||
editing.hasSoftSaved = true;
|
||||
refreshStyleSnapshots(editing);
|
||||
}
|
||||
}
|
||||
|
||||
/** Finalizes the active inline edit, records it into history, and clears editing state. */
|
||||
export function commitCurrentEdit() {
|
||||
const editing = getEditing();
|
||||
if (!editing) return;
|
||||
|
||||
const { editId, targetElement, originalContent, sessionId, selectionMode } = editing;
|
||||
const newHTML = serializeContentEditableHtml(targetElement);
|
||||
const { style, oldStyle } = buildStyleDiff(editing);
|
||||
const isAssisted = targetElement.hasAttribute('data-edit-assisted-id');
|
||||
|
||||
targetElement.removeAttribute("contenteditable");
|
||||
targetElement.removeAttribute("data-editing");
|
||||
targetElement.style.removeProperty("caret-color");
|
||||
document.getElementById("root")?.removeAttribute("data-inline-editing");
|
||||
targetElement.removeEventListener("keydown", onInlineEditKeyDown);
|
||||
targetElement.removeEventListener("beforeinput", onInlineEditBeforeInput);
|
||||
targetElement.removeEventListener("input", onInlineEditInput);
|
||||
targetElement.removeEventListener("tft-color-commit", saveCurrentEdit);
|
||||
document.removeEventListener("selectionchange", onSelectionChange);
|
||||
hideTextFormatToolbar();
|
||||
|
||||
patchRemoveChild(targetElement);
|
||||
targetElement.innerHTML = newHTML;
|
||||
|
||||
const { hasSoftSaved } = editing;
|
||||
|
||||
if (newHTML !== originalContent || style) {
|
||||
recordEdit(editId, { beforeContent: originalContent, afterContent: newHTML }, { style, oldStyle, element: targetElement, sessionId, isAssisted, selectionMode });
|
||||
notifyDraftStateChanged();
|
||||
} else if (!hasSoftSaved) {
|
||||
postToParent(ParentMessage.EDIT_CANCEL, {});
|
||||
}
|
||||
|
||||
clearEditing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Blocks browser replacement-text input during inline edit.
|
||||
* @param {InputEvent} e
|
||||
*/
|
||||
export function onInlineEditBeforeInput(event) {
|
||||
if (event.inputType === "insertReplacementText") {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
scheduleRepositionEditOverlays();
|
||||
}
|
||||
|
||||
/** Repositions the toolbar as the editable content grows/shrinks. */
|
||||
export function onInlineEditInput() {
|
||||
if (!getEditing()) return;
|
||||
saveCurrentEdit();
|
||||
scheduleRepositionEditOverlays();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles Escape/Enter to commit and stops propagation for other keys.
|
||||
* @param {KeyboardEvent} e
|
||||
*/
|
||||
export function onInlineEditKeyDown(event) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
commitCurrentEdit();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
if (event.shiftKey) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
commitCurrentEdit();
|
||||
return;
|
||||
}
|
||||
if (event.key === " " && event.currentTarget.tagName.toLowerCase() === "button") {
|
||||
// Native <button> treats Space as activation, not text input, so the
|
||||
// browser never inserts a character. Prevent the activation and insert
|
||||
// the space manually (execCommand keeps the input event).
|
||||
event.preventDefault();
|
||||
document.execCommand("insertText", false, " ");
|
||||
return;
|
||||
}
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { EDIT_BORDER_COLOR, EDIT_BACKGROUND_COLOR, PANEL_BG, BORDER_COLOR, COLOR_WHITE, Z_INDEX_EDITOR_OVERLAY } from '../../constants/theme.js';
|
||||
import { ICON_TEXT_EDIT_CURSOR } from '../../constants/icons.js';
|
||||
import { HOVER_OUTLINE_STROKE_WIDTH } from '../../constants/layout.js';
|
||||
import { EDITOR_UI_SELECTOR } from '../../constants/selectors.js';
|
||||
|
||||
/** Injected CSS for edit-mode hover outlines, contenteditable affordances, and the element-type tooltip. */
|
||||
export const EDIT_MODE_STYLES = `
|
||||
#root[data-edit-mode-enabled="true"] [data-edit-id],
|
||||
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id] {
|
||||
cursor: default !important;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#root[data-edit-mode-enabled="true"] [data-edit-id][contenteditable="true"],
|
||||
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id][contenteditable="true"] {
|
||||
outline: 1px solid ${EDIT_BORDER_COLOR};
|
||||
caret-color: currentColor;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
cursor: url('data:image/svg+xml,${ICON_TEXT_EDIT_CURSOR}') 12 12, text !important;
|
||||
}
|
||||
#root[data-edit-mode-enabled="true"] [data-edit-id][contenteditable="true"] *,
|
||||
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id][contenteditable="true"] * {
|
||||
cursor: url('data:image/svg+xml,${ICON_TEXT_EDIT_CURSOR}') 12 12, text !important;
|
||||
}
|
||||
|
||||
/* Suppress native cursor on actionable elements while editing. */
|
||||
#root[data-edit-mode-enabled="true"] a,
|
||||
#root[data-edit-mode-enabled="true"] button,
|
||||
#root[data-edit-mode-enabled="true"] [role="button"],
|
||||
#root[data-edit-mode-enabled="true"] [type="submit"] {
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.inline-editor-hover-outline,
|
||||
.inline-editor-locked-outline {
|
||||
outline-offset: -${HOVER_OUTLINE_STROKE_WIDTH}px;
|
||||
box-shadow: inset 0 0 0 999px ${EDIT_BACKGROUND_COLOR};
|
||||
}
|
||||
|
||||
.inline-editor-hover-outline {
|
||||
outline: ${HOVER_OUTLINE_STROKE_WIDTH}px dashed ${EDIT_BORDER_COLOR};
|
||||
}
|
||||
|
||||
.inline-editor-locked-outline {
|
||||
outline: ${HOVER_OUTLINE_STROKE_WIDTH}px solid ${EDIT_BORDER_COLOR};
|
||||
}
|
||||
|
||||
@keyframes element-type-tooltip-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.element-type-tooltip {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: ${PANEL_BG};
|
||||
color: ${COLOR_WHITE};
|
||||
padding: 0 4px;
|
||||
border-radius: 6px;
|
||||
font-family: DM Sans, sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
z-index: ${Z_INDEX_EDITOR_OVERLAY};
|
||||
}
|
||||
|
||||
.element-type-tooltip.active {
|
||||
display: block;
|
||||
animation: element-type-tooltip-fade-in 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
${EDITOR_UI_SELECTOR} {
|
||||
pointer-events: auto;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,71 @@
|
||||
export const HOVER_OUTLINE_CLASS = 'inline-editor-hover-outline';
|
||||
export const LOCKED_OUTLINE_CLASS = 'inline-editor-locked-outline';
|
||||
|
||||
/** @param {HTMLElement|HTMLElement[]|null} value @returns {HTMLElement[]} */
|
||||
function toArray(value) {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value.filter(Boolean) : [value];
|
||||
}
|
||||
|
||||
/** @param {HTMLElement[]} elements @param {string} className */
|
||||
function addClassTo(elements, className) {
|
||||
for (const element of elements) element.classList.add(className);
|
||||
}
|
||||
|
||||
/** @param {HTMLElement[]} elements @param {string} className */
|
||||
function removeClassFrom(elements, className) {
|
||||
for (const element of elements) element.classList.remove(className);
|
||||
}
|
||||
|
||||
/** @type {HTMLElement[]} */
|
||||
let _hoveredElements = [];
|
||||
/** @type {HTMLElement[]} */
|
||||
let _lockedElements = [];
|
||||
let _holdCount = 0;
|
||||
|
||||
/**
|
||||
* Dashed outline for the transient hover. Elements already carrying a locked
|
||||
* outline are skipped so they aren't outlined twice.
|
||||
* @param {HTMLElement|HTMLElement[]} elements
|
||||
*/
|
||||
export function showHoverOutline(elements) {
|
||||
const targets = toArray(elements).filter(element => !_lockedElements.includes(element));
|
||||
if (!targets.length) return;
|
||||
removeClassFrom(_hoveredElements, HOVER_OUTLINE_CLASS);
|
||||
_hoveredElements = targets;
|
||||
addClassTo(_hoveredElements, HOVER_OUTLINE_CLASS);
|
||||
}
|
||||
|
||||
/** Hides the hover outline (no-op while held). */
|
||||
export function hideHoverOutline() {
|
||||
if (_holdCount > 0) return;
|
||||
removeClassFrom(_hoveredElements, HOVER_OUTLINE_CLASS);
|
||||
_hoveredElements = [];
|
||||
}
|
||||
|
||||
export function holdHoverOutline() { _holdCount++; }
|
||||
export function releaseHoverOutline() {
|
||||
_holdCount = Math.max(0, _holdCount - 1);
|
||||
if (_holdCount === 0) hideHoverOutline();
|
||||
}
|
||||
|
||||
/**
|
||||
* Solid outline for a committed selection — a drag preview, a shift+click
|
||||
* group, or the element(s) an open annotation panel targets.
|
||||
* @param {HTMLElement|HTMLElement[]} elements
|
||||
*/
|
||||
export function lockHoverOutline(elements) {
|
||||
const targets = toArray(elements);
|
||||
if (!targets.length) {
|
||||
unlockHoverOutline();
|
||||
return;
|
||||
}
|
||||
removeClassFrom(_lockedElements, LOCKED_OUTLINE_CLASS);
|
||||
_lockedElements = targets;
|
||||
addClassTo(_lockedElements, LOCKED_OUTLINE_CLASS);
|
||||
}
|
||||
|
||||
export function unlockHoverOutline() {
|
||||
removeClassFrom(_lockedElements, LOCKED_OUTLINE_CLASS);
|
||||
_lockedElements = [];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { SELECTION_RECT_ID, isInsideEditorUi, getEditId } from '../../constants/selectors.js';
|
||||
import { EDIT_BACKGROUND_COLOR, Z_INDEX_EDITOR_OVERLAY, EDIT_BORDER_COLOR } from '../../constants/theme.js';
|
||||
import { MIN_SELECTION_CANDIDATE_SIZE, MAX_SELECTED_ELEMENTS } from '../../constants/layout.js';
|
||||
import { getAppRoot, getOpenModals } from '../../utils/dom-utils.js';
|
||||
import { orderSelection } from '../../state/multi-select-state.js';
|
||||
|
||||
/** Set on `<body>` for the duration of a selection gesture, to keep the browser from painting text over it. */
|
||||
export const NO_TEXT_SELECT_CLASS = 'selection-mode-no-text-select';
|
||||
|
||||
const SELECTION_RECT_STYLES = `
|
||||
#${SELECTION_RECT_ID} {
|
||||
position: fixed;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
background: ${EDIT_BACKGROUND_COLOR};
|
||||
border: 1px solid ${EDIT_BORDER_COLOR};
|
||||
z-index: ${Z_INDEX_EDITOR_OVERLAY};
|
||||
}
|
||||
|
||||
body.${NO_TEXT_SELECT_CLASS}, body.${NO_TEXT_SELECT_CLASS} * {
|
||||
user-select: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
let _rectElement = null;
|
||||
let _stylesInjected = false;
|
||||
|
||||
function ensureStyles() {
|
||||
if (_stylesInjected) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'selection-rect-styles';
|
||||
style.textContent = SELECTION_RECT_STYLES;
|
||||
document.head.appendChild(style);
|
||||
_stylesInjected = true;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Geometry
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Normalises two drag corners into a viewport-space rect.
|
||||
* @returns {{x: number, y: number, w: number, h: number}}
|
||||
*/
|
||||
export function getSelectionRect(x1, y1, x2, y2) {
|
||||
return { x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1) };
|
||||
}
|
||||
|
||||
function intersects(bounds, rect) {
|
||||
return bounds.right > rect.x && bounds.left < rect.x + rect.w
|
||||
&& bounds.bottom > rect.y && bounds.top < rect.y + rect.h;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Candidate resolution
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* True when the element is a plausible annotation target: visible, big enough to
|
||||
* aim at, and not part of the editor's own UI.
|
||||
* @param {HTMLElement} element
|
||||
* @param {DOMRect} bounds
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isSelectableCandidate(element, bounds) {
|
||||
if (bounds.width < MIN_SELECTION_CANDIDATE_SIZE || bounds.height < MIN_SELECTION_CANDIDATE_SIZE) return false;
|
||||
if (isInsideEditorUi(element)) return false;
|
||||
|
||||
const styles = window.getComputedStyle(element);
|
||||
return styles.display !== 'none' && styles.visibility !== 'hidden';
|
||||
}
|
||||
|
||||
/** Children the band also touches; an empty result means this element is as deep as it goes. */
|
||||
function intersectingChildren(element, rect) {
|
||||
return Array.from(element.children).filter((child) => {
|
||||
const bounds = child.getBoundingClientRect();
|
||||
return isSelectableCandidate(child, bounds) && intersects(bounds, rect);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the band touches, taking the deepest element on each branch so a
|
||||
* container never comes along with the children that made it a hit.
|
||||
* @param {{x: number, y: number, w: number, h: number}} rect
|
||||
* @returns {HTMLElement[]} Document-ordered, with no member nested in another.
|
||||
*/
|
||||
export function collectSelection(rect) {
|
||||
const selected = [];
|
||||
const openModals = getOpenModals();
|
||||
const roots = openModals.length ? openModals : [getAppRoot()];
|
||||
const queue = roots.flatMap((root) => [...root.children]);
|
||||
|
||||
while (queue.length && selected.length < MAX_SELECTED_ELEMENTS) {
|
||||
const element = queue.shift();
|
||||
const bounds = element.getBoundingClientRect();
|
||||
// No overlap prunes the whole subtree: children sit inside their parent's box.
|
||||
if (!isSelectableCandidate(element, bounds) || !intersects(bounds, rect)) continue;
|
||||
|
||||
// An edit id marks the editor's own annotation target, so stop there rather
|
||||
// than splitting a button or heading into the spans it is built from.
|
||||
const children = getEditId(element) ? [] : intersectingChildren(element, rect);
|
||||
if (children.length) {
|
||||
queue.push(...children);
|
||||
} else {
|
||||
selected.push(element);
|
||||
}
|
||||
}
|
||||
|
||||
return orderSelection(selected);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ *
|
||||
* Band rendering
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
export function showSelectionRect(x1, y1, x2, y2) {
|
||||
if (!_rectElement) {
|
||||
ensureStyles();
|
||||
_rectElement = document.createElement('div');
|
||||
_rectElement.id = SELECTION_RECT_ID;
|
||||
document.body.appendChild(_rectElement);
|
||||
}
|
||||
if (!_rectElement.isConnected) document.body.appendChild(_rectElement);
|
||||
|
||||
const rect = getSelectionRect(x1, y1, x2, y2);
|
||||
_rectElement.style.left = `${rect.x}px`;
|
||||
_rectElement.style.top = `${rect.y}px`;
|
||||
_rectElement.style.width = `${rect.w}px`;
|
||||
_rectElement.style.height = `${rect.h}px`;
|
||||
_rectElement.style.display = 'block';
|
||||
}
|
||||
|
||||
export function hideSelectionRect() {
|
||||
if (_rectElement) _rectElement.style.display = 'none';
|
||||
}
|
||||
|
||||
export function destroySelectionRect() {
|
||||
_rectElement?.remove();
|
||||
_rectElement = null;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { getElementType } from '../../utils/dom-utils.js';
|
||||
import { TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN } from '../../constants/layout.js';
|
||||
import { ELEMENT_TYPE_TRANSLATIONS } from '../../constants/selectors.js';
|
||||
import { showHoverOutline, hideHoverOutline } from './hover-outline.js';
|
||||
import { highlightMarkerForElement, unhighlightAllMarkers } from '../annotation-panel/annotation-markers.js';
|
||||
import { getEditorTranslations } from '../../state/annotation-state.js';
|
||||
|
||||
const ELEMENT_TYPE_TOOLTIP_CLASS = 'element-type-tooltip';
|
||||
|
||||
let typeTooltipElement = null;
|
||||
let displayedType = null;
|
||||
let cachedWidth = 0;
|
||||
let cachedHeight = 0;
|
||||
|
||||
function createTypeTooltip() {
|
||||
if (typeTooltipElement) return;
|
||||
typeTooltipElement = document.createElement('div');
|
||||
typeTooltipElement.className = ELEMENT_TYPE_TOOLTIP_CLASS;
|
||||
document.body.appendChild(typeTooltipElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions the tooltip's top-left corner near `(clientX, clientY)`, clamped
|
||||
* per axis so it never crosses within `TOOLTIP_VIEWPORT_MARGIN` of the
|
||||
* viewport edge. The cursor is free to keep moving over the parked tooltip —
|
||||
* it is `pointer-events: none`, so hit-testing resolves straight through it.
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
*/
|
||||
function positionAt(clientX, clientY) {
|
||||
const maxLeft = Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerWidth - cachedWidth - TOOLTIP_VIEWPORT_MARGIN);
|
||||
const maxTop = Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerHeight - cachedHeight - TOOLTIP_VIEWPORT_MARGIN);
|
||||
|
||||
const left = Math.min(Math.max(clientX + TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN), maxLeft);
|
||||
const top = Math.min(Math.max(clientY + TYPE_TOOLTIP_CURSOR_OFFSET, TOOLTIP_VIEWPORT_MARGIN), maxTop);
|
||||
|
||||
typeTooltipElement.style.transform = `translate3d(${left}px, ${top}px, 0)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows the type tooltip near the cursor, labelling `element`'s logical type.
|
||||
* @param {HTMLElement} element
|
||||
* @param {number} clientX
|
||||
* @param {number} clientY
|
||||
*/
|
||||
export function showTypeTooltip(element, clientX, clientY) {
|
||||
const type = getElementType(element);
|
||||
if (!typeTooltipElement) createTypeTooltip();
|
||||
if (!typeTooltipElement.isConnected) document.body.appendChild(typeTooltipElement);
|
||||
|
||||
if (type !== displayedType) {
|
||||
displayedType = type;
|
||||
const translations = getEditorTranslations();
|
||||
const translationKey = ELEMENT_TYPE_TRANSLATIONS[type]?.key;
|
||||
typeTooltipElement.textContent = (translationKey && translations[translationKey]) || type;
|
||||
cachedWidth = typeTooltipElement.offsetWidth;
|
||||
cachedHeight = typeTooltipElement.offsetHeight;
|
||||
}
|
||||
|
||||
typeTooltipElement.classList.add('active');
|
||||
positionAt(clientX, clientY);
|
||||
showHoverOutline(element);
|
||||
unhighlightAllMarkers();
|
||||
highlightMarkerForElement(element);
|
||||
}
|
||||
|
||||
/** Hides the type tooltip and its paired hover outline. */
|
||||
export function hideTypeTooltip() {
|
||||
typeTooltipElement?.classList.remove('active');
|
||||
displayedType = null;
|
||||
hideHoverOutline();
|
||||
unhighlightAllMarkers();
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
|
||||
import { getToolbarEl, getToolbarTarget } from '../toolbar/toolbar.js';
|
||||
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
|
||||
import { getActiveRange, wrapRange, syncUnderlineColorFromElement } from '../style-actions.js';
|
||||
import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js';
|
||||
import { COLOR_ACTION_HTML, SOLID_COLORS, GRADIENTS } from './template.js';
|
||||
import { COLOR_ACTION_STYLES } from './styles.js';
|
||||
import { hsvToHex, hsvToColor, hexToHsv, parseHexInput, parseColor, extractAlpha } from './color-conversions.js';
|
||||
import { parseGradientStops, buildGradient } from './gradient.js';
|
||||
import {
|
||||
initSwatches, syncSolidSwatchActive, syncGradientSwatchActive,
|
||||
ensureCustomSwatch, ensureCustomGradientSwatch,
|
||||
appendCustomSwatch, appendCustomGradientSwatch, restorePersistedSwatches,
|
||||
recolorSolidSwatch, recolorGradientSwatch, setActiveSwatch,
|
||||
} from './swatches.js';
|
||||
import { initHexInput, setHexFromPicker, setHexInputActive, hexPointerDown, hexPointerMove, handleHexKeydown, handleHexPaste, handleHexCopy, selectAllHex } from './hex-input.js';
|
||||
import { initPickerSquare } from './picker-square.js';
|
||||
|
||||
let colorActionElement = null;
|
||||
let savedColorRange = null;
|
||||
|
||||
let pickerHue = 0, pickerSaturation = 1, pickerValue = 1, pickerAlpha = 1;
|
||||
let pickerOnChange = null;
|
||||
export let pickerDragging = false;
|
||||
let hexDragging = false;
|
||||
let activeSwatchOption = null;
|
||||
let lastColorWrapper = null;
|
||||
let _suppressHide = false;
|
||||
let _colorEdited = false;
|
||||
let _pickerSquare = null, _pickerIndicator = null;
|
||||
let _pickerHueInput = null, _pickerAlphaInput = null;
|
||||
let _eyedropperPickAbort = null;
|
||||
|
||||
let gradientColors = ['#FF6B6B', '#FFE66D'];
|
||||
let gradientAngle = 90;
|
||||
let activeGradientStop = 0;
|
||||
|
||||
/**
|
||||
* Runs `handler` with pickerDragging forced on (so live previews skip expensive
|
||||
* input events), restoring the previous flag afterwards.
|
||||
* @param {() => void} handler
|
||||
*/
|
||||
function withPickerDragging(handler) {
|
||||
const wasDragging = pickerDragging;
|
||||
pickerDragging = true;
|
||||
try {
|
||||
handler();
|
||||
} finally {
|
||||
pickerDragging = wasDragging;
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies a solid color to a wrapper element, clearing gradient props. */
|
||||
function setWrapperSolid(element, color) {
|
||||
element.style.color = color;
|
||||
element.style.removeProperty('background-image');
|
||||
element.style.removeProperty('background-clip');
|
||||
element.style.removeProperty('-webkit-background-clip');
|
||||
}
|
||||
|
||||
/** Applies a gradient text fill to a wrapper element. */
|
||||
function setWrapperGradient(element, gradient) {
|
||||
element.style.backgroundImage = gradient;
|
||||
element.style.backgroundClip = 'text';
|
||||
element.style.webkitBackgroundClip = 'text';
|
||||
element.style.color = 'transparent';
|
||||
}
|
||||
|
||||
/** Updates the toolbar's color swatch preview. */
|
||||
function setToolbarPreview(value) {
|
||||
getToolbarEl()?.querySelector('.tft-swatch-preview')?.style.setProperty('background', value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a solid color: restyles the existing wrapper when connected,
|
||||
* otherwise wraps the saved selection silently (no input event).
|
||||
* @param {string} color
|
||||
*/
|
||||
function applySolidFull(color) {
|
||||
if (lastColorWrapper?.isConnected) {
|
||||
setWrapperSolid(lastColorWrapper, color);
|
||||
} else {
|
||||
withPickerDragging(() => applyColorAction(color, false));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits a gradient like {@link applySolidFull}, re-resolving the wrapper
|
||||
* after a fresh wrap so subsequent live updates can target it.
|
||||
* @param {string} gradient
|
||||
*/
|
||||
function applyGradientFull(gradient) {
|
||||
recoverGradientWrapper();
|
||||
if (lastColorWrapper?.isConnected) {
|
||||
setWrapperGradient(lastColorWrapper, gradient);
|
||||
} else {
|
||||
withPickerDragging(() => applyColorAction(gradient, true));
|
||||
recoverGradientWrapper();
|
||||
}
|
||||
}
|
||||
|
||||
function isGradientTabActive() {
|
||||
return !!colorActionElement?.querySelector('.tft-color-tab[data-tab="gradient"]')?.classList.contains('tca-tab-active');
|
||||
}
|
||||
|
||||
function refreshGradientStopSwatches() {
|
||||
if (!colorActionElement) return;
|
||||
[0, 1].forEach(index => {
|
||||
const element = colorActionElement.querySelector(`.tft-gradient-stop-swatch[data-stop="${index}"]`);
|
||||
if (!element) return;
|
||||
element.querySelector('.tft-color-swatch').style.background = gradientColors[index] || '#000000';
|
||||
element.classList.toggle('tca-active', index === activeGradientStop);
|
||||
});
|
||||
}
|
||||
|
||||
function selectGradientStop(stopIndex) {
|
||||
activeGradientStop = stopIndex;
|
||||
refreshGradientStopSwatches();
|
||||
const hex = parseColor(gradientColors[stopIndex]) || '#000000';
|
||||
seedPicker(hex);
|
||||
}
|
||||
|
||||
function updatePickerUI(skipHex = false) {
|
||||
const hex = hsvToHex(pickerHue, pickerSaturation, pickerValue);
|
||||
if (isGradientTabActive()) {
|
||||
gradientColors[activeGradientStop] = hex;
|
||||
refreshGradientStopSwatches();
|
||||
const gradient = buildGradient(gradientAngle, gradientColors[0], gradientColors[1]);
|
||||
if (canApplyColor()) {
|
||||
recoverGradientWrapper();
|
||||
if (lastColorWrapper?.isConnected) {
|
||||
lastColorWrapper.style.backgroundImage = gradient;
|
||||
}
|
||||
}
|
||||
setToolbarPreview(gradient);
|
||||
// Edit the selected gradient swatch in place (keeps it highlighted); otherwise just highlight a match.
|
||||
if (activeSwatchOption?.isConnected && activeSwatchOption.hasAttribute('data-gradient')) {
|
||||
recolorGradientSwatch(activeSwatchOption, gradient);
|
||||
setActiveSwatch(activeSwatchOption);
|
||||
} else {
|
||||
activeSwatchOption = syncGradientSwatchActive(gradient);
|
||||
}
|
||||
} else if (activeSwatchOption?.isConnected && activeSwatchOption.hasAttribute('data-color')) {
|
||||
recolorSolidSwatch(activeSwatchOption, hex);
|
||||
setActiveSwatch(activeSwatchOption);
|
||||
} else {
|
||||
activeSwatchOption = syncSolidSwatchActive(hex);
|
||||
}
|
||||
if (_pickerSquare) _pickerSquare.style.background = `hsl(${pickerHue}, 100%, 50%)`;
|
||||
if (_pickerIndicator) {
|
||||
const width = _pickerSquare.offsetWidth;
|
||||
const height = _pickerSquare.offsetHeight;
|
||||
_pickerIndicator.style.left = `${pickerSaturation * width}px`;
|
||||
_pickerIndicator.style.top = `${(1 - pickerValue) * height}px`;
|
||||
_pickerIndicator.style.background = hex;
|
||||
}
|
||||
if (_pickerHueInput) _pickerHueInput.value = pickerHue;
|
||||
if (_pickerAlphaInput) {
|
||||
const hslColor = `hsl(${pickerHue}, 100%, 50%)`;
|
||||
_pickerAlphaInput.style.background = [
|
||||
`linear-gradient(to right, transparent, ${hslColor})`,
|
||||
`linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 0 0 / 8px 8px`,
|
||||
`linear-gradient(45deg, #555 25%, transparent 25%, transparent 75%, #555 75%) 4px 4px / 8px 8px`,
|
||||
`#2a2b2f`,
|
||||
].join(', ');
|
||||
}
|
||||
if (!skipHex) setHexFromPicker(hex);
|
||||
}
|
||||
|
||||
function seedPicker(hex) {
|
||||
const hsv = hexToHsv(hex) ?? { h: 0, s: 0, v: 0 };
|
||||
pickerHue = hsv.h;
|
||||
pickerSaturation = hsv.s;
|
||||
pickerValue = hsv.v;
|
||||
updatePickerUI();
|
||||
}
|
||||
|
||||
function findGradientWrapper() {
|
||||
if (!getToolbarTarget()) return null;
|
||||
const selection = window.getSelection();
|
||||
let node = selection?.rangeCount > 0 ? selection.getRangeAt(0).commonAncestorContainer : null;
|
||||
if (!node) node = savedColorRange?.commonAncestorContainer;
|
||||
if (node?.nodeType === Node.TEXT_NODE) node = node.parentElement;
|
||||
while (node && node !== getToolbarTarget().parentElement) {
|
||||
if (node === getToolbarTarget()) { node = node.parentElement; continue; }
|
||||
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
if (node.style?.backgroundClip === 'text' || node.style?.webkitBackgroundClip === 'text') {
|
||||
return node;
|
||||
}
|
||||
try {
|
||||
const computedStyle = getComputedStyle(node);
|
||||
const backgroundClip = computedStyle.backgroundClip || computedStyle.webkitBackgroundClip;
|
||||
if (computedStyle.backgroundImage?.includes('gradient') && backgroundClip === 'text') {
|
||||
return node;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function recoverGradientWrapper() {
|
||||
if (lastColorWrapper?.isConnected) return;
|
||||
lastColorWrapper = findGradientWrapper();
|
||||
if (lastColorWrapper) {
|
||||
try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(lastColorWrapper);
|
||||
savedColorRange = range.cloneRange();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function applyLive(color) {
|
||||
if (isGradientTabActive()) {
|
||||
gradientColors[activeGradientStop] = color;
|
||||
refreshGradientStopSwatches();
|
||||
const gradient = buildGradient(gradientAngle, gradientColors[0], gradientColors[1]);
|
||||
if (canApplyColor()) {
|
||||
recoverGradientWrapper();
|
||||
if (lastColorWrapper?.isConnected) {
|
||||
lastColorWrapper.style.backgroundImage = gradient;
|
||||
} else {
|
||||
withPickerDragging(() => applyColorAction(gradient, true));
|
||||
}
|
||||
}
|
||||
setToolbarPreview(gradient);
|
||||
} else {
|
||||
if (canApplyColor()) {
|
||||
if (lastColorWrapper?.isConnected) {
|
||||
lastColorWrapper.style.color = color;
|
||||
} else {
|
||||
withPickerDragging(() => applyColorAction(color, false));
|
||||
}
|
||||
}
|
||||
setToolbarPreview(color);
|
||||
}
|
||||
}
|
||||
|
||||
function notifyChange() {
|
||||
if (!getToolbarTarget()) return;
|
||||
// Swatches are only created via the "+" button now, never auto-added on commit.
|
||||
getToolbarTarget().dispatchEvent(new CustomEvent('tft-color-commit', { bubbles: true }));
|
||||
}
|
||||
|
||||
function applyColorAction(value, isGradient) {
|
||||
if (!savedColorRange || !getToolbarTarget() || !value) return;
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(savedColorRange);
|
||||
applyColor(getToolbarTarget(), value, isGradient);
|
||||
if (selection.rangeCount > 0) savedColorRange = selection.getRangeAt(0).cloneRange();
|
||||
if (!pickerDragging) onSelectionChange();
|
||||
}
|
||||
|
||||
function stripColorProps(element) {
|
||||
element.style.removeProperty('color');
|
||||
element.style.removeProperty('background-image');
|
||||
element.style.removeProperty('background-clip');
|
||||
element.style.removeProperty('-webkit-background-clip');
|
||||
}
|
||||
|
||||
function hasColorProps(element) {
|
||||
return !!(element.style.color || element.style.backgroundImage || element.style.backgroundClip || element.style.webkitBackgroundClip);
|
||||
}
|
||||
|
||||
function findReusableAncestor(range, editTarget) {
|
||||
let element = range.commonAncestorContainer;
|
||||
if (element.nodeType === Node.TEXT_NODE) element = element.parentElement;
|
||||
while (element && element !== editTarget) {
|
||||
if (element.nodeType === Node.ELEMENT_NODE) {
|
||||
try {
|
||||
const elementRange = document.createRange();
|
||||
elementRange.selectNodeContents(element);
|
||||
const selectionText = range.toString();
|
||||
const elementText = elementRange.toString();
|
||||
if (selectionText && elementText && selectionText === elementText) return element;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitParentAroundWrapper(wrapper, parent, styleProperties, isGradient) {
|
||||
const parentParent = parent.parentNode;
|
||||
if (!parentParent) return;
|
||||
|
||||
const before = parent.cloneNode(false);
|
||||
const after = parent.cloneNode(false);
|
||||
|
||||
let node = parent.firstChild;
|
||||
while (node && node !== wrapper) {
|
||||
const next = node.nextSibling;
|
||||
before.appendChild(node);
|
||||
node = next;
|
||||
}
|
||||
node = wrapper.nextSibling;
|
||||
while (node) {
|
||||
const next = node.nextSibling;
|
||||
after.appendChild(node);
|
||||
node = next;
|
||||
}
|
||||
parent.removeChild(wrapper);
|
||||
|
||||
// Inherit parent styles then apply new color
|
||||
wrapper.style.cssText = parent.style.cssText;
|
||||
Object.assign(wrapper.style, styleProperties);
|
||||
if (!isGradient) {
|
||||
wrapper.style.removeProperty('background-image');
|
||||
wrapper.style.removeProperty('background-clip');
|
||||
wrapper.style.removeProperty('-webkit-background-clip');
|
||||
}
|
||||
|
||||
if (before.hasChildNodes()) parentParent.insertBefore(before, parent);
|
||||
parentParent.insertBefore(wrapper, parent);
|
||||
if (after.hasChildNodes()) parentParent.insertBefore(after, parent);
|
||||
parentParent.removeChild(parent);
|
||||
}
|
||||
|
||||
function applyColor(editTarget, value, isGradient) {
|
||||
const range = getActiveRange(editTarget);
|
||||
if (!range) return;
|
||||
const styleProperties = isGradient
|
||||
? { backgroundImage: value, backgroundClip: 'text', webkitBackgroundClip: 'text', color: 'transparent' }
|
||||
: { color: value };
|
||||
|
||||
const reuse = findReusableAncestor(range, editTarget);
|
||||
if (reuse) {
|
||||
Object.assign(reuse.style, styleProperties);
|
||||
if (!isGradient) {
|
||||
reuse.style.removeProperty('background-image');
|
||||
reuse.style.removeProperty('background-clip');
|
||||
reuse.style.removeProperty('-webkit-background-clip');
|
||||
}
|
||||
lastColorWrapper = reuse;
|
||||
reuse.querySelectorAll('*').forEach(element => {
|
||||
if (hasColorProps(element)) stripColorProps(element);
|
||||
});
|
||||
syncUnderlineColorFromElement(reuse, editTarget);
|
||||
if (!pickerDragging) editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = wrapRange(range, 'span', styleProperties);
|
||||
lastColorWrapper = wrapper ?? null;
|
||||
if (wrapper) {
|
||||
wrapper.querySelectorAll('*').forEach(element => {
|
||||
if (hasColorProps(element)) stripColorProps(element);
|
||||
});
|
||||
const parent = wrapper.parentElement;
|
||||
if (parent && parent !== editTarget && parent.tagName === 'SPAN' && hasColorProps(parent)) {
|
||||
const parentRange = document.createRange();
|
||||
parentRange.selectNodeContents(parent);
|
||||
if (parentRange.toString() === wrapper.textContent) {
|
||||
stripColorProps(parent);
|
||||
} else {
|
||||
splitParentAroundWrapper(wrapper, parent, styleProperties, isGradient);
|
||||
}
|
||||
}
|
||||
syncUnderlineColorFromElement(wrapper, editTarget);
|
||||
}
|
||||
if (!pickerDragging) {
|
||||
requestAnimationFrame(() => {
|
||||
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function positionColorAction() {
|
||||
if (!isPanelVisible(PanelId.COLOR) || !colorActionElement) return;
|
||||
positionDropdownPanel(colorActionElement, { buttonAction: 'color' });
|
||||
}
|
||||
|
||||
const outsideDismiss = createOutsideDismiss({
|
||||
getElement: () => colorActionElement,
|
||||
dismiss: () => {
|
||||
_suppressHide = false;
|
||||
hideColorAction();
|
||||
},
|
||||
ignoreToolbar: false,
|
||||
ignoreMouseDown: (event) => !!event.target.closest('[data-action="color"]'),
|
||||
captureMouseDown: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* @returns {boolean} Whether color can be applied to content. A highlighted
|
||||
* range colors that selection; a collapsed caret inside the edit target colors
|
||||
* the whole element (same as bold/italic via {@link getActiveRange}).
|
||||
*/
|
||||
function canApplyColor() {
|
||||
if (!savedColorRange || !getToolbarTarget()) return false;
|
||||
if (!savedColorRange.collapsed) return true;
|
||||
return getToolbarTarget().contains(savedColorRange.commonAncestorContainer);
|
||||
}
|
||||
|
||||
/** True only when the user has highlighted text (not a bare caret). */
|
||||
function hasExpandedTextSelection() {
|
||||
return !!savedColorRange && !savedColorRange.collapsed;
|
||||
}
|
||||
|
||||
function onSelectionChangeForColor() {
|
||||
if (!isPanelVisible(PanelId.COLOR)) return;
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!getToolbarTarget()?.contains(range.commonAncestorContainer)) return;
|
||||
savedColorRange = range.cloneRange();
|
||||
}
|
||||
|
||||
function switchColorTab(tab) {
|
||||
if (!colorActionElement) return;
|
||||
colorActionElement.querySelectorAll('.tft-color-tab').forEach(tabElement =>
|
||||
tabElement.classList.toggle('tca-tab-active', tabElement.dataset.tab === tab));
|
||||
colorActionElement.querySelectorAll('[data-tab-panel]').forEach(panelElement =>
|
||||
panelElement.classList.toggle('tca-hidden', panelElement.dataset.tabPanel !== tab));
|
||||
|
||||
if (tab === 'gradient' && isPanelVisible(PanelId.COLOR)) {
|
||||
const firstPreset = parseGradientStops(GRADIENTS[0]);
|
||||
if (firstPreset) {
|
||||
gradientAngle = firstPreset.angle;
|
||||
gradientColors = firstPreset.colors.map(color => parseColor(color) || color);
|
||||
}
|
||||
refreshGradientStopSwatches();
|
||||
selectGradientStop(0);
|
||||
activeSwatchOption = syncGradientSwatchActive(GRADIENTS[0]);
|
||||
if (hasExpandedTextSelection() && getToolbarTarget()) {
|
||||
applyGradientFull(GRADIENTS[0]);
|
||||
notifyChange();
|
||||
}
|
||||
setToolbarPreview(GRADIENTS[0]);
|
||||
} else if (tab === 'solid' && isPanelVisible(PanelId.COLOR)) {
|
||||
const defaultSolid = SOLID_COLORS[0];
|
||||
seedPicker(defaultSolid);
|
||||
activeSwatchOption = syncSolidSwatchActive(defaultSolid);
|
||||
syncGradientSwatchActive(null);
|
||||
if (hasExpandedTextSelection() && getToolbarTarget()) {
|
||||
recoverGradientWrapper();
|
||||
applySolidFull(defaultSolid);
|
||||
notifyChange();
|
||||
}
|
||||
setToolbarPreview(defaultSolid);
|
||||
}
|
||||
|
||||
positionColorAction();
|
||||
}
|
||||
|
||||
function initColorAction() {
|
||||
if (colorActionElement) return;
|
||||
colorActionElement = createPanelElement({
|
||||
styleId: 'text-format-color-action-styles',
|
||||
styles: COLOR_ACTION_STYLES,
|
||||
html: COLOR_ACTION_HTML,
|
||||
});
|
||||
|
||||
initSwatches(colorActionElement);
|
||||
restorePersistedSwatches();
|
||||
|
||||
colorActionElement.addEventListener('mousedown', (event) => {
|
||||
if (event.target.closest('input[type="range"]')) return;
|
||||
event.preventDefault();
|
||||
if (event.target.closest('.tft-picker-hex-input')) {
|
||||
setHexInputActive(true);
|
||||
hexPointerDown(event.clientX);
|
||||
hexDragging = true;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (event) => {
|
||||
if (hexDragging) hexPointerMove(event.clientX);
|
||||
});
|
||||
document.addEventListener('mouseup', () => { hexDragging = false; });
|
||||
|
||||
_pickerSquare = colorActionElement.querySelector('.tft-picker-square');
|
||||
_pickerIndicator = colorActionElement.querySelector('.tft-picker-indicator');
|
||||
_pickerHueInput = colorActionElement.querySelector('.tft-picker-hue');
|
||||
_pickerAlphaInput = colorActionElement.querySelector('.tft-picker-alpha');
|
||||
const hexElement = colorActionElement.querySelector('.tft-picker-hex-input');
|
||||
initHexInput(hexElement);
|
||||
|
||||
initPickerSquare(_pickerSquare, {
|
||||
onDragStart: (saturation, value) => {
|
||||
pickerDragging = true;
|
||||
_colorEdited = true;
|
||||
if (canApplyColor() && !lastColorWrapper?.isConnected) {
|
||||
if (isGradientTabActive()) {
|
||||
applyColorAction(buildGradient(gradientAngle, gradientColors[0], gradientColors[1]), true);
|
||||
} else {
|
||||
applyColorAction(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha), false);
|
||||
}
|
||||
}
|
||||
pickerSaturation = saturation; pickerValue = value;
|
||||
updatePickerUI();
|
||||
},
|
||||
onPickChange: (saturation, value) => {
|
||||
if (!pickerDragging) return;
|
||||
pickerSaturation = saturation; pickerValue = value;
|
||||
updatePickerUI();
|
||||
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
|
||||
},
|
||||
onDragEnd: () => {
|
||||
pickerDragging = false;
|
||||
notifyChange();
|
||||
},
|
||||
});
|
||||
|
||||
_pickerHueInput.addEventListener('input', () => {
|
||||
_colorEdited = true;
|
||||
pickerHue = Number(_pickerHueInput.value);
|
||||
updatePickerUI();
|
||||
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
|
||||
});
|
||||
_pickerHueInput.addEventListener('change', () => notifyChange());
|
||||
|
||||
_pickerAlphaInput.addEventListener('input', () => {
|
||||
_colorEdited = true;
|
||||
pickerAlpha = Number(_pickerAlphaInput.value) / 100;
|
||||
updatePickerUI(true);
|
||||
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
|
||||
});
|
||||
_pickerAlphaInput.addEventListener('change', () => notifyChange());
|
||||
|
||||
const eyedropperButton = colorActionElement.querySelector('.tft-picker-hex-picker');
|
||||
if (eyedropperButton) {
|
||||
if (!('EyeDropper' in window)) {
|
||||
eyedropperButton.style.display = 'none';
|
||||
} else {
|
||||
eyedropperButton.addEventListener('click', async (event) => {
|
||||
event.stopPropagation();
|
||||
_suppressHide = true;
|
||||
_eyedropperPickAbort?.abort();
|
||||
_eyedropperPickAbort = new AbortController();
|
||||
try {
|
||||
const { sRGBHex } = await new window.EyeDropper().open({ signal: _eyedropperPickAbort.signal });
|
||||
const parsed = parseHexInput(sRGBHex);
|
||||
if (!parsed) return;
|
||||
_colorEdited = true;
|
||||
seedPicker(parsed);
|
||||
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
|
||||
notifyChange();
|
||||
} catch (error) {
|
||||
if (error?.name !== 'AbortError') console.warn('EyeDropper failed', error);
|
||||
} finally {
|
||||
_eyedropperPickAbort = null;
|
||||
requestAnimationFrame(() => { _suppressHide = false; });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
colorActionElement.addEventListener('dblclick', (event) => {
|
||||
if (event.target.closest('.tft-picker-hex-input')) {
|
||||
event.preventDefault();
|
||||
selectAllHex();
|
||||
}
|
||||
});
|
||||
|
||||
colorActionElement.addEventListener('click', (event) => {
|
||||
const tab = event.target.closest('.tft-color-tab');
|
||||
if (tab) {
|
||||
switchColorTab(tab.dataset.tab);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest('.tft-picker-hex-input')) return;
|
||||
const stopSwatch = event.target.closest('.tft-gradient-stop-swatch');
|
||||
if (stopSwatch) {
|
||||
selectGradientStop(Number(stopSwatch.dataset.stop));
|
||||
return;
|
||||
}
|
||||
|
||||
const addButton = event.target.closest('.tft-color-add');
|
||||
if (addButton) {
|
||||
_colorEdited = true;
|
||||
if (isGradientTabActive()) {
|
||||
const gradient = activeSwatchOption?.dataset.gradient
|
||||
|| buildGradient(gradientAngle, gradientColors[0], gradientColors[1]);
|
||||
const newSwatch = appendCustomGradientSwatch(gradient);
|
||||
if (newSwatch) { activeSwatchOption = newSwatch; setActiveSwatch(newSwatch); }
|
||||
setToolbarPreview(gradient);
|
||||
} else {
|
||||
const hex = activeSwatchOption?.dataset.color
|
||||
|| hsvToHex(pickerHue, pickerSaturation, pickerValue);
|
||||
const newSwatch = appendCustomSwatch(hex);
|
||||
if (newSwatch) { activeSwatchOption = newSwatch; setActiveSwatch(newSwatch); }
|
||||
setToolbarPreview(hex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const option = event.target.closest('.tft-color-option');
|
||||
if (!option) return;
|
||||
|
||||
if (option.dataset.gradient) {
|
||||
const gradient = option.dataset.gradient;
|
||||
activeSwatchOption = option;
|
||||
setActiveSwatch(option);
|
||||
const parsed = parseGradientStops(gradient);
|
||||
if (parsed) {
|
||||
gradientAngle = parsed.angle;
|
||||
gradientColors = [...parsed.colors];
|
||||
}
|
||||
refreshGradientStopSwatches();
|
||||
selectGradientStop(0);
|
||||
if (canApplyColor()) applyGradientFull(gradient);
|
||||
setToolbarPreview(gradient);
|
||||
notifyChange();
|
||||
} else {
|
||||
const hex = option.dataset.color;
|
||||
activeSwatchOption = option;
|
||||
setActiveSwatch(option);
|
||||
seedPicker(hex);
|
||||
if (canApplyColor()) applySolidFull(hex);
|
||||
setToolbarPreview(hex);
|
||||
notifyChange();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function tryLiveFromHex(rawValue) {
|
||||
const hex = parseHexInput(rawValue);
|
||||
if (!hex) return;
|
||||
_colorEdited = true;
|
||||
const hsv = hexToHsv(hex);
|
||||
if (!hsv) return;
|
||||
pickerHue = hsv.h; pickerSaturation = hsv.s; pickerValue = hsv.v;
|
||||
updatePickerUI(true);
|
||||
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
|
||||
}
|
||||
|
||||
function onHexCommit(hex) {
|
||||
seedPicker(hex);
|
||||
_suppressHide = false;
|
||||
if (isGradientTabActive()) {
|
||||
applyLive(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
|
||||
} else {
|
||||
pickerOnChange?.(hsvToColor(pickerHue, pickerSaturation, pickerValue, pickerAlpha));
|
||||
}
|
||||
hideColorAction();
|
||||
}
|
||||
|
||||
function onHexCancel() {
|
||||
_suppressHide = false;
|
||||
hideColorAction();
|
||||
}
|
||||
|
||||
function onHexKeydownHandler(event) {
|
||||
if (!isPanelVisible(PanelId.COLOR)) return;
|
||||
handleHexKeydown(event, { onLiveChange: tryLiveFromHex, onCommit: onHexCommit, onCancel: onHexCancel });
|
||||
}
|
||||
|
||||
function onHexPasteHandler(event) {
|
||||
if (!isPanelVisible(PanelId.COLOR)) return;
|
||||
const newValue = handleHexPaste(event);
|
||||
if (newValue !== undefined) tryLiveFromHex(newValue);
|
||||
}
|
||||
|
||||
function onHexCopyHandler(event) {
|
||||
if (!isPanelVisible(PanelId.COLOR)) return;
|
||||
const cut = handleHexCopy(event);
|
||||
if (cut) tryLiveFromHex('');
|
||||
}
|
||||
|
||||
/** Opens the color panel seeded from the current selection styles. */
|
||||
export function showColorAction() {
|
||||
if (!colorActionElement) initColorAction();
|
||||
|
||||
savedColorRange = captureSelectionRange();
|
||||
|
||||
const styles = getStylesAtCursor();
|
||||
const currentValue = (styles?.color ?? '').toLowerCase();
|
||||
const isGradientActive = !!styles?.isGradient;
|
||||
|
||||
_colorEdited = false;
|
||||
|
||||
if (isGradientActive) {
|
||||
ensureCustomGradientSwatch(currentValue);
|
||||
activeSwatchOption = syncGradientSwatchActive(currentValue);
|
||||
syncSolidSwatchActive(null);
|
||||
} else {
|
||||
const parsedColor = parseColor(currentValue);
|
||||
if (parsedColor) ensureCustomSwatch(parsedColor);
|
||||
activeSwatchOption = syncSolidSwatchActive(parsedColor);
|
||||
syncGradientSwatchActive(null);
|
||||
}
|
||||
|
||||
switchColorTab(isGradientActive ? 'gradient' : 'solid');
|
||||
|
||||
colorActionElement.classList.add('tca-visible');
|
||||
setPanelVisible(PanelId.COLOR, true);
|
||||
_suppressHide = true;
|
||||
positionColorAction();
|
||||
|
||||
pickerAlpha = isGradientActive ? 1 : extractAlpha(currentValue);
|
||||
if (_pickerAlphaInput) _pickerAlphaInput.value = Math.round(pickerAlpha * 100);
|
||||
|
||||
pickerOnChange = (color) => {
|
||||
if (canApplyColor()) applySolidFull(color);
|
||||
setToolbarPreview(color);
|
||||
notifyChange();
|
||||
};
|
||||
setHexInputActive(false);
|
||||
|
||||
if (isGradientActive) {
|
||||
const parsed = parseGradientStops(currentValue);
|
||||
if (parsed) {
|
||||
gradientAngle = parsed.angle;
|
||||
gradientColors = parsed.colors.map(color => parseColor(color) || color);
|
||||
}
|
||||
refreshGradientStopSwatches();
|
||||
selectGradientStop(0);
|
||||
} else {
|
||||
activeGradientStop = 0;
|
||||
seedPicker(parseColor(currentValue) || '#000000');
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onHexKeydownHandler, true);
|
||||
document.addEventListener('paste', onHexPasteHandler, true);
|
||||
document.addEventListener('copy', onHexCopyHandler, true);
|
||||
document.addEventListener('cut', onHexCopyHandler, true);
|
||||
document.addEventListener('selectionchange', onSelectionChangeForColor);
|
||||
outsideDismiss.attach();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the color panel and resets picker state.
|
||||
* @param {boolean} [force] - Close even when hide is suppressed (e.g. eyedropper).
|
||||
*/
|
||||
export function hideColorAction(force = false) {
|
||||
if (!force && _suppressHide) return;
|
||||
if (!colorActionElement) return;
|
||||
_eyedropperPickAbort?.abort();
|
||||
_eyedropperPickAbort = null;
|
||||
// Swatches are added/edited explicitly via the grid now — nothing to persist on close.
|
||||
_colorEdited = false;
|
||||
_suppressHide = false;
|
||||
colorActionElement.classList.remove('tca-visible');
|
||||
setPanelVisible(PanelId.COLOR, false);
|
||||
savedColorRange = null;
|
||||
pickerOnChange = null;
|
||||
activeSwatchOption = null;
|
||||
lastColorWrapper = null;
|
||||
setHexInputActive(false);
|
||||
gradientColors = ['#FF6B6B', '#FFE66D'];
|
||||
gradientAngle = 90;
|
||||
activeGradientStop = 0;
|
||||
document.removeEventListener('keydown', onHexKeydownHandler, true);
|
||||
document.removeEventListener('paste', onHexPasteHandler, true);
|
||||
document.removeEventListener('copy', onHexCopyHandler, true);
|
||||
document.removeEventListener('cut', onHexCopyHandler, true);
|
||||
document.removeEventListener('selectionchange', onSelectionChangeForColor);
|
||||
outsideDismiss.detach();
|
||||
}
|
||||
|
||||
/** Toggles the color panel visibility. */
|
||||
export function toggleColorAction() {
|
||||
if (isPanelVisible(PanelId.COLOR)) {
|
||||
_suppressHide = false;
|
||||
hideColorAction();
|
||||
} else {
|
||||
showColorAction();
|
||||
}
|
||||
}
|
||||
|
||||
registerPanel({ id: PanelId.COLOR, hide: hideColorAction });
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* @param {number} hue - Hue 0–360.
|
||||
* @param {number} saturation - Saturation 0–1.
|
||||
* @param {number} value - Value 0–1.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function hsvToHex(hue, saturation, value) {
|
||||
const f = (n, k = (n + hue / 60) % 6) => value - value * saturation * Math.max(Math.min(k, 4 - k, 1), 0);
|
||||
const toHex = x => Math.round(x * 255).toString(16).padStart(2, '0');
|
||||
return `#${toHex(f(5))}${toHex(f(3))}${toHex(f(1))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} hue
|
||||
* @param {number} saturation
|
||||
* @param {number} value
|
||||
* @param {number} [alpha] - Alpha 0–1.
|
||||
* @returns {string}
|
||||
*/
|
||||
export function hsvToColor(hue, saturation, value, alpha = 1) {
|
||||
const hex = hsvToHex(hue, saturation, value);
|
||||
if (alpha >= 1) return hex;
|
||||
const red = parseInt(hex.slice(1, 3), 16);
|
||||
const green = parseInt(hex.slice(3, 5), 16);
|
||||
const blue = parseInt(hex.slice(5, 7), 16);
|
||||
return `rgba(${red}, ${green}, ${blue}, ${parseFloat(alpha.toFixed(3))})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} hex
|
||||
* @returns {{ h: number, s: number, v: number }|null}
|
||||
*/
|
||||
export function hexToHsv(hex) {
|
||||
hex = hex.replace('#', '');
|
||||
if (hex.length === 3) hex = hex.split('').map(character => character + character).join('');
|
||||
if (hex.length !== 6) return null;
|
||||
const red = parseInt(hex.slice(0, 2), 16) / 255;
|
||||
const green = parseInt(hex.slice(2, 4), 16) / 255;
|
||||
const blue = parseInt(hex.slice(4, 6), 16) / 255;
|
||||
const max = Math.max(red, green, blue), min = Math.min(red, green, blue);
|
||||
const value = max, delta = max - min;
|
||||
const saturation = max === 0 ? 0 : delta / max;
|
||||
let hue = 0;
|
||||
if (delta !== 0) {
|
||||
switch (max) {
|
||||
case red: hue = ((green - blue) / delta + (green < blue ? 6 : 0)) / 6; break;
|
||||
case green: hue = ((blue - red) / delta + 2) / 6; break;
|
||||
default: hue = ((red - green) / delta + 4) / 6; break;
|
||||
}
|
||||
}
|
||||
return { h: hue * 360, s: saturation, v: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string|null} Normalized #rrggbb or null.
|
||||
*/
|
||||
export function parseHexInput(value) {
|
||||
value = value.trim().replace(/^#/, '');
|
||||
if (value.length === 3) value = value.split('').map(character => character + character).join('');
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(value)) return null;
|
||||
return `#${value.toLowerCase()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value - Any CSS color string.
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function cssColorToHex(value) {
|
||||
const cleaned = value.replace(/\s+[\d.]+(%|px|em|rem)\s*$/, '').trim();
|
||||
try {
|
||||
const context = document.createElement('canvas').getContext('2d');
|
||||
context.fillStyle = '#010203';
|
||||
context.fillStyle = cleaned;
|
||||
const result = context.fillStyle;
|
||||
if (result === '#010203' && cleaned !== '#010203') return null;
|
||||
return parseHexInput(result);
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {string|null} Hex color when parseable.
|
||||
*/
|
||||
export function parseColor(value) {
|
||||
if (!value) return null;
|
||||
const hex = parseHexInput(value);
|
||||
if (hex) return hex;
|
||||
const rgbMatch = value.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)/);
|
||||
if (rgbMatch) return `#${[rgbMatch[1], rgbMatch[2], rgbMatch[3]].map(component => Number(component).toString(16).padStart(2, '0')).join('')}`;
|
||||
const rgbaMatch = value.match(/rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([\d.]+)\s*\)/);
|
||||
if (rgbaMatch) return `#${[rgbaMatch[1], rgbaMatch[2], rgbaMatch[3]].map(component => Number(component).toString(16).padStart(2, '00')).join('')}`;
|
||||
return cssColorToHex(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} value
|
||||
* @returns {number} Alpha 0–1.
|
||||
*/
|
||||
export function extractAlpha(value) {
|
||||
if (!value) return 1;
|
||||
const match = value.match(/rgba\(\s*\d+\s*,\s*\d+\s*,\s*\d+\s*,\s*([\d.]+)\s*\)/);
|
||||
return match ? Math.min(1, Math.max(0, parseFloat(match[1]))) : 1;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { parseColor } from './color-conversions.js';
|
||||
|
||||
/**
|
||||
* @param {string} value - CSS linear-gradient value.
|
||||
* @returns {{ angle: number, colors: string[] }|null}
|
||||
*/
|
||||
export function parseGradientStops(value) {
|
||||
if (!value) return null;
|
||||
const match = value.match(/linear-gradient\(\s*([\s\S]+)\s*\)/);
|
||||
if (!match) return null;
|
||||
const segments = [];
|
||||
let depth = 0, current = '';
|
||||
for (const character of match[1] + ',') {
|
||||
if (character === '(') { depth++; current += character; }
|
||||
else if (character === ')') { depth--; current += character; }
|
||||
else if (character === ',' && depth === 0) { segments.push(current.trim()); current = ''; }
|
||||
else { current += character; }
|
||||
}
|
||||
if (segments.length < 2) return null;
|
||||
let angle = 90;
|
||||
let colorStart = 0;
|
||||
const first = segments[0];
|
||||
if (/^\d+(\.\d+)?deg$/.test(first)) {
|
||||
angle = parseFloat(first);
|
||||
colorStart = 1;
|
||||
} else if (/^to\s+/i.test(first)) {
|
||||
const map = { 'to right': 90, 'to left': 270, 'to top': 0, 'to bottom': 180,
|
||||
'to top right': 45, 'to top left': 315, 'to bottom right': 135, 'to bottom left': 225 };
|
||||
angle = map[first.toLowerCase()] ?? 90;
|
||||
colorStart = 1;
|
||||
}
|
||||
const colors = segments.slice(colorStart);
|
||||
if (colors.length < 2) return null;
|
||||
return { angle, colors: [colors[0], colors[1]] };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {number} angle - Degrees.
|
||||
* @param {string} firstColor
|
||||
* @param {string} secondColor
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildGradient(angle, firstColor, secondColor) {
|
||||
return `linear-gradient(${angle}deg, ${firstColor}, ${secondColor})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalized key for comparing gradient presets and custom swatches.
|
||||
* @param {string} value
|
||||
* @returns {string}
|
||||
*/
|
||||
export function canonicalGradientKey(value) {
|
||||
if (!value) return '';
|
||||
const parsed = parseGradientStops(value);
|
||||
if (!parsed) return value.replace(/\s/g, '').toLowerCase();
|
||||
const firstColor = (parseColor(parsed.colors[0]) || parsed.colors[0]).toLowerCase();
|
||||
const secondColor = (parseColor(parsed.colors[1]) || parsed.colors[1]).toLowerCase();
|
||||
return `${parsed.angle}|${firstColor}|${secondColor}`;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { parseHexInput } from './color-conversions.js';
|
||||
|
||||
let _element = null;
|
||||
let hexVirtualValue = '';
|
||||
let hexVirtualCursor = 0;
|
||||
let hexAnchor = 0;
|
||||
let hexInputActive = false;
|
||||
|
||||
/** @param {HTMLElement} element */
|
||||
export function initHexInput(element) { _element = element; }
|
||||
|
||||
/** @returns {boolean} */
|
||||
export function isHexInputActive() { return hexInputActive; }
|
||||
/** @returns {string} */
|
||||
export function getHexValue() { return hexVirtualValue; }
|
||||
|
||||
/** @returns {number} Lower selection bound. */
|
||||
function selectionStart() { return Math.min(hexAnchor, hexVirtualCursor); }
|
||||
/** @returns {number} Upper selection bound. */
|
||||
function selectionEnd() { return Math.max(hexAnchor, hexVirtualCursor); }
|
||||
/** @returns {boolean} Whether a (non-collapsed) selection exists. */
|
||||
function hasSelection() { return hexAnchor !== hexVirtualCursor; }
|
||||
/** Collapses the selection onto the caret. */
|
||||
function collapseSelection() { hexAnchor = hexVirtualCursor; }
|
||||
|
||||
/**
|
||||
* @param {string} hex
|
||||
*/
|
||||
export function setHexFromPicker(hex) {
|
||||
hexVirtualValue = hex;
|
||||
hexVirtualCursor = hex.length;
|
||||
hexAnchor = hex.length;
|
||||
renderHexInput();
|
||||
}
|
||||
|
||||
/** @param {boolean} active */
|
||||
export function setHexInputActive(active) {
|
||||
hexInputActive = active;
|
||||
if (!active) {
|
||||
_element?.classList.remove('tca-focused');
|
||||
collapseSelection();
|
||||
}
|
||||
}
|
||||
|
||||
/** Selects the whole hex value (e.g. on double-click) so the next input/paste replaces it. */
|
||||
export function selectAllHex() {
|
||||
hexInputActive = true;
|
||||
_element?.classList.add('tca-focused');
|
||||
hexAnchor = 0;
|
||||
hexVirtualCursor = hexVirtualValue.length;
|
||||
renderHexInput();
|
||||
}
|
||||
|
||||
/** Renders the virtual hex field as per-character spans (so selection can be highlighted). */
|
||||
export function renderHexInput() {
|
||||
if (!_element) return;
|
||||
if (!hexInputActive) {
|
||||
_element.textContent = hexVirtualValue;
|
||||
return;
|
||||
}
|
||||
const start = selectionStart();
|
||||
const end = selectionEnd();
|
||||
const caret = '<span class="tft-picker-hex-cursor"></span>';
|
||||
let html = '';
|
||||
for (let index = 0; index < hexVirtualValue.length; index++) {
|
||||
if (index === hexVirtualCursor) html += caret;
|
||||
const selectionClass = index >= start && index < end ? ' tft-picker-hex-csel' : '';
|
||||
html += `<span class="tft-picker-hex-char${selectionClass}" data-i="${index}">${hexVirtualValue[index]}</span>`;
|
||||
}
|
||||
if (hexVirtualCursor === hexVirtualValue.length) html += caret;
|
||||
_element.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a viewport x coordinate to a caret index between characters.
|
||||
* @param {number} pointerX
|
||||
* @returns {number}
|
||||
*/
|
||||
function caretIndexFromPointerX(pointerX) {
|
||||
if (!_element) return 0;
|
||||
const characters = _element.querySelectorAll('.tft-picker-hex-char');
|
||||
for (let index = 0; index < characters.length; index++) {
|
||||
const characterBounds = characters[index].getBoundingClientRect();
|
||||
if (pointerX < characterBounds.left + characterBounds.width / 2) return index;
|
||||
}
|
||||
return characters.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins a click/drag in the field: activates it and drops a collapsed caret.
|
||||
* @param {number} pointerX
|
||||
*/
|
||||
export function hexPointerDown(pointerX) {
|
||||
hexInputActive = true;
|
||||
_element?.classList.add('tca-focused');
|
||||
renderHexInput();
|
||||
const caretIndex = caretIndexFromPointerX(pointerX);
|
||||
hexAnchor = caretIndex;
|
||||
hexVirtualCursor = caretIndex;
|
||||
renderHexInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the selection to the dragged-to position.
|
||||
* @param {number} pointerX
|
||||
*/
|
||||
export function hexPointerMove(pointerX) {
|
||||
if (!hexInputActive) return;
|
||||
hexVirtualCursor = caretIndexFromPointerX(pointerX);
|
||||
renderHexInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {KeyboardEvent} event
|
||||
* @param {{ onLiveChange: (hex: string) => void, onCommit: (hex: string) => void, onCancel: () => void }} callbacks
|
||||
*/
|
||||
export function handleHexKeydown(event, { onLiveChange, onCommit, onCancel }) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const hex = parseHexInput(hexVirtualValue);
|
||||
if (hex) onCommit(hex);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hexInputActive) return;
|
||||
|
||||
if (event.key === 'a' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
hexAnchor = 0;
|
||||
hexVirtualCursor = hexVirtualValue.length;
|
||||
renderHexInput();
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasSelection()) {
|
||||
const start = selectionStart();
|
||||
const end = selectionEnd();
|
||||
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && /[#0-9a-fA-F]/.test(event.key)) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
hexVirtualValue = (hexVirtualValue.slice(0, start) + event.key + hexVirtualValue.slice(end)).slice(0, 7);
|
||||
hexVirtualCursor = start + 1;
|
||||
collapseSelection();
|
||||
renderHexInput();
|
||||
onLiveChange(hexVirtualValue);
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Backspace' || event.key === 'Delete') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
hexVirtualValue = hexVirtualValue.slice(0, start) + hexVirtualValue.slice(end);
|
||||
hexVirtualCursor = start;
|
||||
collapseSelection();
|
||||
renderHexInput();
|
||||
onLiveChange(hexVirtualValue);
|
||||
return;
|
||||
}
|
||||
collapseSelection();
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (hexVirtualCursor > 0) {
|
||||
hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor - 1) + hexVirtualValue.slice(hexVirtualCursor);
|
||||
hexVirtualCursor--;
|
||||
collapseSelection();
|
||||
renderHexInput();
|
||||
onLiveChange(hexVirtualValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Delete') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (hexVirtualCursor < hexVirtualValue.length) {
|
||||
hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor) + hexVirtualValue.slice(hexVirtualCursor + 1);
|
||||
collapseSelection();
|
||||
renderHexInput();
|
||||
onLiveChange(hexVirtualValue);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if ((event.key === 'ArrowLeft' && (event.metaKey || event.ctrlKey)) || event.key === 'Home') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
hexVirtualCursor = 0; collapseSelection(); renderHexInput(); return;
|
||||
}
|
||||
if ((event.key === 'ArrowRight' && (event.metaKey || event.ctrlKey)) || event.key === 'End') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
hexVirtualCursor = hexVirtualValue.length; collapseSelection(); renderHexInput(); return;
|
||||
}
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
hexVirtualCursor = Math.max(0, hexVirtualCursor - 1); collapseSelection(); renderHexInput(); return;
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
hexVirtualCursor = Math.min(hexVirtualValue.length, hexVirtualCursor + 1); collapseSelection(); renderHexInput(); return;
|
||||
}
|
||||
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (hexVirtualValue.length < 7 && /[#0-9a-fA-F]/.test(event.key)) {
|
||||
hexVirtualValue = hexVirtualValue.slice(0, hexVirtualCursor) + event.key + hexVirtualValue.slice(hexVirtualCursor);
|
||||
hexVirtualCursor++;
|
||||
collapseSelection();
|
||||
renderHexInput();
|
||||
onLiveChange(hexVirtualValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles copy/cut for the virtual hex field: writes the selected text to the
|
||||
* clipboard ourselves (the field isn't a real input, so the browser can't).
|
||||
* @param {ClipboardEvent} event
|
||||
* @returns {boolean} Whether the value changed (cut).
|
||||
*/
|
||||
export function handleHexCopy(event) {
|
||||
if (!hexInputActive || !hasSelection()) return false;
|
||||
const text = hexVirtualValue.slice(selectionStart(), selectionEnd());
|
||||
if (!text) return false;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
event.clipboardData?.setData('text/plain', text);
|
||||
if (event.type === 'cut') {
|
||||
const start = selectionStart();
|
||||
hexVirtualValue = hexVirtualValue.slice(0, start) + hexVirtualValue.slice(selectionEnd());
|
||||
hexVirtualCursor = start;
|
||||
collapseSelection();
|
||||
renderHexInput();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ClipboardEvent} event
|
||||
* @returns {string|undefined} Updated hex value after paste.
|
||||
*/
|
||||
export function handleHexPaste(event) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const inserted = (event.clipboardData?.getData('text/plain') || '').replace(/[^#0-9a-fA-F]/g, '');
|
||||
// Replace the current selection (if any), otherwise insert at the caret.
|
||||
const start = selectionStart();
|
||||
const end = selectionEnd();
|
||||
const next = (hexVirtualValue.slice(0, start) + inserted + hexVirtualValue.slice(end)).slice(0, 7);
|
||||
hexVirtualCursor = Math.min(next.length, start + inserted.length);
|
||||
hexVirtualValue = next;
|
||||
collapseSelection();
|
||||
renderHexInput();
|
||||
return hexVirtualValue;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Wires mousedown/drag on the SV square to saturation/value callbacks.
|
||||
* @param {HTMLElement} squareElement
|
||||
* @param {{ onDragStart: (s: number, v: number) => void, onPickChange: (s: number, v: number) => void, onDragEnd: () => void }} callbacks
|
||||
*/
|
||||
export function initPickerSquare(squareElement, callbacks) {
|
||||
const { onDragStart, onPickChange, onDragEnd } = callbacks;
|
||||
|
||||
squareElement.addEventListener('mousedown', (event) => {
|
||||
const { s: saturation, v: value } = getSaturationValueFromPointer(event, squareElement);
|
||||
onDragStart(saturation, value);
|
||||
|
||||
function onMove(event) {
|
||||
event.preventDefault();
|
||||
const { s: saturation, v: value } = getSaturationValueFromPointer(event, squareElement);
|
||||
onPickChange(saturation, value);
|
||||
}
|
||||
function onUp(event) {
|
||||
event.preventDefault();
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
onDragEnd();
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
function getSaturationValueFromPointer(event, squareElement) {
|
||||
const bounds = squareElement.getBoundingClientRect();
|
||||
return {
|
||||
s: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
|
||||
v: Math.max(0, Math.min(1, 1 - (event.clientY - bounds.top) / bounds.height)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { COLOR_ACTION_ID } from './template.js';
|
||||
import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_FOCUS, HOVER_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
|
||||
|
||||
/** Injected CSS for the text color and gradient action panel. */
|
||||
export const COLOR_ACTION_STYLES = `
|
||||
#${COLOR_ACTION_ID} {
|
||||
position: fixed;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
width: 216px;
|
||||
background: ${PANEL_BG};
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
border-radius: 20px;
|
||||
padding: 12px;
|
||||
box-shadow: ${BOX_SHADOW_DROPDOWN};
|
||||
font-family: DM Sans, sans-serif;
|
||||
user-select: none;
|
||||
z-index: ${Z_INDEX_EDITOR_PANEL};
|
||||
}
|
||||
|
||||
#${COLOR_ACTION_ID}.tca-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tft-color-tabs {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
padding: 1px;
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tft-color-tab {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: ${COLOR_WHITE};
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.tft-color-tab:hover {
|
||||
background: ${HOVER_BG};
|
||||
}
|
||||
|
||||
.tft-color-tab.tca-tab-active {
|
||||
background: ${BORDER_COLOR};
|
||||
}
|
||||
|
||||
.tft-color-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 10px 0px;
|
||||
}
|
||||
|
||||
.tft-color-grid.tca-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tft-color-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
.tft-color-swatch {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #262831;
|
||||
pointer-events: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
/* Indicate hover/active by recoloring the swatch's own border (no extra ring). */
|
||||
.tft-color-option:hover .tft-color-swatch,
|
||||
.tft-color-option.tca-active .tft-color-swatch {
|
||||
border-color: ${COLOR_WHITE};
|
||||
}
|
||||
|
||||
.tft-color-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid transparent;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tft-color-add-box {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid ${COLOR_WHITE}4d;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
/* Plus drawn with two bars so it is always geometrically centered. */
|
||||
.tft-color-add-box::before,
|
||||
.tft-color-add-box::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: ${COLOR_WHITE}99;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.tft-color-add-box::before { width: 9px; height: 1px; }
|
||||
.tft-color-add-box::after { width: 1px; height: 9px; }
|
||||
|
||||
.tft-color-add:hover .tft-color-add-box {
|
||||
border-color: ${COLOR_WHITE};
|
||||
}
|
||||
|
||||
.tft-color-add:hover .tft-color-add-box::before,
|
||||
.tft-color-add:hover .tft-color-add-box::after {
|
||||
background: ${COLOR_WHITE};
|
||||
}
|
||||
|
||||
.tft-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tft-picker-square {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 116px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.tft-picker-sq-white {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to right, ${COLOR_WHITE}, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tft-picker-sq-black {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, #000, transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tft-picker-indicator {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid ${COLOR_WHITE};
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.4);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tft-picker-hue,
|
||||
.tft-picker-alpha {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.tft-picker-hue {
|
||||
background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
|
||||
}
|
||||
|
||||
.tft-picker-hue::-webkit-slider-thumb,
|
||||
.tft-picker-alpha::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: ${COLOR_WHITE};
|
||||
border: 1.5px solid rgba(255,255,255,0.4);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tft-picker-hue::-moz-range-thumb,
|
||||
.tft-picker-alpha::-moz-range-thumb {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: ${COLOR_WHITE};
|
||||
border: 1.5px solid rgba(255,255,255,0.4);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tft-picker-hex-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tft-picker-hex-input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
border-radius: 8px;
|
||||
color: ${COLOR_WHITE};
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 6px 8px;
|
||||
height: 28px;
|
||||
box-sizing: border-box;
|
||||
cursor: text;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.tft-picker-hex-input.tca-focused {
|
||||
border-color: ${BORDER_COLOR_FOCUS};
|
||||
}
|
||||
|
||||
.tft-picker-hex-cursor {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 1em;
|
||||
background: ${COLOR_WHITE};
|
||||
margin: 0 -1px;
|
||||
vertical-align: text-bottom;
|
||||
animation: tft-cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
.tft-picker-hex-char {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.tft-picker-hex-csel {
|
||||
background: #357DF966;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
@keyframes tft-cursor-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.tft-picker-hex-picker {
|
||||
background: transparent;
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
padding: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tft-picker-hex-picker:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
|
||||
.tft-gradient-stops {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.tft-gradient-stops.tca-hidden { display: none; }
|
||||
.tft-gradient-stop-swatch {
|
||||
flex: 1;
|
||||
height: 28px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.tft-gradient-stop-swatch .tft-color-swatch {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,295 @@
|
||||
import { SOLID_COLORS, GRADIENTS } from './template.js';
|
||||
import { canonicalGradientKey } from './gradient.js';
|
||||
|
||||
/** Maximum total swatches shown in a grid (presets + custom): 3 rows of 7. */
|
||||
export const COLOR_GRID_MAX = 21;
|
||||
|
||||
/** Maximum custom solid/gradient swatches persisted in localStorage. */
|
||||
export const CUSTOM_SWATCHES_MAX = 32;
|
||||
|
||||
/** localStorage key for user-added solid color swatches. */
|
||||
const CUSTOM_SOLIDS_KEY = 'tft-color-custom-solids';
|
||||
|
||||
/** localStorage key for user-added gradient swatches. */
|
||||
const CUSTOM_GRADIENTS_KEY = 'tft-color-custom-gradients';
|
||||
|
||||
/** Lowercase preset solid colors used to skip duplicate custom entries. */
|
||||
const PRESET_SOLIDS_LOWERCASE = SOLID_COLORS.map(color => color.toLowerCase());
|
||||
|
||||
/** Canonical keys for preset gradients used to skip duplicate custom entries. */
|
||||
const PRESET_GRADIENT_KEYS = GRADIENTS.map(gradient => canonicalGradientKey(gradient));
|
||||
|
||||
let _element = null;
|
||||
|
||||
/** @param {HTMLElement} colorActionElement */
|
||||
export function initSwatches(colorActionElement) {
|
||||
_element = colorActionElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} grid
|
||||
* @returns {boolean} Whether the grid is already at the 21-swatch cap.
|
||||
*/
|
||||
function isGridFull(grid) {
|
||||
return grid.querySelectorAll('.tft-color-option').length >= COLOR_GRID_MAX;
|
||||
}
|
||||
|
||||
/** @param {HTMLElement} option @returns {boolean} */
|
||||
export function isCustomSwatchEl(option) {
|
||||
return !!option && option.classList?.contains('tft-custom');
|
||||
}
|
||||
|
||||
/** Highlights `option` as the active swatch within its grid (and clears siblings). */
|
||||
export function setActiveSwatch(option) {
|
||||
const grid = option?.parentElement;
|
||||
if (!grid) return;
|
||||
grid.querySelectorAll('.tft-color-option').forEach(swatchOption => swatchOption.classList.toggle('tca-active', swatchOption === option));
|
||||
}
|
||||
|
||||
/** Shows/hides the grid's "+" add button based on the 21-swatch cap. */
|
||||
export function updateAddButtonVisibility(grid) {
|
||||
const addButton = grid?.querySelector('.tft-color-add');
|
||||
if (!addButton) return;
|
||||
addButton.style.display = isGridFull(grid) ? 'none' : '';
|
||||
}
|
||||
|
||||
/** Inserts a swatch before the trailing "+" add button (or appends it). */
|
||||
function appendSwatch(grid, element) {
|
||||
const addButton = grid.querySelector('.tft-color-add');
|
||||
if (addButton) grid.insertBefore(element, addButton);
|
||||
else grid.appendChild(element);
|
||||
updateAddButtonVisibility(grid);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key - localStorage key.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function loadCustomList(key) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
const array = raw ? JSON.parse(raw) : [];
|
||||
return Array.isArray(array) ? array.filter(value => typeof value === 'string') : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string[]} array
|
||||
*/
|
||||
export function saveCustomList(key, array) {
|
||||
try { localStorage.setItem(key, JSON.stringify(array.slice(-CUSTOM_SWATCHES_MAX))); } catch { /* quota / disabled */ }
|
||||
}
|
||||
|
||||
/** Rewrites the persisted custom solids list from the swatches currently in the grid. */
|
||||
function persistSolidsFromDom(grid) {
|
||||
const customs = Array.from(grid.querySelectorAll('.tft-color-option.tft-custom'))
|
||||
.map(option => (option.dataset.color || '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
saveCustomList(CUSTOM_SOLIDS_KEY, customs);
|
||||
}
|
||||
|
||||
/** Rewrites the persisted custom gradients list from the swatches currently in the grid. */
|
||||
function persistGradientsFromDom(grid) {
|
||||
const customs = Array.from(grid.querySelectorAll('.tft-color-option.tft-custom'))
|
||||
.map(option => (option.dataset.gradient || '').toLowerCase())
|
||||
.filter(Boolean);
|
||||
saveCustomList(CUSTOM_GRADIENTS_KEY, customs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights the solid swatch matching the given hex.
|
||||
* @param {string|null} hex
|
||||
* @returns {HTMLElement|null} Matched option element.
|
||||
*/
|
||||
export function syncSolidSwatchActive(hex) {
|
||||
if (!_element) return null;
|
||||
const target = hex?.toLowerCase();
|
||||
let found = null;
|
||||
_element.querySelectorAll('.tft-color-grid[data-tab-panel="solid"] .tft-color-option').forEach(option => {
|
||||
const match = !!target && (option.dataset.color || '').toLowerCase() === target;
|
||||
option.classList.toggle('tca-active', match);
|
||||
if (match) found = option;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Highlights the gradient swatch matching the given CSS gradient.
|
||||
* @param {string|null} gradient
|
||||
* @returns {HTMLElement|null} Matched option element.
|
||||
*/
|
||||
export function syncGradientSwatchActive(gradient) {
|
||||
if (!_element) return null;
|
||||
const targetKey = gradient ? canonicalGradientKey(gradient) : '';
|
||||
let found = null;
|
||||
_element.querySelectorAll('.tft-color-grid[data-tab-panel="gradient"] .tft-color-option').forEach(option => {
|
||||
const optionKey = canonicalGradientKey(option.dataset.gradient || '');
|
||||
const match = !!targetKey && optionKey === targetKey;
|
||||
option.classList.toggle('tca-active', match);
|
||||
if (match) found = option;
|
||||
});
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Recolors a swatch in place (presets too); re-persists when it's a custom one. */
|
||||
export function recolorSolidSwatch(option, hex) {
|
||||
if (!option || !hex) return;
|
||||
hex = hex.toLowerCase();
|
||||
option.dataset.color = hex;
|
||||
const innerSwatch = option.querySelector('.tft-color-swatch');
|
||||
if (innerSwatch) innerSwatch.style.background = hex;
|
||||
if (isCustomSwatchEl(option)) persistSolidsFromDom(option.parentElement);
|
||||
}
|
||||
|
||||
/** Recolors a gradient swatch in place (presets too); re-persists when custom. */
|
||||
export function recolorGradientSwatch(option, gradient) {
|
||||
if (!option || !gradient) return;
|
||||
const normalizedGradient = gradient.toLowerCase();
|
||||
option.dataset.gradient = normalizedGradient;
|
||||
const innerSwatch = option.querySelector('.tft-color-swatch');
|
||||
if (innerSwatch) innerSwatch.style.background = normalizedGradient;
|
||||
if (isCustomSwatchEl(option)) persistGradientsFromDom(option.parentElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the existing solid swatch matching `hex`, or appends a new custom one
|
||||
* (up to the 21-swatch cap). Returns null only when the grid is full.
|
||||
* @param {string} hex
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
export function ensureCustomSwatch(hex) {
|
||||
if (!_element || !hex) return null;
|
||||
hex = hex.toLowerCase();
|
||||
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]');
|
||||
if (!grid) return null;
|
||||
const existing = Array.from(grid.querySelectorAll('.tft-color-option'))
|
||||
.find(candidateOption => (candidateOption.dataset.color || '').toLowerCase() === hex);
|
||||
if (existing) return existing;
|
||||
if (isGridFull(grid)) return null;
|
||||
const option = document.createElement('div');
|
||||
option.className = 'tft-color-option tft-custom';
|
||||
option.dataset.color = hex;
|
||||
option.innerHTML = `<span class="tft-color-swatch" style="background:${hex}"></span>`;
|
||||
appendSwatch(grid, option);
|
||||
persistSolidsFromDom(grid);
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the existing gradient swatch matching `gradient`, or appends a new
|
||||
* custom one (up to the cap). Returns null only when the grid is full.
|
||||
* @param {string} gradient
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
export function ensureCustomGradientSwatch(gradient) {
|
||||
if (!_element || !gradient) return null;
|
||||
const normalizedGradient = gradient.toLowerCase();
|
||||
const key = canonicalGradientKey(normalizedGradient);
|
||||
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]');
|
||||
if (!grid) return null;
|
||||
const existing = Array.from(grid.querySelectorAll('.tft-color-option'))
|
||||
.find(candidateOption => canonicalGradientKey(candidateOption.dataset.gradient || '') === key);
|
||||
if (existing) return existing;
|
||||
if (isGridFull(grid)) return null;
|
||||
const option = document.createElement('div');
|
||||
option.className = 'tft-color-option tft-custom';
|
||||
option.dataset.gradient = normalizedGradient;
|
||||
option.innerHTML = `<span class="tft-color-swatch" style="background:${normalizedGradient}"></span>`;
|
||||
appendSwatch(grid, option);
|
||||
persistGradientsFromDom(grid);
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a new custom solid swatch even when the color already exists — used by
|
||||
* the "+" button to duplicate the active swatch. Returns null when the grid is full.
|
||||
* @param {string} hex
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
export function appendCustomSwatch(hex) {
|
||||
if (!_element || !hex) return null;
|
||||
hex = hex.toLowerCase();
|
||||
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]');
|
||||
if (!grid || isGridFull(grid)) return null;
|
||||
const option = document.createElement('div');
|
||||
option.className = 'tft-color-option tft-custom';
|
||||
option.dataset.color = hex;
|
||||
option.innerHTML = `<span class="tft-color-swatch" style="background:${hex}"></span>`;
|
||||
appendSwatch(grid, option);
|
||||
persistSolidsFromDom(grid);
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a new custom gradient swatch even when it already exists — used by the
|
||||
* "+" button to duplicate the active gradient. Returns null when the grid is full.
|
||||
* @param {string} gradient
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
export function appendCustomGradientSwatch(gradient) {
|
||||
if (!_element || !gradient) return null;
|
||||
const normalizedGradient = gradient.toLowerCase();
|
||||
const grid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]');
|
||||
if (!grid || isGridFull(grid)) return null;
|
||||
const option = document.createElement('div');
|
||||
option.className = 'tft-color-option tft-custom';
|
||||
option.dataset.gradient = normalizedGradient;
|
||||
option.innerHTML = `<span class="tft-color-swatch" style="background:${normalizedGradient}"></span>`;
|
||||
appendSwatch(grid, option);
|
||||
persistGradientsFromDom(grid);
|
||||
return option;
|
||||
}
|
||||
|
||||
/** Rebuilds custom solid and gradient swatches from localStorage. */
|
||||
export function restorePersistedSwatches() {
|
||||
if (!_element) return;
|
||||
|
||||
const solidGrid = _element.querySelector('.tft-color-grid[data-tab-panel="solid"]');
|
||||
if (solidGrid) {
|
||||
const stored = loadCustomList(CUSTOM_SOLIDS_KEY);
|
||||
const cleaned = [];
|
||||
const seen = new Set();
|
||||
stored.forEach(hex => {
|
||||
const lowercaseHex = (hex || '').toLowerCase();
|
||||
if (!lowercaseHex || PRESET_SOLIDS_LOWERCASE.includes(lowercaseHex) || seen.has(lowercaseHex)) return;
|
||||
seen.add(lowercaseHex);
|
||||
cleaned.push(lowercaseHex);
|
||||
const exists = Array.from(solidGrid.querySelectorAll('.tft-color-option'))
|
||||
.some(option => (option.dataset.color || '').toLowerCase() === lowercaseHex);
|
||||
if (exists || isGridFull(solidGrid)) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tft-color-option tft-custom';
|
||||
div.dataset.color = lowercaseHex;
|
||||
div.innerHTML = `<span class="tft-color-swatch" style="background:${lowercaseHex}"></span>`;
|
||||
appendSwatch(solidGrid, div);
|
||||
});
|
||||
if (cleaned.length !== stored.length) saveCustomList(CUSTOM_SOLIDS_KEY, cleaned);
|
||||
updateAddButtonVisibility(solidGrid);
|
||||
}
|
||||
|
||||
const gradientGrid = _element.querySelector('.tft-color-grid[data-tab-panel="gradient"]');
|
||||
if (gradientGrid) {
|
||||
const stored = loadCustomList(CUSTOM_GRADIENTS_KEY);
|
||||
const cleaned = [];
|
||||
const seen = new Set();
|
||||
stored.forEach(gradient => {
|
||||
const lowercaseGradient = (gradient || '').toLowerCase();
|
||||
if (!lowercaseGradient) return;
|
||||
const key = canonicalGradientKey(lowercaseGradient);
|
||||
if (PRESET_GRADIENT_KEYS.includes(key) || seen.has(key)) return;
|
||||
seen.add(key);
|
||||
cleaned.push(lowercaseGradient);
|
||||
const exists = Array.from(gradientGrid.querySelectorAll('.tft-color-option'))
|
||||
.some(option => canonicalGradientKey(option.dataset.gradient || '') === key);
|
||||
if (exists || isGridFull(gradientGrid)) return;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'tft-color-option tft-custom';
|
||||
div.dataset.gradient = lowercaseGradient;
|
||||
div.innerHTML = `<span class="tft-color-swatch" style="background:${lowercaseGradient}"></span>`;
|
||||
appendSwatch(gradientGrid, div);
|
||||
});
|
||||
if (cleaned.length !== stored.length) saveCustomList(CUSTOM_GRADIENTS_KEY, cleaned);
|
||||
updateAddButtonVisibility(gradientGrid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/** DOM id for the text color/gradient action panel root element. */
|
||||
export const COLOR_ACTION_ID = 'text-format-color-action';
|
||||
|
||||
/** Preset solid fill colors for the swatch grid. */
|
||||
export const SOLID_COLORS = [
|
||||
'#000000',
|
||||
'#FF3B30',
|
||||
'#FF9500',
|
||||
'#34C759',
|
||||
'#007AFF',
|
||||
];
|
||||
|
||||
/** Preset CSS linear gradients for the swatch grid. */
|
||||
export const GRADIENTS = [
|
||||
'linear-gradient(90deg, #FF6B6B, #FFE66D)',
|
||||
'linear-gradient(90deg, #4A90E2, #50C9C3)',
|
||||
'linear-gradient(90deg, #F12711, #F5AF19)',
|
||||
'linear-gradient(90deg, #8E2DE2, #4A00E0)',
|
||||
'linear-gradient(90deg, #00B09B, #96C93D)',
|
||||
];
|
||||
|
||||
/** HTML scaffold for the color action panel (tabs, swatches, picker). */
|
||||
export const COLOR_ACTION_HTML = `
|
||||
<div id="${COLOR_ACTION_ID}">
|
||||
<div class="tft-color-tabs">
|
||||
<button class="tft-color-tab tca-tab-active" data-tab="solid">Solid</button>
|
||||
<button class="tft-color-tab" data-tab="gradient">Gradient</button>
|
||||
</div>
|
||||
<div class="tft-color-grid" data-tab-panel="solid">
|
||||
${SOLID_COLORS.map(color => `<div class="tft-color-option" data-color="${color}"><span class="tft-color-swatch" style="background:${color}"></span></div>`).join('\n ')}
|
||||
<div class="tft-color-add" role="button" aria-label="Add color" title="Add color"><span class="tft-color-add-box"></span></div>
|
||||
</div>
|
||||
<div class="tft-color-grid tca-hidden" data-tab-panel="gradient">
|
||||
${GRADIENTS.map(gradient => `<div class="tft-color-option" data-gradient="${gradient}"><span class="tft-color-swatch" style="background:${gradient}"></span></div>`).join('\n ')}
|
||||
<div class="tft-color-add" role="button" aria-label="Add gradient" title="Add gradient"><span class="tft-color-add-box"></span></div>
|
||||
</div>
|
||||
<div class="tft-gradient-stops tca-hidden" data-tab-panel="gradient">
|
||||
<div class="tft-color-option tft-gradient-stop-swatch" data-stop="0"><span class="tft-color-swatch"></span></div>
|
||||
<div class="tft-color-option tft-gradient-stop-swatch" data-stop="1"><span class="tft-color-swatch"></span></div>
|
||||
</div>
|
||||
<div class="tft-picker">
|
||||
<div class="tft-picker-square">
|
||||
<div class="tft-picker-sq-white"></div>
|
||||
<div class="tft-picker-sq-black"></div>
|
||||
<div class="tft-picker-indicator"></div>
|
||||
</div>
|
||||
<input type="range" class="tft-picker-hue" min="0" max="360" value="0">
|
||||
<input type="range" class="tft-picker-alpha" min="0" max="100" value="100">
|
||||
<div class="tft-picker-hex-row">
|
||||
<div class="tft-picker-hex-input" role="textbox"></div>
|
||||
<button class="tft-picker-hex-picker">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8.22768 7.04306L4.90249 10.3683C4.84755 10.4232 4.8201 10.5012 4.83022 10.5782L4.90249 11.1212L5.39174 11.1661L5.44643 11.1651C5.50056 11.1581 5.55163 11.1331 5.59096 11.0938L8.93471 7.75009L9.99624 8.81064L6.65151 12.1544C6.33072 12.4751 5.89991 12.6576 5.45132 12.6671L5.25796 12.6602L4.52065 12.5938L4.27553 12.839C3.98275 13.1317 3.50791 13.1315 3.21499 12.839C2.94053 12.5645 2.92334 12.1306 3.16323 11.836L3.21499 11.7784L3.44643 11.547L3.34389 10.7735C3.273 10.2342 3.45731 9.69234 3.84194 9.30771L7.16714 5.98251L8.22768 7.04306Z" fill="#ffffff"/>
|
||||
<path d="M10.3869 2.83798C11.1679 2.05694 12.4349 2.05694 13.216 2.83798C13.9966 3.61894 13.9966 4.88512 13.216 5.66611L11.6242 7.25693L11.9777 7.61044C12.2706 7.90334 12.2706 8.37907 11.9777 8.67197C11.6848 8.96443 11.2099 8.96461 10.9171 8.67197L7.38198 5.13584C7.08909 4.84294 7.08909 4.36818 7.38198 4.07529C7.67488 3.78249 8.14967 3.78243 8.44253 4.07529L8.79604 4.4288L10.3869 2.83798Z" fill="#ffffff"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`.trim();
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Shared lifecycle helpers for the toolbar dropdown action panels
|
||||
* (size, font, link, color): DOM bootstrapping, positioning relative to the
|
||||
* toolbar, and outside-interaction dismissal.
|
||||
*/
|
||||
import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT } from '../../constants/layout.js';
|
||||
import { isolateEditorUiEvents } from '../../utils/dom-utils.js';
|
||||
import { getToolbarEl } from './toolbar/toolbar.js';
|
||||
|
||||
/**
|
||||
* Injects the panel stylesheet (once) and mounts the panel root element.
|
||||
* @param {{ styleId: string, styles: string, html: string }} config
|
||||
* @returns {HTMLElement} The mounted panel root.
|
||||
*/
|
||||
export function createPanelElement({ styleId, styles, html }) {
|
||||
if (!document.getElementById(styleId)) {
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
style.textContent = styles;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = html;
|
||||
const element = wrapper.firstElementChild;
|
||||
document.body.appendChild(element);
|
||||
isolateEditorUiEvents(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions a dropdown panel below (or above) the toolbar, left-aligned to its
|
||||
* trigger button and clamped to the viewport.
|
||||
* @param {HTMLElement} element - Panel root.
|
||||
* @param {{ buttonAction?: string|null, matchToolbarWidth?: boolean }} [options]
|
||||
*/
|
||||
export function positionDropdownPanel(element, { buttonAction = null, matchToolbarWidth = false } = {}) {
|
||||
const toolbarElement = getToolbarEl();
|
||||
if (!toolbarElement || !element) return;
|
||||
|
||||
const toolbarBounds = toolbarElement.getBoundingClientRect();
|
||||
if (matchToolbarWidth) element.style.width = `${toolbarBounds.width}px`;
|
||||
|
||||
const button = buttonAction ? toolbarElement.querySelector(`[data-action="${buttonAction}"]`) : null;
|
||||
const anchorBounds = button ? button.getBoundingClientRect() : toolbarBounds;
|
||||
const panelWidth = matchToolbarWidth ? toolbarBounds.width : element.offsetWidth;
|
||||
const panelHeight = element.offsetHeight;
|
||||
|
||||
const spaceBelow = window.innerHeight - PARENT_TOOLBAR_HEIGHT - toolbarBounds.bottom;
|
||||
const top = Math.max(PANEL_MARGIN, spaceBelow >= panelHeight + PANEL_GAP
|
||||
? toolbarBounds.bottom + PANEL_GAP
|
||||
: toolbarBounds.top - panelHeight - PANEL_GAP);
|
||||
|
||||
const left = Math.max(PANEL_MARGIN, Math.min(anchorBounds.left, window.innerWidth - panelWidth - PANEL_MARGIN));
|
||||
|
||||
element.style.left = `${left}px`;
|
||||
element.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds attach/detach-able listeners that dismiss a panel on outside
|
||||
* mousedown.
|
||||
* @param {{
|
||||
* getElement: () => HTMLElement|null,
|
||||
* dismiss: () => void,
|
||||
* ignoreToolbar?: boolean,
|
||||
* ignoreMouseDown?: ((event: MouseEvent) => boolean)|null,
|
||||
* captureMouseDown?: boolean,
|
||||
* }} config
|
||||
* @returns {{ attach: () => void, detach: () => void }}
|
||||
*/
|
||||
export function createOutsideDismiss({ getElement, dismiss, ignoreToolbar = true, ignoreMouseDown = null, captureMouseDown = false }) {
|
||||
function onMouseDown(event) {
|
||||
if (getElement()?.contains(event.target)) return;
|
||||
if (ignoreToolbar && getToolbarEl()?.contains(event.target)) return;
|
||||
if (ignoreMouseDown?.(event)) return;
|
||||
dismiss();
|
||||
}
|
||||
return {
|
||||
attach() {
|
||||
document.addEventListener('mousedown', onMouseDown, captureMouseDown);
|
||||
},
|
||||
detach() {
|
||||
document.removeEventListener('mousedown', onMouseDown, captureMouseDown);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Range|null} Clone of the current selection range, if any.
|
||||
*/
|
||||
export function captureSelectionRange() {
|
||||
const selection = window.getSelection();
|
||||
return selection && selection.rangeCount > 0 ? selection.getRangeAt(0).cloneRange() : null;
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
|
||||
import { getToolbarTarget } from '../toolbar/toolbar.js';
|
||||
import { wrapSelectionWithStyle } from '../style-actions.js';
|
||||
import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js';
|
||||
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
|
||||
import { SYSTEM_FONTS, FONT_ACTION_HTML } from './template.js';
|
||||
import { FONT_ACTION_STYLES } from './styles.js';
|
||||
|
||||
let fontActionElement = null;
|
||||
let savedFontRange = null;
|
||||
|
||||
const outsideDismiss = createOutsideDismiss({
|
||||
getElement: () => fontActionElement,
|
||||
dismiss: () => hideFontAction(),
|
||||
});
|
||||
|
||||
function getAvailableFonts() {
|
||||
const fonts = [...SYSTEM_FONTS];
|
||||
const seen = new Set(SYSTEM_FONTS.map(font => font.name.toLowerCase()));
|
||||
document.fonts?.forEach(face => {
|
||||
if (face.status !== 'loaded') return;
|
||||
const family = face.family.replace(/^['"]|['"]$/g, '');
|
||||
const key = family.toLowerCase();
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const needsQuotes = /\s/.test(family);
|
||||
const quoted = needsQuotes ? `"${family}"` : family;
|
||||
fonts.push({ name: family, stack: `${quoted}, sans-serif` });
|
||||
});
|
||||
return fonts;
|
||||
}
|
||||
|
||||
function renderFontOptions() {
|
||||
if (!fontActionElement) return;
|
||||
fontActionElement.innerHTML = getAvailableFonts()
|
||||
.map(font => `<div class="tft-font-option" data-font='${font.stack}' style='font-family: ${font.stack}'>${font.name}</div>`)
|
||||
.join('\n ');
|
||||
}
|
||||
|
||||
function initFontAction() {
|
||||
if (fontActionElement) return;
|
||||
fontActionElement = createPanelElement({
|
||||
styleId: 'text-format-font-action-styles',
|
||||
styles: FONT_ACTION_STYLES,
|
||||
html: FONT_ACTION_HTML,
|
||||
});
|
||||
|
||||
fontActionElement.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
|
||||
fontActionElement.addEventListener('click', (event) => {
|
||||
const option = event.target.closest('.tft-font-option');
|
||||
if (!option) return;
|
||||
applyFontAction(option.dataset.font);
|
||||
hideFontAction();
|
||||
});
|
||||
}
|
||||
|
||||
function applyFontAction(family) {
|
||||
if (!savedFontRange || !getToolbarTarget() || !family) return;
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(savedFontRange);
|
||||
wrapSelectionWithStyle(getToolbarTarget(), 'fontFamily', 'font-family', family);
|
||||
onSelectionChange();
|
||||
}
|
||||
|
||||
export function positionFontAction() {
|
||||
if (!isPanelVisible(PanelId.FONT) || !fontActionElement) return;
|
||||
positionDropdownPanel(fontActionElement, { buttonAction: 'font-family' });
|
||||
}
|
||||
|
||||
/** Opens the font family panel with the current family highlighted. */
|
||||
export function showFontAction() {
|
||||
if (!fontActionElement) initFontAction();
|
||||
renderFontOptions();
|
||||
|
||||
savedFontRange = captureSelectionRange();
|
||||
|
||||
const styles = getStylesAtCursor();
|
||||
const currentFamily = styles?.fontFamily
|
||||
?? (getToolbarTarget() ? getComputedStyle(getToolbarTarget()).fontFamily : '');
|
||||
const normalizedCurrent = currentFamily.split(',')[0].replace(/['"]/g, '').trim().toLowerCase();
|
||||
fontActionElement.querySelectorAll('.tft-font-option').forEach(option => {
|
||||
const optionName = option.dataset.font.split(',')[0].replace(/['"]/g, '').trim().toLowerCase();
|
||||
option.classList.toggle('tfa-active', optionName === normalizedCurrent);
|
||||
});
|
||||
|
||||
fontActionElement.classList.add('tfa-visible');
|
||||
setPanelVisible(PanelId.FONT, true);
|
||||
positionFontAction();
|
||||
|
||||
outsideDismiss.attach();
|
||||
}
|
||||
|
||||
/** Closes the font family panel. */
|
||||
export function hideFontAction() {
|
||||
if (!fontActionElement) return;
|
||||
fontActionElement.classList.remove('tfa-visible');
|
||||
setPanelVisible(PanelId.FONT, false);
|
||||
savedFontRange = null;
|
||||
outsideDismiss.detach();
|
||||
}
|
||||
|
||||
/** Toggles the font family panel visibility. */
|
||||
export function toggleFontAction() {
|
||||
if (isPanelVisible(PanelId.FONT)) {
|
||||
hideFontAction();
|
||||
} else {
|
||||
showFontAction();
|
||||
}
|
||||
}
|
||||
|
||||
registerPanel({ id: PanelId.FONT, hide: hideFontAction });
|
||||
@@ -0,0 +1,41 @@
|
||||
import { FONT_ACTION_ID } from './template.js';
|
||||
import { PANEL_BG, BORDER_COLOR, HOVER_BG, ACTIVE_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
|
||||
|
||||
/** Injected CSS for the font family dropdown panel. */
|
||||
export const FONT_ACTION_STYLES = `
|
||||
#${FONT_ACTION_ID} {
|
||||
position: fixed;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
max-height: 280px;
|
||||
background: ${PANEL_BG};
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
box-shadow: ${BOX_SHADOW_DROPDOWN};
|
||||
user-select: none;
|
||||
z-index: ${Z_INDEX_EDITOR_PANEL};
|
||||
}
|
||||
|
||||
#${FONT_ACTION_ID}.tfa-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tft-font-option {
|
||||
padding: 4px 10px;
|
||||
color: ${COLOR_WHITE};
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tft-font-option:hover {
|
||||
background: ${HOVER_BG};
|
||||
}
|
||||
|
||||
.tft-font-option.tfa-active {
|
||||
background: ${ACTIVE_BG};
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,16 @@
|
||||
/** DOM id for the font family action panel root element. */
|
||||
export const FONT_ACTION_ID = 'text-format-font-action';
|
||||
|
||||
/** System font presets shown in the font family dropdown. */
|
||||
export const SYSTEM_FONTS = [
|
||||
{ name: 'Arial', stack: 'Arial, sans-serif' },
|
||||
{ name: 'Helvetica', stack: 'Helvetica, sans-serif' },
|
||||
{ name: 'Verdana', stack: 'Verdana, sans-serif' },
|
||||
{ name: 'Trebuchet MS', stack: '"Trebuchet MS", sans-serif' },
|
||||
{ name: 'Georgia', stack: 'Georgia, serif' },
|
||||
{ name: 'Times New Roman', stack: '"Times New Roman", serif' },
|
||||
{ name: 'Courier New', stack: '"Courier New", monospace' },
|
||||
];
|
||||
|
||||
/** HTML scaffold for the font family action panel. */
|
||||
export const FONT_ACTION_HTML = `<div id="${FONT_ACTION_ID}"></div>`;
|
||||
@@ -0,0 +1,427 @@
|
||||
import { getEditing } from '../../../state/editing-state.js';
|
||||
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
|
||||
import { recordEdit } from '../../../state/history-state.js';
|
||||
import { notifyDraftStateChanged } from '../../../api/draft-snapshot.js';
|
||||
import { escapeHtml } from '../../../utils/html-utils.js';
|
||||
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
|
||||
import { captureElementMetadata } from '../../../utils/selection-mode-metadata.js';
|
||||
import { LINK_ACTION_HTML } from './template.js';
|
||||
import { LINK_ACTION_STYLES } from './styles.js';
|
||||
|
||||
let linkActionElement = null;
|
||||
let savedLinkRange = null;
|
||||
let linkVirtualValue = '';
|
||||
let linkVirtualCursor = 0;
|
||||
let linkAnchor = 0;
|
||||
let linkDragging = false;
|
||||
|
||||
const outsideDismiss = createOutsideDismiss({
|
||||
getElement: () => linkActionElement,
|
||||
dismiss: () => saveLink(),
|
||||
});
|
||||
|
||||
/** @returns {number} Lower selection bound. */
|
||||
function selectionStart() { return Math.min(linkAnchor, linkVirtualCursor); }
|
||||
/** @returns {number} Upper selection bound. */
|
||||
function selectionEnd() { return Math.max(linkAnchor, linkVirtualCursor); }
|
||||
/** @returns {boolean} Whether a (non-collapsed) selection exists. */
|
||||
function hasSelection() { return linkAnchor !== linkVirtualCursor; }
|
||||
/** Collapses the selection onto the caret. */
|
||||
function collapseSelection() { linkAnchor = linkVirtualCursor; }
|
||||
|
||||
function renderLinkInput() {
|
||||
const element = linkActionElement?.querySelector('#tft-link-url');
|
||||
if (!element) return;
|
||||
const caret = '<span class="tft-link-vcursor"></span>';
|
||||
if (!linkVirtualValue) {
|
||||
element.innerHTML = `<span class="tft-link-placeholder">Paste or type a link</span>${caret}`;
|
||||
return;
|
||||
}
|
||||
const start = selectionStart();
|
||||
const end = selectionEnd();
|
||||
let html = '';
|
||||
for (let index = 0; index < linkVirtualValue.length; index++) {
|
||||
if (index === linkVirtualCursor) html += caret;
|
||||
const selectionClass = index >= start && index < end ? ' tft-link-csel' : '';
|
||||
html += `<span class="tft-link-char${selectionClass}">${escapeHtml(linkVirtualValue[index])}</span>`;
|
||||
}
|
||||
if (linkVirtualCursor === linkVirtualValue.length) html += caret;
|
||||
element.innerHTML = html;
|
||||
}
|
||||
|
||||
/** Selects the whole link value (double-click / Cmd+A) so the next input or paste replaces it. */
|
||||
function selectAllLink() {
|
||||
linkAnchor = 0;
|
||||
linkVirtualCursor = linkVirtualValue.length;
|
||||
renderLinkInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a viewport x coordinate to a caret index between characters.
|
||||
* @param {number} pointerX
|
||||
* @returns {number}
|
||||
*/
|
||||
function caretIndexFromPointerX(pointerX) {
|
||||
const element = linkActionElement?.querySelector('#tft-link-url');
|
||||
if (!element) return 0;
|
||||
const characters = element.querySelectorAll('.tft-link-char');
|
||||
for (let index = 0; index < characters.length; index++) {
|
||||
const characterBounds = characters[index].getBoundingClientRect();
|
||||
// Caret goes before this character once the click lands left of its midpoint.
|
||||
if (pointerX < characterBounds.left + characterBounds.width / 2) return index;
|
||||
}
|
||||
return characters.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins a click/drag in the field: drops a collapsed caret at the pointer.
|
||||
* @param {number} pointerX
|
||||
*/
|
||||
function linkPointerDown(pointerX) {
|
||||
renderLinkInput();
|
||||
const caretIndex = caretIndexFromPointerX(pointerX);
|
||||
linkAnchor = caretIndex;
|
||||
linkVirtualCursor = caretIndex;
|
||||
renderLinkInput();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends the selection to the dragged-to position.
|
||||
* @param {number} pointerX
|
||||
*/
|
||||
function linkPointerMove(pointerX) {
|
||||
linkVirtualCursor = caretIndexFromPointerX(pointerX);
|
||||
renderLinkInput();
|
||||
}
|
||||
|
||||
function onLinkKeydown(event) {
|
||||
if (!isPanelVisible(PanelId.LINK)) return;
|
||||
if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); saveLink(); return; }
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); hideLinkAction(); return; }
|
||||
if (event.key === 'a' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
selectAllLink();
|
||||
return;
|
||||
}
|
||||
if (hasSelection()) {
|
||||
const start = selectionStart();
|
||||
const end = selectionEnd();
|
||||
if (event.key === 'Backspace' || event.key === 'Delete') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
linkVirtualValue = linkVirtualValue.slice(0, start) + linkVirtualValue.slice(end);
|
||||
linkVirtualCursor = start;
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
return;
|
||||
}
|
||||
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
linkVirtualValue = linkVirtualValue.slice(0, start) + event.key + linkVirtualValue.slice(end);
|
||||
linkVirtualCursor = start + 1;
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
return;
|
||||
}
|
||||
// Any other key collapses the selection before normal handling.
|
||||
collapseSelection();
|
||||
}
|
||||
if (event.key === 'Backspace') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (linkVirtualCursor > 0) {
|
||||
linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor - 1) + linkVirtualValue.slice(linkVirtualCursor);
|
||||
linkVirtualCursor--;
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Delete') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (linkVirtualCursor < linkVirtualValue.length) {
|
||||
linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor) + linkVirtualValue.slice(linkVirtualCursor + 1);
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Home' || (event.key === 'ArrowLeft' && (event.metaKey || event.ctrlKey))) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
linkVirtualCursor = 0;
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'End' || (event.key === 'ArrowRight' && (event.metaKey || event.ctrlKey))) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
linkVirtualCursor = linkVirtualValue.length;
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
linkVirtualCursor = Math.max(0, linkVirtualCursor - 1);
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowRight') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
linkVirtualCursor = Math.min(linkVirtualValue.length, linkVirtualCursor + 1);
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
return;
|
||||
}
|
||||
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
linkVirtualValue = linkVirtualValue.slice(0, linkVirtualCursor) + event.key + linkVirtualValue.slice(linkVirtualCursor);
|
||||
linkVirtualCursor++;
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
}
|
||||
}
|
||||
|
||||
function onLinkCopy(event) {
|
||||
if (!isPanelVisible(PanelId.LINK)) return;
|
||||
if (!hasSelection()) return;
|
||||
const text = linkVirtualValue.slice(selectionStart(), selectionEnd());
|
||||
if (!text) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
event.clipboardData?.setData('text/plain', text);
|
||||
if (event.type === 'cut') {
|
||||
const start = selectionStart();
|
||||
linkVirtualValue = linkVirtualValue.slice(0, start) + linkVirtualValue.slice(selectionEnd());
|
||||
linkVirtualCursor = start;
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
}
|
||||
}
|
||||
|
||||
function onLinkPaste(event) {
|
||||
if (!isPanelVisible(PanelId.LINK)) return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
const text = (event.clipboardData?.getData('text/plain') || '').replace(/\s/g, '');
|
||||
// Replace the current selection (if any), otherwise insert at the caret.
|
||||
const start = selectionStart();
|
||||
const end = selectionEnd();
|
||||
linkVirtualValue = linkVirtualValue.slice(0, start) + text + linkVirtualValue.slice(end);
|
||||
linkVirtualCursor = start + text.length;
|
||||
collapseSelection();
|
||||
renderLinkInput();
|
||||
}
|
||||
|
||||
function initLinkAction() {
|
||||
if (linkActionElement) return;
|
||||
linkActionElement = createPanelElement({
|
||||
styleId: 'text-format-link-action-styles',
|
||||
styles: LINK_ACTION_STYLES,
|
||||
html: LINK_ACTION_HTML,
|
||||
});
|
||||
|
||||
linkActionElement.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
// Start a drag-selection inside the virtual link field (it isn't a real
|
||||
// <input>, so we emulate caret/selection from pointer coordinates).
|
||||
if (event.target.closest('#tft-link-url')) {
|
||||
linkActionElement.querySelector('#tft-link-url')?.classList.add('tla-focused');
|
||||
linkPointerDown(event.clientX);
|
||||
linkDragging = true;
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (event) => {
|
||||
if (linkDragging) linkPointerMove(event.clientX);
|
||||
});
|
||||
document.addEventListener('mouseup', () => { linkDragging = false; });
|
||||
|
||||
linkActionElement.addEventListener('dblclick', (event) => {
|
||||
if (event.target.closest('#tft-link-url')) {
|
||||
event.preventDefault();
|
||||
selectAllLink();
|
||||
}
|
||||
});
|
||||
|
||||
linkActionElement.addEventListener('click', (event) => {
|
||||
const labelElement = event.target.closest('.tft-link-newwindow');
|
||||
if (labelElement) {
|
||||
const checkbox = labelElement.querySelector('#tft-link-new-window');
|
||||
if (checkbox) checkbox.checked = !checkbox.checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function findAnchorInRange(range) {
|
||||
if (!range) return null;
|
||||
for (const container of [range.startContainer, range.endContainer]) {
|
||||
let node = container.nodeType === Node.TEXT_NODE ? container.parentNode : container;
|
||||
while (node && node !== document.body) {
|
||||
if (node.tagName === 'A') return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// A "source anchor" is an <a> rendered from JSX <a> or <Link> in the user's source
|
||||
// (carries data-edit-url-attr). Its URL is editable in place by writing back to source.
|
||||
// Distinct from toolbar-inserted anchors which only live inside the contenteditable.
|
||||
function findSourceAnchorInRange(range) {
|
||||
if (!range) return null;
|
||||
for (const container of [range.startContainer, range.endContainer]) {
|
||||
let node = container.nodeType === Node.TEXT_NODE ? container.parentNode : container;
|
||||
while (node && node !== document.body) {
|
||||
if (node.nodeType === Node.ELEMENT_NODE && node.hasAttribute('data-edit-url-attr')) return node;
|
||||
node = node.parentNode;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function unwrapAnchor(anchor) {
|
||||
const parent = anchor.parentNode;
|
||||
while (anchor.firstChild) parent.insertBefore(anchor.firstChild, anchor);
|
||||
parent.removeChild(anchor);
|
||||
}
|
||||
|
||||
function applyLink(range, url, openNewTab) {
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.style.textDecoration = 'underline';
|
||||
if (openNewTab) {
|
||||
anchor.target = '_blank';
|
||||
anchor.rel = 'noopener noreferrer';
|
||||
}
|
||||
try {
|
||||
range.surroundContents(anchor);
|
||||
} catch {
|
||||
anchor.appendChild(range.extractContents());
|
||||
range.insertNode(anchor);
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
const newRange = document.createRange();
|
||||
newRange.selectNodeContents(anchor);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
}
|
||||
|
||||
function saveLink() {
|
||||
const url = linkVirtualValue.trim();
|
||||
const newTab = linkActionElement.querySelector('#tft-link-new-window').checked;
|
||||
|
||||
let range = savedLinkRange;
|
||||
|
||||
// Source-anchor path: rendered <a>/<Link> from JSX. Update href in place and
|
||||
// persist the attribute edit. We must NOT unwrap — that destroys the contenteditable target.
|
||||
const sourceAnchor = findSourceAnchorInRange(range);
|
||||
if (sourceAnchor) {
|
||||
// Empty URL on a source anchor would break navigation; no "remove link" semantic
|
||||
// for source-attached anchors — leave as-is.
|
||||
if (!url) { hideLinkAction(); return; }
|
||||
|
||||
const editId = sourceAnchor.getAttribute('data-edit-id');
|
||||
const urlAttribute = sourceAnchor.getAttribute('data-edit-url-attr');
|
||||
const oldUrl = sourceAnchor.getAttribute('href');
|
||||
|
||||
if (oldUrl !== url && editId && urlAttribute) {
|
||||
const selectionMode = captureElementMetadata(sourceAnchor);
|
||||
sourceAnchor.setAttribute('href', url);
|
||||
recordEdit(editId, { beforeContent: oldUrl, afterContent: url }, { attribute: urlAttribute, element: sourceAnchor, selectionMode });
|
||||
notifyDraftStateChanged();
|
||||
}
|
||||
hideLinkAction();
|
||||
return;
|
||||
}
|
||||
|
||||
// Inline anchor path: anchors that live only inside contenteditable HTML
|
||||
// (created/edited by the toolbar itself). Wrap/unwrap the DOM and let the
|
||||
// outer text edit's innerHTML diff carry the change to source on commit.
|
||||
const existing = findAnchorInRange(range);
|
||||
let changed = false;
|
||||
|
||||
if (existing) {
|
||||
const children = Array.from(existing.childNodes);
|
||||
const entirelyInside = children.length > 0
|
||||
&& existing.contains(range.startContainer)
|
||||
&& existing.contains(range.endContainer);
|
||||
unwrapAnchor(existing);
|
||||
changed = true;
|
||||
if (entirelyInside) {
|
||||
range = document.createRange();
|
||||
range.setStartBefore(children[0]);
|
||||
range.setEndAfter(children[children.length - 1]);
|
||||
}
|
||||
}
|
||||
if (url && range) {
|
||||
applyLink(range, url, newTab);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
getEditing()?.targetElement?.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
hideLinkAction();
|
||||
}
|
||||
|
||||
export function positionLinkAction() {
|
||||
if (!isPanelVisible(PanelId.LINK) || !linkActionElement) return;
|
||||
positionDropdownPanel(linkActionElement, { matchToolbarWidth: true });
|
||||
}
|
||||
|
||||
/** Opens the link panel prefilled from an existing anchor in the selection. */
|
||||
export function showLinkAction() {
|
||||
if (!linkActionElement) initLinkAction();
|
||||
|
||||
savedLinkRange = captureSelectionRange();
|
||||
|
||||
const existing = findAnchorInRange(savedLinkRange);
|
||||
linkVirtualValue = existing ? (existing.getAttribute('href') || '') : '';
|
||||
linkVirtualCursor = linkVirtualValue.length;
|
||||
linkAnchor = linkVirtualValue.length;
|
||||
|
||||
// Source anchors don't roundtrip target/rel back to JSX yet, so hide the
|
||||
// "open in new tab" checkbox to avoid a no-op control.
|
||||
const sourceAnchor = findSourceAnchorInRange(savedLinkRange);
|
||||
const newTabLabel = linkActionElement.querySelector('.tft-link-newwindow');
|
||||
if (newTabLabel) newTabLabel.style.display = sourceAnchor ? 'none' : '';
|
||||
linkActionElement.querySelector('#tft-link-new-window').checked = !sourceAnchor && existing ? existing.target === '_blank' : false;
|
||||
|
||||
linkActionElement.classList.add('tla-visible');
|
||||
setPanelVisible(PanelId.LINK, true);
|
||||
linkActionElement.querySelector('#tft-link-url')?.classList.add('tla-focused');
|
||||
positionLinkAction();
|
||||
renderLinkInput();
|
||||
|
||||
document.addEventListener('keydown', onLinkKeydown, true);
|
||||
document.addEventListener('paste', onLinkPaste, true);
|
||||
document.addEventListener('copy', onLinkCopy, true);
|
||||
document.addEventListener('cut', onLinkCopy, true);
|
||||
outsideDismiss.attach();
|
||||
}
|
||||
|
||||
/** Closes the link panel and clears saved range. */
|
||||
export function hideLinkAction() {
|
||||
if (!linkActionElement) return;
|
||||
linkActionElement.classList.remove('tla-visible');
|
||||
linkActionElement.querySelector('#tft-link-url')?.classList.remove('tla-focused');
|
||||
setPanelVisible(PanelId.LINK, false);
|
||||
savedLinkRange = null;
|
||||
linkAnchor = linkVirtualCursor;
|
||||
linkDragging = false;
|
||||
document.removeEventListener('keydown', onLinkKeydown, true);
|
||||
document.removeEventListener('paste', onLinkPaste, true);
|
||||
document.removeEventListener('copy', onLinkCopy, true);
|
||||
document.removeEventListener('cut', onLinkCopy, true);
|
||||
outsideDismiss.detach();
|
||||
}
|
||||
|
||||
/** Toggles the link panel visibility. */
|
||||
export function toggleLinkAction() {
|
||||
if (isPanelVisible(PanelId.LINK)) {
|
||||
hideLinkAction();
|
||||
} else {
|
||||
showLinkAction();
|
||||
}
|
||||
}
|
||||
|
||||
registerPanel({ id: PanelId.LINK, hide: hideLinkAction });
|
||||
@@ -0,0 +1,120 @@
|
||||
import { LINK_ACTION_ID } from './template.js';
|
||||
import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_FOCUS, BOX_SHADOW_DROPDOWN, COLOR_WHITE, PLACEHOLDER_COLOR, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
|
||||
|
||||
/** Injected CSS for the link insertion panel. */
|
||||
export const LINK_ACTION_STYLES = `
|
||||
#${LINK_ACTION_ID} {
|
||||
position: fixed;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
background: ${PANEL_BG};
|
||||
padding: 8px;
|
||||
border-radius: 12px;
|
||||
box-sizing: border-box;
|
||||
box-shadow: ${BOX_SHADOW_DROPDOWN};
|
||||
user-select: none;
|
||||
font-family: DM Sans, sans-serif;
|
||||
z-index: ${Z_INDEX_EDITOR_PANEL};
|
||||
}
|
||||
|
||||
#${LINK_ACTION_ID}.tla-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tft-link-input {
|
||||
background: transparent;
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
border-radius: 8px;
|
||||
color: ${COLOR_WHITE};
|
||||
font-family: DM Sans, sans-serif;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 6px 8px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 32px;
|
||||
cursor: text;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tft-link-input.tla-focused {
|
||||
border-color: ${BORDER_COLOR_FOCUS};
|
||||
}
|
||||
|
||||
.tft-link-placeholder {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 8px;
|
||||
color: ${PLACEHOLDER_COLOR};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tft-link-char {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.tft-link-csel {
|
||||
background: #357DF966;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.tft-link-vcursor {
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 1em;
|
||||
background: ${COLOR_WHITE};
|
||||
margin-right: -1px;
|
||||
vertical-align: text-bottom;
|
||||
animation: tft-cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes tft-cursor-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.tft-link-newwindow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tft-link-checkbox {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1.5px solid ${COLOR_WHITE}4d;
|
||||
border-radius: 3px;
|
||||
background: ${COLOR_WHITE}08;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.tft-link-checkbox:checked {
|
||||
background: ${COLOR_WHITE};
|
||||
border-color: ${COLOR_WHITE};
|
||||
}
|
||||
.tft-link-checkbox:checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 4px;
|
||||
top: 2px;
|
||||
width: 4px;
|
||||
height: 7px;
|
||||
border: 2px solid ${PANEL_BG};
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.tft-link-newwindow-label {
|
||||
font-size: 12px;
|
||||
color: ${COLOR_WHITE};
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,13 @@
|
||||
/** DOM id for the link action panel root element. */
|
||||
export const LINK_ACTION_ID = 'text-format-link-action';
|
||||
|
||||
/** HTML scaffold for the link panel (URL input and new-tab checkbox). */
|
||||
export const LINK_ACTION_HTML = `
|
||||
<div id="${LINK_ACTION_ID}">
|
||||
<div id="tft-link-url" class="tft-link-input" role="textbox" aria-label="URL"></div>
|
||||
<label class="tft-link-newwindow">
|
||||
<input class="tft-link-checkbox" type="checkbox" id="tft-link-new-window" />
|
||||
<span class="tft-link-newwindow-label">New tab</span>
|
||||
</label>
|
||||
</div>
|
||||
`.trim();
|
||||
@@ -0,0 +1,102 @@
|
||||
import { registerPanel, PanelId, setPanelVisible, isPanelVisible } from '../../../state/panel-state.js';
|
||||
import { getToolbarTarget, stopFontSizeEdit } from '../toolbar/toolbar.js';
|
||||
import { wrapSelectionWithStyle } from '../style-actions.js';
|
||||
import { getStylesAtCursor, onSelectionChange } from '../../inline-edit/cursor-styles.js';
|
||||
import { createPanelElement, positionDropdownPanel, createOutsideDismiss, captureSelectionRange } from '../dropdown-panel.js';
|
||||
import { SIZE_ACTION_HTML } from './template.js';
|
||||
import { SIZE_ACTION_STYLES } from './styles.js';
|
||||
|
||||
let sizeActionElement = null;
|
||||
let savedSizeRange = null;
|
||||
|
||||
const outsideDismiss = createOutsideDismiss({
|
||||
getElement: () => sizeActionElement,
|
||||
dismiss: () => {
|
||||
stopFontSizeEdit();
|
||||
hideSizeAction();
|
||||
},
|
||||
});
|
||||
|
||||
function initSizeAction() {
|
||||
if (sizeActionElement) return;
|
||||
sizeActionElement = createPanelElement({
|
||||
styleId: 'text-format-size-action-styles',
|
||||
styles: SIZE_ACTION_STYLES,
|
||||
html: SIZE_ACTION_HTML,
|
||||
});
|
||||
|
||||
sizeActionElement.addEventListener('mousedown', (event) => event.preventDefault());
|
||||
|
||||
sizeActionElement.addEventListener('click', (event) => {
|
||||
const option = event.target.closest('.tft-size-option');
|
||||
if (!option) return;
|
||||
applySizeAction(parseInt(option.dataset.size, 10));
|
||||
stopFontSizeEdit();
|
||||
hideSizeAction();
|
||||
});
|
||||
}
|
||||
|
||||
export function positionSizeAction() {
|
||||
if (!isPanelVisible(PanelId.SIZE) || !sizeActionElement) return;
|
||||
positionDropdownPanel(sizeActionElement, { buttonAction: 'font-size' });
|
||||
}
|
||||
|
||||
/** Saves the current selection range for a subsequent size apply. */
|
||||
export function saveSizeRange() {
|
||||
const range = captureSelectionRange();
|
||||
if (range) savedSizeRange = range;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies font size in pixels to the saved selection.
|
||||
* @param {number} pixels
|
||||
*/
|
||||
export function applySizeAction(pixels) {
|
||||
if (!savedSizeRange || !getToolbarTarget() || !(pixels > 0)) return;
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(savedSizeRange);
|
||||
wrapSelectionWithStyle(getToolbarTarget(), 'fontSize', 'font-size', typeof pixels === 'number' ? `${pixels}px` : pixels);
|
||||
onSelectionChange();
|
||||
}
|
||||
|
||||
/** Opens the font size panel with the current size highlighted. */
|
||||
export function showSizeAction() {
|
||||
if (!sizeActionElement) initSizeAction();
|
||||
|
||||
savedSizeRange = captureSelectionRange();
|
||||
|
||||
const styles = getStylesAtCursor();
|
||||
const currentSize = Math.round(parseFloat(
|
||||
styles?.fontSize ?? getComputedStyle(getToolbarTarget()).fontSize
|
||||
));
|
||||
sizeActionElement.querySelectorAll('.tft-size-option').forEach(option => {
|
||||
option.classList.toggle('tsa-active', Number(option.dataset.size) === currentSize);
|
||||
});
|
||||
|
||||
sizeActionElement.classList.add('tsa-visible');
|
||||
setPanelVisible(PanelId.SIZE, true);
|
||||
positionSizeAction();
|
||||
|
||||
outsideDismiss.attach();
|
||||
}
|
||||
|
||||
/** Closes the font size panel. */
|
||||
export function hideSizeAction() {
|
||||
if (!sizeActionElement) return;
|
||||
sizeActionElement.classList.remove('tsa-visible');
|
||||
setPanelVisible(PanelId.SIZE, false);
|
||||
savedSizeRange = null;
|
||||
outsideDismiss.detach();
|
||||
}
|
||||
|
||||
/** Toggles the font size panel visibility. */
|
||||
export function toggleSizeAction() {
|
||||
if (isPanelVisible(PanelId.SIZE)) {
|
||||
hideSizeAction();
|
||||
} else {
|
||||
showSizeAction();
|
||||
}
|
||||
}
|
||||
|
||||
registerPanel({ id: PanelId.SIZE, hide: hideSizeAction });
|
||||
@@ -0,0 +1,42 @@
|
||||
import { SIZE_ACTION_ID } from './template.js';
|
||||
import { PANEL_BG, BORDER_COLOR, HOVER_BG, ACTIVE_BG, BOX_SHADOW_DROPDOWN, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
|
||||
|
||||
/** Injected CSS for the font size dropdown panel. */
|
||||
export const SIZE_ACTION_STYLES = `
|
||||
#${SIZE_ACTION_ID} {
|
||||
position: fixed;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
max-height: 240px;
|
||||
background: ${PANEL_BG};
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
box-shadow: ${BOX_SHADOW_DROPDOWN};
|
||||
font-family: DM Sans, sans-serif;
|
||||
user-select: none;
|
||||
z-index: ${Z_INDEX_EDITOR_PANEL};
|
||||
}
|
||||
|
||||
#${SIZE_ACTION_ID}.tsa-visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.tft-size-option {
|
||||
padding: 6px 12px;
|
||||
color: ${COLOR_WHITE};
|
||||
font-size: 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tft-size-option:hover {
|
||||
background: ${HOVER_BG};
|
||||
}
|
||||
|
||||
.tft-size-option.tsa-active {
|
||||
background: ${ACTIVE_BG};
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,12 @@
|
||||
/** DOM id for the font size action panel root element. */
|
||||
export const SIZE_ACTION_ID = 'text-format-size-action';
|
||||
|
||||
/** Preset font sizes (px) listed in the size dropdown. */
|
||||
export const TEXT_SIZES = [6, 8, 10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42, 48, 56, 64, 72, 80, 88, 96, 104, 120, 144];
|
||||
|
||||
/** HTML scaffold for the font size dropdown options. */
|
||||
export const SIZE_ACTION_HTML = `
|
||||
<div id="${SIZE_ACTION_ID}">
|
||||
${TEXT_SIZES.map(size => `<div class="tft-size-option" data-size="${size}">${size}</div>`).join('')}
|
||||
</div>
|
||||
`.trim();
|
||||
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* DOM range helpers for applying bold, italic, underline, and alignment inside
|
||||
* the active contenteditable selection.
|
||||
*/
|
||||
import {
|
||||
getTextNodesInRange,
|
||||
isNodeBold,
|
||||
isNodeItalic,
|
||||
isNodeUnderlined,
|
||||
} from '../../utils/inline-style-state.js';
|
||||
|
||||
function hasAncestorTag(textNode, tagNames, stopAt) {
|
||||
let element = textNode.parentElement;
|
||||
while (element && element !== stopAt) {
|
||||
if (tagNames.includes(element.tagName.toLowerCase())) return true;
|
||||
element = element.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isFullyWrapped(range, tagNames, editTarget) {
|
||||
if (range.collapsed) return false;
|
||||
const nodes = getTextNodesInRange(range);
|
||||
return nodes.length > 0 && nodes.every(node => hasAncestorTag(node, tagNames, editTarget));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a range in an element, with optional inline styles.
|
||||
* @param {Range} range
|
||||
* @param {string} tagName
|
||||
* @param {object} [styleProperties]
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function wrapRange(range, tagName, styleProperties) {
|
||||
const wrapper = document.createElement(tagName);
|
||||
if (styleProperties) Object.assign(wrapper.style, styleProperties);
|
||||
try {
|
||||
range.surroundContents(wrapper);
|
||||
} catch {
|
||||
wrapper.appendChild(range.extractContents());
|
||||
range.insertNode(wrapper);
|
||||
}
|
||||
if (!styleProperties) {
|
||||
wrapper.querySelectorAll(tagName).forEach(element => element.replaceWith(...element.childNodes));
|
||||
wrapper.normalize();
|
||||
}
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function restoreSelectionOver(firstNode, lastNode) {
|
||||
if (!firstNode || !lastNode) return;
|
||||
try {
|
||||
const newRange = document.createRange();
|
||||
if (firstNode.nodeType === Node.TEXT_NODE) {
|
||||
newRange.setStart(firstNode, 0);
|
||||
} else {
|
||||
newRange.setStartBefore(firstNode);
|
||||
}
|
||||
if (lastNode.nodeType === Node.TEXT_NODE) {
|
||||
newRange.setEnd(lastNode, lastNode.length);
|
||||
} else {
|
||||
newRange.setEndAfter(lastNode);
|
||||
}
|
||||
const selection = window.getSelection();
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(newRange);
|
||||
} catch { /* ignore invalid range errors */ }
|
||||
}
|
||||
|
||||
function ancestorDepthWithin(element, editTarget) {
|
||||
let depth = 0;
|
||||
let current = element;
|
||||
while (current && current !== editTarget) {
|
||||
depth += 1;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a single inline wrapper element over the portion covered by `range`,
|
||||
* keeping the parts outside the range wrapped and preserving every descendant
|
||||
* (e.g. a nested `<strong>`) of the in-range portion. Splitting the element when
|
||||
* the selection only partially covers it.
|
||||
* @param {HTMLElement} element
|
||||
* @param {Range} range
|
||||
*/
|
||||
function unwrapElementOverRange(element, range) {
|
||||
const elementContents = document.createRange();
|
||||
elementContents.selectNodeContents(element);
|
||||
|
||||
const selectionStartsInside = range.compareBoundaryPoints(Range.START_TO_START, elementContents) > 0;
|
||||
const selectionEndsInside = range.compareBoundaryPoints(Range.END_TO_END, elementContents) < 0;
|
||||
|
||||
if (selectionEndsInside) {
|
||||
const afterRange = document.createRange();
|
||||
afterRange.setStart(range.endContainer, range.endOffset);
|
||||
afterRange.setEnd(elementContents.endContainer, elementContents.endOffset);
|
||||
const afterContent = afterRange.extractContents();
|
||||
if (afterContent.textContent !== '') {
|
||||
const afterClone = element.cloneNode(false);
|
||||
afterClone.appendChild(afterContent);
|
||||
element.after(afterClone);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectionStartsInside) {
|
||||
const beforeRange = document.createRange();
|
||||
beforeRange.setStart(elementContents.startContainer, elementContents.startOffset);
|
||||
beforeRange.setEnd(range.startContainer, range.startOffset);
|
||||
const beforeContent = beforeRange.extractContents();
|
||||
if (beforeContent.textContent !== '') {
|
||||
const beforeClone = element.cloneNode(false);
|
||||
beforeClone.appendChild(beforeContent);
|
||||
element.before(beforeClone);
|
||||
}
|
||||
}
|
||||
|
||||
element.replaceWith(...element.childNodes);
|
||||
}
|
||||
|
||||
function unwrapTagsInRange(range, tagNames, editTarget) {
|
||||
const tagSet = tagNames.map(tag => tag.toLowerCase());
|
||||
const textNodes = getTextNodesInRange(range);
|
||||
const firstTextNode = textNodes[0] ?? null;
|
||||
const lastTextNode = textNodes[textNodes.length - 1] ?? null;
|
||||
|
||||
const targetElements = [];
|
||||
for (const textNode of textNodes) {
|
||||
let element = textNode.parentElement;
|
||||
while (element && element !== editTarget) {
|
||||
if (tagSet.includes(element.tagName?.toLowerCase()) && !targetElements.includes(element)) {
|
||||
targetElements.push(element);
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
// Unwrap the deepest wrappers first so re-parenting never shifts the
|
||||
// boundaries of a shallower wrapper still waiting to be processed.
|
||||
targetElements.sort((a, b) => ancestorDepthWithin(b, editTarget) - ancestorDepthWithin(a, editTarget));
|
||||
for (const element of targetElements) {
|
||||
unwrapElementOverRange(element, range);
|
||||
}
|
||||
|
||||
restoreSelectionOver(firstTextNode, lastTextNode);
|
||||
editTarget.normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the user's active text selection inside the edit target.
|
||||
* When the caret is collapsed (no highlighted text), the entire element's
|
||||
* contents are selected so that style actions apply to all text.
|
||||
* @param {HTMLElement} editTarget
|
||||
* @returns {Range|null} Selection range inside the edit target.
|
||||
*/
|
||||
export function getActiveRange(editTarget) {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return null;
|
||||
|
||||
if (selection.isCollapsed) {
|
||||
if (!editTarget.contains(selection.anchorNode)) return null;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(editTarget);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
return range;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
if (!editTarget.contains(range.commonAncestorContainer)) return null;
|
||||
return range;
|
||||
}
|
||||
|
||||
/** Tailwind utility classes cleared when toggling bold off on an edit target. */
|
||||
const TAILWIND_BOLD_CLASSES = ['font-bold', 'font-semibold', 'font-extrabold', 'font-black'];
|
||||
|
||||
/** Tailwind utility classes cleared when toggling italic off on an edit target. */
|
||||
const TAILWIND_ITALIC_CLASSES = ['italic'];
|
||||
|
||||
function isFullyBold(range, editTarget) {
|
||||
if (range.collapsed) return false;
|
||||
const nodes = getTextNodesInRange(range);
|
||||
return nodes.length > 0 && nodes.every((node) => isNodeBold(node, editTarget));
|
||||
}
|
||||
|
||||
function isFullyItalic(range, editTarget) {
|
||||
if (range.collapsed) return false;
|
||||
const nodes = getTextNodesInRange(range);
|
||||
return nodes.length > 0 && nodes.every((node) => isNodeItalic(node, editTarget));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles bold on the current selection within the edit target.
|
||||
* @param {HTMLElement} editTarget
|
||||
*/
|
||||
export function applyBold(editTarget) {
|
||||
const range = getActiveRange(editTarget);
|
||||
if (!range) return;
|
||||
if (isFullyBold(range, editTarget)) {
|
||||
unwrapTagsInRange(range, ['strong', 'b'], editTarget);
|
||||
TAILWIND_BOLD_CLASSES.forEach(className => editTarget.classList.remove(className));
|
||||
if (editTarget.style.fontWeight) editTarget.style.removeProperty('font-weight');
|
||||
const selection = window.getSelection();
|
||||
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const selectionRange = selection.getRangeAt(0);
|
||||
if (isFullyBold(selectionRange, editTarget)) wrapRange(selectionRange, 'span', { fontWeight: 'normal' });
|
||||
}
|
||||
} else {
|
||||
wrapRange(range, 'strong');
|
||||
}
|
||||
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles italic on the current selection within the edit target.
|
||||
* @param {HTMLElement} editTarget
|
||||
*/
|
||||
export function applyItalic(editTarget) {
|
||||
const range = getActiveRange(editTarget);
|
||||
if (!range) return;
|
||||
if (isFullyItalic(range, editTarget)) {
|
||||
unwrapTagsInRange(range, ['em', 'i'], editTarget);
|
||||
TAILWIND_ITALIC_CLASSES.forEach(className => editTarget.classList.remove(className));
|
||||
if (editTarget.style.fontStyle) editTarget.style.removeProperty('font-style');
|
||||
const selection = window.getSelection();
|
||||
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const selectionRange = selection.getRangeAt(0);
|
||||
if (isFullyItalic(selectionRange, editTarget)) wrapRange(selectionRange, 'span', { fontStyle: 'normal' });
|
||||
}
|
||||
} else {
|
||||
wrapRange(range, 'em');
|
||||
}
|
||||
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/** Tailwind utility classes cleared when toggling underline off on an edit target. */
|
||||
const TAILWIND_UNDERLINE_CLASSES = ['underline'];
|
||||
|
||||
function isRangeFullyUnderlined(range, editTarget) {
|
||||
if (range.collapsed) return false;
|
||||
const nodes = getTextNodesInRange(range);
|
||||
return nodes.length > 0 && nodes.every((node) => isNodeUnderlined(node, editTarget));
|
||||
}
|
||||
|
||||
function removeExplicitUnderlineInRange(range, editTarget) {
|
||||
getTextNodesInRange(range).forEach(node => {
|
||||
let element = node.parentElement;
|
||||
while (element && element !== editTarget) {
|
||||
if (element.style?.textDecoration?.includes('underline') || element.style?.textDecorationLine?.includes('underline')) {
|
||||
element.style.removeProperty('text-decoration');
|
||||
element.style.removeProperty('text-decoration-line');
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function getContextualDecorationStyle(range) {
|
||||
// When text sits between CSS-class underline siblings (after a split), inherit
|
||||
// their full decoration style so re-applied underline visually matches.
|
||||
const node = range.startContainer;
|
||||
if (node.nodeType !== Node.TEXT_NODE) return null;
|
||||
|
||||
for (const sibling of [node.previousSibling, node.nextSibling]) {
|
||||
if (!sibling || sibling.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
const computedStyle = getComputedStyle(sibling);
|
||||
const textDecorationLine = computedStyle.textDecorationLine || computedStyle.textDecoration || '';
|
||||
if (!textDecorationLine.includes('underline')) continue;
|
||||
|
||||
const style = {};
|
||||
if (computedStyle.textDecorationColor) {
|
||||
style.textDecorationColor = computedStyle.textDecorationColor;
|
||||
}
|
||||
if (computedStyle.textDecorationThickness && computedStyle.textDecorationThickness !== 'auto') {
|
||||
style.textDecorationThickness = computedStyle.textDecorationThickness;
|
||||
}
|
||||
if (computedStyle.textDecorationStyle && computedStyle.textDecorationStyle !== 'solid') {
|
||||
style.textDecorationStyle = computedStyle.textDecorationStyle;
|
||||
}
|
||||
return Object.keys(style).length > 0 ? style : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEffectiveTextColor(range, editTarget) {
|
||||
const nodes = getTextNodesInRange(range);
|
||||
if (nodes.length === 0 && range.startContainer.nodeType === Node.TEXT_NODE) {
|
||||
nodes.push(range.startContainer);
|
||||
}
|
||||
if (nodes.length === 0) return null;
|
||||
|
||||
const containerColor = getComputedStyle(editTarget).color;
|
||||
for (const textNode of nodes) {
|
||||
let element = textNode.parentElement;
|
||||
while (element && element !== editTarget) {
|
||||
const inlineColor = element.style?.color;
|
||||
if (inlineColor && inlineColor !== 'transparent') return inlineColor;
|
||||
element = element.parentElement;
|
||||
}
|
||||
const parent = textNode.parentElement;
|
||||
if (!parent) continue;
|
||||
const computed = getComputedStyle(parent).color;
|
||||
if (computed && computed !== containerColor && computed !== 'rgba(0, 0, 0, 0)') {
|
||||
return computed;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildUnderlineWrapStyle(range, editTarget) {
|
||||
const style = getContextualDecorationStyle(range) ?? {};
|
||||
const textColor = getEffectiveTextColor(range, editTarget);
|
||||
if (textColor) {
|
||||
style.textDecorationColor = textColor;
|
||||
}
|
||||
return Object.keys(style).length > 0 ? style : null;
|
||||
}
|
||||
|
||||
/** Keeps `<u>` decoration color in sync when nested text color changes. */
|
||||
export function syncUnderlineColorFromElement(element, editTarget) {
|
||||
if (!element || !editTarget) return;
|
||||
const inlineColor = element.style?.color;
|
||||
const containerColor = getComputedStyle(editTarget).color;
|
||||
const decorationColor = inlineColor
|
||||
|| (() => {
|
||||
const computed = getComputedStyle(element).color;
|
||||
return computed !== containerColor && computed !== 'rgba(0, 0, 0, 0)' ? computed : null;
|
||||
})();
|
||||
if (!decorationColor) return;
|
||||
|
||||
let node = element.parentElement;
|
||||
while (node && node !== editTarget) {
|
||||
if (node.tagName?.toLowerCase() === 'u') {
|
||||
node.style.textDecorationColor = decorationColor;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
function findCssUnderlineAncestor(node, editTarget) {
|
||||
// Find the closest ancestor that sources underline via a CSS class, not a <u> tag
|
||||
// or inline style — those are handled by other steps.
|
||||
let element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
|
||||
while (element && element !== editTarget) {
|
||||
if (element.tagName?.toLowerCase() === 'u') { element = element.parentElement; continue; }
|
||||
const hasInline = element.style?.textDecoration?.includes('underline') || element.style?.textDecorationLine?.includes('underline');
|
||||
if (hasInline) { element = element.parentElement; continue; }
|
||||
const textDecorationLine = getComputedStyle(element).textDecorationLine || getComputedStyle(element).textDecoration || '';
|
||||
if (textDecorationLine.includes('underline')) return element;
|
||||
element = element.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitElementAroundRange(range, ancestor, editTarget) {
|
||||
const fragment = range.extractContents();
|
||||
const selectionFirst = fragment.firstChild;
|
||||
const selectionLast = fragment.lastChild;
|
||||
|
||||
const parent = ancestor.parentNode;
|
||||
const nextSibling = ancestor.nextSibling;
|
||||
|
||||
const afterRange = document.createRange();
|
||||
afterRange.setStart(range.startContainer, range.startOffset);
|
||||
if (ancestor.lastChild) afterRange.setEndAfter(ancestor.lastChild);
|
||||
else afterRange.collapse(true);
|
||||
const afterContent = ancestor.lastChild ? afterRange.extractContents() : null;
|
||||
|
||||
parent.insertBefore(fragment, nextSibling);
|
||||
|
||||
if (afterContent?.textContent) {
|
||||
const afterClone = ancestor.cloneNode(false);
|
||||
afterClone.appendChild(afterContent);
|
||||
parent.insertBefore(afterClone, nextSibling);
|
||||
}
|
||||
|
||||
if (!ancestor.textContent) ancestor.remove();
|
||||
|
||||
restoreSelectionOver(selectionFirst, selectionLast);
|
||||
editTarget.normalize();
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles underline on the current selection within the edit target.
|
||||
* @param {HTMLElement} editTarget
|
||||
*/
|
||||
export function applyUnderline(editTarget) {
|
||||
const range = getActiveRange(editTarget);
|
||||
if (!range) return;
|
||||
|
||||
if (isRangeFullyUnderlined(range, editTarget)) {
|
||||
if (getTextNodesInRange(range).some(node => hasAncestorTag(node, ['u'], editTarget))) {
|
||||
unwrapTagsInRange(range, ['u'], editTarget);
|
||||
}
|
||||
let selection = window.getSelection();
|
||||
if (selection?.rangeCount > 0) removeExplicitUnderlineInRange(selection.getRangeAt(0), editTarget);
|
||||
// Strip Tailwind underline classes from the editTarget itself
|
||||
TAILWIND_UNDERLINE_CLASSES.forEach(className => editTarget.classList.remove(className));
|
||||
selection = window.getSelection();
|
||||
if (selection?.rangeCount > 0 && !selection.isCollapsed) {
|
||||
const selectionRange = selection.getRangeAt(0);
|
||||
if (isRangeFullyUnderlined(selectionRange, editTarget)) {
|
||||
const textNodes = getTextNodesInRange(selectionRange);
|
||||
const cssAncestor = textNodes.length > 0 ? findCssUnderlineAncestor(textNodes[0], editTarget) : null;
|
||||
if (cssAncestor) splitElementAroundRange(selectionRange, cssAncestor, editTarget);
|
||||
else wrapRange(selectionRange, 'span', { textDecoration: 'none' });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const decorationStyle = buildUnderlineWrapStyle(range, editTarget);
|
||||
wrapRange(range, 'u', decorationStyle ?? undefined);
|
||||
}
|
||||
|
||||
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the nearest ancestor `<span>` that already carries `styleProperty` and whose
|
||||
* text content exactly matches the selection. Reusing it (instead of nesting a
|
||||
* new inner span) keeps in-between elements like `<u>` at the same font-size as
|
||||
* the text, so e.g. underline thickness stays proportional.
|
||||
* @param {Range} range
|
||||
* @param {HTMLElement} editTarget
|
||||
* @param {string} styleProperty
|
||||
* @returns {HTMLElement|null}
|
||||
*/
|
||||
function findStyledAncestorWrappingRange(range, editTarget, styleProperty) {
|
||||
let element = range.commonAncestorContainer;
|
||||
if (element.nodeType === Node.TEXT_NODE) element = element.parentElement;
|
||||
const selectionText = range.toString();
|
||||
if (!selectionText) return null;
|
||||
let match = null;
|
||||
while (element && element !== editTarget) {
|
||||
if (element.nodeType === Node.ELEMENT_NODE && element.tagName === 'SPAN' && element.style[styleProperty]) {
|
||||
const elementRange = document.createRange();
|
||||
elementRange.selectNodeContents(element);
|
||||
if (elementRange.toString() === selectionText) match = element;
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
return match;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the active selection in a span carrying one inline style prop,
|
||||
* stripping the same prop from descendants (and a redundant single-child
|
||||
* parent span) so values never nest.
|
||||
* @param {HTMLElement} editTarget
|
||||
* @param {string} styleProperty - camelCase property (e.g. 'fontSize').
|
||||
* @param {string} cssProperty - kebab-case property (e.g. 'font-size').
|
||||
* @param {string} value
|
||||
*/
|
||||
export function wrapSelectionWithStyle(editTarget, styleProperty, cssProperty, value) {
|
||||
const range = getActiveRange(editTarget);
|
||||
if (!range) return;
|
||||
const reuse = findStyledAncestorWrappingRange(range, editTarget, styleProperty);
|
||||
if (reuse) {
|
||||
reuse.style[styleProperty] = value;
|
||||
if (styleProperty === 'fontSize') reuse.style.lineHeight = 'normal';
|
||||
reuse.querySelectorAll('*').forEach(element => {
|
||||
if (element.style[styleProperty]) {
|
||||
element.style.removeProperty(cssProperty);
|
||||
if (!element.style.cssText.trim()) element.replaceWith(...element.childNodes);
|
||||
}
|
||||
});
|
||||
reuse.normalize();
|
||||
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapperStyle = { [styleProperty]: value };
|
||||
if (styleProperty === 'fontSize') wrapperStyle.lineHeight = 'normal';
|
||||
const wrapper = wrapRange(range, 'span', wrapperStyle);
|
||||
if (wrapper) {
|
||||
wrapper.querySelectorAll('*').forEach(element => {
|
||||
if (element.style[styleProperty]) {
|
||||
element.style.removeProperty(cssProperty);
|
||||
if (!element.style.cssText.trim()) element.replaceWith(...element.childNodes);
|
||||
}
|
||||
});
|
||||
const parentElement = wrapper.parentElement;
|
||||
if (parentElement && parentElement !== editTarget && parentElement.tagName === 'SPAN' && parentElement.style[styleProperty] && parentElement.childNodes.length === 1) {
|
||||
parentElement.style.removeProperty(cssProperty);
|
||||
if (!parentElement.style.cssText.trim()) parentElement.replaceWith(...parentElement.childNodes);
|
||||
}
|
||||
wrapper.normalize();
|
||||
}
|
||||
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets text-align on the edit target element.
|
||||
* @param {HTMLElement} editTarget
|
||||
* @param {'left'|'center'|'right'} alignment
|
||||
*/
|
||||
export function applyAlign(editTarget, alignment) {
|
||||
const current = getComputedStyle(editTarget).textAlign;
|
||||
if (current === alignment || (alignment === 'left' && (current === 'start' || current === 'left'))) return;
|
||||
editTarget.style.textAlign = alignment;
|
||||
editTarget.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { PARENT_TOOLBAR_HEIGHT } from '../../../constants/layout.js';
|
||||
import { isInFixedContext } from '../../../utils/dom-utils.js';
|
||||
|
||||
let _toolbarElement = null;
|
||||
let _toolbarTarget = null;
|
||||
let dragOffset = { x: 0, y: 0 };
|
||||
let isDragging = false;
|
||||
let dragStart = { x: 0, y: 0 };
|
||||
let toolbarSide = 'below';
|
||||
|
||||
/** @param {HTMLElement|null} element */
|
||||
export function setPositioningEl(element) { _toolbarElement = element; }
|
||||
/** @param {HTMLElement|null} element */
|
||||
export function setPositioningTarget(element) { _toolbarTarget = element; }
|
||||
/** Resets user drag offset to zero. */
|
||||
export function resetDragOffset() { dragOffset = { x: 0, y: 0 }; }
|
||||
|
||||
/**
|
||||
* Chooses above vs below placement based on viewport space.
|
||||
* @returns {'above'|'below'}
|
||||
*/
|
||||
export function lockToolbarSide() {
|
||||
const targetBounds = _toolbarTarget.getBoundingClientRect();
|
||||
const toolbarHeight = _toolbarElement.offsetHeight;
|
||||
const GAP = 10;
|
||||
const fitsBelow = window.innerHeight - PARENT_TOOLBAR_HEIGHT - targetBounds.bottom >= 88;
|
||||
const fitsAbove = targetBounds.top - toolbarHeight - GAP >= 0;
|
||||
toolbarSide = (!fitsBelow && fitsAbove) ? 'above' : 'below';
|
||||
return toolbarSide;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {boolean} True when the toolbar is anchored to a viewport-fixed
|
||||
* element (fixed/sticky ancestor) and must therefore use `position: fixed`.
|
||||
* Document-flow elements use `position: absolute` so the toolbar scrolls with
|
||||
* the page natively (no per-scroll repositioning, no jitter).
|
||||
*/
|
||||
export function isToolbarFixedToViewport() {
|
||||
return !!_toolbarTarget && isInFixedContext(_toolbarTarget);
|
||||
}
|
||||
|
||||
/** Applies locked side and drag offset to position the toolbar. */
|
||||
export function positionToolbar() {
|
||||
if (!_toolbarTarget || !_toolbarElement) return;
|
||||
const targetBounds = _toolbarTarget.getBoundingClientRect();
|
||||
const toolbarWidth = _toolbarElement.offsetWidth;
|
||||
const toolbarHeight = _toolbarElement.offsetHeight;
|
||||
const GAP = 10;
|
||||
const MARGIN = 8;
|
||||
|
||||
// Anchor in document coordinates (position:absolute) so the toolbar scrolls
|
||||
// together with the element natively. Elements inside fixed/sticky ancestors
|
||||
// stay in the viewport while the page scrolls, so they keep position:fixed.
|
||||
const fixed = isInFixedContext(_toolbarTarget);
|
||||
_toolbarElement.style.position = fixed ? 'fixed' : 'absolute';
|
||||
const scrollX = fixed ? 0 : window.scrollX;
|
||||
const scrollY = fixed ? 0 : window.scrollY;
|
||||
|
||||
// Use the side locked when the toolbar was shown — don't re-evaluate on scroll.
|
||||
const baseTop = (toolbarSide === 'above'
|
||||
? targetBounds.top - toolbarHeight - GAP
|
||||
: targetBounds.bottom + GAP) + scrollY;
|
||||
|
||||
const baseLeft = Math.min(targetBounds.left - 4, window.innerWidth - toolbarWidth - MARGIN) + scrollX;
|
||||
|
||||
let left = baseLeft + dragOffset.x;
|
||||
let top = baseTop + dragOffset.y;
|
||||
|
||||
if (isDragging) {
|
||||
left = Math.max(MARGIN + scrollX, Math.min(left, scrollX + window.innerWidth - toolbarWidth - MARGIN));
|
||||
top = Math.max(MARGIN + scrollY, Math.min(top, scrollY + window.innerHeight - PARENT_TOOLBAR_HEIGHT - toolbarHeight - MARGIN));
|
||||
}
|
||||
|
||||
_toolbarElement.style.left = `${left}px`;
|
||||
_toolbarElement.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function onDragMove(event) {
|
||||
if (!isDragging) return;
|
||||
dragOffset.x = event.clientX - dragStart.x;
|
||||
dragOffset.y = event.clientY - dragStart.y;
|
||||
positionToolbar();
|
||||
}
|
||||
|
||||
function onDragEnd() {
|
||||
isDragging = false;
|
||||
_toolbarElement?.classList.remove('tft-dragging');
|
||||
document.removeEventListener('mousemove', onDragMove);
|
||||
document.removeEventListener('mouseup', onDragEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Begins toolbar drag tracking.
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
export function startDrag(event) {
|
||||
isDragging = true;
|
||||
dragStart = { x: event.clientX - dragOffset.x, y: event.clientY - dragOffset.y };
|
||||
_toolbarElement.classList.add('tft-dragging');
|
||||
document.addEventListener('mousemove', onDragMove);
|
||||
document.addEventListener('mouseup', onDragEnd);
|
||||
}
|
||||
|
||||
/** Repositions the toolbar on scroll and closes the more-formatting panel. */
|
||||
export function onToolbarScroll() {
|
||||
_toolbarElement.querySelector('.tft-more-panel')?.classList.remove('tft-open');
|
||||
positionToolbar();
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { TOOLBAR_ID } from './template.js';
|
||||
import { PANEL_BG, BORDER_COLOR, BORDER_COLOR_TRANSLUCENT, HOVER_BG, ACTIVE_BG, BOX_SHADOW_TOOLBAR, COLOR_WHITE, Z_INDEX_EDITOR_PANEL } from '../../../constants/theme.js';
|
||||
|
||||
/** Injected CSS for the floating text-format toolbar. */
|
||||
export const TOOLBAR_STYLES = `
|
||||
#${TOOLBAR_ID} {
|
||||
position: fixed;
|
||||
display: none;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
background: ${PANEL_BG};
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
border-radius: 12px;
|
||||
box-shadow: ${BOX_SHADOW_TOOLBAR};
|
||||
user-select: none;
|
||||
color: ${COLOR_WHITE};
|
||||
font-family: DM Sans, sans-serif;
|
||||
white-space: nowrap;
|
||||
cursor: grab;
|
||||
z-index: ${Z_INDEX_EDITOR_PANEL};
|
||||
/* Never exceed the viewport — the inner row scrolls horizontally instead. */
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
#${TOOLBAR_ID}.tft-visible { display: flex; }
|
||||
#${TOOLBAR_ID}.tft-dragging { cursor: grabbing; }
|
||||
|
||||
/* Horizontally scrollable control row. The more-panel is a sibling (not inside
|
||||
this container) so it is never clipped by the scroll overflow. */
|
||||
.tft-scroll {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.tft-scroll::-webkit-scrollbar { display: none; }
|
||||
|
||||
.tft-text-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: ${COLOR_WHITE};
|
||||
font-family: DM Sans, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tft-text-btn:hover { background: ${HOVER_BG}; }
|
||||
.tft-text-btn svg { flex-shrink: 0; }
|
||||
|
||||
.tft-swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tft-swatch:hover { background: ${HOVER_BG}; }
|
||||
|
||||
.tft-swatch-preview {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 6px;
|
||||
background: ${COLOR_WHITE};
|
||||
border: 1px solid ${BORDER_COLOR_TRANSLUCENT};
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tft-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: ${COLOR_WHITE}26;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tft-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: ${COLOR_WHITE};
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
min-width: 32px;
|
||||
max-width: 120px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tft-select:hover { background: ${HOVER_BG}; }
|
||||
.tft-select--size { width: 64px; }
|
||||
|
||||
/* Fills the button so long font names ellipsis-truncate instead of widening it. */
|
||||
.tft-label {
|
||||
flex: 1 0 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tft-font-size {
|
||||
color: ${COLOR_WHITE};
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
font-family: DM Sans, sans-serif;
|
||||
cursor: text;
|
||||
}
|
||||
.tft-font-size--editing::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1px;
|
||||
height: 0.9em;
|
||||
background: ${COLOR_WHITE};
|
||||
margin-left: 1px;
|
||||
vertical-align: text-bottom;
|
||||
animation: tft-cursor-blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes tft-cursor-blink {
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.tft-btn-group {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.tft-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tft-btn:hover {
|
||||
background: ${ACTIVE_BG};
|
||||
color: ${COLOR_WHITE};
|
||||
}
|
||||
|
||||
.tft-btn.tft-active {
|
||||
background: ${ACTIVE_BG};
|
||||
color: ${COLOR_WHITE};
|
||||
}
|
||||
|
||||
.tft-btn--italic {
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.tft-btn--more { display: none; }
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.tft-btn-group .tft-btn[data-action="bold"],
|
||||
.tft-btn-group .tft-btn[data-action="italic"],
|
||||
.tft-btn-group .tft-btn[data-action="underline"],
|
||||
.tft-btn-group .tft-btn[data-action="align"] { display: none; }
|
||||
.tft-btn--more { display: flex; }
|
||||
}
|
||||
|
||||
.tft-more-panel {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px;
|
||||
background: ${PANEL_BG};
|
||||
border: 1px solid ${BORDER_COLOR};
|
||||
border-radius: 12px;
|
||||
box-shadow: ${BOX_SHADOW_TOOLBAR};
|
||||
}
|
||||
|
||||
.tft-more-panel.tft-open {
|
||||
display: flex;
|
||||
}
|
||||
`;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { ICON_CHEVRON_DOWN, ICON_BOLD, ICON_ITALIC, ICON_UNDERLINE, ICON_ALIGN_LEFT, ICON_MORE, ICON_LINK, ICON_SPARKLES } from "../../../constants/icons.js";
|
||||
|
||||
/** DOM id for the floating text-format toolbar root element. */
|
||||
export const TOOLBAR_ID = 'text-format-toolbar';
|
||||
|
||||
/** HTML scaffold for the inline text-format toolbar buttons. */
|
||||
export const TOOLBAR_HTML = `
|
||||
<div id="${TOOLBAR_ID}">
|
||||
<div class="tft-scroll">
|
||||
<button class="tft-text-btn" data-action="edit-with-ai" title="Edit with AI">
|
||||
${ICON_SPARKLES}
|
||||
<span class="tft-text-btn-label">Edit with AI</span>
|
||||
</button>
|
||||
<span class="tft-divider"></span>
|
||||
<button class="tft-swatch" data-action="color" title="Text color">
|
||||
<span class="tft-swatch-preview"></span>
|
||||
</button>
|
||||
<span class="tft-divider"></span>
|
||||
<button class="tft-select" data-action="font-family" title="Font family">
|
||||
<span class="tft-label tft-font-family">Inter</span>
|
||||
${ICON_CHEVRON_DOWN}
|
||||
</button>
|
||||
<span class="tft-divider"></span>
|
||||
<button class="tft-select tft-select--size" data-action="font-size" title="Font size">
|
||||
<span class="tft-label tft-font-size">16</span>
|
||||
${ICON_CHEVRON_DOWN}
|
||||
</button>
|
||||
<span class="tft-divider"></span>
|
||||
<div class="tft-btn-group" role="group" aria-label="Text formatting options">
|
||||
<button class="tft-btn" data-action="bold" title="Bold">${ICON_BOLD}</button>
|
||||
<button class="tft-btn tft-btn--italic" data-action="italic" title="Italic">${ICON_ITALIC}</button>
|
||||
<button class="tft-btn" data-action="underline" title="Underline">${ICON_UNDERLINE}</button>
|
||||
<button class="tft-btn" data-action="align" title="Text alignment">${ICON_ALIGN_LEFT}</button>
|
||||
<button class="tft-btn tft-btn--more" data-action="more" title="More formatting">${ICON_MORE}</button>
|
||||
<button class="tft-btn" data-action="link" title="Link">${ICON_LINK}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tft-more-panel">
|
||||
<button class="tft-btn" data-action="bold" title="Bold">${ICON_BOLD}</button>
|
||||
<button class="tft-btn tft-btn--italic" data-action="italic" title="Italic">${ICON_ITALIC}</button>
|
||||
<button class="tft-btn" data-action="underline" title="Underline">${ICON_UNDERLINE}</button>
|
||||
<button class="tft-btn" data-action="align" title="Text alignment">${ICON_ALIGN_LEFT}</button>
|
||||
</div>
|
||||
</div>
|
||||
`.trim();
|
||||
|
||||
/** SVG icons for left, center, and right text alignment toolbar states. */
|
||||
export const ALIGN_ICONS = {
|
||||
left: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M10.5771 11.2539C10.9551 11.2925 11.25 11.6118 11.25 12C11.25 12.3882 10.9551 12.7075 10.5771 12.7461L10.5 12.75H3.5C3.08579 12.75 2.75 12.4142 2.75 12C2.75 11.5858 3.08579 11.25 3.5 11.25H10.5L10.5771 11.2539Z" fill="white"/><path d="M7.5 7.25C7.91421 7.25 8.25 7.58579 8.25 8C8.25 8.41421 7.91421 8.75 7.5 8.75H3.5C3.08579 8.75 2.75 8.41421 2.75 8C2.75 7.58579 3.08579 7.25 3.5 7.25H7.5Z" fill="white"/><path d="M13.5 3.25C13.9142 3.25007 14.25 3.58583 14.25 4C14.25 4.41417 13.9142 4.74993 13.5 4.75H3.5C3.08579 4.75 2.75 4.41421 2.75 4C2.75 3.58579 3.08579 3.25 3.5 3.25H13.5Z" fill="white"/></svg>`,
|
||||
center: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M11.5771 11.2539C11.9551 11.2925 12.25 11.6118 12.25 12C12.25 12.3882 11.9551 12.7075 11.5771 12.7461L11.5 12.75H4.5C4.08579 12.75 3.75 12.4142 3.75 12C3.75 11.5858 4.08579 11.25 4.5 11.25H11.5L11.5771 11.2539Z" fill="white"/><path d="M10.0771 7.25391C10.4551 7.29253 10.75 7.61183 10.75 8C10.75 8.38817 10.4551 8.70747 10.0771 8.74609L10 8.75H6C5.58579 8.75 5.25 8.41421 5.25 8C5.25 7.58579 5.58579 7.25 6 7.25H10L10.0771 7.25391Z" fill="white"/><path d="M13 3.25C13.4142 3.25 13.75 3.58579 13.75 4C13.75 4.41421 13.4142 4.75 13 4.75H3C2.58579 4.75 2.25 4.41421 2.25 4C2.25 3.58579 2.58579 3.25 3 3.25H13Z" fill="white"/></svg>`,
|
||||
right: `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M12.5 11.25C12.9142 11.25 13.25 11.5858 13.25 12C13.25 12.4142 12.9142 12.75 12.5 12.75H5.5L5.42285 12.7461C5.04488 12.7075 4.75 12.3882 4.75 12C4.75 11.6118 5.04488 11.2925 5.42285 11.2539L5.5 11.25H12.5Z" fill="white"/><path d="M12.5 7.25C12.9142 7.25 13.25 7.58579 13.25 8C13.25 8.41421 12.9142 8.75 12.5 8.75H8.5C8.08579 8.75 7.75 8.41421 7.75 8C7.75 7.58579 8.08579 7.25 8.5 7.25H12.5Z" fill="white"/><path d="M12.5 3.25C12.9142 3.25 13.25 3.58579 13.25 4C13.25 4.41421 12.9142 4.75 12.5 4.75H2.5C2.08579 4.75 1.75 4.41421 1.75 4C1.75 3.58579 2.08579 3.25 2.5 3.25H12.5Z" fill="white"/></svg>`,
|
||||
};
|
||||
@@ -0,0 +1,357 @@
|
||||
import { isolateEditorUiEvents } from '../../../utils/dom-utils.js';
|
||||
import { hideLinkAction, toggleLinkAction, positionLinkAction } from '../link-action/link-action.js';
|
||||
import { hideFontAction, toggleFontAction, positionFontAction } from '../font-family-action/font-family-action.js';
|
||||
import { isPanelVisible, PanelId } from '../../../state/panel-state.js';
|
||||
import { hideSizeAction, showSizeAction, toggleSizeAction, saveSizeRange, applySizeAction, positionSizeAction } from '../size-action/size-action.js';
|
||||
import { hideColorAction, toggleColorAction, positionColorAction } from '../color-action/color-action.js';
|
||||
import { applyBold, applyItalic, applyUnderline, applyAlign } from '../style-actions.js';
|
||||
import { onSelectionChange } from '../../inline-edit/cursor-styles.js';
|
||||
import { placeCursorAtEnd } from '../../inline-edit/edit-action.js';
|
||||
import { TOOLBAR_HTML, ALIGN_ICONS } from './template.js';
|
||||
import { TOOLBAR_STYLES } from './styles.js';
|
||||
import {
|
||||
setPositioningEl, setPositioningTarget, resetDragOffset,
|
||||
lockToolbarSide, positionToolbar, startDrag, isToolbarFixedToViewport,
|
||||
} from './positioning.js';
|
||||
import { openOrCreateAnnotation, getAnnotationPanelEl, hideAnnotationPanel } from '../../annotation-panel/panel.js';
|
||||
import { getEditorTranslations } from '../../../state/annotation-state.js';
|
||||
import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT, ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT } from '../../../constants/layout.js';
|
||||
|
||||
let toolbarElement = null;
|
||||
let toolbarTarget = null;
|
||||
let toolbarResizeObserver = null;
|
||||
let toolbarMutationObserver = null;
|
||||
let windowResizeListener = null;
|
||||
let repositionAnimationFrameId = null;
|
||||
|
||||
let fontSizeEditing = false;
|
||||
let fontSizeOriginal = '';
|
||||
|
||||
function onFontSizeKeydown(event) {
|
||||
const span = toolbarElement?.querySelector('.tft-font-size');
|
||||
if (!span) return;
|
||||
if (/^\d$/.test(event.key)) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (span.textContent.length < 3) span.textContent += event.key;
|
||||
} else if (event.key === 'Backspace') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
span.textContent = span.textContent.slice(0, -1);
|
||||
} else if (event.key === 'Enter') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
applySizeAction(parseInt(span.textContent, 10));
|
||||
stopFontSizeEdit();
|
||||
hideSizeAction();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
span.textContent = fontSizeOriginal;
|
||||
stopFontSizeEdit();
|
||||
hideSizeAction();
|
||||
}
|
||||
}
|
||||
|
||||
function startFontSizeEdit() {
|
||||
if (fontSizeEditing) return;
|
||||
const span = toolbarElement?.querySelector('.tft-font-size');
|
||||
if (!span) return;
|
||||
fontSizeEditing = true;
|
||||
fontSizeOriginal = span.textContent;
|
||||
span.textContent = '';
|
||||
span.classList.add('tft-font-size--editing');
|
||||
saveSizeRange();
|
||||
document.addEventListener('keydown', onFontSizeKeydown, true);
|
||||
}
|
||||
|
||||
/** Ends inline font-size keyboard editing on the toolbar label. */
|
||||
export function stopFontSizeEdit() {
|
||||
if (!fontSizeEditing) return;
|
||||
fontSizeEditing = false;
|
||||
const span = toolbarElement?.querySelector('.tft-font-size');
|
||||
if (span) {
|
||||
span.classList.remove('tft-font-size--editing');
|
||||
if (!span.textContent) span.textContent = fontSizeOriginal;
|
||||
}
|
||||
document.removeEventListener('keydown', onFontSizeKeydown, true);
|
||||
}
|
||||
|
||||
/** @returns {HTMLElement|null} */
|
||||
export function getToolbarEl() { return toolbarElement; }
|
||||
/** @returns {HTMLElement|null} The element currently being formatted. */
|
||||
export function getToolbarTarget() { return toolbarTarget; }
|
||||
|
||||
function restoreSelectionAndCloseLinkPanel() {
|
||||
if (!isPanelVisible(PanelId.LINK)) return;
|
||||
hideLinkAction();
|
||||
}
|
||||
|
||||
function repositionAttachedPanels() {
|
||||
positionColorAction();
|
||||
positionFontAction();
|
||||
positionSizeAction();
|
||||
positionLinkAction();
|
||||
repositionAnnotationToToolbar();
|
||||
}
|
||||
|
||||
function repositionAnnotationToToolbar() {
|
||||
const panel = getAnnotationPanelEl();
|
||||
if (!panel?.classList.contains('active') || !toolbarElement) return;
|
||||
if (panel.style.position === 'absolute') return;
|
||||
const rect = toolbarElement.getBoundingClientRect();
|
||||
|
||||
let left = rect.left;
|
||||
let top = rect.bottom + PANEL_GAP;
|
||||
|
||||
if (left + ANNOTATION_PANEL_WIDTH > window.innerWidth - PANEL_MARGIN) {
|
||||
left = window.innerWidth - ANNOTATION_PANEL_WIDTH - PANEL_MARGIN;
|
||||
}
|
||||
if (left < PANEL_MARGIN) left = PANEL_MARGIN;
|
||||
if (top + ANNOTATION_PANEL_ESTIMATED_HEIGHT > window.innerHeight - PARENT_TOOLBAR_HEIGHT - PANEL_MARGIN) {
|
||||
top = rect.top - ANNOTATION_PANEL_ESTIMATED_HEIGHT - PANEL_GAP;
|
||||
}
|
||||
if (top < PANEL_MARGIN) top = PANEL_MARGIN;
|
||||
|
||||
panel.style.left = `${left}px`;
|
||||
panel.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function repositionEditOverlays() {
|
||||
if (!toolbarTarget || !toolbarElement?.classList.contains('tft-visible')) return;
|
||||
positionToolbar();
|
||||
repositionAttachedPanels();
|
||||
}
|
||||
|
||||
/** Repositions toolbar and open sub-panels after the edit target changes size. */
|
||||
export function scheduleRepositionEditOverlays() {
|
||||
if (repositionAnimationFrameId) cancelAnimationFrame(repositionAnimationFrameId);
|
||||
repositionAnimationFrameId = requestAnimationFrame(() => {
|
||||
repositionAnimationFrameId = null;
|
||||
repositionEditOverlays();
|
||||
});
|
||||
}
|
||||
|
||||
function observeToolbarTarget() {
|
||||
disconnectToolbarTargetObserver();
|
||||
if (!toolbarTarget) return;
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
toolbarResizeObserver = new ResizeObserver(scheduleRepositionEditOverlays);
|
||||
toolbarResizeObserver.observe(toolbarTarget);
|
||||
}
|
||||
|
||||
if (typeof MutationObserver !== 'undefined') {
|
||||
toolbarMutationObserver = new MutationObserver(scheduleRepositionEditOverlays);
|
||||
toolbarMutationObserver.observe(toolbarTarget, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
}
|
||||
|
||||
windowResizeListener = () => scheduleRepositionEditOverlays();
|
||||
window.addEventListener('resize', windowResizeListener, { passive: true });
|
||||
}
|
||||
|
||||
function disconnectToolbarTargetObserver() {
|
||||
toolbarResizeObserver?.disconnect();
|
||||
toolbarResizeObserver = null;
|
||||
toolbarMutationObserver?.disconnect();
|
||||
toolbarMutationObserver = null;
|
||||
if (windowResizeListener) {
|
||||
window.removeEventListener('resize', windowResizeListener);
|
||||
windowResizeListener = null;
|
||||
}
|
||||
if (repositionAnimationFrameId) {
|
||||
cancelAnimationFrame(repositionAnimationFrameId);
|
||||
repositionAnimationFrameId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function onToolbarScroll() {
|
||||
toolbarElement?.querySelector('.tft-more-panel')?.classList.remove('tft-open');
|
||||
|
||||
if (isToolbarFixedToViewport()) {
|
||||
// The element lives in a fixed/sticky ancestor, so it stays in the
|
||||
// viewport while the page scrolls; keep the viewport-anchored toolbar
|
||||
// and its panels aligned to it.
|
||||
repositionEditOverlays();
|
||||
return;
|
||||
}
|
||||
|
||||
// Document-flow element: the absolutely-positioned toolbar scrolls with the
|
||||
// page natively (no reposition needed, so no bounce). Only realign open
|
||||
// sub-panels, which are viewport-fixed and anchored to the toolbar.
|
||||
repositionAttachedPanels();
|
||||
}
|
||||
|
||||
function initToolbar() {
|
||||
if (toolbarElement) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'text-format-toolbar-styles';
|
||||
style.textContent = TOOLBAR_STYLES;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.innerHTML = TOOLBAR_HTML;
|
||||
toolbarElement = wrapper.firstElementChild;
|
||||
document.body.appendChild(toolbarElement);
|
||||
isolateEditorUiEvents(toolbarElement);
|
||||
|
||||
const editWithAiLabel = getEditorTranslations().toolbarEditWithAi;
|
||||
if (editWithAiLabel) {
|
||||
const labelSpan = toolbarElement.querySelector('[data-action="edit-with-ai"] .tft-text-btn-label');
|
||||
if (labelSpan) labelSpan.textContent = editWithAiLabel;
|
||||
}
|
||||
|
||||
setPositioningEl(toolbarElement);
|
||||
|
||||
toolbarElement.addEventListener('mousedown', (event) => {
|
||||
event.preventDefault();
|
||||
if (event.target.closest('[data-action]')) return;
|
||||
hideFontAction();
|
||||
hideLinkAction();
|
||||
hideSizeAction();
|
||||
hideColorAction(true);
|
||||
startDrag(event);
|
||||
});
|
||||
|
||||
toolbarElement.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-action]');
|
||||
if (!button || !toolbarTarget) return;
|
||||
|
||||
const action = button.dataset.action;
|
||||
const opensDropdownPanel = action === 'color' || action === 'font-family' || action === 'font-size' || action === 'link';
|
||||
|
||||
if (action !== 'font-family') hideFontAction();
|
||||
if (action !== 'font-size') { hideSizeAction(); stopFontSizeEdit(); }
|
||||
if (action !== 'color') hideColorAction();
|
||||
if (action !== 'link') restoreSelectionAndCloseLinkPanel();
|
||||
if (action !== 'edit-with-ai') {
|
||||
const annotationWasOpen = getAnnotationPanelEl()?.classList.contains('active');
|
||||
// Skip the focus/caret restore when opening another dropdown panel —
|
||||
// it fires a selectionchange event that would immediately close the
|
||||
// panel this same click is about to open.
|
||||
hideAnnotationPanel({ restoreFocus: !opensDropdownPanel });
|
||||
if (annotationWasOpen && toolbarTarget && !opensDropdownPanel) {
|
||||
toolbarTarget.focus();
|
||||
placeCursorAtEnd(toolbarTarget);
|
||||
}
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'color': toggleColorAction(); break;
|
||||
case 'font-family': toggleFontAction(); break;
|
||||
case 'more':
|
||||
toolbarElement.querySelector('.tft-more-panel')?.classList.toggle('tft-open');
|
||||
break;
|
||||
case 'font-size':
|
||||
if (event.target.closest('.tft-font-size')) {
|
||||
showSizeAction();
|
||||
startFontSizeEdit();
|
||||
} else {
|
||||
stopFontSizeEdit();
|
||||
toggleSizeAction();
|
||||
}
|
||||
break;
|
||||
case 'bold': applyBold(toolbarTarget); onSelectionChange(true); break;
|
||||
case 'italic': applyItalic(toolbarTarget); onSelectionChange(true); break;
|
||||
case 'underline': applyUnderline(toolbarTarget); onSelectionChange(true); break;
|
||||
case 'align': {
|
||||
const current = getComputedStyle(toolbarTarget).textAlign;
|
||||
const next = current === 'left' || current === 'start' ? 'center'
|
||||
: current === 'center' ? 'right'
|
||||
: 'left';
|
||||
applyAlign(toolbarTarget, next);
|
||||
onSelectionChange(true);
|
||||
break;
|
||||
}
|
||||
case 'link': toggleLinkAction(); break;
|
||||
case 'edit-with-ai': {
|
||||
const target = toolbarTarget;
|
||||
if (!target) break;
|
||||
|
||||
if (getAnnotationPanelEl()?.classList.contains('active')) {
|
||||
hideAnnotationPanel();
|
||||
break;
|
||||
}
|
||||
|
||||
const toolbarRect = toolbarElement.getBoundingClientRect();
|
||||
openOrCreateAnnotation(target, toolbarRect.left, toolbarRect.bottom, toolbarRect.top);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows and positions the toolbar for the given edit target.
|
||||
* @param {HTMLElement} targetElement
|
||||
*/
|
||||
export function showTextFormatToolbar(targetElement) {
|
||||
if (!toolbarElement) initToolbar();
|
||||
else if (!toolbarElement.isConnected) document.body.appendChild(toolbarElement);
|
||||
toolbarTarget = targetElement;
|
||||
setPositioningTarget(targetElement);
|
||||
resetDragOffset();
|
||||
toolbarElement.classList.add('tft-visible');
|
||||
lockToolbarSide();
|
||||
positionToolbar();
|
||||
observeToolbarTarget();
|
||||
document.addEventListener('scroll', onToolbarScroll, true);
|
||||
}
|
||||
|
||||
/** Hides the toolbar and all sub-panels. */
|
||||
export function hideTextFormatToolbar() {
|
||||
if (!toolbarElement) return;
|
||||
toolbarElement.classList.remove('tft-visible');
|
||||
toolbarElement.querySelector('.tft-more-panel')?.classList.remove('tft-open');
|
||||
hideFontAction();
|
||||
hideLinkAction();
|
||||
hideSizeAction();
|
||||
hideColorAction(true);
|
||||
stopFontSizeEdit();
|
||||
toolbarTarget = null;
|
||||
setPositioningTarget(null);
|
||||
disconnectToolbarTargetObserver();
|
||||
document.removeEventListener('scroll', onToolbarScroll, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs toolbar controls to styles reported at the caret.
|
||||
* @param {object} styles - From getStylesAtCursor().
|
||||
*/
|
||||
export function updateTextFormatToolbar(styles) {
|
||||
if (!toolbarElement?.classList.contains('tft-visible')) return;
|
||||
|
||||
const swatch = toolbarElement.querySelector('.tft-swatch-preview');
|
||||
if (swatch) swatch.style.background = styles.color;
|
||||
|
||||
const fontFamilyLabel = toolbarElement.querySelector('.tft-font-family');
|
||||
if (fontFamilyLabel) {
|
||||
fontFamilyLabel.textContent = styles.fontFamily
|
||||
.split(',')[0]
|
||||
.replace(/['"]/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
const fontSizeLabel = toolbarElement.querySelector('.tft-font-size');
|
||||
if (fontSizeLabel && !fontSizeEditing) {
|
||||
fontSizeLabel.textContent = String(Math.round(parseFloat(styles.fontSize)));
|
||||
}
|
||||
|
||||
const isBold = !!styles.isBold;
|
||||
const isItalic = !!styles.isItalic;
|
||||
const isUnderline = !!styles.isUnderline;
|
||||
const align = styles.textAlign === 'center' ? 'center'
|
||||
: styles.textAlign === 'right' ? 'right'
|
||||
: 'left';
|
||||
|
||||
toolbarElement.querySelectorAll('[data-action="bold"]').forEach(button =>
|
||||
button.classList.toggle('tft-active', isBold));
|
||||
toolbarElement.querySelectorAll('[data-action="italic"]').forEach(button =>
|
||||
button.classList.toggle('tft-active', isItalic));
|
||||
toolbarElement.querySelectorAll('[data-action="underline"]').forEach(button =>
|
||||
button.classList.toggle('tft-active', isUnderline));
|
||||
toolbarElement.querySelectorAll('[data-action="align"]').forEach(button => {
|
||||
button.innerHTML = ALIGN_ICONS[align];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { FIXED_CONTEXT_SELECTOR, ELEMENT_TYPE_MAP } from "../constants/selectors.js";
|
||||
|
||||
/**
|
||||
* Logical type of an element for the hover tooltip: `text`, `button`, `image`,
|
||||
* or `block` otherwise. Edit targets (`data-edit-id` / `data-edit-assisted-id`)
|
||||
* that are not button/image tags are `text`.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getElementType(element) {
|
||||
const tag = element?.tagName?.toLowerCase();
|
||||
const hasEditId = element?.hasAttribute?.('data-edit-id') || element?.hasAttribute?.('data-edit-assisted-id');
|
||||
|
||||
for (const [type, tags] of Object.entries(ELEMENT_TYPE_MAP)) {
|
||||
if (tags.some(candidate => candidate.toLowerCase() === tag)) {
|
||||
if (!hasEditId && type === 'text') return 'block';
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasEditId) return 'text';
|
||||
|
||||
return 'block';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The app's mount point, which roots every structural walk so the editor's own
|
||||
* body-level surfaces (panels, markers, outlines) are never traversed.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
export function getAppRoot() {
|
||||
return document.getElementById('root') || document.body;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* True when the element sits inside a fixed/sticky ancestor, meaning overlays
|
||||
* must use viewport (fixed) positioning instead of document coordinates.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isInFixedContext(element) {
|
||||
return !!element.closest(FIXED_CONTEXT_SELECTOR);
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-page modal dialogs that are currently open and visible. Mounted-but-closed
|
||||
* dialogs are skipped so a dismissed modal never keeps blocking the page.
|
||||
* @returns {HTMLElement[]}
|
||||
*/
|
||||
export function getOpenModals() {
|
||||
return Array.from(document.querySelectorAll('[role="dialog"]')).filter(
|
||||
(modal) => modal.getAttribute('data-state') !== 'closed' && modal.checkVisibility?.() !== false,
|
||||
);
|
||||
}
|
||||
|
||||
const HOST_ISOLATED_EVENTS = ['pointerdown', 'pointerup', 'keydown', 'keyup'];
|
||||
|
||||
/**
|
||||
* Prevents host-page libraries (Radix DismissableLayer, FocusScope, etc.) from
|
||||
* seeing pointer and keyboard events that originate inside the editor's floating UI.
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
export function isolateEditorUiEvents(element) {
|
||||
for (const eventName of HOST_ISOLATED_EVENTS) {
|
||||
element.addEventListener(eventName, (event) => event.stopPropagation());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { IMPORTANT_STYLES } from '../constants/selectors.js';
|
||||
|
||||
const TEXT_CONTEXT_MAX_LENGTH = 500;
|
||||
const OUTER_HTML_MAX_LENGTH = 5000;
|
||||
|
||||
/**
|
||||
* Decodes HTML entity-encoded characters back to their literal form.
|
||||
* @param {string} string
|
||||
* @returns {string}
|
||||
*/
|
||||
export function decodeHtmlEntities(string) {
|
||||
return string
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a kebab-case CSS property name to camelCase.
|
||||
* @param {string} property
|
||||
* @returns {string}
|
||||
*/
|
||||
export function cssPropToCamel(property) {
|
||||
return property.replace(/-([a-z])/g, (_, character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
const HTML_TO_JSX_ATTRIBUTE_RENAME_MAP = { class: 'className', for: 'htmlFor' };
|
||||
|
||||
/**
|
||||
* Converts an HTML attribute name to its JSX/React equivalent (renames `class`/`for`,
|
||||
* camelCases hyphenated names except `data-*`/`aria-*`, e.g. `stroke-width` -> `strokeWidth`).
|
||||
* @param {string} name
|
||||
* @returns {string}
|
||||
*/
|
||||
export function toJsxAttributeName(name) {
|
||||
if (Object.prototype.hasOwnProperty.call(HTML_TO_JSX_ATTRIBUTE_RENAME_MAP, name)) return HTML_TO_JSX_ATTRIBUTE_RENAME_MAP[name];
|
||||
if (name.startsWith('data-') || name.startsWith('aria-')) return name;
|
||||
return name.replace(/-([a-z0-9])/g, (_, character) => character.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML special characters in a plain string (used to build safe HTML markup).
|
||||
* @param {string} string
|
||||
* @returns {string}
|
||||
*/
|
||||
export function escapeHtml(string) {
|
||||
return string.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips inline HTML tags from text, converting <br> to newlines.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function stripInlineTags(text) {
|
||||
if (typeof text !== "string") return "";
|
||||
return text
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/?(strong|em|b|i|u|a|span)\b[^>]*>/gi, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next node in document order within `root`, or null at the end.
|
||||
* @param {Node} node
|
||||
* @param {Node} root
|
||||
* @returns {Node|null}
|
||||
*/
|
||||
function nextNodeInFlow(node, root) {
|
||||
if (node.firstChild) return node.firstChild;
|
||||
let current = node;
|
||||
while (current && current !== root) {
|
||||
if (current.nextSibling) return current.nextSibling;
|
||||
current = current.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a text node sits at the end of a line: nothing follows it, or the next
|
||||
* node in flow is a <br>. Only such trailing spaces get collapsed by the browser
|
||||
* on re-parse and need preserving — spaces between inline siblings (e.g. coloured
|
||||
* <span> words) must stay normal breaking spaces or the line can't wrap.
|
||||
* @param {Node} node
|
||||
* @param {Node} root
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isAtLineEnd(node, root) {
|
||||
const next = nextNodeInFlow(node, root);
|
||||
if (!next) return true;
|
||||
let candidate = next;
|
||||
while (candidate && candidate.nodeType === Node.ELEMENT_NODE) {
|
||||
if (candidate.tagName.toLowerCase() === 'br') return true;
|
||||
candidate = candidate.firstChild;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces line-end trailing keyboard spaces in text nodes with U+00A0 so innerHTML
|
||||
* round-trips preserve them (browsers drop trailing U+0020 on re-parse). Spaces
|
||||
* between inline siblings are left intact so the content can still wrap.
|
||||
* @param {Node} root
|
||||
*/
|
||||
function preserveTrailingSpacesInTextNodes(root) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
const textNodes = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
textNodes.push(node);
|
||||
}
|
||||
for (const node of textNodes) {
|
||||
const text = node.nodeValue ?? '';
|
||||
if (!/ $/.test(text) || !isAtLineEnd(node, root)) continue;
|
||||
node.nodeValue = text.replace(/ +$/g, (spaces) => '\u00a0'.repeat(spaces.length));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads contenteditable markup from the live DOM after keyboard input, preserving
|
||||
* trailing spaces that `innerHTML` alone would drop on commit.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeContentEditableHtml(element) {
|
||||
if (!element) return '';
|
||||
const clone = element.cloneNode(true);
|
||||
preserveTrailingSpacesInTextNodes(clone);
|
||||
return normalizeContentEditableHtml(clone.innerHTML);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises contenteditable innerHTML into a canonical form by collapsing
|
||||
* Chrome's <div>-based line wrapping back into <br> separators.
|
||||
* Intentionally keeps ` ` entities so keyboard-typed trailing spaces persist.
|
||||
* @param {string} html
|
||||
* @returns {string}
|
||||
*/
|
||||
export function normalizeContentEditableHtml(html) {
|
||||
if (typeof html !== "string") return "";
|
||||
return html
|
||||
.replace(/<div><br\s*\/?><\/div>/gi, "<br>")
|
||||
.replace(/<br\s*\/?>\s*(?=<div>)/gi, "")
|
||||
.replace(/<div>/gi, "<br>")
|
||||
.replace(/<\/div>/gi, "")
|
||||
.replace(/^<br>/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against "Failed to execute 'removeChild'" when innerHTML is set
|
||||
* while a browser async task still holds a reference to a child node.
|
||||
* @param {HTMLElement} element
|
||||
*/
|
||||
export function patchRemoveChild(element) {
|
||||
if (element.__removeChildPatched) return;
|
||||
const original = element.removeChild;
|
||||
element.removeChild = function (child) {
|
||||
if (child.parentNode !== this) return child;
|
||||
return original.call(this, child);
|
||||
};
|
||||
element.__removeChildPatched = true;
|
||||
}
|
||||
|
||||
let reactDomMutationsPatched = false;
|
||||
|
||||
/**
|
||||
* Inline edits rewrite a target element's subtree via `innerHTML`, which orphans
|
||||
* the DOM nodes React still references in its fiber tree (including *nested*
|
||||
* nodes like <em>/<strong> inside an edited <h1>). On its next render React then
|
||||
* calls `removeChild`/`insertBefore` against nodes that are no longer where it
|
||||
* expects, throwing "The node to be removed is not a child of this node".
|
||||
*
|
||||
* Patching the per-element `removeChild` only covers the edit target, not its
|
||||
* descendants, so we guard the prototypes once instead: mismatched removals
|
||||
* become no-ops and mismatched insertions fall back to an append, letting React
|
||||
* reconcile to the correct state on the next render. Idempotent.
|
||||
*/
|
||||
export function patchReactDomMutations() {
|
||||
if (reactDomMutationsPatched) return;
|
||||
reactDomMutationsPatched = true;
|
||||
|
||||
const originalRemoveChild = Node.prototype.removeChild;
|
||||
Node.prototype.removeChild = function (child) {
|
||||
if (child && child.parentNode !== this) return child;
|
||||
return originalRemoveChild.call(this, child);
|
||||
};
|
||||
|
||||
const originalInsertBefore = Node.prototype.insertBefore;
|
||||
Node.prototype.insertBefore = function (newNode, referenceNode) {
|
||||
if (referenceNode && referenceNode.parentNode !== this) {
|
||||
return originalInsertBefore.call(this, newNode, null);
|
||||
}
|
||||
return originalInsertBefore.call(this, newNode, referenceNode);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes raw HTML characters while preserving allowed inline tags
|
||||
* (strong, em, a, span, br, etc.) so user content is safe to inject into the DOM.
|
||||
* @param {string} text
|
||||
* @returns {string}
|
||||
*/
|
||||
export function sanitizeText(text) {
|
||||
if (typeof text !== "string") return "";
|
||||
const normalized = normalizeContentEditableHtml(text);
|
||||
const preserved = [];
|
||||
let result = normalized.replace(/<(\/?)(?:strong|em|b|i|u|a|span|br)\b[^>]*\/?>/gi, (match) => {
|
||||
preserved.push(match);
|
||||
return `\x00ITAG${preserved.length - 1}\x00`;
|
||||
});
|
||||
result = result
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/{/g, "{")
|
||||
.replace(/}/g, "}");
|
||||
result = result.replace(/\x00ITAG(\d+)\x00/g, (_, index) => preserved[parseInt(index)]);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a CSS selector path to uniquely identify the element.
|
||||
* Uses tag + classes + nth-of-type where IDs are unavailable.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string}
|
||||
*/
|
||||
export function buildCssSelector(element) {
|
||||
const path = [];
|
||||
let current = element;
|
||||
let depth = 0;
|
||||
const maxDepth = 20;
|
||||
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
|
||||
let selector = current.nodeName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
path.unshift(selector);
|
||||
break;
|
||||
}
|
||||
|
||||
if (current.className && typeof current.className === 'string') {
|
||||
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
||||
if (classes.length > 0) {
|
||||
selector += `.${classes.join('.')}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.parentElement) {
|
||||
const siblings = Array.from(current.parentElement.children);
|
||||
const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName);
|
||||
if (sameTypeSiblings.length > 1) {
|
||||
const index = sameTypeSiblings.indexOf(current) + 1;
|
||||
selector += `:nth-of-type(${index})`;
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
return path.join(' > ');
|
||||
}
|
||||
|
||||
function getComputedStylesForElement(element) {
|
||||
const computedStyles = window.getComputedStyle(element);
|
||||
|
||||
return Object.fromEntries(IMPORTANT_STYLES.map((style) => {
|
||||
const styleValue = computedStyles.getPropertyValue(style)?.trim();
|
||||
|
||||
return styleValue && styleValue !== 'none' && styleValue !== 'normal'
|
||||
? [style, styleValue]
|
||||
: null;
|
||||
}).filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a rich DOM context payload for an element: selector, attributes,
|
||||
* computed styles, and truncated text content.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {object|null}
|
||||
*/
|
||||
export function extractDOMContext(element) {
|
||||
if (!element) return null;
|
||||
|
||||
const textContent = element.textContent?.trim();
|
||||
|
||||
return {
|
||||
outerHTML: element.outerHTML.slice(0, OUTER_HTML_MAX_LENGTH),
|
||||
selector: buildCssSelector(element),
|
||||
attributes: (element.attributes && element.attributes.length > 0)
|
||||
? Object.fromEntries(Array.from(element.attributes).map((attr) => [attr.name, attr.value]))
|
||||
: {},
|
||||
computedStyles: getComputedStylesForElement(element),
|
||||
textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH)
|
||||
? textContent
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the React fiber tree from a DOM node to find the source file path.
|
||||
* @param {HTMLElement} node
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getFilePathFromNode(node) {
|
||||
const fiberKey = Object.keys(node).find(k => k.startsWith('__reactFiber'));
|
||||
if (!fiberKey) return null;
|
||||
|
||||
let currentFiber = node[fiberKey];
|
||||
while (currentFiber) {
|
||||
const source = currentFiber._debugSource
|
||||
|| currentFiber.memoizedProps?.__source
|
||||
|| currentFiber.pendingProps?.__source;
|
||||
|
||||
if (source?.fileName) return source.fileName;
|
||||
currentFiber = currentFiber.return;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips the path prefix up to and including `public_html/`.
|
||||
* @param {string} filePath
|
||||
* @returns {string}
|
||||
*/
|
||||
export function stripFilePath(filePath) {
|
||||
if (!filePath) return filePath;
|
||||
const publicHtmlIndex = filePath.indexOf('public_html/');
|
||||
if (publicHtmlIndex !== -1) return filePath.substring(publicHtmlIndex + 'public_html/'.length);
|
||||
return filePath;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/** Minimum computed font-weight treated as bold (matches toolbar + toggle-off). */
|
||||
const BOLD_WEIGHT_THRESHOLD = 600;
|
||||
|
||||
/**
|
||||
* @param {Range} range
|
||||
* @returns {Text[]}
|
||||
*/
|
||||
export function getTextNodesInRange(range) {
|
||||
const root = range.commonAncestorContainer.nodeType === Node.TEXT_NODE
|
||||
? range.commonAncestorContainer.parentElement
|
||||
: range.commonAncestorContainer;
|
||||
const nodes = [];
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
if (range.intersectsNode(walker.currentNode)) nodes.push(walker.currentNode);
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function elementFromNode(node) {
|
||||
if (!node) return null;
|
||||
return node.nodeType === Node.TEXT_NODE ? node.parentElement : node;
|
||||
}
|
||||
|
||||
function walkAncestors(element, container, predicate) {
|
||||
let current = element;
|
||||
while (current && current !== container.parentElement) {
|
||||
if (predicate(current)) return true;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @returns {boolean} True when `node` sits inside bold markup or computed weight. */
|
||||
export function isNodeBold(node, container) {
|
||||
if (parseInt(getComputedStyle(container).fontWeight) >= BOLD_WEIGHT_THRESHOLD) return true;
|
||||
const element = elementFromNode(node);
|
||||
if (!element) return false;
|
||||
return walkAncestors(element, container, (current) => {
|
||||
// `<strong>`/`<b>` apply `font-weight: bolder`, which resolves to only 400
|
||||
// when the parent is a light weight (e.g. Tailwind `font-light`, 300), so a
|
||||
// computed-weight check alone misses them. Treat the tags as bold directly.
|
||||
const tag = current.tagName?.toLowerCase();
|
||||
if (tag === 'strong' || tag === 'b') return true;
|
||||
return parseInt(getComputedStyle(current).fontWeight) >= BOLD_WEIGHT_THRESHOLD;
|
||||
});
|
||||
}
|
||||
|
||||
/** @returns {boolean} True when `node` sits inside italic markup or computed style. */
|
||||
export function isNodeItalic(node, container) {
|
||||
if (getComputedStyle(container).fontStyle === 'italic') return true;
|
||||
const element = elementFromNode(node);
|
||||
if (!element) return false;
|
||||
return walkAncestors(element, container, (current) => getComputedStyle(current).fontStyle === 'italic');
|
||||
}
|
||||
|
||||
/** @returns {boolean} True when `node` sits inside underlined markup or decoration. */
|
||||
export function isNodeUnderlined(node, container) {
|
||||
const containerTextDecorationLine = getComputedStyle(container).textDecorationLine || getComputedStyle(container).textDecoration || '';
|
||||
if (containerTextDecorationLine.includes('underline')) return true;
|
||||
const element = elementFromNode(node);
|
||||
if (!element) return false;
|
||||
return walkAncestors(element, container, (current) => {
|
||||
const textDecorationLine = getComputedStyle(current).textDecorationLine || getComputedStyle(current).textDecoration || '';
|
||||
return textDecorationLine.includes('underline');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves coexisting bold / italic / underline at a single DOM position.
|
||||
* Each flag is independent — e.g. `<em><u><strong>t</strong></u></em>` is all three.
|
||||
* @param {Node} node
|
||||
* @param {HTMLElement} container
|
||||
* @returns {{ isBold: boolean, isItalic: boolean, isUnderline: boolean }}
|
||||
*/
|
||||
export function resolveInlineStylesAtNode(node, container) {
|
||||
return {
|
||||
isBold: isNodeBold(node, container),
|
||||
isItalic: isNodeItalic(node, container),
|
||||
isUnderline: isNodeUnderlined(node, container),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves toolbar toggle state for the current selection.
|
||||
* Collapsed → styles at caret. Range → active only when every covered text node has the style.
|
||||
* @param {Range} range
|
||||
* @param {HTMLElement} container
|
||||
* @returns {{ isBold: boolean, isItalic: boolean, isUnderline: boolean }}
|
||||
*/
|
||||
export function resolveInlineStylesAtRange(range, container) {
|
||||
if (range.collapsed) {
|
||||
return resolveInlineStylesAtNode(range.startContainer, container);
|
||||
}
|
||||
const nodes = getTextNodesInRange(range);
|
||||
if (nodes.length === 0) {
|
||||
return resolveInlineStylesAtNode(range.startContainer, container);
|
||||
}
|
||||
return {
|
||||
isBold: nodes.every((node) => isNodeBold(node, container)),
|
||||
isItalic: nodes.every((node) => isNodeItalic(node, container)),
|
||||
isUnderline: nodes.every((node) => isNodeUnderlined(node, container)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/** Origins allowed to receive messages from this iframe. */
|
||||
export const ALLOWED_PARENT_ORIGINS = [
|
||||
'https://horizons.hostinger.com',
|
||||
'https://horizons.hostinger.dev',
|
||||
'https://horizons-frontend-local.hostinger.dev',
|
||||
'http://localhost:4000',
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolves the parent frame origin from `ancestorOrigins` or `document.referrer`.
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function getParentOrigin() {
|
||||
if (window.location.ancestorOrigins && window.location.ancestorOrigins.length > 0) {
|
||||
return window.location.ancestorOrigins[0];
|
||||
}
|
||||
|
||||
if (document.referrer) {
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch {
|
||||
console.warn('[plugin] Invalid referrer URL:', document.referrer);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a typed message to the parent frame when the origin is allowlisted.
|
||||
* @param {string} type - A {@link ParentMessage} value.
|
||||
* @param {object} [payload]
|
||||
*/
|
||||
export function postToParent(type, payload = {}) {
|
||||
const parentOrigin = getParentOrigin();
|
||||
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
window.parent.postMessage({ type, payload }, parentOrigin);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
/** Shared DOM-context capture for the unified visual-edit contract. */
|
||||
|
||||
const IMPORTANT_STYLES = [
|
||||
'display',
|
||||
'position',
|
||||
'flex-direction',
|
||||
'justify-content',
|
||||
'align-items',
|
||||
'width',
|
||||
'height',
|
||||
'padding',
|
||||
'margin',
|
||||
'border',
|
||||
'background-color',
|
||||
'color',
|
||||
'font-size',
|
||||
'font-weight',
|
||||
'font-family',
|
||||
'border-radius',
|
||||
'box-shadow',
|
||||
'gap',
|
||||
'grid-template-columns',
|
||||
];
|
||||
|
||||
const TEXT_CONTEXT_MAX_LENGTH = 500;
|
||||
|
||||
/** Backend DTO caps `selectionMode.selector` at this length; must match `src/constants/message.ts`. */
|
||||
const SELECTOR_MAX_LENGTH = 1_000;
|
||||
|
||||
/** Keeps generated context well below the backend's 50,000-character `outerHTML` cap. */
|
||||
const DOM_CONTEXT_MAX_LENGTH = 5_000;
|
||||
const DOM_CONTEXT_MAX_DEPTH = 3;
|
||||
const DOM_CONTEXT_MAX_CHILD_ELEMENTS = 5;
|
||||
const DOM_CONTEXT_EDGE_CHILD_ELEMENTS = 2;
|
||||
const DOM_CONTEXT_MAX_TEXT_LENGTH = 300;
|
||||
const DOM_CONTEXT_TEXT_EDGE_LENGTH = 140;
|
||||
const DOM_CONTEXT_MAX_ATTRIBUTE_LENGTH = 300;
|
||||
const DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH = 140;
|
||||
|
||||
const OMITTED_ELEMENT_NAMES = new Set([
|
||||
'base',
|
||||
'head',
|
||||
'link',
|
||||
'meta',
|
||||
'noscript',
|
||||
'script',
|
||||
'style',
|
||||
'template',
|
||||
]);
|
||||
|
||||
function contextAttributeEntries(element) {
|
||||
if (!element.attributes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.from(element.attributes).flatMap((attribute) => {
|
||||
const name = attribute.name.toLowerCase();
|
||||
const value = attribute.value;
|
||||
if (
|
||||
name.startsWith('data-edit-')
|
||||
|| name === 'data-selection-mode-enabled'
|
||||
|| name.startsWith('on')
|
||||
|| name === 'srcdoc'
|
||||
|| value.trimStart().toLowerCase().startsWith('data:')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (value.length <= DOM_CONTEXT_MAX_ATTRIBUTE_LENGTH) {
|
||||
return [[attribute.name, value]];
|
||||
}
|
||||
|
||||
const omittedLength = value.length - (DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH * 2);
|
||||
return [[
|
||||
attribute.name,
|
||||
`${value.slice(0, DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH)}[${omittedLength} chars omitted]${value.slice(-DOM_CONTEXT_ATTRIBUTE_EDGE_LENGTH)}`,
|
||||
]];
|
||||
});
|
||||
}
|
||||
|
||||
function cloneElementShell(element) {
|
||||
const clone = element.cloneNode(false);
|
||||
Array.from(clone.attributes ?? []).forEach(attribute => clone.removeAttribute(attribute.name));
|
||||
contextAttributeEntries(element).forEach(([name, value]) => clone.setAttribute(name, value));
|
||||
return clone;
|
||||
}
|
||||
|
||||
function appendTextContext(parent, value) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (value.length <= DOM_CONTEXT_MAX_TEXT_LENGTH) {
|
||||
parent.appendChild(parent.ownerDocument.createTextNode(value));
|
||||
return;
|
||||
}
|
||||
|
||||
const omittedLength = value.length - (DOM_CONTEXT_TEXT_EDGE_LENGTH * 2);
|
||||
parent.appendChild(parent.ownerDocument.createTextNode(value.slice(0, DOM_CONTEXT_TEXT_EDGE_LENGTH)));
|
||||
parent.appendChild(parent.ownerDocument.createComment(`${omittedLength} text chars omitted`));
|
||||
parent.appendChild(parent.ownerDocument.createTextNode(value.slice(-DOM_CONTEXT_TEXT_EDGE_LENGTH)));
|
||||
}
|
||||
|
||||
function contextElementChildren(element) {
|
||||
return Array.from(element.children)
|
||||
.filter(child => !OMITTED_ELEMENT_NAMES.has(child.tagName.toLowerCase()));
|
||||
}
|
||||
|
||||
function collectVisibleText(element) {
|
||||
if (element.tagName.toLowerCase() === 'svg') {
|
||||
return Array.from(element.children)
|
||||
.filter(child => ['title', 'desc'].includes(child.tagName.toLowerCase()))
|
||||
.map(child => child.textContent ?? '')
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return Array.from(element.childNodes).map((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
return child.textContent ?? '';
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) {
|
||||
return '';
|
||||
}
|
||||
if (OMITTED_ELEMENT_NAMES.has(child.tagName.toLowerCase())) {
|
||||
return '';
|
||||
}
|
||||
return collectVisibleText(child);
|
||||
})
|
||||
.join(' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function appendSvgContext(source, clone) {
|
||||
Array.from(source.children)
|
||||
.filter(child => ['title', 'desc'].includes(child.tagName.toLowerCase()))
|
||||
.forEach((child) => {
|
||||
const semanticClone = cloneElementShell(child);
|
||||
appendTextContext(semanticClone, collectVisibleText(child));
|
||||
clone.appendChild(semanticClone);
|
||||
});
|
||||
clone.appendChild(clone.ownerDocument.createComment('SVG content omitted'));
|
||||
}
|
||||
|
||||
function cloneElementContext(element, depth) {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
if (OMITTED_ELEMENT_NAMES.has(tagName)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const clone = cloneElementShell(element);
|
||||
if (tagName === 'svg') {
|
||||
appendSvgContext(element, clone);
|
||||
return clone;
|
||||
}
|
||||
if (tagName === 'canvas') {
|
||||
appendTextContext(clone, collectVisibleText(element).trim());
|
||||
clone.appendChild(clone.ownerDocument.createComment('canvas bitmap not captured'));
|
||||
return clone;
|
||||
}
|
||||
|
||||
const elementChildren = contextElementChildren(element);
|
||||
if (depth >= DOM_CONTEXT_MAX_DEPTH && elementChildren.length > 0) {
|
||||
appendTextContext(clone, collectVisibleText(element).trim());
|
||||
clone.appendChild(clone.ownerDocument.createComment('descendants omitted'));
|
||||
return clone;
|
||||
}
|
||||
|
||||
const keptChildren = elementChildren.length > DOM_CONTEXT_MAX_CHILD_ELEMENTS
|
||||
? new Set([
|
||||
...elementChildren.slice(0, DOM_CONTEXT_EDGE_CHILD_ELEMENTS),
|
||||
...elementChildren.slice(-DOM_CONTEXT_EDGE_CHILD_ELEMENTS),
|
||||
])
|
||||
: new Set(elementChildren);
|
||||
const omittedChildrenCount = elementChildren.length - keptChildren.size;
|
||||
let omissionMarkerAdded = false;
|
||||
|
||||
Array.from(element.childNodes).forEach((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
appendTextContext(clone, child.textContent ?? '');
|
||||
return;
|
||||
}
|
||||
if (child.nodeType !== Node.ELEMENT_NODE || OMITTED_ELEMENT_NAMES.has(child.tagName.toLowerCase())) {
|
||||
return;
|
||||
}
|
||||
if (!keptChildren.has(child)) {
|
||||
if (!omissionMarkerAdded) {
|
||||
clone.appendChild(clone.ownerDocument.createComment(`${omittedChildrenCount} elements omitted`));
|
||||
omissionMarkerAdded = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const childClone = cloneElementContext(child, depth + 1);
|
||||
if (childClone) {
|
||||
clone.appendChild(childClone);
|
||||
}
|
||||
});
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds compact, valid HTML context while retaining the selected element's
|
||||
* identifying structure and visible text.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string}
|
||||
*/
|
||||
export function serializeElementContext(element) {
|
||||
const clone = cloneElementContext(element, 0);
|
||||
if (!clone) {
|
||||
return '<!-- context omitted -->';
|
||||
}
|
||||
|
||||
const serialized = clone.outerHTML;
|
||||
if (serialized.length <= DOM_CONTEXT_MAX_LENGTH) {
|
||||
return serialized;
|
||||
}
|
||||
|
||||
const fallback = cloneElementShell(element);
|
||||
fallback.appendChild(fallback.ownerDocument.createComment('context omitted: size limit'));
|
||||
while (fallback.outerHTML.length > DOM_CONTEXT_MAX_LENGTH && fallback.attributes.length > 0) {
|
||||
fallback.removeAttribute(fallback.attributes.item(fallback.attributes.length - 1).name);
|
||||
}
|
||||
return fallback.outerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a CSS selector path to uniquely identify the element.
|
||||
* Uses tag + classes + nth-of-type where IDs are unavailable. Ancestors with many
|
||||
* utility classes (e.g. Tailwind) can make the full path exceed the backend's length
|
||||
* cap, so distant ancestors are dropped first since the leaf end is more identifying.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string} CSS selector path, truncated to fit {@link SELECTOR_MAX_LENGTH}
|
||||
*/
|
||||
export function buildCssSelector(element) {
|
||||
const path = [];
|
||||
let current = element;
|
||||
let depth = 0;
|
||||
const maxDepth = 20; // Prevent infinite loops
|
||||
|
||||
while (current && current.nodeType === Node.ELEMENT_NODE && depth < maxDepth) {
|
||||
let selector = current.nodeName.toLowerCase();
|
||||
|
||||
if (current.id) {
|
||||
selector += `#${current.id}`;
|
||||
path.unshift(selector);
|
||||
break;
|
||||
}
|
||||
|
||||
if (current.className && typeof current.className === 'string') {
|
||||
const classes = current.className.trim().split(/\s+/).filter(c => c.length > 0);
|
||||
if (classes.length > 0) {
|
||||
selector += `.${classes.join('.')}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (current.parentElement) {
|
||||
const siblings = Array.from(current.parentElement.children);
|
||||
const sameTypeSiblings = siblings.filter(s => s.nodeName === current.nodeName);
|
||||
if (sameTypeSiblings.length > 1) {
|
||||
const index = sameTypeSiblings.indexOf(current) + 1;
|
||||
selector += `:nth-of-type(${index})`;
|
||||
}
|
||||
}
|
||||
|
||||
path.unshift(selector);
|
||||
current = current.parentElement;
|
||||
depth++;
|
||||
}
|
||||
|
||||
while (path.length > 1 && path.join(' > ').length > SELECTOR_MAX_LENGTH) {
|
||||
path.shift();
|
||||
}
|
||||
|
||||
return path.join(' > ').slice(0, SELECTOR_MAX_LENGTH);
|
||||
}
|
||||
|
||||
function getComputedStylesSnapshot(element) {
|
||||
const computedStyles = window.getComputedStyle(element);
|
||||
|
||||
return Object.fromEntries(IMPORTANT_STYLES.map((style) => {
|
||||
const styleValue = computedStyles.getPropertyValue(style)?.trim();
|
||||
|
||||
return styleValue && styleValue !== 'none' && styleValue !== 'normal'
|
||||
? [style, styleValue]
|
||||
: null;
|
||||
})
|
||||
.filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips everything up to and including `public_html/` so the path matches
|
||||
* the workspace-relative form the backend/AI expect.
|
||||
* @param {string|null} filePath
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function stripPublicHtmlPrefix(filePath) {
|
||||
if (!filePath) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const publicHtmlIndex = filePath.indexOf('public_html/');
|
||||
if (publicHtmlIndex !== -1) {
|
||||
return filePath.substring(publicHtmlIndex + 'public_html/'.length);
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts source location from a `filePath:line:column` edit id (the
|
||||
* format the visual-editor transform writes into `data-edit-id` /
|
||||
* `data-edit-assisted-id` — see `transform/inject-attributes.js` and the
|
||||
* matching parser in `server/source-writer.js`).
|
||||
* @param {string} editId
|
||||
* @returns {{filePath: string, line: number, column: number}|null}
|
||||
*/
|
||||
export function sourceLocationFromEditId(editId) {
|
||||
const segments = editId.split(':');
|
||||
if (segments.length < 3) {
|
||||
return null;
|
||||
}
|
||||
const line = Number(segments.at(-2));
|
||||
const column = Number(segments.at(-1));
|
||||
const filePath = segments.slice(0, -2).join(':');
|
||||
if (!filePath || !Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
|
||||
return null;
|
||||
}
|
||||
return { filePath, line, column };
|
||||
}
|
||||
|
||||
/**
|
||||
* Walks the React Fiber tree to find the source file path of an element with
|
||||
* no visual-editor edit-id attribute (plain selection-mode clicks).
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function filePathFromReactFiber(element) {
|
||||
const fiberKey = Object.keys(element).find(key => key.startsWith('__reactFiber'));
|
||||
if (!fiberKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let currentFiber = element[fiberKey];
|
||||
while (currentFiber) {
|
||||
const source = currentFiber._debugSource
|
||||
|| currentFiber.memoizedProps?.__source
|
||||
|| currentFiber.pendingProps?.__source;
|
||||
|
||||
if (source?.fileName) {
|
||||
return source.fileName;
|
||||
}
|
||||
|
||||
currentFiber = currentFiber.return;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the source file path for `element`. Prefers the visual editor's
|
||||
* own `data-edit-id` / `data-edit-assisted-id` attribute — already
|
||||
* `filePath:line:col`, no extra work — falling back to walking the React
|
||||
* Fiber tree for elements the editor hasn't tagged (plain selection-mode
|
||||
* clicks).
|
||||
* @param {HTMLElement} element
|
||||
* @returns {string} File path, or "" if it could not be resolved.
|
||||
*/
|
||||
export function resolveElementFilePath(element) {
|
||||
const editId = element.getAttribute?.('data-edit-id') || element.getAttribute?.('data-edit-assisted-id');
|
||||
if (editId) {
|
||||
const sourceLocation = sourceLocationFromEditId(editId);
|
||||
if (sourceLocation) {
|
||||
return stripPublicHtmlPrefix(sourceLocation.filePath) ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
return stripPublicHtmlPrefix(filePathFromReactFiber(element)) ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Captures the full `selectionMode` context for `element`: source file
|
||||
* path and optional source position, outer HTML, a CSS selector path, its
|
||||
* attributes, a snapshot of the important computed styles, and trimmed text
|
||||
* content (when short enough to be useful). Shared by selection mode and the
|
||||
* visual editor so both emit the exact same shape the backend validates and
|
||||
* the AI consumes.
|
||||
* @param {HTMLElement} element
|
||||
* @returns {{filePath: string, sourceLine?: number, sourceColumn?: number, outerHTML: string, selector: string, attributes: Record<string, string>, computedStyles: Record<string, string>, textContent: string|null}|null}
|
||||
*/
|
||||
export function captureElementMetadata(element) {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textContent = element.textContent?.trim();
|
||||
const editId = element.getAttribute?.('data-edit-id') || element.getAttribute?.('data-edit-assisted-id');
|
||||
const sourceLocation = editId ? sourceLocationFromEditId(editId) : null;
|
||||
|
||||
return {
|
||||
filePath: resolveElementFilePath(element),
|
||||
...(sourceLocation && {
|
||||
sourceLine: sourceLocation.line,
|
||||
sourceColumn: sourceLocation.column,
|
||||
}),
|
||||
outerHTML: serializeElementContext(element),
|
||||
selector: buildCssSelector(element),
|
||||
attributes: Object.fromEntries(contextAttributeEntries(element)),
|
||||
computedStyles: getComputedStylesSnapshot(element),
|
||||
textContent: (textContent && textContent.length > 0 && textContent.length < TEXT_CONTEXT_MAX_LENGTH)
|
||||
? textContent
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ParentMessage } from '../constants/messages.js';
|
||||
import { undo, redo } from '../state/history-state.js';
|
||||
import { notifyDraftStateChanged } from '../api/draft-snapshot.js';
|
||||
|
||||
/**
|
||||
* Applies one undo/redo step and notifies the parent frame when an action ran.
|
||||
* @param {'undo'|'redo'} direction
|
||||
* @returns {object|null}
|
||||
*/
|
||||
function applySessionHistoryStep(direction) {
|
||||
const action = direction === 'undo' ? undo() : redo();
|
||||
if (!action) return null;
|
||||
|
||||
notifyDraftStateChanged(
|
||||
direction === 'undo' ? ParentMessage.EDIT_UNDO_APPLIED : ParentMessage.EDIT_REDO_APPLIED,
|
||||
{
|
||||
editId: action.editId,
|
||||
value: direction === 'undo' ? action.instruction.beforeContent : action.instruction.afterContent,
|
||||
},
|
||||
);
|
||||
|
||||
return action;
|
||||
}
|
||||
|
||||
/** @returns {object|null} */
|
||||
export function applySessionUndo() {
|
||||
return applySessionHistoryStep('undo');
|
||||
}
|
||||
|
||||
/** @returns {object|null} */
|
||||
export function applySessionRedo() {
|
||||
return applySessionHistoryStep('redo');
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { context } from 'esbuild';
|
||||
import { resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { EDIT_MODE_STYLES } from './ui/inline-edit/styles.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = resolve(__filename, '..');
|
||||
|
||||
/**
|
||||
* Bundles the multi-file contenteditable edit-mode runtime with esbuild and
|
||||
* injects it (plus the edit-mode styles) into the dev preview HTML.
|
||||
*
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
export default function inlineEditDevPlugin() {
|
||||
let buildContext = null;
|
||||
|
||||
return {
|
||||
name: 'vite:inline-edit-dev',
|
||||
apply: 'serve',
|
||||
|
||||
async buildStart() {
|
||||
buildContext = await context({
|
||||
entryPoints: [resolve(__dirname, 'edit-mode-script.js')],
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
sourcemap: 'inline',
|
||||
write: false,
|
||||
platform: 'browser',
|
||||
target: 'es2020',
|
||||
});
|
||||
},
|
||||
|
||||
async transformIndexHtml() {
|
||||
const { outputFiles } = await buildContext.rebuild();
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: outputFiles[0].text,
|
||||
injectTo: 'body',
|
||||
},
|
||||
{
|
||||
tag: 'style',
|
||||
children: EDIT_MODE_STYLES,
|
||||
injectTo: 'head',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
closeBundle() {
|
||||
buildContext?.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
import nodePath from 'path';
|
||||
import { parse } from '@babel/parser';
|
||||
import traverseBabel from '@babel/traverse';
|
||||
import * as t from '@babel/types';
|
||||
import fs from 'fs';
|
||||
import {
|
||||
parseFileToAST,
|
||||
generateSourceWithMap,
|
||||
VITE_PROJECT_ROOT
|
||||
} from '../utils/ast-utils.js';
|
||||
import { isSameOriginRequest } from '../utils/same-origin.js';
|
||||
import { groupEditsByFile, applyElementEdit, renderEditedSource } from './server/source-writer.js';
|
||||
import {
|
||||
EDITABLE_HTML_TAGS,
|
||||
INLINE_EDIT_HTML_TAGS,
|
||||
checkTagNameEditable,
|
||||
validateImageSrc,
|
||||
hasMeaningfulTextContent,
|
||||
shouldDisableDueToChildren,
|
||||
evalChildComposition,
|
||||
subtreeRequiresAssistance,
|
||||
hasEditableAncestor,
|
||||
} from './transform/editability.js';
|
||||
import {
|
||||
detectUrlAttribute,
|
||||
attachEditAttributes,
|
||||
attachAssistedEditId,
|
||||
} from './transform/inject-attributes.js';
|
||||
|
||||
export default function inlineEditPlugin() {
|
||||
return {
|
||||
name: 'vite-inline-edit-plugin',
|
||||
enforce: 'pre',
|
||||
|
||||
transform(code, id) {
|
||||
if (!/\.(jsx|tsx)$/.test(id) || !id.startsWith(VITE_PROJECT_ROOT) || id.includes('node_modules')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativeFilePath = nodePath.relative(VITE_PROJECT_ROOT, id);
|
||||
const webRelativeFilePath = relativeFilePath.split(nodePath.sep).join('/');
|
||||
|
||||
try {
|
||||
const babelAst = parse(code, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript'],
|
||||
errorRecovery: true
|
||||
});
|
||||
|
||||
let attributesAdded = 0;
|
||||
|
||||
traverseBabel.default(babelAst, {
|
||||
JSXOpeningElement(path) {
|
||||
const openingNode = path.node;
|
||||
const elementNode = path.parentPath.node;
|
||||
|
||||
if (!openingNode.loc) return;
|
||||
|
||||
const originalLength = openingNode.attributes.length;
|
||||
openingNode.attributes = openingNode.attributes.filter(attribute =>
|
||||
!(t.isJSXAttribute(attribute) && attribute.name?.name?.startsWith('data-edit-'))
|
||||
);
|
||||
if (openingNode.attributes.length < originalLength) {
|
||||
attributesAdded++;
|
||||
}
|
||||
|
||||
const line = openingNode.loc.start.line;
|
||||
const column = openingNode.loc.start.column + 1;
|
||||
const editId = `${webRelativeFilePath}:${line}:${column}`;
|
||||
|
||||
// Condition 1: an annotated ancestor already owns this subtree. Applies to
|
||||
// assisted ids too, so a branch never ends up with nested edit targets.
|
||||
if (hasEditableAncestor(path)) return;
|
||||
|
||||
// Condition 2: tags outside the editable list cannot be edited in place,
|
||||
// but still qualify for an assisted id when they hold text of their own.
|
||||
if (!checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS)) {
|
||||
if (checkTagNameEditable(openingNode, INLINE_EDIT_HTML_TAGS)) return;
|
||||
|
||||
if (t.isJSXElement(elementNode) && hasMeaningfulTextContent(elementNode)) {
|
||||
attachAssistedEditId(openingNode, editId);
|
||||
attributesAdded++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Condition 3: image src must be a non-empty static string
|
||||
const imageValidation = validateImageSrc(openingNode);
|
||||
if (!imageValidation.isValid) {
|
||||
attachAssistedEditId(openingNode, editId);
|
||||
attributesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Condition 4: mark as assisted-edit if spread props or dynamic children
|
||||
if (shouldDisableDueToChildren(elementNode, openingNode)) {
|
||||
attachAssistedEditId(openingNode, editId);
|
||||
attributesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Condition 5: skip or mark assisted-edit based on child composition
|
||||
const childResult = evalChildComposition(elementNode, openingNode);
|
||||
if (childResult === 'skip') return;
|
||||
if (childResult === 'disabled') {
|
||||
attachAssistedEditId(openingNode, editId);
|
||||
attributesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Condition 6: recursive subtree check — dynamic expressions,
|
||||
// spread props, or conditionals anywhere below this element make
|
||||
// a direct edit unsafe (it would overwrite the JSX with static text).
|
||||
if (subtreeRequiresAssistance(elementNode)) {
|
||||
attachAssistedEditId(openingNode, editId);
|
||||
attributesAdded++;
|
||||
return;
|
||||
}
|
||||
|
||||
const urlAttributeName = detectUrlAttribute(openingNode);
|
||||
attachEditAttributes(openingNode, editId, urlAttributeName);
|
||||
attributesAdded++;
|
||||
}
|
||||
});
|
||||
|
||||
if (attributesAdded > 0) {
|
||||
const output = generateSourceWithMap(babelAst, webRelativeFilePath, code);
|
||||
return { code: output.code, map: output.map };
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[vite][visual-editor] Error transforming ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Computes the source a batch of draft edits would produce and returns it.
|
||||
// Nothing is written here — the preview posts the result up to the editor,
|
||||
// which persists it through the authenticated backend.
|
||||
configureServer(server) {
|
||||
server.middlewares.use('/api/draft-save', async (request, response, next) => {
|
||||
if (request.method !== 'POST') return next();
|
||||
|
||||
// Defence in depth only: this endpoint is public and Sec-Fetch-Site is
|
||||
// client-supplied, so it stops browser-driven cross-origin calls and
|
||||
// nothing more. Authorisation happens on the backend persist route.
|
||||
if (!isSameOriginRequest(request)) {
|
||||
response.writeHead(403, { 'Content-Type': 'application/json' });
|
||||
return response.end(JSON.stringify({ error: 'Forbidden' }));
|
||||
}
|
||||
|
||||
let body = '';
|
||||
request.on('data', chunk => { body += chunk.toString(); });
|
||||
|
||||
request.on('end', async () => {
|
||||
let parsedBody;
|
||||
try {
|
||||
parsedBody = JSON.parse(body);
|
||||
} catch {
|
||||
response.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return response.end(JSON.stringify({ error: 'Invalid JSON request body' }));
|
||||
}
|
||||
|
||||
if (!parsedBody || typeof parsedBody !== 'object') {
|
||||
response.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return response.end(JSON.stringify({ error: 'Invalid JSON request body' }));
|
||||
}
|
||||
|
||||
try {
|
||||
const { draft } = parsedBody;
|
||||
|
||||
if (!draft || typeof draft !== 'object') {
|
||||
response.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
return response.end(JSON.stringify({ error: 'Missing or invalid draft object' }));
|
||||
}
|
||||
|
||||
const { editsByFile, results } = groupEditsByFile(draft);
|
||||
|
||||
for (const [absoluteFilePath, fileEdits] of editsByFile) {
|
||||
const originalContent = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||
const babelAst = parseFileToAST(absoluteFilePath);
|
||||
let fileModified = false;
|
||||
|
||||
for (const { draftKey, edit, parsedId } of fileEdits) {
|
||||
const { modified, error } = applyElementEdit(babelAst, edit, parsedId);
|
||||
if (!modified) {
|
||||
results.push({ draftKey, success: false, error });
|
||||
continue;
|
||||
}
|
||||
fileModified = true;
|
||||
}
|
||||
|
||||
if (fileModified) {
|
||||
results.push(renderEditedSource(babelAst, absoluteFilePath, originalContent));
|
||||
}
|
||||
}
|
||||
|
||||
response.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ success: true, results }));
|
||||
} catch (error) {
|
||||
console.error('[vite][visual-editor] Error in draft-save:', error);
|
||||
response.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
response.end(JSON.stringify({ error: 'Internal server error during draft save.' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
export default function iframeRouteRestorationPlugin() {
|
||||
return {
|
||||
name: 'vite:iframe-route-restoration',
|
||||
apply: 'serve',
|
||||
transformIndexHtml() {
|
||||
const script = `
|
||||
const ALLOWED_PARENT_ORIGINS = [
|
||||
"https://horizons.hostinger.com",
|
||||
"https://horizons.hostinger.dev",
|
||||
"https://horizons-frontend-local.hostinger.dev",
|
||||
];
|
||||
|
||||
// Check to see if the page is in an iframe
|
||||
if (window.self !== window.top) {
|
||||
const STORAGE_KEY = 'horizons-iframe-saved-route';
|
||||
|
||||
const getCurrentRoute = () => location.pathname + location.search + location.hash;
|
||||
|
||||
const save = () => {
|
||||
try {
|
||||
const currentRoute = getCurrentRoute();
|
||||
sessionStorage.setItem(STORAGE_KEY, currentRoute);
|
||||
window.parent.postMessage({message: 'route-changed', route: currentRoute}, '*');
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const replaceHistoryState = (url) => {
|
||||
try {
|
||||
history.replaceState(null, '', url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate', { state: history.state }));
|
||||
return true;
|
||||
} catch {}
|
||||
return false;
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
try {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!saved) return;
|
||||
|
||||
if (!saved.startsWith('/')) {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = getCurrentRoute();
|
||||
if (current !== saved) {
|
||||
if (!replaceHistoryState(saved)) {
|
||||
replaceHistoryState('/');
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => setTimeout(() => {
|
||||
try {
|
||||
const text = (document.body?.innerText || '').trim();
|
||||
|
||||
// If the restored route results in too little content, assume it is invalid and navigate home
|
||||
if (text.length < 50) {
|
||||
replaceHistoryState('/');
|
||||
}
|
||||
} catch {}
|
||||
}, 1000));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const originalPushState = history.pushState;
|
||||
history.pushState = function(...args) {
|
||||
originalPushState.apply(this, args);
|
||||
save();
|
||||
};
|
||||
|
||||
const originalReplaceState = history.replaceState;
|
||||
history.replaceState = function(...args) {
|
||||
originalReplaceState.apply(this, args);
|
||||
save();
|
||||
};
|
||||
|
||||
const getParentOrigin = () => {
|
||||
if (
|
||||
window.location.ancestorOrigins &&
|
||||
window.location.ancestorOrigins.length > 0
|
||||
) {
|
||||
return window.location.ancestorOrigins[0];
|
||||
}
|
||||
|
||||
if (document.referrer) {
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch (e) {
|
||||
console.warn("Invalid referrer URL:", document.referrer);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
window.addEventListener('popstate', save);
|
||||
window.addEventListener('hashchange', save);
|
||||
window.addEventListener("message", function (event) {
|
||||
const parentOrigin = getParentOrigin();
|
||||
if (!parentOrigin || !ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) return;
|
||||
|
||||
if (event.data?.type === "redirect-home") {
|
||||
const saved = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (saved && saved !== '/') {
|
||||
replaceHistoryState('/');
|
||||
}
|
||||
}
|
||||
|
||||
if (event.data?.type === "navigate-to-route" && typeof event.data.path === "string") {
|
||||
const target = event.data.path.startsWith('/') ? event.data.path : '/' + event.data.path;
|
||||
const current = getCurrentRoute();
|
||||
if (current !== target) {
|
||||
replaceHistoryState(target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
restore();
|
||||
}
|
||||
`;
|
||||
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: script,
|
||||
injectTo: 'head'
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export default function pocketbaseAuthPlugin() {
|
||||
return {
|
||||
name: 'vite:pocketbase-auth',
|
||||
apply: 'serve',
|
||||
transformIndexHtml() {
|
||||
const script = `
|
||||
const ALLOWED_PARENT_ORIGINS = [
|
||||
"https://horizons.hostinger.com",
|
||||
"https://horizons.hostinger.dev",
|
||||
"https://horizons-frontend-local.hostinger.dev",
|
||||
];
|
||||
const getParentOrigin = () => {
|
||||
if (
|
||||
window.location.ancestorOrigins &&
|
||||
window.location.ancestorOrigins.length > 0
|
||||
) {
|
||||
return window.location.ancestorOrigins[0];
|
||||
}
|
||||
|
||||
if (document.referrer) {
|
||||
try {
|
||||
return new URL(document.referrer).origin;
|
||||
} catch (e) {
|
||||
console.warn("Invalid referrer URL:", document.referrer);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
window.addEventListener("message", function (event) {
|
||||
const parentOrigin = getParentOrigin();
|
||||
|
||||
if (event.data?.type === "database_preview_auth" && parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||
localStorage.setItem("__pb_superuser_auth__", event.data.value);
|
||||
}
|
||||
});
|
||||
`;
|
||||
|
||||
return [
|
||||
{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: script,
|
||||
injectTo: 'head'
|
||||
}
|
||||
];
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { parse } from '@babel/parser';
|
||||
import { VITE_PROJECT_ROOT } from './utils/ast-utils.js';
|
||||
import { isSameOriginRequest } from './utils/same-origin.js';
|
||||
import {
|
||||
buildPageList,
|
||||
scanFile,
|
||||
upsertFile,
|
||||
} from './site-pages/site-pages-server.js';
|
||||
|
||||
const __dirname = resolve(fileURLToPath(import.meta.url), '..');
|
||||
const SITE_PAGES_ENDPOINT = '/__horizons/site-pages';
|
||||
const SITE_PAGES_SCRIPT = readFileSync(resolve(__dirname, 'site-pages/site-pages-script.js'), 'utf-8');
|
||||
|
||||
/**
|
||||
* Scan routes from JSX, expose them at /__horizons/site-pages,
|
||||
* and post them to the Horizons parent frame from the preview iframe.
|
||||
*
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
export default function sitePagesPlugin() {
|
||||
let index = { routerEntries: [], titlesByPageFile: {}, ecommercePageFiles: {} };
|
||||
|
||||
return {
|
||||
name: 'vite:site-pages',
|
||||
apply: 'serve',
|
||||
enforce: 'pre',
|
||||
|
||||
transform(code, id) {
|
||||
if (!/\.(jsx|tsx)$/.test(id) || !id.startsWith(VITE_PROJECT_ROOT) || id.includes('node_modules')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const ast = parse(code, {
|
||||
sourceType: 'module',
|
||||
plugins: ['jsx', 'typescript'],
|
||||
errorRecovery: true,
|
||||
});
|
||||
|
||||
index = upsertFile(index, id, scanFile(ast));
|
||||
} catch (error) {
|
||||
console.error(`[site-pages] Error scanning ${id}:`, error);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
configureServer(server) {
|
||||
server.middlewares.use(SITE_PAGES_ENDPOINT, async (request, response, next) => {
|
||||
if (request.method !== 'GET') {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSameOriginRequest(request)) {
|
||||
response.statusCode = 403;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify({ error: 'Forbidden' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pages = await buildPageList(
|
||||
index,
|
||||
(specifier, importer) => server.pluginContainer.resolveId(specifier, importer),
|
||||
VITE_PROJECT_ROOT,
|
||||
);
|
||||
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify(pages));
|
||||
} catch (error) {
|
||||
response.statusCode = 500;
|
||||
response.setHeader('Content-Type', 'application/json');
|
||||
response.end(JSON.stringify({ error: error.message }));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
transformIndexHtml() {
|
||||
return [{
|
||||
tag: 'script',
|
||||
attrs: { type: 'module' },
|
||||
children: SITE_PAGES_SCRIPT,
|
||||
injectTo: 'head',
|
||||
}];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
<IfModule mod_rewrite.c>
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteCond %{REQUEST_FILENAME} !-d
|
||||
RewriteRule ^ index.html [L]
|
||||
</IfModule>
|
||||
|
||||
<IfModule mod_headers.c>
|
||||
Header set X-Powered-By "Hostinger Horizons"
|
||||
|
||||
# Cache everything on CDN by default
|
||||
Header set Cache-Control "public, s-maxage=604800, max-age=0"
|
||||
|
||||
# Cache in browser all assets
|
||||
<If "%{REQUEST_URI} =~ m#^/assets/.*$#">
|
||||
Header set Cache-Control "public, max-age=604800"
|
||||
</If>
|
||||
</IfModule>
|
||||
@@ -0,0 +1,2 @@
|
||||
## Pages
|
||||
- [Kat & Her Dogs - Professional Pet Sitting & Dog Walking in St. Petersburg, FL](/): Professional in-home pet sitting and dog walking services in St. Petersburg, Florida. Trusted pet care with love and attention. Call (727) 520-4185 today!
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Route, Routes, BrowserRouter as Router } from 'react-router-dom';
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import { Toaster } from './components/ui/toaster';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
</Routes>
|
||||
<Toaster />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLayoutEffect } from 'react';
|
||||
|
||||
const ScrollToTop = () => {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useLayoutEffect(() => {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: 'instant' });
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default ScrollToTop;
|
||||
@@ -0,0 +1,290 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Mail, Send } from 'lucide-react';
|
||||
import emailjs from '@emailjs/browser';
|
||||
|
||||
/*
|
||||
* ARCHITECTURAL NOTE:
|
||||
* This project is running in a Frontend-Only environment (Vite + React).
|
||||
* We cannot implement a custom Node.js/Express backend or use server-side libraries like 'nodemailer'.
|
||||
*
|
||||
* The contact form uses EmailJS to send emails directly from the browser securely.
|
||||
* This ensures functionality without requiring a separate backend server.
|
||||
*/
|
||||
|
||||
const ContactSection = () => {
|
||||
const { toast } = useToast();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
petNames: '',
|
||||
address: '',
|
||||
serviceType: '',
|
||||
message: ''
|
||||
});
|
||||
|
||||
// EMAILJS CONFIGURATION
|
||||
// Replace these with your actual EmailJS credentials
|
||||
// Sign up at https://www.emailjs.com/
|
||||
const SERVICE_ID = 'YOUR_SERVICE_ID';
|
||||
const TEMPLATE_ID = 'YOUR_TEMPLATE_ID';
|
||||
const PUBLIC_KEY = 'YOUR_PUBLIC_KEY';
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleServiceChange = (value) => {
|
||||
setFormData(prev => ({ ...prev, serviceType: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.address.trim()) {
|
||||
toast({
|
||||
title: "Validation Error",
|
||||
description: "Please provide your address.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if credentials are set
|
||||
if (SERVICE_ID === 'YOUR_SERVICE_ID') {
|
||||
toast({
|
||||
title: "Configuration Missing",
|
||||
description: "EmailJS is not configured. Please update the ContactSection.jsx file with your Service ID, Template ID, and Public Key.",
|
||||
variant: "destructive"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// In a full-stack app, we would POST to an endpoint here.
|
||||
// Since we are frontend-only, we use EmailJS.
|
||||
const response = await emailjs.send(
|
||||
SERVICE_ID,
|
||||
TEMPLATE_ID,
|
||||
{
|
||||
from_name: formData.name,
|
||||
from_email: formData.email,
|
||||
phone: formData.phone,
|
||||
pet_names: formData.petNames,
|
||||
address: formData.address,
|
||||
service_type: formData.serviceType,
|
||||
message: formData.message,
|
||||
to_email: 'katnherdogs32@gmail.com',
|
||||
},
|
||||
PUBLIC_KEY
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
toast({
|
||||
title: "Message Sent! 🐾",
|
||||
description: "Thank you for contacting us! We'll get back to you soon.",
|
||||
variant: "default"
|
||||
});
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
petNames: '',
|
||||
address: '',
|
||||
serviceType: '',
|
||||
message: ''
|
||||
});
|
||||
} else {
|
||||
throw new Error('Failed to send message');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('EmailJS Error:', error);
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to send message. Please try calling us directly at (727) 520-4185.",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="contact" className="py-16 bg-gradient-to-br from-cream via-white to-turquoise/10 relative overflow-hidden">
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute top-0 right-0 w-96 h-96 bg-turquoise/5 rounded-full blur-3xl"></div>
|
||||
<div className="absolute bottom-0 left-0 w-96 h-96 bg-coral/5 rounded-full blur-3xl"></div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h2 className="text-4xl sm:text-5xl font-bold text-navy mb-4">Get In Touch</h2>
|
||||
<p className="text-lg text-navy/80 max-w-2xl mx-auto">
|
||||
Ready to give your pets the care they deserve? Contact us today!
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-2xl p-8 border-2 border-turquoise/20"
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name" className="text-navy font-medium">Your Name <span className="text-coral">*</span></Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="John Doe"
|
||||
className="border-turquoise/30 focus:border-turquoise text-gray-900 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-navy font-medium">Email Address <span className="text-coral">*</span></Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="john@example.com"
|
||||
className="border-turquoise/30 focus:border-turquoise text-gray-900 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone" className="text-navy font-medium">Phone Number <span className="text-coral">*</span></Label>
|
||||
<Input
|
||||
id="phone"
|
||||
name="phone"
|
||||
type="tel"
|
||||
required
|
||||
value={formData.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="(727) 555-0123"
|
||||
className="border-turquoise/30 focus:border-turquoise text-gray-900 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="petNames" className="text-navy font-medium">Pet Name(s) <span className="text-coral">*</span></Label>
|
||||
<Input
|
||||
id="petNames"
|
||||
name="petNames"
|
||||
type="text"
|
||||
required
|
||||
value={formData.petNames}
|
||||
onChange={handleChange}
|
||||
placeholder="Max, Bella"
|
||||
className="border-turquoise/30 focus:border-turquoise text-gray-900 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="address" className="text-navy font-medium">Your Address <span className="text-coral">*</span></Label>
|
||||
<Input
|
||||
id="address"
|
||||
name="address"
|
||||
type="text"
|
||||
required
|
||||
value={formData.address}
|
||||
onChange={handleChange}
|
||||
placeholder="123 Main St, St. Petersburg, FL"
|
||||
className="border-turquoise/30 focus:border-turquoise text-gray-900 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="serviceType" className="text-navy font-medium">Service Type <span className="text-coral">*</span></Label>
|
||||
<Select value={formData.serviceType} onValueChange={handleServiceChange} required>
|
||||
<SelectTrigger className="border-turquoise/30 focus:border-turquoise text-gray-900">
|
||||
<SelectValue placeholder="Select a service" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="in-home-sitting">In-Home Pet Sitting</SelectItem>
|
||||
<SelectItem value="dog-walking">Dog Walking</SelectItem>
|
||||
<SelectItem value="both">Both Services</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="message" className="text-navy font-medium">Message <span className="text-coral">*</span></Label>
|
||||
<Textarea
|
||||
id="message"
|
||||
name="message"
|
||||
required
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
placeholder="Tell us about your pets and what you need..."
|
||||
rows={5}
|
||||
className="border-turquoise/30 focus:border-turquoise resize-none text-gray-900 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-coral hover:bg-coral/90 text-white text-lg py-6 rounded-lg shadow-lg hover:shadow-xl transform hover:scale-[1.02] transition-all duration-300"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>Sending...</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5 mr-2" />
|
||||
Send Message
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
whileInView={{ opacity: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
className="text-center mt-8"
|
||||
>
|
||||
<p className="text-navy/70 flex items-center justify-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
Or email us directly at{' '}
|
||||
<a href="mailto:katnherdogs32@gmail.com" className="text-turquoise hover:underline font-medium">
|
||||
katnherdogs32@gmail.com
|
||||
</a>
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactSection;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { Phone, Mail, MapPin, Instagram } from 'lucide-react';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer className="bg-navy text-cream py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{/* Company Info - Left Column */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-white mb-4">Kat & Her Dogs</h3>
|
||||
<p className="text-cream/80 mb-4">
|
||||
Professional pet care services with love and dedication. Your pets are our family!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Contact Information - Right Column */}
|
||||
<div className="md:text-right">
|
||||
<h4 className="text-xl font-semibold text-white mb-4">Contact Us</h4>
|
||||
<div className="space-y-3">
|
||||
<a
|
||||
href="tel:7275204185"
|
||||
className="flex items-center md:justify-end gap-2 text-cream/80 hover:text-turquoise transition-colors"
|
||||
>
|
||||
<Phone className="w-5 h-5" />
|
||||
(727) 520-4185
|
||||
</a>
|
||||
<a
|
||||
href="mailto:katnherdogs32@gmail.com"
|
||||
className="flex items-center md:justify-end gap-2 text-cream/80 hover:text-turquoise transition-colors"
|
||||
>
|
||||
<Mail className="w-5 h-5" />
|
||||
katnherdogs32@gmail.com
|
||||
</a>
|
||||
<a
|
||||
href="https://www.instagram.com/katandherdogsllc/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center md:justify-end gap-2 text-cream/80 hover:text-turquoise transition-colors"
|
||||
>
|
||||
<Instagram className="w-5 h-5" />
|
||||
@katandherdogsllc
|
||||
</a>
|
||||
<div className="flex items-center md:justify-end gap-2 text-cream/80">
|
||||
<MapPin className="w-5 h-5" />
|
||||
St. Petersburg, Florida
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-cream/20 mt-8 pt-8 text-center">
|
||||
<p className="text-cream/60">
|
||||
© {new Date().getFullYear()} Kat & Her Dogs. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const images = [
|
||||
{
|
||||
src: 'https://horizons-cdn.hostinger.com/1edbac22-ab36-41d9-aff9-44eb1506363e/05485a6602c92213d17f6b69f0f71c10.jpg',
|
||||
alt: 'Black and white dog playing with green tire toy',
|
||||
title: 'Playtime Fun'
|
||||
},
|
||||
{
|
||||
src: 'https://horizons-cdn.hostinger.com/1edbac22-ab36-41d9-aff9-44eb1506363e/95ed79c076c5d409d3f94dbb6239cc1a.jpg',
|
||||
alt: 'Chihuahua in purple and blue sweater',
|
||||
title: 'Cozy & Cute'
|
||||
},
|
||||
{
|
||||
src: 'https://horizons-cdn.hostinger.com/1edbac22-ab36-41d9-aff9-44eb1506363e/ed25cc9ec1febedad8a061beaf64bcc4.jpg',
|
||||
alt: 'Two black and white dogs indoors',
|
||||
title: 'Best Friends'
|
||||
},
|
||||
{
|
||||
src: 'https://horizons-cdn.hostinger.com/1edbac22-ab36-41d9-aff9-44eb1506363e/f76d2140ccab435fb133f9a5365192ab.jpg',
|
||||
alt: 'Black dog with white chest and tan Labrador on dirt path',
|
||||
title: 'Adventure Time'
|
||||
},
|
||||
{
|
||||
src: 'https://horizons-cdn.hostinger.com/1edbac22-ab36-41d9-aff9-44eb1506363e/b01e33957207904c84325bed7d8400fd.jpg',
|
||||
alt: 'Group of four dogs on brick patio',
|
||||
title: 'The Whole Pack'
|
||||
}
|
||||
];
|
||||
|
||||
const GallerySection = () => {
|
||||
return (
|
||||
<section className="py-20 bg-white relative overflow-hidden">
|
||||
{/* Decorative background elements */}
|
||||
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-30">
|
||||
<div className="absolute -top-20 -right-20 w-64 h-64 bg-turquoise/20 rounded-full blur-3xl"></div>
|
||||
<div className="absolute top-40 -left-20 w-72 h-72 bg-coral/10 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-navy mb-4">Our Happy Clients</h2>
|
||||
<p className="text-lg text-navy/70 max-w-2xl mx-auto">
|
||||
Take a look at some of the furry friends we've had the pleasure of caring for!
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{images.map((image, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
className="group relative aspect-[4/3] rounded-xl overflow-hidden shadow-lg bg-gray-100"
|
||||
>
|
||||
<img
|
||||
src={image.src}
|
||||
alt={image.alt}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-navy/70 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end p-6">
|
||||
<p className="text-white font-semibold text-lg translate-y-4 group-hover:translate-y-0 transition-transform duration-300">
|
||||
{image.title}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* Optional placeholder for grid balance if needed, or simply let it flow */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
whileInView={{ opacity: 1, scale: 1 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: 0.5 }}
|
||||
className="flex flex-col items-center justify-center p-8 text-center bg-turquoise/5 rounded-xl border-2 border-dashed border-turquoise/30 aspect-[4/3]"
|
||||
>
|
||||
<p className="text-navy font-medium text-lg mb-2">Your Pet Could Be Here!</p>
|
||||
<p className="text-navy/60 text-sm">Join our happy family today.</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default GallerySection;
|
||||
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Phone } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
const HeroSection = () => {
|
||||
return (
|
||||
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
{/* Background Image with Overlay */}
|
||||
<div
|
||||
className="absolute inset-0 bg-cover bg-center"
|
||||
style={{
|
||||
backgroundImage: 'url(https://images.unsplash.com/photo-1690294888500-77548d31c390)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-turquoise/90 via-turquoise/80 to-navy/70"></div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
>
|
||||
<h1 className="text-5xl sm:text-6xl lg:text-7xl font-bold text-white mb-6 drop-shadow-lg">
|
||||
Kat & Her Dogs
|
||||
</h1>
|
||||
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, delay: 0.2 }}
|
||||
className="text-xl sm:text-2xl lg:text-3xl text-cream font-medium mb-12 drop-shadow-md"
|
||||
>
|
||||
Professional Pet Sitting & Dog Walking in St. Petersburg, Florida
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.4 }}
|
||||
>
|
||||
<Button
|
||||
asChild
|
||||
className="bg-coral hover:bg-coral/90 text-white text-lg px-8 py-6 rounded-full shadow-2xl hover:shadow-3xl transform hover:scale-105 transition-all duration-300"
|
||||
>
|
||||
<a href="tel:7275204185" className="flex items-center gap-3">
|
||||
<Phone className="w-6 h-6" />
|
||||
Call Now: (727) 520-4185
|
||||
</a>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Decorative Elements */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-cream to-transparent"></div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroSection;
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Home, Footprints } from 'lucide-react';
|
||||
|
||||
const services = [
|
||||
{
|
||||
id: 1,
|
||||
icon: Home,
|
||||
title: 'In-Home Pet Sitting',
|
||||
description: 'Daily visits to care for your pets in the comfort of their own home. We provide feeding, fresh water, playtime, medication administration, and lots of love and attention while you\'re away.',
|
||||
features: ['Daily visits', 'Feeding & fresh water', 'Playtime & cuddles', 'Medication administration', 'Litter box cleaning', 'Home security checks'],
|
||||
image: 'https://horizons-cdn.hostinger.com/1edbac22-ab36-41d9-aff9-44eb1506363e/95ed79c076c5d409d3f94dbb6239cc1a.jpg',
|
||||
alt: 'Chihuahua wearing a purple and blue sweater looking at the camera'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
icon: Footprints,
|
||||
title: 'Dog Walking',
|
||||
description: 'Outdoor exercise and adventures for your furry friends. We provide neighborhood walks, pet socialization, and plenty of outdoor fun to keep your dogs happy and healthy.',
|
||||
features: ['Neighborhood walks', 'Exercise & outdoor fun', 'Pet socialization', 'Fresh air & exploration', 'Flexible scheduling', 'Individual attention'],
|
||||
image: 'https://horizons-cdn.hostinger.com/1edbac22-ab36-41d9-aff9-44eb1506363e/f76d2140ccab435fb133f9a5365192ab.jpg',
|
||||
alt: 'Two dogs, one black with a white chest and a tan Labrador, walking on a dirt path with one wearing a bandana'
|
||||
}
|
||||
];
|
||||
|
||||
const ServicesSection = () => {
|
||||
return (
|
||||
<section id="services" className="py-16 bg-cream">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl sm:text-5xl font-bold text-navy mb-4">Our Services</h2>
|
||||
<p className="text-lg text-navy/80 max-w-2xl mx-auto">
|
||||
Trusted pet care services tailored to your furry family members' needs
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Adjusted grid to max-w-5xl and md:grid-cols-2 to center and balance the 2 items */}
|
||||
<div className="max-w-5xl mx-auto grid md:grid-cols-2 gap-8 lg:gap-12">
|
||||
{services.map((service, index) => (
|
||||
<motion.div
|
||||
key={service.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.6, delay: index * 0.15 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
className="bg-white rounded-xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden border-4 border-turquoise/20 hover:border-turquoise/40 flex flex-col"
|
||||
>
|
||||
<div className="h-64 w-full overflow-hidden relative aspect-video">
|
||||
<img
|
||||
src={service.image}
|
||||
alt={service.alt}
|
||||
className="w-full h-full object-cover object-center transform hover:scale-110 transition-transform duration-500"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-8 flex-1 flex flex-col">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-16 h-16 bg-turquoise/10 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<service.icon className="w-8 h-8 text-turquoise" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-navy">{service.title}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-navy/80 mb-6 leading-relaxed flex-1">{service.description}</p>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{service.features.map((feature, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2 text-navy/70">
|
||||
<span className="w-2 h-2 bg-turquoise rounded-full flex-shrink-0"></span>
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServicesSection;
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import React from 'react';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline:
|
||||
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2',
|
||||
sm: 'h-9 rounded-md px-3',
|
||||
lg: 'h-11 rounded-md px-8',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
);
|
||||
|
||||
const Label = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
));
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
));
|
||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Textarea = React.forwardRef(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export { Textarea };
|
||||
@@ -0,0 +1,101 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { X } from 'lucide-react';
|
||||
import React from 'react';
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider;
|
||||
|
||||
const ToastViewport = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||
|
||||
const toastVariants = cva(
|
||||
'data-[swipe=move]:transition-none group relative pointer-events-auto flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full data-[state=closed]:slide-out-to-right-full',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-background border',
|
||||
destructive:
|
||||
'group destructive border-destructive bg-destructive text-destructive-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Toast = React.forwardRef(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||
|
||||
const ToastAction = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-destructive/30 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||
|
||||
const ToastClose = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||
className,
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
));
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||
|
||||
const ToastTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||
|
||||
const ToastDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm opacity-90', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||
|
||||
export {
|
||||
Toast,
|
||||
ToastAction,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from '@/components/ui/toast';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import React from 'react';
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
);
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState, useEffect } from "react"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
|
||||
let count = 0
|
||||
function generateId() {
|
||||
count = (count + 1) % Number.MAX_VALUE
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
const toastStore = {
|
||||
state: {
|
||||
toasts: [],
|
||||
},
|
||||
listeners: [],
|
||||
|
||||
getState: () => toastStore.state,
|
||||
|
||||
setState: (nextState) => {
|
||||
if (typeof nextState === 'function') {
|
||||
toastStore.state = nextState(toastStore.state)
|
||||
} else {
|
||||
toastStore.state = { ...toastStore.state, ...nextState }
|
||||
}
|
||||
|
||||
toastStore.listeners.forEach(listener => listener(toastStore.state))
|
||||
},
|
||||
|
||||
subscribe: (listener) => {
|
||||
toastStore.listeners.push(listener)
|
||||
return () => {
|
||||
toastStore.listeners = toastStore.listeners.filter(l => l !== listener)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const toast = ({ ...props }) => {
|
||||
const id = generateId()
|
||||
|
||||
const update = (props) =>
|
||||
toastStore.setState((state) => ({
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === id ? { ...t, ...props } : t
|
||||
),
|
||||
}))
|
||||
|
||||
const dismiss = () => toastStore.setState((state) => ({
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== id),
|
||||
}))
|
||||
|
||||
toastStore.setState((state) => ({
|
||||
...state,
|
||||
toasts: [
|
||||
{ ...props, id, dismiss },
|
||||
...state.toasts,
|
||||
].slice(0, TOAST_LIMIT),
|
||||
}))
|
||||
|
||||
return {
|
||||
id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [state, setState] = useState(toastStore.getState())
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = toastStore.subscribe((state) => {
|
||||
setState(state)
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timeouts = []
|
||||
|
||||
state.toasts.forEach((toast) => {
|
||||
if (toast.duration === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toast.dismiss()
|
||||
}, toast.duration || 5000)
|
||||
|
||||
timeouts.push(timeout)
|
||||
})
|
||||
|
||||
return () => {
|
||||
timeouts.forEach((timeout) => clearTimeout(timeout))
|
||||
}
|
||||
}, [state.toasts])
|
||||
|
||||
return {
|
||||
toast,
|
||||
toasts: state.toasts,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 210 40% 98%;
|
||||
--primary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 212.7 26.8% 83.9%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Poppins', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from '@/App';
|
||||
import '@/index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<App />
|
||||
);
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import HeroSection from '@/components/landing/HeroSection';
|
||||
import ServicesSection from '@/components/landing/ServicesSection';
|
||||
import GallerySection from '@/components/landing/GallerySection';
|
||||
import ContactSection from '@/components/landing/ContactSection';
|
||||
import Footer from '@/components/landing/Footer';
|
||||
|
||||
const LandingPage = () => {
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>Kat & Her Dogs - Professional Pet Sitting & Dog Walking in St. Petersburg, FL</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Professional in-home pet sitting and dog walking services in St. Petersburg, Florida. Trusted pet care with love and attention. Call (727) 520-4185 today!"
|
||||
/>
|
||||
</Helmet>
|
||||
<div className="min-h-screen">
|
||||
<HeroSection />
|
||||
<ServicesSection />
|
||||
<GallerySection />
|
||||
<ContactSection />
|
||||
<Footer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LandingPage;
|
||||
@@ -0,0 +1,128 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
darkMode: ['class'],
|
||||
content: [
|
||||
'./pages/**/*.{js,jsx}',
|
||||
'./components/**/*.{js,jsx}',
|
||||
'./app/**/*.{js,jsx}',
|
||||
'./src/**/*.{js,jsx}',
|
||||
],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
padding: '2rem',
|
||||
screens: {
|
||||
'2xl': '1400px',
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))',
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))',
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))',
|
||||
},
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
turquoise: {
|
||||
DEFAULT: '#4ECDC4',
|
||||
50: '#E8FAF9',
|
||||
100: '#D1F5F3',
|
||||
200: '#A3EBE7',
|
||||
300: '#75E1DB',
|
||||
400: '#4ECDC4',
|
||||
500: '#2AB7AE',
|
||||
600: '#229088',
|
||||
700: '#196A62',
|
||||
800: '#11453C',
|
||||
900: '#082016',
|
||||
},
|
||||
navy: {
|
||||
DEFAULT: '#1A2B3D',
|
||||
50: '#E8EAED',
|
||||
100: '#D1D5DB',
|
||||
200: '#A3ABB7',
|
||||
300: '#758193',
|
||||
400: '#47576F',
|
||||
500: '#1A2B3D',
|
||||
600: '#152231',
|
||||
700: '#101A25',
|
||||
800: '#0B1119',
|
||||
900: '#06090D',
|
||||
},
|
||||
coral: {
|
||||
DEFAULT: '#FF6B6B',
|
||||
50: '#FFF0F0',
|
||||
100: '#FFE1E1',
|
||||
200: '#FFC3C3',
|
||||
300: '#FFA5A5',
|
||||
400: '#FF8787',
|
||||
500: '#FF6B6B',
|
||||
600: '#FF3939',
|
||||
700: '#FF0707',
|
||||
800: '#D40000',
|
||||
900: '#A20000',
|
||||
},
|
||||
cream: {
|
||||
DEFAULT: '#FFF8F3',
|
||||
50: '#FFFEFB',
|
||||
100: '#FFF8F3',
|
||||
200: '#FFF1E7',
|
||||
300: '#FFEADB',
|
||||
400: '#FFE3CF',
|
||||
500: '#FFDCC3',
|
||||
600: '#FFD5B7',
|
||||
700: '#FFCEAB',
|
||||
800: '#FFC79F',
|
||||
900: '#FFC093',
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)',
|
||||
},
|
||||
keyframes: {
|
||||
'accordion-down': {
|
||||
from: { height: 0 },
|
||||
to: { height: 'var(--radix-accordion-content-height)' },
|
||||
},
|
||||
'accordion-up': {
|
||||
from: { height: 'var(--radix-accordion-content-height)' },
|
||||
to: { height: 0 },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [require('tailwindcss-animate')],
|
||||
};
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const CLEAN_CONTENT_REGEX = {
|
||||
comments: /\/\*[\s\S]*?\*\/|\/\/.*$/gm,
|
||||
templateLiterals: /`[\s\S]*?`/g,
|
||||
strings: /'[^']*'|"[^"]*"/g,
|
||||
jsxExpressions: /\{.*?\}/g,
|
||||
htmlEntities: {
|
||||
quot: /"/g,
|
||||
amp: /&/g,
|
||||
lt: /</g,
|
||||
gt: />/g,
|
||||
apos: /'/g
|
||||
}
|
||||
};
|
||||
|
||||
const EXTRACTION_REGEX = {
|
||||
// `element={<Page />}` puts a `>` inside the tag, so the attribute scan has
|
||||
// to step over brace groups instead of stopping at the first `>`.
|
||||
route: /<Route\s+(?:[^>{]|\{[^}]*\})*\/?>/g,
|
||||
path: /path=["']([^"']+)["']/,
|
||||
element: /element=\{([\s\S]*)\}/,
|
||||
elementComponent: /<(\w+)/g,
|
||||
helmet: /<Helmet[^>]*?>([\s\S]*?)<\/Helmet>/i,
|
||||
helmetTest: /<Helmet[\s\S]*?<\/Helmet>/i,
|
||||
title: /<title[^>]*?>\s*(.*?)\s*<\/title>/i,
|
||||
description: /<meta\s+name=["']description["']\s+content=["'](.*?)["']/i
|
||||
};
|
||||
|
||||
function cleanContent(content) {
|
||||
return content
|
||||
.replace(CLEAN_CONTENT_REGEX.comments, '')
|
||||
.replace(CLEAN_CONTENT_REGEX.templateLiterals, '""')
|
||||
.replace(CLEAN_CONTENT_REGEX.strings, '""');
|
||||
}
|
||||
|
||||
function cleanText(text) {
|
||||
if (!text) return text;
|
||||
|
||||
return text
|
||||
.replace(CLEAN_CONTENT_REGEX.jsxExpressions, '')
|
||||
.replace(CLEAN_CONTENT_REGEX.htmlEntities.quot, '"')
|
||||
.replace(CLEAN_CONTENT_REGEX.htmlEntities.amp, '&')
|
||||
.replace(CLEAN_CONTENT_REGEX.htmlEntities.lt, '<')
|
||||
.replace(CLEAN_CONTENT_REGEX.htmlEntities.gt, '>')
|
||||
.replace(CLEAN_CONTENT_REGEX.htmlEntities.apos, "'")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractRoutes(appJsxPath) {
|
||||
if (!fs.existsSync(appJsxPath)) return new Map();
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(appJsxPath, 'utf8');
|
||||
const routes = new Map();
|
||||
const routeMatches = [...content.matchAll(EXTRACTION_REGEX.route)];
|
||||
|
||||
for (const match of routeMatches) {
|
||||
const routeTag = match[0];
|
||||
const pathMatch = routeTag.match(EXTRACTION_REGEX.path);
|
||||
const elementMatch = routeTag.match(EXTRACTION_REGEX.element);
|
||||
const isIndex = routeTag.includes('index');
|
||||
|
||||
if (elementMatch) {
|
||||
// A guarded route reads `element={<ProtectedRoute><SettingsPage /></ProtectedRoute>}`,
|
||||
// so the page is the innermost component rather than the first one.
|
||||
const componentNames = [...elementMatch[1].matchAll(EXTRACTION_REGEX.elementComponent)];
|
||||
const componentName = componentNames.at(-1)?.[1];
|
||||
|
||||
if (!componentName) continue;
|
||||
|
||||
let routePath;
|
||||
|
||||
if (isIndex) {
|
||||
routePath = '/';
|
||||
} else if (pathMatch) {
|
||||
routePath = pathMatch[1].startsWith('/') ? pathMatch[1] : `/${pathMatch[1]}`;
|
||||
}
|
||||
|
||||
routes.set(componentName, routePath);
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
} catch (error) {
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
function findReactFiles(dir) {
|
||||
return fs.readdirSync(dir).map(item => path.join(dir, item));
|
||||
}
|
||||
|
||||
function extractHelmetData(content, filePath, routes) {
|
||||
const cleanedContent = cleanContent(content);
|
||||
|
||||
if (!EXTRACTION_REGEX.helmetTest.test(cleanedContent)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const helmetMatch = content.match(EXTRACTION_REGEX.helmet);
|
||||
if (!helmetMatch) return null;
|
||||
|
||||
const helmetContent = helmetMatch[1];
|
||||
const titleMatch = helmetContent.match(EXTRACTION_REGEX.title);
|
||||
const descMatch = helmetContent.match(EXTRACTION_REGEX.description);
|
||||
|
||||
const title = cleanText(titleMatch?.[1]);
|
||||
const description = cleanText(descMatch?.[1]);
|
||||
|
||||
const fileName = path.basename(filePath, path.extname(filePath));
|
||||
const url = routes.size && routes.has(fileName)
|
||||
? routes.get(fileName)
|
||||
: generateFallbackUrl(fileName);
|
||||
|
||||
return {
|
||||
url,
|
||||
title: title || 'Untitled Page',
|
||||
description: description || 'No description available'
|
||||
};
|
||||
}
|
||||
|
||||
function generateFallbackUrl(fileName) {
|
||||
const cleanName = fileName.replace(/Page$/, '').toLowerCase();
|
||||
return cleanName === 'app' ? '/' : `/${cleanName}`;
|
||||
}
|
||||
|
||||
function generateLlmsTxt(pages) {
|
||||
const sortedPages = pages.sort((a, b) => a.title.localeCompare(b.title));
|
||||
const pageEntries = sortedPages.map(page =>
|
||||
`- [${page.title}](${page.url}): ${page.description}`
|
||||
).join('\n');
|
||||
|
||||
return `## Pages\n${pageEntries}`;
|
||||
}
|
||||
|
||||
function ensureDirectoryExists(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function processPageFile(filePath, routes) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
return extractHelmetData(content, filePath, routes);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error processing ${filePath}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const pagesDir = path.join(process.cwd(), 'src', 'pages');
|
||||
const appJsxPath = path.join(process.cwd(), 'src', 'App.jsx');
|
||||
|
||||
let pages = [];
|
||||
|
||||
if (!fs.existsSync(pagesDir)) {
|
||||
pages.push(processPageFile(appJsxPath, new Map()))
|
||||
pages = pages.filter(Boolean);
|
||||
} else {
|
||||
const routes = extractRoutes(appJsxPath);
|
||||
const reactFiles = findReactFiles(pagesDir);
|
||||
|
||||
pages = reactFiles
|
||||
.map(filePath => processPageFile(filePath, routes))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (pages.length === 0) {
|
||||
console.error('❌ No pages with Helmet components found!');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
const llmsTxtContent = generateLlmsTxt(pages);
|
||||
const outputPath = path.join(process.cwd(), 'public', 'llms.txt');
|
||||
|
||||
ensureDirectoryExists(path.dirname(outputPath));
|
||||
fs.writeFileSync(outputPath, llmsTxtContent, 'utf8');
|
||||
}
|
||||
|
||||
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
|
||||
|
||||
if (isMainModule) {
|
||||
main();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user