commit ed891034d0f9534fc472838b33e2d4441f4d9d55 Author: Andres Santana Date: Fri Sep 25 10:55:51 2026 -0400 chore(repo): reinitialize repository for clean deploy-ready version diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..221e434 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +node_modules +apps/web/node_modules +dist +vault +*.log +.DS_Store diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3dc7bb --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# Dependencies +node_modules/ + +# Build output +dist/ + +# Environment variables +.env +.env.* +!.env.example + +# Logs +*.log +npm-debug.log* + +# OS / editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.version b/.version new file mode 100644 index 0000000..21e72e8 --- /dev/null +++ b/.version @@ -0,0 +1 @@ +48 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..581fb1d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# ---------- Build stage ---------- +# glibc (Debian) base: the lockfile's native binaries (@rollup/rollup-linux-x64-gnu, +# @esbuild/linux-x64) are built against glibc. alpine uses musl and would fail to load them. +FROM node:22-slim AS build +WORKDIR /app + +# Copy lockfile + package manifests first so this layer is cached when only source changes. +COPY package.json package-lock.json ./ +COPY apps/web/package.json ./apps/web/package.json + +# SINGLE install. --include=optional brings the platform-native Rollup/esbuild binaries +# Vite needs. Never add a second `npm ci --workspace=apps/web` here: it reinstalls node_modules +# in a way that drops `vite` from the bin path, producing `sh: 1: vite: not found` (exit 127). +# Never use --no-optional / --omit=optional either: it omits @rollup/rollup-linux-x64-gnu +# and the Vite build crashes with "Cannot find module @rollup/rollup-linux-x64-gnu". +RUN npm ci --include=optional --no-audit --no-fund + +# Copy the app source only (keeps the build context small; .dockerignore excludes node_modules). +COPY apps/web ./apps/web + +# Build the static site. We call vite directly via its installed binary instead of the +# `npm run build` workspace script, so the build never depends on npm's script PATH resolution. +# generate-llms.js is optional SEO scaffolding (`|| true`); vite build is the real step. +RUN cd apps/web \ + && (node tools/generate-llms.js || true) \ + && node ../../node_modules/vite/bin/vite.js build --outDir ../../dist/apps/web + +# ---------- Production stage ---------- +# Pure static hosting — no Node.js runtime in the final image. +FROM nginx:stable-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist/apps/web /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/apps/web/.version b/apps/web/.version new file mode 100644 index 0000000..82cced2 --- /dev/null +++ b/apps/web/.version @@ -0,0 +1 @@ +51 diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..d376401 --- /dev/null +++ b/apps/web/components.json @@ -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": {} +} diff --git a/apps/web/eslint.config.mjs b/apps/web/eslint.config.mjs new file mode 100644 index 0000000..3ffacac --- /dev/null +++ b/apps/web/eslint.config.mjs @@ -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 } }, +]; diff --git a/apps/web/eslint.unicode-escapes-formatter.mjs b/apps/web/eslint.unicode-escapes-formatter.mjs new file mode 100644 index 0000000..5e9cb92 --- /dev/null +++ b/apps/web/eslint.unicode-escapes-formatter.mjs @@ -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`; +} diff --git a/apps/web/eslint.unicode-escapes-plugin.mjs b/apps/web/eslint.unicode-escapes-plugin.mjs new file mode 100644 index 0000000..e0236b9 --- /dev/null +++ b/apps/web/eslint.unicode-escapes-plugin.mjs @@ -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); + } + }, + }; + }, + }, + }, +}; diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..ec3f6aa --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + Hostinger Horizons + + +
+ + + \ No newline at end of file diff --git a/apps/web/jsconfig.json b/apps/web/jsconfig.json new file mode 100644 index 0000000..5a6e281 --- /dev/null +++ b/apps/web/jsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..d2b4e35 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,50 @@ +{ + "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": { + "@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": "^6.16.0", + "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" + } +} diff --git a/apps/web/plugins/selection-mode/selection-mode-script.js b/apps/web/plugins/selection-mode/selection-mode-script.js new file mode 100644 index 0000000..82afab5 --- /dev/null +++ b/apps/web/plugins/selection-mode/selection-mode-script.js @@ -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(); + } +}); diff --git a/apps/web/plugins/selection-mode/vite-plugin-selection-mode.js b/apps/web/plugins/selection-mode/vite-plugin-selection-mode.js new file mode 100644 index 0000000..aa0c627 --- /dev/null +++ b/apps/web/plugins/selection-mode/vite-plugin-selection-mode.js @@ -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', + }, + ]; + }, + }; +} diff --git a/apps/web/plugins/session-journal/session-journal-client.js b/apps/web/plugins/session-journal/session-journal-client.js new file mode 100644 index 0000000..9c866cb --- /dev/null +++ b/apps/web/plugins/session-journal/session-journal-client.js @@ -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 }); +} diff --git a/apps/web/plugins/session-journal/vite-plugin-session-journal.js b/apps/web/plugins/session-journal/vite-plugin-session-journal.js new file mode 100644 index 0000000..4f3a194 --- /dev/null +++ b/apps/web/plugins/session-journal/vite-plugin-session-journal.js @@ -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( + '', + ` \n`, + ); + }, + + 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); + } + }); + }, + }; +} diff --git a/apps/web/plugins/site-pages/site-pages-script.js b/apps/web/plugins/site-pages/site-pages-script.js new file mode 100644 index 0000000..da70d52 --- /dev/null +++ b/apps/web/plugins/site-pages/site-pages-script.js @@ -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(); + } + }); +} diff --git a/apps/web/plugins/site-pages/site-pages-server.js b/apps/web/plugins/site-pages/site-pages-server.js new file mode 100644 index 0000000..4ea964b --- /dev/null +++ b/apps/web/plugins/site-pages/site-pages-server.js @@ -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 `` 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} + */ +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 `` 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(); +} diff --git a/apps/web/plugins/utils/ast-utils.js b/apps/web/plugins/utils/ast-utils.js new file mode 100644 index 0000000..1145086 --- /dev/null +++ b/apps/web/plugins/utils/ast-utils.js @@ -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 }; diff --git a/apps/web/plugins/utils/same-origin.js b/apps/web/plugins/utils/same-origin.js new file mode 100644 index 0000000..611836b --- /dev/null +++ b/apps/web/plugins/utils/same-origin.js @@ -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; + } +} diff --git a/apps/web/plugins/visual-editor/api/draft-snapshot.js b/apps/web/plugins/visual-editor/api/draft-snapshot.js new file mode 100644 index 0000000..bb15ca7 --- /dev/null +++ b/apps/web/plugins/visual-editor/api/draft-snapshot.js @@ -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}`, + }); +} diff --git a/apps/web/plugins/visual-editor/api/draft.js b/apps/web/plugins/visual-editor/api/draft.js new file mode 100644 index 0000000..da7a552 --- /dev/null +++ b/apps/web/plugins/visual-editor/api/draft.js @@ -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); + } +} diff --git a/apps/web/plugins/visual-editor/constants/icons.js b/apps/web/plugins/visual-editor/constants/icons.js new file mode 100644 index 0000000..9d18a72 --- /dev/null +++ b/apps/web/plugins/visual-editor/constants/icons.js @@ -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(); \ No newline at end of file diff --git a/apps/web/plugins/visual-editor/constants/layout.js b/apps/web/plugins/visual-editor/constants/layout.js new file mode 100644 index 0000000..cec79a7 --- /dev/null +++ b/apps/web/plugins/visual-editor/constants/layout.js @@ -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; diff --git a/apps/web/plugins/visual-editor/constants/messages.js b/apps/web/plugins/visual-editor/constants/messages.js new file mode 100644 index 0000000..46090ba --- /dev/null +++ b/apps/web/plugins/visual-editor/constants/messages.js @@ -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', +} diff --git a/apps/web/plugins/visual-editor/constants/selectors.js b/apps/web/plugins/visual-editor/constants/selectors.js new file mode 100644 index 0000000..8bbd150 --- /dev/null +++ b/apps/web/plugins/visual-editor/constants/selectors.js @@ -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); +} diff --git a/apps/web/plugins/visual-editor/constants/theme.js b/apps/web/plugins/visual-editor/constants/theme.js new file mode 100644 index 0000000..5e9dd59 --- /dev/null +++ b/apps/web/plugins/visual-editor/constants/theme.js @@ -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)'; diff --git a/apps/web/plugins/visual-editor/edit-mode-script.js b/apps/web/plugins/visual-editor/edit-mode-script.js new file mode 100644 index 0000000..85f8f10 --- /dev/null +++ b/apps/web/plugins/visual-editor/edit-mode-script.js @@ -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); + } +}); diff --git a/apps/web/plugins/visual-editor/server/source-writer.js b/apps/web/plugins/visual-editor/server/source-writer.js new file mode 100644 index 0000000..8571931 --- /dev/null +++ b/apps/web/plugins/visual-editor/server/source-writer.js @@ -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 }; +} diff --git a/apps/web/plugins/visual-editor/state/annotation-state.js b/apps/web/plugins/visual-editor/state/annotation-state.js new file mode 100644 index 0000000..955979a --- /dev/null +++ b/apps/web/plugins/visual-editor/state/annotation-state.js @@ -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; } diff --git a/apps/web/plugins/visual-editor/state/editing-state.js b/apps/web/plugins/visual-editor/state/editing-state.js new file mode 100644 index 0000000..6e33102 --- /dev/null +++ b/apps/web/plugins/visual-editor/state/editing-state.js @@ -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; +} diff --git a/apps/web/plugins/visual-editor/state/history-state.js b/apps/web/plugins/visual-editor/state/history-state.js new file mode 100644 index 0000000..28c7cd3 --- /dev/null +++ b/apps/web/plugins/visual-editor/state/history-state.js @@ -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 }; +} diff --git a/apps/web/plugins/visual-editor/state/multi-select-state.js b/apps/web/plugins/visual-editor/state/multi-select-state.js new file mode 100644 index 0000000..202f1d4 --- /dev/null +++ b/apps/web/plugins/visual-editor/state/multi-select-state.js @@ -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; +} diff --git a/apps/web/plugins/visual-editor/state/panel-state.js b/apps/web/plugins/visual-editor/state/panel-state.js new file mode 100644 index 0000000..7aeff08 --- /dev/null +++ b/apps/web/plugins/visual-editor/state/panel-state.js @@ -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); +} diff --git a/apps/web/plugins/visual-editor/transform/editability.js b/apps/web/plugins/visual-editor/transform/editability.js new file mode 100644 index 0000000..09a6951 --- /dev/null +++ b/apps/web/plugins/visual-editor/transform/editability.js @@ -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; +} diff --git a/apps/web/plugins/visual-editor/transform/inject-attributes.js b/apps/web/plugins/visual-editor/transform/inject-attributes.js new file mode 100644 index 0000000..daea340 --- /dev/null +++ b/apps/web/plugins/visual-editor/transform/inject-attributes.js @@ -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)) + ); +} diff --git a/apps/web/plugins/visual-editor/ui/annotation-panel/annotation-markers.js b/apps/web/plugins/visual-editor/ui/annotation-panel/annotation-markers.js new file mode 100644 index 0000000..0700470 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/annotation-panel/annotation-markers.js @@ -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; +} diff --git a/apps/web/plugins/visual-editor/ui/annotation-panel/panel.js b/apps/web/plugins/visual-editor/ui/annotation-panel/panel.js new file mode 100644 index 0000000..bdd8be1 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/annotation-panel/panel.js @@ -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); + } +} diff --git a/apps/web/plugins/visual-editor/ui/annotation-panel/styles.js b/apps/web/plugins/visual-editor/ui/annotation-panel/styles.js new file mode 100644 index 0000000..41954c2 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/annotation-panel/styles.js @@ -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); + } +`; diff --git a/apps/web/plugins/visual-editor/ui/annotation-panel/template.js b/apps/web/plugins/visual-editor/ui/annotation-panel/template.js new file mode 100644 index 0000000..d7d99ea --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/annotation-panel/template.js @@ -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> +`; diff --git a/apps/web/plugins/visual-editor/ui/inline-edit/cursor-styles.js b/apps/web/plugins/visual-editor/ui/inline-edit/cursor-styles.js new file mode 100644 index 0000000..bb7262d --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/inline-edit/cursor-styles.js @@ -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(); +} diff --git a/apps/web/plugins/visual-editor/ui/inline-edit/edit-action.js b/apps/web/plugins/visual-editor/ui/inline-edit/edit-action.js new file mode 100644 index 0000000..5a632d7 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/inline-edit/edit-action.js @@ -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(); +} + diff --git a/apps/web/plugins/visual-editor/ui/inline-edit/styles.js b/apps/web/plugins/visual-editor/ui/inline-edit/styles.js new file mode 100644 index 0000000..2cf706a --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/inline-edit/styles.js @@ -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; + } +`; diff --git a/apps/web/plugins/visual-editor/ui/overlays/hover-outline.js b/apps/web/plugins/visual-editor/ui/overlays/hover-outline.js new file mode 100644 index 0000000..b073f23 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/overlays/hover-outline.js @@ -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 = []; +} diff --git a/apps/web/plugins/visual-editor/ui/overlays/selection-rect.js b/apps/web/plugins/visual-editor/ui/overlays/selection-rect.js new file mode 100644 index 0000000..2ab2c79 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/overlays/selection-rect.js @@ -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; +} diff --git a/apps/web/plugins/visual-editor/ui/overlays/type-tooltip.js b/apps/web/plugins/visual-editor/ui/overlays/type-tooltip.js new file mode 100644 index 0000000..56f3378 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/overlays/type-tooltip.js @@ -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(); +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/color-action.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/color-action.js new file mode 100644 index 0000000..ccf9434 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/color-action.js @@ -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 }); diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/color-conversions.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/color-conversions.js new file mode 100644 index 0000000..9b8eb2a --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/color-conversions.js @@ -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; +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/gradient.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/gradient.js new file mode 100644 index 0000000..b62d591 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/gradient.js @@ -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}`; +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/hex-input.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/hex-input.js new file mode 100644 index 0000000..48cd965 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/hex-input.js @@ -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; +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/picker-square.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/picker-square.js new file mode 100644 index 0000000..90b3f50 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/picker-square.js @@ -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)), + }; +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/styles.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/styles.js new file mode 100644 index 0000000..8c200b6 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/styles.js @@ -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; +} +`; diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/swatches.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/swatches.js new file mode 100644 index 0000000..80f0838 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/swatches.js @@ -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); + } +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/color-action/template.js b/apps/web/plugins/visual-editor/ui/text-format/color-action/template.js new file mode 100644 index 0000000..fd80603 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/color-action/template.js @@ -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(); \ No newline at end of file diff --git a/apps/web/plugins/visual-editor/ui/text-format/dropdown-panel.js b/apps/web/plugins/visual-editor/ui/text-format/dropdown-panel.js new file mode 100644 index 0000000..0ca5ae1 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/dropdown-panel.js @@ -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; +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/font-family-action/font-family-action.js b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/font-family-action.js new file mode 100644 index 0000000..f709432 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/font-family-action.js @@ -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 }); diff --git a/apps/web/plugins/visual-editor/ui/text-format/font-family-action/styles.js b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/styles.js new file mode 100644 index 0000000..0c1d698 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/styles.js @@ -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}; +} +`; diff --git a/apps/web/plugins/visual-editor/ui/text-format/font-family-action/template.js b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/template.js new file mode 100644 index 0000000..5c53c2b --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/font-family-action/template.js @@ -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>`; \ No newline at end of file diff --git a/apps/web/plugins/visual-editor/ui/text-format/link-action/link-action.js b/apps/web/plugins/visual-editor/ui/text-format/link-action/link-action.js new file mode 100644 index 0000000..0d6958d --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/link-action/link-action.js @@ -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 }); diff --git a/apps/web/plugins/visual-editor/ui/text-format/link-action/styles.js b/apps/web/plugins/visual-editor/ui/text-format/link-action/styles.js new file mode 100644 index 0000000..98cad8e --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/link-action/styles.js @@ -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}; +} +`; diff --git a/apps/web/plugins/visual-editor/ui/text-format/link-action/template.js b/apps/web/plugins/visual-editor/ui/text-format/link-action/template.js new file mode 100644 index 0000000..ab457cb --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/link-action/template.js @@ -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(); diff --git a/apps/web/plugins/visual-editor/ui/text-format/size-action/size-action.js b/apps/web/plugins/visual-editor/ui/text-format/size-action/size-action.js new file mode 100644 index 0000000..a32932a --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/size-action/size-action.js @@ -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 }); diff --git a/apps/web/plugins/visual-editor/ui/text-format/size-action/styles.js b/apps/web/plugins/visual-editor/ui/text-format/size-action/styles.js new file mode 100644 index 0000000..94695ae --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/size-action/styles.js @@ -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}; +} +`; diff --git a/apps/web/plugins/visual-editor/ui/text-format/size-action/template.js b/apps/web/plugins/visual-editor/ui/text-format/size-action/template.js new file mode 100644 index 0000000..db28702 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/size-action/template.js @@ -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(); diff --git a/apps/web/plugins/visual-editor/ui/text-format/style-actions.js b/apps/web/plugins/visual-editor/ui/text-format/style-actions.js new file mode 100644 index 0000000..e44803d --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/style-actions.js @@ -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 })); +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/toolbar/positioning.js b/apps/web/plugins/visual-editor/ui/text-format/toolbar/positioning.js new file mode 100644 index 0000000..c29e7bb --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/toolbar/positioning.js @@ -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(); +} diff --git a/apps/web/plugins/visual-editor/ui/text-format/toolbar/styles.js b/apps/web/plugins/visual-editor/ui/text-format/toolbar/styles.js new file mode 100644 index 0000000..05f980f --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/toolbar/styles.js @@ -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; +} +`; diff --git a/apps/web/plugins/visual-editor/ui/text-format/toolbar/template.js b/apps/web/plugins/visual-editor/ui/text-format/toolbar/template.js new file mode 100644 index 0000000..9207342 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/toolbar/template.js @@ -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>`, +}; diff --git a/apps/web/plugins/visual-editor/ui/text-format/toolbar/toolbar.js b/apps/web/plugins/visual-editor/ui/text-format/toolbar/toolbar.js new file mode 100644 index 0000000..5f7c000 --- /dev/null +++ b/apps/web/plugins/visual-editor/ui/text-format/toolbar/toolbar.js @@ -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]; + }); +} diff --git a/apps/web/plugins/visual-editor/utils/dom-utils.js b/apps/web/plugins/visual-editor/utils/dom-utils.js new file mode 100644 index 0000000..90f8bfd --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/dom-utils.js @@ -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()); + } +} diff --git a/apps/web/plugins/visual-editor/utils/html-utils.js b/apps/web/plugins/visual-editor/utils/html-utils.js new file mode 100644 index 0000000..99c70c3 --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/html-utils.js @@ -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; +} diff --git a/apps/web/plugins/visual-editor/utils/inline-style-state.js b/apps/web/plugins/visual-editor/utils/inline-style-state.js new file mode 100644 index 0000000..22d0bfc --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/inline-style-state.js @@ -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)), + }; +} diff --git a/apps/web/plugins/visual-editor/utils/parent-frame.js b/apps/web/plugins/visual-editor/utils/parent-frame.js new file mode 100644 index 0000000..b014ff0 --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/parent-frame.js @@ -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); + } +} diff --git a/apps/web/plugins/visual-editor/utils/selection-mode-metadata.js b/apps/web/plugins/visual-editor/utils/selection-mode-metadata.js new file mode 100644 index 0000000..0de70f9 --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/selection-mode-metadata.js @@ -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, + }; +} diff --git a/apps/web/plugins/visual-editor/utils/session-history-command.js b/apps/web/plugins/visual-editor/utils/session-history-command.js new file mode 100644 index 0000000..73ecfe2 --- /dev/null +++ b/apps/web/plugins/visual-editor/utils/session-history-command.js @@ -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'); +} diff --git a/apps/web/plugins/visual-editor/vite-plugin-edit-mode.js b/apps/web/plugins/visual-editor/vite-plugin-edit-mode.js new file mode 100644 index 0000000..f1ff662 --- /dev/null +++ b/apps/web/plugins/visual-editor/vite-plugin-edit-mode.js @@ -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(); + }, + }; +} diff --git a/apps/web/plugins/visual-editor/vite-plugin-react-inline-editor.js b/apps/web/plugins/visual-editor/vite-plugin-react-inline-editor.js new file mode 100644 index 0000000..8857a4c --- /dev/null +++ b/apps/web/plugins/visual-editor/vite-plugin-react-inline-editor.js @@ -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.' })); + } + }); + }); + } + }; +} diff --git a/apps/web/plugins/vite-plugin-iframe-route-restoration.js b/apps/web/plugins/vite-plugin-iframe-route-restoration.js new file mode 100644 index 0000000..9fbdc4d --- /dev/null +++ b/apps/web/plugins/vite-plugin-iframe-route-restoration.js @@ -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' + } + ]; + } + }; +} diff --git a/apps/web/plugins/vite-plugin-pocketbase-auth.js b/apps/web/plugins/vite-plugin-pocketbase-auth.js new file mode 100644 index 0000000..6cd17ea --- /dev/null +++ b/apps/web/plugins/vite-plugin-pocketbase-auth.js @@ -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' + } + ]; + } + }; +} diff --git a/apps/web/plugins/vite-plugin-site-pages.js b/apps/web/plugins/vite-plugin-site-pages.js new file mode 100644 index 0000000..56ad334 --- /dev/null +++ b/apps/web/plugins/vite-plugin-site-pages.js @@ -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', + }]; + }, + }; +} diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..5b756c1 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; \ No newline at end of file diff --git a/apps/web/public/.htaccess b/apps/web/public/.htaccess new file mode 100644 index 0000000..d3770d9 --- /dev/null +++ b/apps/web/public/.htaccess @@ -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> diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt new file mode 100644 index 0000000..39cecb8 --- /dev/null +++ b/apps/web/public/llms.txt @@ -0,0 +1,6 @@ +## Pages +- [Acerca de Nosotros - VALEA Inversiones](/acerca-de): Conozca la historia, misión y valores de VALEA Inversiones, líder en soluciones industriales desde 2017. +- [Contacto - VALEA Inversiones](/contacto): Contáctenos para solicitar información sobre nuestros servicios de ingeniería industrial y mantenimiento. +- [Proyectos - VALEA Inversiones](/proyectos): Conozca nuestros proyectos exitosos ejecutados para las principales empresas del sector industrial venezolano. +- [Servicios - VALEA Inversiones](/servicios): Descubra nuestros servicios de mantenimiento industrial, instrumentación, ingeniería y alquiler de equipos. +- [VALEA Inversiones - Soluciones Integrales en Ingeniería Industrial](/): VALEA Inversiones ofrece mantenimiento industrial, instrumentación, ingeniería y alquiler de equipos en Barcelona, Venezuela desde 2017. \ No newline at end of file diff --git a/apps/web/src/App.jsx b/apps/web/src/App.jsx new file mode 100644 index 0000000..fe92375 --- /dev/null +++ b/apps/web/src/App.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { Helmet } from 'react-helmet'; +import Navigation from '@/components/Navigation'; +import Footer from '@/components/Footer'; +import ScrollToTop from '@/components/ScrollToTop'; +import Home from '@/pages/Home'; +import Services from '@/pages/Services'; +import About from '@/pages/About'; +import Projects from '@/pages/Projects'; +import Contact from '@/pages/Contact'; +import { Toaster } from '@/components/ui/toaster'; + +function App() { + const websiteUrl = typeof window !== 'undefined' ? window.location.origin : 'https://valea-inversiones.com'; + + return ( + <Router> + <ScrollToTop /> + <Helmet> + <title>VALEA Inversiones - Soluciones Integrales en Ingeniería Industrial + + + {/* Open Graph / Facebook */} + + + + + + {/* Twitter */} + + + + + + + +
+ + + } /> + } /> + } /> + } /> + } /> + +
+ +
+ + ); +} + +export default App; \ No newline at end of file diff --git a/apps/web/src/components/AboutSection.jsx b/apps/web/src/components/AboutSection.jsx new file mode 100644 index 0000000..a3d0130 --- /dev/null +++ b/apps/web/src/components/AboutSection.jsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Shield, Users, Target, Award, Lightbulb, Heart } from 'lucide-react'; + +const AboutSection = () => { + const values = [ + { icon: Shield, title: 'Conducta Ética', description: 'Integridad y transparencia en todas nuestras acciones' }, + { icon: Users, title: 'Enfoque al Cliente', description: 'Compromiso total con la satisfacción del cliente' }, + { icon: Target, title: 'Liderazgo Técnico', description: 'Excelencia técnica y conocimiento especializado' }, + { icon: Award, title: 'Calidad', description: 'Estándares superiores en cada proyecto' }, + { icon: Lightbulb, title: 'Innovación', description: 'Soluciones creativas y tecnología de vanguardia' }, + { icon: Heart, title: 'Responsabilidad Social', description: 'Compromiso con nuestra comunidad y entorno' } + ]; + + return ( +
+
+ +

+ Acerca de VALEA +

+
+

+ Fundada en 2017 en Barcelona, Estado Anzoátegui, Venezuela, VALEA Inversiones se ha consolidado como líder en soluciones integrales de ingeniería industrial y mantenimiento. +

+

+ Nuestra misión es ofrecer servicios de excelencia técnica, enfocados en las necesidades específicas de nuestros clientes, garantizando calidad, seguridad y eficiencia en cada proyecto que emprendemos. +

+
+
+ + {/* Values Grid */} +
+ {values.map((value, index) => { + const Icon = value.icon; + return ( + +
+ +
+

{value.title}

+

{value.description}

+
+ ); + })} +
+
+
+ ); +}; + +export default AboutSection; \ No newline at end of file diff --git a/apps/web/src/components/ClientsSection.jsx b/apps/web/src/components/ClientsSection.jsx new file mode 100644 index 0000000..a5d7e8a --- /dev/null +++ b/apps/web/src/components/ClientsSection.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Building } from 'lucide-react'; + +const ClientsSection = () => { + const clients = [ + 'Sinovensa', 'Chevron', 'Petromiranda', 'PDVSA', + 'Pequiven', 'Supermetanol', 'Metor', 'Fertilizantes de Oriente' + ]; + + return ( +
+
+ +

+ Nuestros Clientes +

+

+ Confianza de las empresas líderes del sector energético e industrial +

+
+ +
+ {clients.map((client, index) => ( + +
+ +
+ {client} +
+ ))} +
+
+
+ ); +}; + +export default ClientsSection; \ No newline at end of file diff --git a/apps/web/src/components/ContactForm.jsx b/apps/web/src/components/ContactForm.jsx new file mode 100644 index 0000000..db1724c --- /dev/null +++ b/apps/web/src/components/ContactForm.jsx @@ -0,0 +1,105 @@ +import React, { useState, useRef } from 'react'; +import { motion } from 'framer-motion'; +import { Send, Loader2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useToast } from '@/components/ui/use-toast'; + +const ContactForm = () => { + const { toast } = useToast(); + const form = useRef(); + const [loading, setLoading] = useState(false); + const [formData, setFormData] = useState({ + nombre: '', email: '', telefono: '', empresa: '', asunto: '', mensaje: '' + }); + const [errors, setErrors] = useState({}); + + const validateForm = () => { + const newErrors = {}; + if (!formData.nombre.trim()) newErrors.nombre = 'El nombre es requerido'; + if (!formData.email.trim()) newErrors.email = 'El email es requerido'; + else if (!/\S+@\S+\.\S+/.test(formData.email)) newErrors.email = 'Email inválido'; + if (!formData.telefono.trim()) newErrors.telefono = 'El teléfono es requerido'; + if (!formData.asunto.trim()) newErrors.asunto = 'El asunto es requerido'; + if (!formData.mensaje.trim()) newErrors.mensaje = 'El mensaje es requerido'; + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ ...prev, [name]: value })); + if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!validateForm()) { + toast({ title: "Error de validación", description: "Por favor complete todos los campos requeridos.", variant: "destructive" }); + return; + } + setLoading(true); + try { + await new Promise(resolve => setTimeout(resolve, 1500)); + toast({ title: "¡Mensaje enviado!", description: "Gracias por contactarnos. Le responderemos pronto." }); + setFormData({ nombre: '', email: '', telefono: '', empresa: '', asunto: '', mensaje: '' }); + } catch (error) { + toast({ title: "Error", description: "Hubo un problema. Intente de nuevo.", variant: "destructive" }); + } finally { + setLoading(false); + } + }; + + const inputClasses = (error) => `w-full px-4 py-3 bg-blanco border ${error ? 'border-red-500' : 'border-azulClaro'} rounded-lg text-azulOscuro placeholder-grisMedio focus:outline-none focus:ring-2 focus:ring-azulClaro focus:border-azulPrincipal transition-colors`; + + return ( + +
+
+ + + {errors.nombre &&

{errors.nombre}

} +
+
+ + + {errors.email &&

{errors.email}

} +
+
+ + + {errors.telefono &&

{errors.telefono}

} +
+
+ + +
+
+ +
+ + + {errors.asunto &&

{errors.asunto}

} +
+ +
+ +