Initial commit: Korrith website, assessment API, PocketBase schema, Docker/Coolify deployment
This commit is contained in:
@@ -0,0 +1,16 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
apps/pocketbase/pb_data
|
||||||
|
apps/pocketbase/pb_backups
|
||||||
|
apps/pocketbase/pb_snapshots
|
||||||
|
apps/pocketbase/pocketbase
|
||||||
|
vault
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Root environment file — used by docker-compose.yml (Coolify injects these too).
|
||||||
|
# Copy to .env and fill in real values. NEVER commit .env.
|
||||||
|
|
||||||
|
# --- PocketBase ---
|
||||||
|
# Initial admin account, seeded on first boot (also used by the API to read/write).
|
||||||
|
PB_SUPERUSER_EMAIL=admin@example.com
|
||||||
|
PB_SUPERUSER_PASSWORD=change-me-strong-password
|
||||||
|
# 32+ random characters; encrypts PocketBase settings at rest.
|
||||||
|
PB_ENCRYPTION_KEY=change-me-32-plus-random-chars
|
||||||
|
|
||||||
|
# --- SMTP (assessment confirmation + admin notification emails) ---
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=smtp@example.com
|
||||||
|
SMTP_PASSWORD=change-me
|
||||||
|
|
||||||
|
# --- Optional ---
|
||||||
|
# MAIL_FROM=smtp@example.com
|
||||||
|
# MAIL_REPLY_TO=noreply@example.com
|
||||||
|
# ADMIN_EMAILS=hello@example.com,ops@example.com
|
||||||
|
# CORS_ORIGIN=*
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
# Secrets — never commit real credentials
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# PocketBase runtime data & binary (recreated on deploy)
|
||||||
|
pb_data/
|
||||||
|
apps/pocketbase/pb_data/
|
||||||
|
apps/pocketbase/pb_backups/
|
||||||
|
apps/pocketbase/pb_snapshots/
|
||||||
|
apps/pocketbase/pocketbase
|
||||||
|
|
||||||
|
# Platform tooling internals (not part of the app)
|
||||||
|
vault/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Korrith — Website & Assessment Platform
|
||||||
|
|
||||||
|
Marketing site and security-assessment intake for Korrith. npm-workspaces monorepo, Node 22 (see `.nvmrc`).
|
||||||
|
|
||||||
|
| App | Stack | Port | Purpose |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `apps/web` | React 18 + Vite + Tailwind + shadcn/ui | 3000 | Public website (static build served by nginx in production) |
|
||||||
|
| `apps/api` | Express 5 + Nodemailer | 3001 | Assessment intake: validates, stores in PocketBase, emails the submitter + admins |
|
||||||
|
| `apps/pocketbase` | PocketBase 0.40.4 (SQLite) | 8090 | Database, auth, admin UI |
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/
|
||||||
|
web/ # Frontend (src/pages, src/components, nginx.conf + Dockerfile for prod)
|
||||||
|
api/ # Express API (src/routes, src/utils, Dockerfile)
|
||||||
|
pocketbase/ # pb_migrations (schema, append-only), pb_hooks, Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
1. `npm install`
|
||||||
|
2. `cp apps/api/.env.example apps/api/.env` and fill in real values.
|
||||||
|
3. Download the PocketBase 0.40.4 binary into `apps/pocketbase/pocketbase`
|
||||||
|
(it is gitignored): https://github.com/pocketbase/pocketbase/releases/tag/v0.40.4
|
||||||
|
4. `npm run dev` — starts web (3000), api (3001) and pocketbase (8090) together.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
| Variable | Used by | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `PB_SUPERUSER_EMAIL` / `PB_SUPERUSER_PASSWORD` | pocketbase (first boot), api | Initial PocketBase admin account; the API uses it to read/write |
|
||||||
|
| `PB_ENCRYPTION_KEY` | pocketbase | 32+ random chars; encrypts settings at rest |
|
||||||
|
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` | api | Transactional email (587 = STARTTLS, 465 = SSL) |
|
||||||
|
| `MAIL_FROM` / `MAIL_REPLY_TO` | api | Optional sender identity overrides |
|
||||||
|
| `ADMIN_EMAILS` | api | Optional comma-separated notification recipients |
|
||||||
|
| `CORS_ORIGIN` | api | Default `*` |
|
||||||
|
|
||||||
|
## Deploying to a VPS with Coolify
|
||||||
|
|
||||||
|
1. Push this repo to GitHub. `.gitignore` already excludes secrets, `node_modules`,
|
||||||
|
build output, the PocketBase binary and its database.
|
||||||
|
2. In Coolify: **New Resource → from GitHub repo → Docker Compose** build pack
|
||||||
|
(uses the root `docker-compose.yml`).
|
||||||
|
3. Set the environment variables above in Coolify's UI.
|
||||||
|
4. Expose the **`web`** service (container port **80**) with your domain. It serves
|
||||||
|
the site and reverse-proxies same-origin paths the frontend depends on:
|
||||||
|
- `/hcgi/api/*` → `api:3001` (Express)
|
||||||
|
- `/hcgi/platform/*` → `pocketbase:8090` (admin UI at `https://your-domain/hcgi/platform/_/`)
|
||||||
|
5. The `pocketbase-data` volume persists the database. On first boot, migrations
|
||||||
|
build the schema and seed the admin account from the env vars.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- `apps/pocketbase/pb_migrations` is append-only history — never edit or delete
|
||||||
|
applied migrations; add new ones instead.
|
||||||
|
- The PocketBase Dockerfile uses Docker's `TARGETARCH`, so arm64 VPSes work too.
|
||||||
|
- Building the frontend image needs ~2 GB RAM on the VPS.
|
||||||
|
- Assessment emails are sent only by the Express API (Nodemailer); PocketBase
|
||||||
|
ships with an empty hooks directory in production.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# API environment — copy to apps/api/.env for local development.
|
||||||
|
PORT=3001
|
||||||
|
CORS_ORIGIN=*
|
||||||
|
|
||||||
|
# PocketBase runs on localhost:8090 (fixed). In docker-compose the API reaches
|
||||||
|
# it automatically via a loopback forwarder — no configuration needed.
|
||||||
|
PB_SUPERUSER_EMAIL=admin@example.com
|
||||||
|
PB_SUPERUSER_PASSWORD=change-me-strong-password
|
||||||
|
|
||||||
|
# SMTP (assessment confirmation + admin notification emails)
|
||||||
|
SMTP_HOST=smtp.example.com
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=smtp@example.com
|
||||||
|
SMTP_PASSWORD=change-me
|
||||||
|
|
||||||
|
# Optional overrides
|
||||||
|
# MAIL_FROM=smtp@example.com
|
||||||
|
# MAIL_REPLY_TO=noreply@example.com
|
||||||
|
# ADMIN_EMAILS=hello@example.com,ops@example.com
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Express API — build context must be the REPO ROOT (npm workspaces).
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install workspace dependencies first for better layer caching.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps/api/package.json ./apps/api/package.json
|
||||||
|
RUN npm ci --omit=dev --workspace apps/api
|
||||||
|
|
||||||
|
COPY apps/api ./apps/api
|
||||||
|
WORKDIR /app/apps/api
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Environment variables are injected by Coolify / docker-compose — no .env file needed.
|
||||||
|
EXPOSE 3001
|
||||||
|
CMD ["node", "docker-entrypoint.js"]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// Docker-only entrypoint.
|
||||||
|
// The API's PocketBase client targets http://localhost:8090 (fixed in a
|
||||||
|
// platform-managed file). In docker-compose, PocketBase runs in its own
|
||||||
|
// container, so forward localhost:8090 -> the pocketbase service before
|
||||||
|
// starting the API. Local development (npm run dev) does not use this file.
|
||||||
|
|
||||||
|
import net from 'node:net';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
|
||||||
|
const UPSTREAM_HOST = process.env.PB_UPSTREAM_HOST || 'pocketbase';
|
||||||
|
const UPSTREAM_PORT = 8090;
|
||||||
|
|
||||||
|
function forward(socket) {
|
||||||
|
const upstream = net.connect(UPSTREAM_PORT, UPSTREAM_HOST);
|
||||||
|
socket.pipe(upstream).pipe(socket);
|
||||||
|
socket.on('error', () => socket.destroy());
|
||||||
|
upstream.on('error', () => socket.destroy());
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind(host) {
|
||||||
|
const server = net.createServer(forward);
|
||||||
|
server.on('error', () => {}); // e.g. IPv6 unavailable in the container
|
||||||
|
server.listen(UPSTREAM_PORT, host);
|
||||||
|
}
|
||||||
|
|
||||||
|
bind('127.0.0.1');
|
||||||
|
bind('::1');
|
||||||
|
|
||||||
|
const child = spawn('node', ['src/main.js'], { stdio: 'inherit', env: process.env });
|
||||||
|
child.on('exit', (code) => process.exit(code ?? 0));
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import js from '@eslint/js';
|
||||||
|
import importPlugin from 'eslint-plugin-import';
|
||||||
|
import globals from 'globals';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['node_modules/**'] },
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ['**/*.js'],
|
||||||
|
plugins: { import: importPlugin },
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'import/resolver': {
|
||||||
|
node: { extensions: ['.js'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...importPlugin.flatConfigs.recommended.rules,
|
||||||
|
'no-console': 'off',
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'no-undef': 'error',
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }],
|
||||||
|
'import/no-named-as-default': 'off',
|
||||||
|
'import/no-named-as-default-member': 'off',
|
||||||
|
'import/no-self-import': 'error',
|
||||||
|
'import/no-cycle': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "api",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node --trace-deprecation --env-file=.env src/main.js",
|
||||||
|
"start": "node --env-file=.env src/main.js",
|
||||||
|
"lint": "eslint . --quiet"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
|
"express": "^5.0.1",
|
||||||
|
"express-rate-limit": "^8.3.1",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"nodemailer": "^6.9.0",
|
||||||
|
"pocketbase": "^0.27.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-import": "^2.32.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const NodeEnv = {
|
||||||
|
Development: 'development',
|
||||||
|
Production: 'production',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BodyLimit = 1024 * 1024 * 20;
|
||||||
|
|
||||||
|
export { NodeEnv, BodyLimit };
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
|
||||||
|
import routes from './routes/index.js';
|
||||||
|
import { errorMiddleware } from './middleware/error.js';
|
||||||
|
import { globalRateLimit } from './middleware/global-rate-limit.js';
|
||||||
|
import logger from './utils/logger.js';
|
||||||
|
import { BodyLimit } from './constants/common.js';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
logger.error('Uncaught exception:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
logger.error('Unhandled rejection at:', promise, 'reason:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', async () => {
|
||||||
|
logger.info('Interrupted');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', async () => {
|
||||||
|
logger.info('SIGTERM signal received');
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||||
|
|
||||||
|
logger.info('Exiting');
|
||||||
|
process.exit();
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(helmet());
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CORS_ORIGIN,
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
app.use(morgan('combined'));
|
||||||
|
app.use(globalRateLimit);
|
||||||
|
app.use(express.json({
|
||||||
|
limit: BodyLimit,
|
||||||
|
}));
|
||||||
|
app.use(express.urlencoded({
|
||||||
|
extended: true,
|
||||||
|
limit: BodyLimit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use('/', routes());
|
||||||
|
|
||||||
|
app.use(errorMiddleware);
|
||||||
|
|
||||||
|
app.use((req, res) => {
|
||||||
|
res.status(404).json({ error: 'Route not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
logger.info(`🚀 API Server running on http://localhost:${port}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import logger from '../utils/logger.js';
|
||||||
|
import { NodeEnv } from '../constants/common.js';
|
||||||
|
|
||||||
|
const errorMiddleware = (err, req, res, next) => {
|
||||||
|
logger.error(err.message, err.stack);
|
||||||
|
|
||||||
|
if (res.headersSent) {
|
||||||
|
return next(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
message: 'Something went wrong!',
|
||||||
|
...(process.env.NODE_ENV !== NodeEnv.Production && {
|
||||||
|
error: {
|
||||||
|
name: err.name,
|
||||||
|
message: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default errorMiddleware;
|
||||||
|
export { errorMiddleware };
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
|
||||||
|
export const globalRateLimit = rateLimit({
|
||||||
|
windowMs: 5 * 60 * 1000,
|
||||||
|
max: 100,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many requests, please try again later' },
|
||||||
|
validate: { trustProxy: false },
|
||||||
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import pb from '../utils/pocketbaseClient.js';
|
||||||
|
import { sendEmail } from '../utils/mailService.js';
|
||||||
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || 'hello@korrith.com,smtp@korrith.com')
|
||||||
|
.split(',')
|
||||||
|
.map((address) => address.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
router.post('/submit', async (req, res) => {
|
||||||
|
const { firstName, lastName, email, industry, websites, applications, systems } = req.body;
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!firstName || !lastName || !email) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required fields: firstName, lastName, and email are required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate industry is provided
|
||||||
|
if (!industry) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Industry selection is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the exact request body being sent to PocketBase
|
||||||
|
logger.info('Assessment submission received', {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
industry,
|
||||||
|
websites,
|
||||||
|
applications,
|
||||||
|
systems
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prepare the data object for PocketBase
|
||||||
|
const assessmentData = {
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
industry,
|
||||||
|
websites: websites || '',
|
||||||
|
applications: applications || '',
|
||||||
|
systems: systems || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('Sending to PocketBase with data:', assessmentData);
|
||||||
|
|
||||||
|
// Create assessment record in PocketBase
|
||||||
|
// If validation fails, PocketBase will throw an error with details
|
||||||
|
const record = await pb.collection('assessments').create(assessmentData, { $autoCancel: false });
|
||||||
|
|
||||||
|
logger.info('Assessment record created successfully', { id: record.id });
|
||||||
|
|
||||||
|
// Build client confirmation email body
|
||||||
|
const clientHtmlBody = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background-color: #f8f9fa; padding: 20px; border-radius: 5px; margin-bottom: 20px; }
|
||||||
|
.header h1 { margin: 0; color: #2c3e50; font-size: 24px; }
|
||||||
|
.section { margin-bottom: 20px; }
|
||||||
|
.section-title { font-weight: bold; color: #2c3e50; margin-bottom: 10px; font-size: 16px; }
|
||||||
|
.field { margin-bottom: 10px; }
|
||||||
|
.field-label { font-weight: bold; color: #555; }
|
||||||
|
.field-value { color: #333; margin-left: 10px; }
|
||||||
|
.footer { border-top: 1px solid #ddd; padding-top: 20px; margin-top: 30px; font-size: 12px; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Assessment Submission Received</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<p>Dear ${firstName},</p>
|
||||||
|
<p>Thank you for submitting your assessment. We have received your submission and will review it shortly.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Submission Summary</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Industry:</span>
|
||||||
|
<span class="field-value">${industry}</span>
|
||||||
|
</div>
|
||||||
|
${websites ? `
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Websites:</span>
|
||||||
|
<span class="field-value">${websites}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${applications ? `
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Applications:</span>
|
||||||
|
<span class="field-value">${applications}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${systems ? `
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Systems:</span>
|
||||||
|
<span class="field-value">${systems}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<p>We will be in touch with you soon regarding your assessment.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated email from the Assessment System. Please do not reply to this email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Send confirmation email to client
|
||||||
|
logger.info('Sending confirmation email to client', {
|
||||||
|
recipient: email,
|
||||||
|
subject: 'Assessment Submission Confirmation',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendEmail(email, 'Assessment Submission Confirmation', clientHtmlBody);
|
||||||
|
|
||||||
|
logger.info('Client confirmation email sent successfully', {
|
||||||
|
recipient: email,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build admin notification email body
|
||||||
|
const adminHtmlBody = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||||
|
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||||
|
.header { background-color: #f8f9fa; padding: 20px; border-radius: 5px; margin-bottom: 20px; }
|
||||||
|
.header h1 { margin: 0; color: #2c3e50; font-size: 24px; }
|
||||||
|
.section { margin-bottom: 20px; }
|
||||||
|
.section-title { font-weight: bold; color: #2c3e50; margin-bottom: 10px; font-size: 16px; }
|
||||||
|
.field { margin-bottom: 10px; }
|
||||||
|
.field-label { font-weight: bold; color: #555; }
|
||||||
|
.field-value { color: #333; margin-left: 10px; }
|
||||||
|
.footer { border-top: 1px solid #ddd; padding-top: 20px; margin-top: 30px; font-size: 12px; color: #666; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>New Assessment Submission</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<p>A new assessment has been submitted and requires review.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div class="section-title">Submission Details</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Name:</span>
|
||||||
|
<span class="field-value">${firstName} ${lastName}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Email:</span>
|
||||||
|
<span class="field-value">${email}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Industry:</span>
|
||||||
|
<span class="field-value">${industry}</span>
|
||||||
|
</div>
|
||||||
|
${websites ? `
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Websites:</span>
|
||||||
|
<span class="field-value">${websites}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${applications ? `
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Applications:</span>
|
||||||
|
<span class="field-value">${applications}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${systems ? `
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Systems:</span>
|
||||||
|
<span class="field-value">${systems}</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Record ID:</span>
|
||||||
|
<span class="field-value">${record.id}</span>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">Submitted:</span>
|
||||||
|
<span class="field-value">${new Date().toISOString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<p>Please review this assessment in the admin panel.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>This is an automated email from the Assessment System. Please do not reply to this email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Send notification email to admin addresses
|
||||||
|
logger.info('Sending notification email to admin addresses', {
|
||||||
|
recipients: ADMIN_EMAILS.join(', '),
|
||||||
|
subject: 'New Assessment Submission',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
await sendEmail(ADMIN_EMAILS, 'New Assessment Submission', adminHtmlBody);
|
||||||
|
|
||||||
|
logger.info('Admin notification email sent successfully', {
|
||||||
|
recipients: ADMIN_EMAILS.join(', '),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return success response
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Assessment request submitted successfully',
|
||||||
|
id: record.id
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import healthCheckRoutes from './health-check.js';
|
||||||
|
import assessmentRoutes from './assessment.js';
|
||||||
|
|
||||||
|
export default function routes() {
|
||||||
|
const router = express.Router();
|
||||||
|
router.use('/health-check', healthCheckRoutes);
|
||||||
|
router.use('/assessment', assessmentRoutes);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
// Helpers for third-party integrations that need a USER-SUPPLIED secret
|
||||||
|
// (Stripe, PayPal, Twilio, TMDB, ...). A missing user key is NOT a server
|
||||||
|
// error: do NOT throw it. errorMiddleware would turn a throw into a 500 the
|
||||||
|
// user cannot fix, and the site would retry it on every page load. Report
|
||||||
|
// "not configured yet" instead, so the UI can show a setup state and the user
|
||||||
|
// can add the key.
|
||||||
|
|
||||||
|
export const INTEGRATION_NOT_CONFIGURED = 'INTEGRATION_NOT_CONFIGURED';
|
||||||
|
|
||||||
|
// True only when at least one key is listed and every listed env var holds a
|
||||||
|
// non-empty, non-whitespace value. Fails closed: an unset, blank, or
|
||||||
|
// whitespace-only key (or no keys at all) reads as "not configured".
|
||||||
|
export function isIntegrationConfigured(...envKeys) {
|
||||||
|
return envKeys.length > 0 && envKeys.every((key) => String(process.env[key] ?? '').trim() !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical response for a missing user-supplied secret. 503 = the feature is
|
||||||
|
// unavailable until the key is provided. The INTEGRATION_NOT_CONFIGURED marker
|
||||||
|
// tells the platform this is an expected setup state, not a bug to auto-fix.
|
||||||
|
export function respondNotConfigured(res, { integration, envKeys }) {
|
||||||
|
const keys = Array.isArray(envKeys) ? envKeys : [envKeys];
|
||||||
|
|
||||||
|
return res.status(503).json({
|
||||||
|
error: INTEGRATION_NOT_CONFIGURED,
|
||||||
|
integration,
|
||||||
|
envKeys: keys,
|
||||||
|
message: `${integration} is not set up yet. Add the required key(s) to enable this feature.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { INTEGRATION_NOT_CONFIGURED, isIntegrationConfigured, respondNotConfigured };
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
const logger = {
|
||||||
|
// Application errors - goes to stdout
|
||||||
|
error: (...args) => {
|
||||||
|
console.log('[ERROR]', ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Critical system errors - goes to stderr
|
||||||
|
fatal: (...args) => {
|
||||||
|
console.error('[FATAL]', ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
info: (...args) => {
|
||||||
|
console.log('[INFO]', ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
debug: (...args) => {
|
||||||
|
console.log('[DEBUG]', ...args);
|
||||||
|
},
|
||||||
|
|
||||||
|
warn: (...args) => {
|
||||||
|
console.log('[WARN]', ...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
|
|
||||||
|
export { logger };
|
||||||
|
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import logger from './logger.js';
|
||||||
|
|
||||||
|
const FROM_EMAIL = process.env.MAIL_FROM || 'smtp@korrith.com';
|
||||||
|
const REPLY_TO_EMAIL = process.env.MAIL_REPLY_TO || 'noreply@korrith.com';
|
||||||
|
|
||||||
|
const smtpPort = parseInt(process.env.SMTP_PORT, 10) || 587;
|
||||||
|
|
||||||
|
// Initialize nodemailer transporter with SMTP credentials
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: smtpPort,
|
||||||
|
secure: smtpPort === 465, // SSL on port 465, STARTTLS otherwise (e.g. 587)
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASSWORD,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email using nodemailer with SMTP
|
||||||
|
* @param {string|string[]} to - Recipient email address(es)
|
||||||
|
* @param {string} subject - Email subject
|
||||||
|
* @param {string} htmlBody - HTML email body
|
||||||
|
* @returns {Promise<Object>} - Mail service response
|
||||||
|
* @throws {Error} - If email sending fails
|
||||||
|
*/
|
||||||
|
async function sendEmail(to, subject, htmlBody) {
|
||||||
|
const recipients = Array.isArray(to) ? to : [to];
|
||||||
|
|
||||||
|
logger.info('Attempting to send email via SMTP', {
|
||||||
|
recipients: recipients.join(', '),
|
||||||
|
subject,
|
||||||
|
from: FROM_EMAIL,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mailOptions = {
|
||||||
|
from: FROM_EMAIL,
|
||||||
|
to: recipients.join(', '),
|
||||||
|
subject,
|
||||||
|
html: htmlBody,
|
||||||
|
replyTo: REPLY_TO_EMAIL,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await transporter.sendMail(mailOptions);
|
||||||
|
|
||||||
|
logger.info('Email sent successfully via SMTP', {
|
||||||
|
recipients: recipients.join(', '),
|
||||||
|
subject,
|
||||||
|
from: FROM_EMAIL,
|
||||||
|
messageId: result.messageId,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Email send failed', {
|
||||||
|
recipients: recipients.join(', '),
|
||||||
|
subject,
|
||||||
|
from: FROM_EMAIL,
|
||||||
|
errorMessage: error.message,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new Error(`SMTP email error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { sendEmail };
|
||||||
|
export default sendEmail;
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import Pocketbase from 'pocketbase';
|
||||||
|
import logger from './logger.js';
|
||||||
|
|
||||||
|
const POCKETBASE_HOST = `http://localhost:8090`;
|
||||||
|
|
||||||
|
async function waitForHealth({ retries = 10, delayMs = 1000 } = {}) {
|
||||||
|
for (let i = 1; i <= retries; i++) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${POCKETBASE_HOST}/api/health`, { method: 'HEAD' });
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// PocketBase not reachable yet; retry below
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(`PocketBase not ready, retrying (${i}/${retries})...`);
|
||||||
|
|
||||||
|
await new Promise((r) => setTimeout(r, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`PocketBase health check failed after ${retries} retries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pocketbaseClient = new Pocketbase(POCKETBASE_HOST);
|
||||||
|
|
||||||
|
pocketbaseClient.autoCancellation(false);
|
||||||
|
|
||||||
|
let authPromise = null;
|
||||||
|
|
||||||
|
pocketbaseClient.beforeSend = async function (url, options) {
|
||||||
|
if (url.includes('/api/collections/_superusers/auth-with-password')) {
|
||||||
|
return { url, options };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pocketbaseClient.authStore.isValid && !authPromise) {
|
||||||
|
authPromise = pocketbaseClient.collection('_superusers').authWithPassword(
|
||||||
|
process.env.PB_SUPERUSER_EMAIL,
|
||||||
|
process.env.PB_SUPERUSER_PASSWORD,
|
||||||
|
).finally(() => {
|
||||||
|
authPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authPromise) {
|
||||||
|
await authPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pocketbaseClient.authStore.isValid && pocketbaseClient.authStore.token) {
|
||||||
|
options.headers = options.headers || {};
|
||||||
|
options.headers['Authorization'] = pocketbaseClient.authStore.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { url, options };
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await waitForHealth();
|
||||||
|
|
||||||
|
if (!pocketbaseClient.authStore.isValid && !authPromise) {
|
||||||
|
authPromise = pocketbaseClient.collection('_superusers').authWithPassword(
|
||||||
|
process.env.PB_SUPERUSER_EMAIL,
|
||||||
|
process.env.PB_SUPERUSER_PASSWORD,
|
||||||
|
).finally(() => {
|
||||||
|
authPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authPromise) {
|
||||||
|
await authPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('PocketBase client initialized successfully');
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to initialize PocketBase client:', err);
|
||||||
|
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
export default pocketbaseClient;
|
||||||
|
export { pocketbaseClient };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pb_data
|
||||||
|
pb_backups
|
||||||
|
pb_snapshots
|
||||||
|
pocketbase
|
||||||
|
.pocketbase-sha256
|
||||||
|
database-types.d.ts
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
66396ed5efec837aa8d2addc009894968a5f4fb7cb2b0ea57db6105cb91b095d
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0.40.4
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# PocketBase 0.40.4 — the schema in pb_migrations is applied automatically on
|
||||||
|
# first boot, and the admin account is seeded from PB_SUPERUSER_EMAIL /
|
||||||
|
# PB_SUPERUSER_PASSWORD. Data persists in the mounted /pb_data volume.
|
||||||
|
|
||||||
|
FROM alpine:3.20
|
||||||
|
|
||||||
|
ARG PB_VERSION=0.40.4
|
||||||
|
# Docker BuildKit sets TARGETARCH (amd64/arm64) — matches PocketBase release assets.
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl ca-certificates unzip \
|
||||||
|
&& curl -fsSL -o /tmp/pocketbase.zip \
|
||||||
|
"https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_${TARGETARCH:-amd64}.zip" \
|
||||||
|
&& unzip /tmp/pocketbase.zip -d /usr/local/bin/ \
|
||||||
|
&& rm /tmp/pocketbase.zip \
|
||||||
|
&& chmod +x /usr/local/bin/pocketbase
|
||||||
|
|
||||||
|
WORKDIR /pb
|
||||||
|
|
||||||
|
COPY pb_migrations ./pb_migrations
|
||||||
|
# All app logic (validation + email) lives in the Express API, so no runtime
|
||||||
|
# hooks are required here — an empty hooks dir keeps startup clean.
|
||||||
|
RUN mkdir -p ./pb_hooks /pb_data
|
||||||
|
|
||||||
|
EXPOSE 8090
|
||||||
|
|
||||||
|
CMD ["pocketbase", "serve", "--http=0.0.0.0:8090", "--dir=/pb_data", "--migrationsDir=/pb/pb_migrations", "--hooksDir=/pb/pb_hooks", "--encryptionEnv=PB_ENCRYPTION_KEY"]
|
||||||
Vendored
+917
@@ -0,0 +1,917 @@
|
|||||||
|
/**
|
||||||
|
* This file contains TypeScript definitions for PocketBase JavaScript migrations,
|
||||||
|
* including operations with collections, records, and the $app instance.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Core App Interface (Transaction Context)
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface App {
|
||||||
|
/**
|
||||||
|
* Find a collection by its name or ID
|
||||||
|
*/
|
||||||
|
findCollectionByNameOrId(nameOrId: string): Collection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an auth record by email
|
||||||
|
*/
|
||||||
|
findAuthRecordByEmail(collection: string, email: string): PBRecord;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a model (Collection, Record, etc.) to the database
|
||||||
|
*/
|
||||||
|
save(model: Model): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save a model without validation
|
||||||
|
*/
|
||||||
|
saveNoValidate(model: Model): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a model from the database
|
||||||
|
*/
|
||||||
|
delete(model: Model): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the app database instance for raw SQL queries
|
||||||
|
*/
|
||||||
|
db(): Database;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get app settings
|
||||||
|
*/
|
||||||
|
settings(): Settings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save app settings
|
||||||
|
*/
|
||||||
|
save(settings: Settings): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute raw SQL query
|
||||||
|
*/
|
||||||
|
newQuery(sql: string): Query;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Collection Operations
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Collection {
|
||||||
|
/**
|
||||||
|
* 15 characters string to store as collection ID.
|
||||||
|
* If not set, it will be auto generated.
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unique collection name (used as a table name for the records table).
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of the collection.
|
||||||
|
* If not set, the collection type will be "base" by default.
|
||||||
|
*/
|
||||||
|
type?: "base" | "view" | "auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List with the collection fields.
|
||||||
|
* This field is optional and autopopulated for "view" collections based on the viewQuery.
|
||||||
|
*/
|
||||||
|
fields?: Array<Field>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The collection indexes and unique constraints.
|
||||||
|
* Note that "view" collections don't support indexes.
|
||||||
|
*/
|
||||||
|
indexes?: Array<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the collection as "system" to prevent being renamed, deleted or modify its API rules.
|
||||||
|
*/
|
||||||
|
system?: boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRUD API rules
|
||||||
|
*/
|
||||||
|
listRule?: null | string;
|
||||||
|
viewRule?: null | string;
|
||||||
|
createRule?: null | string;
|
||||||
|
updateRule?: null | string;
|
||||||
|
deleteRule?: null | string;
|
||||||
|
|
||||||
|
// -------------------------------------------------------
|
||||||
|
// view options
|
||||||
|
// -------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required for view collections
|
||||||
|
*/
|
||||||
|
viewQuery?: string;
|
||||||
|
|
||||||
|
// -------------------------------------------------------
|
||||||
|
// auth options
|
||||||
|
// -------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API rule that gives admin-like permissions to allow fully managing the auth record(s),
|
||||||
|
* e.g. changing the password without requiring to enter the old one, directly updating the
|
||||||
|
* verified state or email, etc. This rule is executed in addition to the createRule and updateRule.
|
||||||
|
*/
|
||||||
|
manageRule?: null | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API rule that could be used to specify additional record constraints applied after record
|
||||||
|
* authentication and right before returning the auth token response to the client.
|
||||||
|
*
|
||||||
|
* For example, to allow only verified users you could set it to "verified = true".
|
||||||
|
*
|
||||||
|
* Set it to empty string to allow any Auth collection record to authenticate.
|
||||||
|
*
|
||||||
|
* Set it to null to disallow authentication altogether for the collection.
|
||||||
|
*/
|
||||||
|
authRule?: null | string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthAlert defines options related to the auth alerts on new device login.
|
||||||
|
*/
|
||||||
|
authAlert?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
emailTemplate?: {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OAuth2 specifies whether OAuth2 auth is enabled for the collection
|
||||||
|
* and which OAuth2 providers are allowed.
|
||||||
|
*/
|
||||||
|
oauth2?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
mappedFields?: {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
username?: string;
|
||||||
|
avatarURL?: string;
|
||||||
|
};
|
||||||
|
providers?: Array<{
|
||||||
|
name: string;
|
||||||
|
clientId: string;
|
||||||
|
clientSecret: string;
|
||||||
|
authUrl?: string;
|
||||||
|
tokenUrl?: string;
|
||||||
|
userApiUrl?: string;
|
||||||
|
displayName?: string;
|
||||||
|
pkce?: null | boolean;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PasswordAuth defines options related to the collection password authentication.
|
||||||
|
*/
|
||||||
|
passwordAuth?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
identityFields: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MFA defines options related to the Multi-factor authentication (MFA).
|
||||||
|
*/
|
||||||
|
mfa?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
duration: number;
|
||||||
|
rule?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OTP defines options related to the One-time password authentication (OTP).
|
||||||
|
*/
|
||||||
|
otp?: {
|
||||||
|
enabled?: boolean;
|
||||||
|
duration: number;
|
||||||
|
length: number;
|
||||||
|
emailTemplate?: {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token configurations.
|
||||||
|
*/
|
||||||
|
authToken?: {
|
||||||
|
duration: number;
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
passwordResetToken?: {
|
||||||
|
duration: number;
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
emailChangeToken?: {
|
||||||
|
duration: number;
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
verificationToken?: {
|
||||||
|
duration: number;
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
fileToken?: {
|
||||||
|
duration: number;
|
||||||
|
secret: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default email templates.
|
||||||
|
*/
|
||||||
|
verificationTemplate?: {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
resetPasswordTemplate?: {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
confirmEmailChangeTemplate?: {
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an index to the collection
|
||||||
|
*/
|
||||||
|
addIndex(
|
||||||
|
name: string,
|
||||||
|
unique: boolean,
|
||||||
|
columnsExpr: string,
|
||||||
|
optWhereExpr?: string
|
||||||
|
): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an index from the collection
|
||||||
|
*/
|
||||||
|
removeIndex(name: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Type Definitions
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DateTime represents a date-time value
|
||||||
|
*/
|
||||||
|
type DateTime = string | Date;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Base Field Interface
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BaseField defines the common properties that all field types share
|
||||||
|
*/
|
||||||
|
interface BaseField {
|
||||||
|
/**
|
||||||
|
* Name (required) is the unique name of the field.
|
||||||
|
*/
|
||||||
|
name: string
|
||||||
|
/**
|
||||||
|
* Id is the unique stable field identifier.
|
||||||
|
*
|
||||||
|
* It is automatically generated from the name when adding to a collection FieldsList.
|
||||||
|
*/
|
||||||
|
id: string
|
||||||
|
/**
|
||||||
|
* System prevents the renaming and removal of the field.
|
||||||
|
*/
|
||||||
|
system: boolean
|
||||||
|
/**
|
||||||
|
* Hidden hides the field from the API response.
|
||||||
|
*/
|
||||||
|
hidden: boolean
|
||||||
|
/**
|
||||||
|
* Presentable hints the Dashboard UI to use the underlying
|
||||||
|
* field record value in the relation preview label.
|
||||||
|
*/
|
||||||
|
presentable: boolean
|
||||||
|
/**
|
||||||
|
* Required will require the field value to be non-empty/non-zero.
|
||||||
|
*/
|
||||||
|
required: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Field Type Definitions
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AutodateField defines an "autodate" type field, aka.
|
||||||
|
* field which datetime value could be auto set on record create/update.
|
||||||
|
*
|
||||||
|
* This field is usually used for defining timestamp fields like "created" and "updated".
|
||||||
|
*
|
||||||
|
* Requires either both or at least one of the OnCreate or OnUpdate options to be set.
|
||||||
|
*/
|
||||||
|
interface AutodateField extends BaseField {
|
||||||
|
/**
|
||||||
|
* OnCreate auto sets the current datetime as field value on record create.
|
||||||
|
*/
|
||||||
|
onCreate: boolean
|
||||||
|
/**
|
||||||
|
* OnUpdate auto sets the current datetime as field value on record update.
|
||||||
|
*/
|
||||||
|
onUpdate: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BoolField defines "bool" type field to store a single true/false value.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is false.
|
||||||
|
*/
|
||||||
|
interface BoolField extends BaseField {
|
||||||
|
// BoolField has no additional properties beyond BaseField
|
||||||
|
// The required property from BaseField will require the field value to be always "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DateField defines "date" type field to store a single [types.DateTime] value.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is the zero [types.DateTime].
|
||||||
|
*/
|
||||||
|
interface DateField extends BaseField {
|
||||||
|
/**
|
||||||
|
* Min specifies the min allowed field value.
|
||||||
|
*
|
||||||
|
* Leave it empty to skip the validator.
|
||||||
|
*/
|
||||||
|
min: DateTime
|
||||||
|
/**
|
||||||
|
* Max specifies the max allowed field value.
|
||||||
|
*
|
||||||
|
* Leave it empty to skip the validator.
|
||||||
|
*/
|
||||||
|
max: DateTime
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* EditorField defines "editor" type field to store HTML formatted text.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is empty string.
|
||||||
|
*/
|
||||||
|
interface EditorField extends BaseField {
|
||||||
|
/**
|
||||||
|
* MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
|
||||||
|
*
|
||||||
|
* If zero, a default limit of ~5MB is applied.
|
||||||
|
*/
|
||||||
|
maxSize: number
|
||||||
|
/**
|
||||||
|
* ConvertURLs is usually used to instruct the editor whether to
|
||||||
|
* apply url conversion (eg. stripping the domain name in case the
|
||||||
|
* urls are using the same domain as the one where the editor is loaded).
|
||||||
|
*
|
||||||
|
* (see also https://www.tiny.cloud/docs/tinymce/6/url-handling/#convert_urls)
|
||||||
|
*/
|
||||||
|
convertURLs: boolean
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* EmailField defines "email" type field for storing a single email string address.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is empty string.
|
||||||
|
*/
|
||||||
|
interface EmailField extends BaseField {
|
||||||
|
/**
|
||||||
|
* ExceptDomains will require the email domain to NOT be included in the listed ones.
|
||||||
|
*
|
||||||
|
* This validator can be set only if OnlyDomains is empty.
|
||||||
|
*/
|
||||||
|
exceptDomains: Array<string>
|
||||||
|
/**
|
||||||
|
* OnlyDomains will require the email domain to be included in the listed ones.
|
||||||
|
*
|
||||||
|
* This validator can be set only if ExceptDomains is empty.
|
||||||
|
*/
|
||||||
|
onlyDomains: Array<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FileField defines "file" type field for managing record file(s).
|
||||||
|
*
|
||||||
|
* Only the file name is stored as part of the record value.
|
||||||
|
* New files (aka. files to upload) are expected to be of *filesytem.File.
|
||||||
|
*
|
||||||
|
* If MaxSelect is not set or <= 1, then the field value is expected to be a single record id.
|
||||||
|
*
|
||||||
|
* If MaxSelect is > 1, then the field value is expected to be a slice of record ids.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is either empty string (single) or empty string slice (multiple).
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* The following additional setter keys are available:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* - "fieldName+" - append one or more files to the existing record one. For example:
|
||||||
|
*
|
||||||
|
* // []string{"old1.txt", "old2.txt", "new1_ajkvass.txt", "new2_klhfnwd.txt"}
|
||||||
|
* record.Set("documents+", []*filesystem.File{new1, new2})
|
||||||
|
*
|
||||||
|
* - "+fieldName" - prepend one or more files to the existing record one. For example:
|
||||||
|
*
|
||||||
|
* // []string{"new1_ajkvass.txt", "new2_klhfnwd.txt", "old1.txt", "old2.txt",}
|
||||||
|
* record.Set("+documents", []*filesystem.File{new1, new2})
|
||||||
|
*
|
||||||
|
* - "fieldName-" - subtract/delete one or more files from the existing record one. For example:
|
||||||
|
*
|
||||||
|
* // []string{"old2.txt",}
|
||||||
|
* record.Set("documents-", "old1.txt")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface FileField extends BaseField {
|
||||||
|
/**
|
||||||
|
* MaxSize specifies the maximum size of a single uploaded file (in bytes and up to 2^53-1).
|
||||||
|
*
|
||||||
|
* If zero, a default limit of 5MB is applied.
|
||||||
|
*/
|
||||||
|
maxSize: number
|
||||||
|
/**
|
||||||
|
* MaxSelect specifies the max allowed files.
|
||||||
|
*
|
||||||
|
* For multiple files the value must be > 1, otherwise fallbacks to single (default).
|
||||||
|
*/
|
||||||
|
maxSelect: number
|
||||||
|
/**
|
||||||
|
* MimeTypes specifies an optional list of the allowed file mime types.
|
||||||
|
*
|
||||||
|
* Leave it empty to disable the validator.
|
||||||
|
*/
|
||||||
|
mimeTypes: Array<string>
|
||||||
|
/**
|
||||||
|
* Thumbs specifies an optional list of the supported thumbs for image based files.
|
||||||
|
*
|
||||||
|
* Each entry must be in one of the following formats:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* - WxH (eg. 100x300) - crop to WxH viewbox (from center)
|
||||||
|
* - WxHt (eg. 100x300t) - crop to WxH viewbox (from top)
|
||||||
|
* - WxHb (eg. 100x300b) - crop to WxH viewbox (from bottom)
|
||||||
|
* - WxHf (eg. 100x300f) - fit inside a WxH viewbox (without cropping)
|
||||||
|
* - 0xH (eg. 0x300) - resize to H height preserving the aspect ratio
|
||||||
|
* - Wx0 (eg. 100x0) - resize to W width preserving the aspect ratio
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
thumbs: Array<string>
|
||||||
|
/**
|
||||||
|
* Protected will require the users to provide a special file token to access the file.
|
||||||
|
*
|
||||||
|
* Note that by default all files are publicly accessible.
|
||||||
|
*
|
||||||
|
* For the majority of the cases this is fine because by default
|
||||||
|
* all file names have random part appended to their name which
|
||||||
|
* need to be known by the user before accessing the file.
|
||||||
|
*/
|
||||||
|
protected: boolean
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* GeoPointField defines "geoPoint" type field for storing latitude and longitude GPS coordinates.
|
||||||
|
*
|
||||||
|
* You can set the record field value as [types.GeoPoint], map or serialized json object with lat-lon props.
|
||||||
|
* The stored value is always converted to [types.GeoPoint].
|
||||||
|
* Nil, empty map, empty bytes slice, etc. results in zero [types.GeoPoint].
|
||||||
|
*
|
||||||
|
* Examples of updating a record's GeoPointField value programmatically:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* record.Set("location", types.GeoPoint{Lat: 123, Lon: 456})
|
||||||
|
* record.Set("location", map[string]any{"lat":123, "lon":456})
|
||||||
|
* record.Set("location", []byte(`{"lat":123, "lon":456}`)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface GeoPointField extends BaseField {
|
||||||
|
// GeoPointField has no additional properties beyond BaseField
|
||||||
|
// The required property from BaseField will require the field coordinates to be non-zero (aka. not "Null Island")
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* JSONField defines "json" type field for storing any serialized JSON value.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is the zero [types.JSONRaw].
|
||||||
|
*/
|
||||||
|
interface JSONField extends BaseField {
|
||||||
|
/**
|
||||||
|
* MaxSize specifies the maximum size of the allowed field value (in bytes and up to 2^53-1).
|
||||||
|
*
|
||||||
|
* If zero, a default limit of 1MB is applied.
|
||||||
|
*/
|
||||||
|
maxSize: number
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* NumberField defines "number" type field for storing numeric (float64) value.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is 0.
|
||||||
|
*
|
||||||
|
* The following additional setter keys are available:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* - "fieldName+" - appends to the existing record value. For example:
|
||||||
|
* record.Set("total+", 5)
|
||||||
|
* - "fieldName-" - subtracts from the existing record value. For example:
|
||||||
|
* record.Set("total-", 5)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface NumberField extends BaseField {
|
||||||
|
/**
|
||||||
|
* Min specifies the min allowed field value.
|
||||||
|
*
|
||||||
|
* Leave it nil to skip the validator.
|
||||||
|
*/
|
||||||
|
min?: number
|
||||||
|
/**
|
||||||
|
* Max specifies the max allowed field value.
|
||||||
|
*
|
||||||
|
* Leave it nil to skip the validator.
|
||||||
|
*/
|
||||||
|
max?: number
|
||||||
|
/**
|
||||||
|
* OnlyInt will require the field value to be integer.
|
||||||
|
*/
|
||||||
|
onlyInt: boolean
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* PasswordField defines "password" type field for storing bcrypt hashed strings
|
||||||
|
* (usually used only internally for the "password" auth collection system field).
|
||||||
|
*
|
||||||
|
* If you want to set a direct bcrypt hash as record field value you can use the SetRaw method, for example:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* // generates a bcrypt hash of "123456" and set it as field value
|
||||||
|
* // (record.GetString("password") returns the plain password until persisted, otherwise empty string)
|
||||||
|
* record.Set("password", "123456")
|
||||||
|
*
|
||||||
|
* // set directly a bcrypt hash of "123456" as field value
|
||||||
|
* // (record.GetString("password") returns empty string)
|
||||||
|
* record.SetRaw("password", "$2a$10$.5Elh8fgxypNUWhpUUr/xOa2sZm0VIaE0qWuGGl9otUfobb46T1Pq")
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* The following additional getter keys are available:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* - "fieldName:hash" - returns the bcrypt hash string of the record field value (if any). For example:
|
||||||
|
* record.GetString("password:hash")
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface PasswordField extends BaseField {
|
||||||
|
/**
|
||||||
|
* Pattern specifies an optional regex pattern to match against the field value.
|
||||||
|
*
|
||||||
|
* Leave it empty to skip the pattern check.
|
||||||
|
*/
|
||||||
|
pattern: string
|
||||||
|
/**
|
||||||
|
* Min specifies an optional required field string length.
|
||||||
|
*/
|
||||||
|
min: number
|
||||||
|
/**
|
||||||
|
* Max specifies an optional required field string length.
|
||||||
|
*
|
||||||
|
* If zero, fallback to max 71 bytes.
|
||||||
|
*/
|
||||||
|
max: number
|
||||||
|
/**
|
||||||
|
* Cost specifies the cost/weight/iteration/etc. bcrypt factor.
|
||||||
|
*
|
||||||
|
* If zero, fallback to [bcrypt.DefaultCost].
|
||||||
|
*
|
||||||
|
* If explicitly set, must be between [bcrypt.MinCost] and [bcrypt.MaxCost].
|
||||||
|
*/
|
||||||
|
cost: number
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* RelationField defines "relation" type field for storing single or
|
||||||
|
* multiple collection record references.
|
||||||
|
*
|
||||||
|
* Requires the CollectionId option to be set.
|
||||||
|
*
|
||||||
|
* If MaxSelect is not set or <= 1, then the field value is expected to be a single record id.
|
||||||
|
*
|
||||||
|
* If MaxSelect is > 1, then the field value is expected to be a slice of record ids.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is either empty string (single) or empty string slice (multiple).
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* The following additional setter keys are available:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* - "fieldName+" - append one or more values to the existing record one. For example:
|
||||||
|
*
|
||||||
|
* record.Set("categories+", []string{"new1", "new2"}) // []string{"old1", "old2", "new1", "new2"}
|
||||||
|
*
|
||||||
|
* - "+fieldName" - prepend one or more values to the existing record one. For example:
|
||||||
|
*
|
||||||
|
* record.Set("+categories", []string{"new1", "new2"}) // []string{"new1", "new2", "old1", "old2"}
|
||||||
|
*
|
||||||
|
* - "fieldName-" - subtract one or more values from the existing record one. For example:
|
||||||
|
*
|
||||||
|
* record.Set("categories-", "old1") // []string{"old2"}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface RelationField extends BaseField {
|
||||||
|
/**
|
||||||
|
* CollectionId is the id of the related collection.
|
||||||
|
*/
|
||||||
|
collectionId: string
|
||||||
|
/**
|
||||||
|
* CascadeDelete indicates whether the root model should be deleted
|
||||||
|
* in case of delete of all linked relations.
|
||||||
|
*/
|
||||||
|
cascadeDelete: boolean
|
||||||
|
/**
|
||||||
|
* MinSelect indicates the min number of allowed relation records
|
||||||
|
* that could be linked to the main model.
|
||||||
|
*
|
||||||
|
* No min limit is applied if it is zero or negative value.
|
||||||
|
*/
|
||||||
|
minSelect: number
|
||||||
|
/**
|
||||||
|
* MaxSelect indicates the max number of allowed relation records
|
||||||
|
* that could be linked to the main model.
|
||||||
|
*
|
||||||
|
* For multiple select the value must be > 1, otherwise fallbacks to single (default).
|
||||||
|
*
|
||||||
|
* If MinSelect is set, MaxSelect must be at least >= MinSelect.
|
||||||
|
*/
|
||||||
|
maxSelect: number
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* SelectField defines "select" type field for storing single or
|
||||||
|
* multiple string values from a predefined list.
|
||||||
|
*
|
||||||
|
* Requires the Values option to be set.
|
||||||
|
*
|
||||||
|
* If MaxSelect is not set or <= 1, then the field value is expected to be a single Values element.
|
||||||
|
*
|
||||||
|
* If MaxSelect is > 1, then the field value is expected to be a subset of Values slice.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is either empty string (single) or empty string slice (multiple).
|
||||||
|
*
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* The following additional setter keys are available:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* - "fieldName+" - append one or more values to the existing record one. For example:
|
||||||
|
*
|
||||||
|
* record.Set("roles+", []string{"new1", "new2"}) // []string{"old1", "old2", "new1", "new2"}
|
||||||
|
*
|
||||||
|
* - "+fieldName" - prepend one or more values to the existing record one. For example:
|
||||||
|
*
|
||||||
|
* record.Set("+roles", []string{"new1", "new2"}) // []string{"new1", "new2", "old1", "old2"}
|
||||||
|
*
|
||||||
|
* - "fieldName-" - subtract one or more values from the existing record one. For example:
|
||||||
|
*
|
||||||
|
* record.Set("roles-", "old1") // []string{"old2"}
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface SelectField extends BaseField {
|
||||||
|
/**
|
||||||
|
* Values specifies the list of accepted values.
|
||||||
|
*/
|
||||||
|
values: Array<string>
|
||||||
|
/**
|
||||||
|
* MaxSelect specifies the max allowed selected values.
|
||||||
|
*
|
||||||
|
* For multiple select the value must be > 1, otherwise fallbacks to single (default).
|
||||||
|
*/
|
||||||
|
maxSelect: number
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* TextField defines "text" type field for storing any string value.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is empty string.
|
||||||
|
*
|
||||||
|
* The following additional setter keys are available:
|
||||||
|
*
|
||||||
|
* - "fieldName:autogenerate" - autogenerate field value if AutogeneratePattern is set. For example:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* record.Set("slug:autogenerate", "") // [random value]
|
||||||
|
* record.Set("slug:autogenerate", "abc-") // abc-[random value]
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
interface TextField extends BaseField {
|
||||||
|
/**
|
||||||
|
* Min specifies the minimum required string characters.
|
||||||
|
*
|
||||||
|
* if zero value, no min limit is applied.
|
||||||
|
*/
|
||||||
|
min: number
|
||||||
|
/**
|
||||||
|
* Max specifies the maximum allowed string characters.
|
||||||
|
*
|
||||||
|
* If zero, a default limit of 5000 is applied.
|
||||||
|
*/
|
||||||
|
max: number
|
||||||
|
/**
|
||||||
|
* Pattern specifies an optional regex pattern to match against the field value.
|
||||||
|
*
|
||||||
|
* Leave it empty to skip the pattern check.
|
||||||
|
*/
|
||||||
|
pattern: string
|
||||||
|
/**
|
||||||
|
* AutogeneratePattern specifies an optional regex pattern that could
|
||||||
|
* be used to generate random string from it and set it automatically
|
||||||
|
* on record create if no explicit value is set or when the `:autogenerate` modifier is used.
|
||||||
|
*
|
||||||
|
* Note: the generated value still needs to satisfy min, max, pattern (if set)
|
||||||
|
*/
|
||||||
|
autogeneratePattern: string
|
||||||
|
/**
|
||||||
|
* PrimaryKey will mark the field as primary key.
|
||||||
|
*
|
||||||
|
* A single collection can have only 1 field marked as primary key.
|
||||||
|
*/
|
||||||
|
primaryKey: boolean
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* URLField defines "url" type field for storing a single URL string value.
|
||||||
|
*
|
||||||
|
* The respective zero record field value is empty string.
|
||||||
|
*/
|
||||||
|
interface URLField extends BaseField {
|
||||||
|
/**
|
||||||
|
* ExceptDomains will require the URL domain to NOT be included in the listed ones.
|
||||||
|
*
|
||||||
|
* This validator can be set only if OnlyDomains is empty.
|
||||||
|
*/
|
||||||
|
exceptDomains: Array<string>
|
||||||
|
/**
|
||||||
|
* OnlyDomains will require the URL domain to be included in the listed ones.
|
||||||
|
*
|
||||||
|
* This validator can be set only if ExceptDomains is empty.
|
||||||
|
*/
|
||||||
|
onlyDomains: Array<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collection field definition
|
||||||
|
*/
|
||||||
|
interface Field {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FieldsList extends Array<Field> {
|
||||||
|
/**
|
||||||
|
* GetByName returns a single field by its name.
|
||||||
|
*/
|
||||||
|
getByName(fieldName: string): Field
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RemoveByName removes a single field by its name.
|
||||||
|
*
|
||||||
|
* This method does nothing if field with the specified name doesn't exist.
|
||||||
|
*/
|
||||||
|
removeByName(fieldName: string): void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add adds one or more fields to the current list.
|
||||||
|
*
|
||||||
|
* By default this method will try to REPLACE existing fields with
|
||||||
|
* the new ones by their id or by their name if the new field doesn't have an explicit id.
|
||||||
|
*
|
||||||
|
* If no matching existing field is found, it will APPEND the field to the end of the list.
|
||||||
|
*
|
||||||
|
* In all cases, if any of the new fields don't have an explicit id it will auto generate a default one for them
|
||||||
|
* (the id value doesn't really matter and it is mostly used as a stable identifier in case of a field rename).
|
||||||
|
*/
|
||||||
|
add(...fields: Field[]): void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AddAt is the same as Add but insert/move the fields at the specific position.
|
||||||
|
*
|
||||||
|
* If pos < 0, then this method acts the same as calling Add.
|
||||||
|
*
|
||||||
|
* If pos > FieldsList total items, then the specified fields are inserted/moved at the end of the list.
|
||||||
|
*/
|
||||||
|
addAt(pos: number, ...fields: Field[]): void
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Record Operations
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface PBRecord {
|
||||||
|
/**
|
||||||
|
* Record ID
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record data
|
||||||
|
*/
|
||||||
|
[key: string]: any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a field value
|
||||||
|
*/
|
||||||
|
set(field: string, value: any): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a field value
|
||||||
|
*/
|
||||||
|
get(field: string): any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if field exists
|
||||||
|
*/
|
||||||
|
has(field: string): boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a field
|
||||||
|
*/
|
||||||
|
remove(field: string): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all field names
|
||||||
|
*/
|
||||||
|
keys(): string[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all field values
|
||||||
|
*/
|
||||||
|
values(): any[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get record as object
|
||||||
|
*/
|
||||||
|
export(): { [key: string]: any };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import data into record
|
||||||
|
*/
|
||||||
|
import(data: { [key: string]: any }): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh record from database
|
||||||
|
*/
|
||||||
|
refresh(): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get collection this record belongs to
|
||||||
|
*/
|
||||||
|
collection(): Collection;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Database Operations
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Database {
|
||||||
|
/**
|
||||||
|
* Create a new query
|
||||||
|
*/
|
||||||
|
newQuery(sql: string): Query;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a query
|
||||||
|
*/
|
||||||
|
execute(): any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Query {
|
||||||
|
/**
|
||||||
|
* Execute the query
|
||||||
|
*/
|
||||||
|
execute(): any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind parameters to the query
|
||||||
|
*/
|
||||||
|
bind(...params: any[]): Query;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Settings
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
/**
|
||||||
|
* App metadata
|
||||||
|
*/
|
||||||
|
meta: {
|
||||||
|
appName: string;
|
||||||
|
appURL: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
// Model defines an interface with common methods that all db models should have.
|
||||||
|
// -------------------------------------------------------------------
|
||||||
|
interface Model {
|
||||||
|
[key: string]: any;
|
||||||
|
tableName(): string;
|
||||||
|
pk(): any;
|
||||||
|
lastSavedPK(): any;
|
||||||
|
isNew(): boolean;
|
||||||
|
markAsNew(): void;
|
||||||
|
markAsNotNew(): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "pocketbase-app",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "./pocketbase serve --http=0.0.0.0:8090 --encryptionEnv=PB_ENCRYPTION_KEY --hooksWatch=false",
|
||||||
|
"start": "./pocketbase serve --http=0.0.0.0:8090 --encryptionEnv=PB_ENCRYPTION_KEY --dir=/data --migrationsDir=./pb_migrations --hooksDir=./pb_hooks --hooksWatch=false",
|
||||||
|
"migrations:up": "./pocketbase migrate up --encryptionEnv=PB_ENCRYPTION_KEY --dir=./pb_data --migrationsDir=./pb_migrations --hooksDir=./pb_hooks --hooksWatch=false",
|
||||||
|
"migrations:revert": "./pocketbase horizons migrations:revert --encryptionEnv=PB_ENCRYPTION_KEY --dir=./pb_data --migrationsDir=./pb_migrations --hooksDir=./pb_hooks --hooksWatch=false",
|
||||||
|
"migrations:snapshot": "mkdir -p ./pb_snapshots && echo 'y' | ./pocketbase migrate collections --encryptionEnv=PB_ENCRYPTION_KEY --dir=./pb_data --migrationsDir=./pb_snapshots",
|
||||||
|
"update": "./pocketbase update --encryptionEnv=PB_ENCRYPTION_KEY --backup=0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
onMailerSend((e) => {
|
||||||
|
if (e.app.settings().smtp.enabled) {
|
||||||
|
return e.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
const senderAddress = $os.getenv("BUILDER_MAILER_SENDER_ADDRESS");
|
||||||
|
const cc = (e.message.cc || []).map((recipient) => recipient.address).filter(Boolean);
|
||||||
|
const bcc = (e.message.bcc || []).map((recipient) => recipient.address).filter(Boolean);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
"subject": e.message.subject,
|
||||||
|
"content": {
|
||||||
|
...(e.message.html ? {
|
||||||
|
"html": e.message.html,
|
||||||
|
} : {
|
||||||
|
"text": e.message.text,
|
||||||
|
}),
|
||||||
|
"type": "plain",
|
||||||
|
},
|
||||||
|
"from": senderAddress,
|
||||||
|
"fromName": e.message.from?.name,
|
||||||
|
"replyTo": senderAddress,
|
||||||
|
"to": e.message.to[0].address,
|
||||||
|
...(cc.length ? { "cc": cc } : {}),
|
||||||
|
...(bcc.length ? { "bcc": bcc } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = $http.send({
|
||||||
|
url: `${$os.getenv("BUILDER_MAILER_API_URL")}/api/v2/email`,
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${$os.getenv("BUILDER_MAILER_API_KEY")}`,
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
$app.logger().error("Failed to send email", "error", response.json);
|
||||||
|
|
||||||
|
throw new ApiError(500, response.json?.message || 'Failed to send email');
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
/*
|
||||||
|
- Usage: ./pocketbase horizons migrations:revert migration1.js migration2.js ... - reverts specific migrations by name (not by count like the built-in "down" command)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Create a new "horizons" command group with a "migrations:revert" subcommand
|
||||||
|
const horizonsCmd = new Command({
|
||||||
|
use: "horizons",
|
||||||
|
short: "Additional migration utilities",
|
||||||
|
})
|
||||||
|
|
||||||
|
const exitOnError = (run) => (...args) => {
|
||||||
|
try {
|
||||||
|
return run(...args)
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
|
||||||
|
// Command callbacks run in a panic-recovering goroutine, so throwing
|
||||||
|
// alone no longer propagates a nonzero status to the parent process.
|
||||||
|
$os.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
horizonsCmd.addCommand(new Command({
|
||||||
|
use: "migrations:revert [migration names...]",
|
||||||
|
short: "Revert specific migrations by name and delete their files",
|
||||||
|
run: exitOnError((_cmd, args) => {
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.log("Usage: horizons migrations:revert <migration1.js> [migration2.js] ...")
|
||||||
|
console.log("No migrations specified to revert.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const migrationsDir = $filepath.join(__hooks, "../pb_migrations")
|
||||||
|
|
||||||
|
const migrationFiles = {}
|
||||||
|
try {
|
||||||
|
const files = $os.readDir(migrationsDir)
|
||||||
|
for (const file of files) {
|
||||||
|
const name = file.name()
|
||||||
|
migrationFiles[name] = $filepath.join(migrationsDir, name)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to read migrations directory:", e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const appliedMigrations = []
|
||||||
|
for (const migrationName of args) {
|
||||||
|
if (!migrationFiles[migrationName]) {
|
||||||
|
console.error(`Migration file not found: ${migrationName}`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = arrayOf(new DynamicModel({ file: "" }))
|
||||||
|
$app.db()
|
||||||
|
.select("file")
|
||||||
|
.from("_migrations")
|
||||||
|
.where($dbx.hashExp({ file: migrationName }))
|
||||||
|
.limit(1)
|
||||||
|
.all(result)
|
||||||
|
|
||||||
|
if (result.length === 0) {
|
||||||
|
console.log(`Migration ${migrationName} is not applied, skipping.`)
|
||||||
|
$os.remove(migrationFiles[migrationName]);
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
appliedMigrations.push(migrationName)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Failed to check migration status for ${migrationName}:`, e)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (appliedMigrations.length === 0) {
|
||||||
|
console.log("No applied migrations found to revert.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nMigrations to revert:")
|
||||||
|
for (const name of appliedMigrations) {
|
||||||
|
console.log(`${name}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const reverted = []
|
||||||
|
|
||||||
|
$app.runInTransaction((txApp) => {
|
||||||
|
for (const migrationName of appliedMigrations) {
|
||||||
|
const filePath = migrationFiles[migrationName]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = toString($os.readFile(filePath))
|
||||||
|
|
||||||
|
// Extract the down function from migrate(up, down) call
|
||||||
|
// We need to execute the file and capture the down function
|
||||||
|
let downFn = null
|
||||||
|
|
||||||
|
const captureMigrate = (up, down) => {
|
||||||
|
downFn = down
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the migration file with our custom migrate function since hooks doesn't have access to migrate function
|
||||||
|
const migrationCode = content.replace(/migrate\s*\(/g, '__captureMigrate(')
|
||||||
|
const fn = new Function('__captureMigrate', '$app', 'Collection', 'Record', migrationCode)
|
||||||
|
fn(captureMigrate, txApp, Collection, Record)
|
||||||
|
|
||||||
|
if (!downFn) {
|
||||||
|
console.log(`Migration ${migrationName} has no down function, just removing from history.`)
|
||||||
|
} else {
|
||||||
|
downFn(txApp)
|
||||||
|
}
|
||||||
|
|
||||||
|
txApp.db().delete("_migrations", $dbx.hashExp({ file: migrationName })).execute()
|
||||||
|
|
||||||
|
reverted.push({ name: migrationName, path: filePath })
|
||||||
|
console.log(`✓ Reverted: ${migrationName}`)
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`failed to revert migration ${migrationName}: ${e}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const migration of reverted) {
|
||||||
|
$os.remove(migration.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully reverted ${reverted.length} migration(s).`)
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
$app.rootCmd.addCommand(horizonsCmd)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
routerAdd("GET", "/{$}", (e) => {
|
||||||
|
const fileContent = $os.readFile(`${__hooks}/../.pocketbase-version`)
|
||||||
|
const contentStr = Array.isArray(fileContent)
|
||||||
|
? String.fromCharCode.apply(null, fileContent)
|
||||||
|
: fileContent
|
||||||
|
const pbVersion = `v${contentStr.replace(/\n/g, '').trim()}`
|
||||||
|
const externalUrl = `https://horizons-static-cdn.hostinger.com/pocketbase/__PB_VERSION__/ui/dist/index.html`.replace("__PB_VERSION__", pbVersion)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = $http.send({
|
||||||
|
url: externalUrl,
|
||||||
|
method: "GET",
|
||||||
|
timeout: 30,
|
||||||
|
})
|
||||||
|
|
||||||
|
const htmlContent = Array.isArray(response.body)
|
||||||
|
? String.fromCharCode.apply(null, response.body)
|
||||||
|
: response.body
|
||||||
|
|
||||||
|
return e.html(response.statusCode, htmlContent)
|
||||||
|
} catch (err) {
|
||||||
|
throw new NotFoundError("Failed to load dashboard", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
// Routes every _logs row to stdout/stderr on production
|
||||||
|
// Forwards every _logs row to the session journal on development and continue writing to the database
|
||||||
|
onModelCreate((e) => {
|
||||||
|
const env = $os.getenv("NODE_ENV")
|
||||||
|
|
||||||
|
if (env === "production") {
|
||||||
|
const write = e.model.level >= 8 ? console.error : console.log
|
||||||
|
|
||||||
|
try {
|
||||||
|
write(JSON.stringify({
|
||||||
|
id: e.model.id,
|
||||||
|
created: e.model.created.string(),
|
||||||
|
level: e.model.level,
|
||||||
|
message: e.model.message,
|
||||||
|
data: JSON.parse(e.model.data.string()),
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to forward log: ${err}`)
|
||||||
|
|
||||||
|
write(JSON.stringify({ level: e.model.level, message: e.model.message }))
|
||||||
|
}
|
||||||
|
|
||||||
|
// do not continue writing to the database
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env === "development" || !env) {
|
||||||
|
try {
|
||||||
|
const journalPath = $filepath.join(__hooks, "..", "..", "..", "vault", "temp", "SESSION_JOURNAL.md")
|
||||||
|
|
||||||
|
const levels = { "-4": "debug", "0": "info", "4": "warn", "8": "error" }
|
||||||
|
const level = levels[String(e.model.level)] || `level${e.model.level}`
|
||||||
|
|
||||||
|
// Matches vite-plugin-session-journal's entry shape
|
||||||
|
const bullet = (key, value) => {
|
||||||
|
const text = String(value)
|
||||||
|
const indented = text.includes("\n") ? `\n ${text.replace(/\n/g, "\n ")}` : text
|
||||||
|
|
||||||
|
return `- ${key}: ${indented}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
let entry = `## ${e.model.created.string()} pocketbase.${level}\n`
|
||||||
|
entry += bullet("message", e.model.message)
|
||||||
|
|
||||||
|
const data = e.model.data.string()
|
||||||
|
if (data && data !== "{}") {
|
||||||
|
entry += bullet("data", data)
|
||||||
|
}
|
||||||
|
entry += "\n"
|
||||||
|
|
||||||
|
$os.mkdirAll($filepath.dir(journalPath), 0o755)
|
||||||
|
|
||||||
|
// $os has no append primitive
|
||||||
|
$os.cmd("sh", "-c", 'printf "%s" "$1" >> "$2"', "sh", entry, journalPath).run()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Failed to journal log: ${err}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next()
|
||||||
|
}, "_logs")
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
const ALLOWED_IPS = [
|
||||||
|
"86.106.20.28", // L2
|
||||||
|
"89.116.209.21", // L2
|
||||||
|
"86.106.20.24", // L3
|
||||||
|
"84.32.173.80", // L3
|
||||||
|
"34.141.125.49", // euw3-stage-koalemos
|
||||||
|
"35.230.138.117", // euw2-prod-apollo
|
||||||
|
"35.242.131.17", // euw2-prod-apollo
|
||||||
|
"34.89.86.34", // euw2-prod-apollo
|
||||||
|
"35.242.132.122", // euw2-prod-apollo
|
||||||
|
"35.234.116.158", // euw3-stage-koalemos
|
||||||
|
"34.159.186.242", // euw3-stage-koalemos
|
||||||
|
]
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ALLOWED_IPS,
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
|
||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
|
||||||
|
onRecordCreateRequest((e) => {
|
||||||
|
const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`)
|
||||||
|
const ip = e.realIP()
|
||||||
|
|
||||||
|
if (!ALLOWED_IPS.includes(ip)) {
|
||||||
|
throw new BadRequestError(`Not allowed to create superuser`)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next()
|
||||||
|
}, "_superusers")
|
||||||
|
|
||||||
|
onRecordUpdateRequest((e) => {
|
||||||
|
const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`)
|
||||||
|
const ip = e.realIP()
|
||||||
|
|
||||||
|
if (!ALLOWED_IPS.includes(ip)) {
|
||||||
|
throw new BadRequestError(`Not allowed to update superuser`)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next()
|
||||||
|
}, "_superusers")
|
||||||
|
|
||||||
|
onRecordDeleteRequest((e) => {
|
||||||
|
const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`)
|
||||||
|
const ip = e.realIP()
|
||||||
|
|
||||||
|
if (!ALLOWED_IPS.includes(ip)) {
|
||||||
|
throw new BadRequestError(`Not allowed to delete superuser`)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next()
|
||||||
|
}, "_superusers")
|
||||||
|
|
||||||
|
onRecordRequestPasswordResetRequest((e) => {
|
||||||
|
const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`)
|
||||||
|
const ip = e.realIP()
|
||||||
|
|
||||||
|
if (!ALLOWED_IPS.includes(ip)) {
|
||||||
|
throw new BadRequestError(`Not allowed to request password reset for superuser`)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next()
|
||||||
|
}, "_superusers")
|
||||||
|
|
||||||
|
onCollectionViewRequest((e) => {
|
||||||
|
const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`)
|
||||||
|
const ip = e.realIP()
|
||||||
|
|
||||||
|
if (!ALLOWED_IPS.includes(ip)) {
|
||||||
|
throw new BadRequestError(`Not allowed to view collection`)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCollectionCreateRequest((e) => {
|
||||||
|
const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`)
|
||||||
|
const ip = e.realIP()
|
||||||
|
|
||||||
|
if (!ALLOWED_IPS.includes(ip)) {
|
||||||
|
throw new BadRequestError(`Not allowed to create collection`)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCollectionUpdateRequest((e) => {
|
||||||
|
const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`)
|
||||||
|
const ip = e.realIP()
|
||||||
|
|
||||||
|
if (!ALLOWED_IPS.includes(ip)) {
|
||||||
|
throw new BadRequestError(`Not allowed to update collection`)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCollectionDeleteRequest((e) => {
|
||||||
|
const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`)
|
||||||
|
const ip = e.realIP()
|
||||||
|
|
||||||
|
if (!ALLOWED_IPS.includes(ip)) {
|
||||||
|
throw new BadRequestError(`Not allowed to delete collection`)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCollectionsImportRequest((e) => {
|
||||||
|
const { ALLOWED_IPS } = require(`${__hooks}/superusers-allow-list.js`)
|
||||||
|
const ip = e.realIP()
|
||||||
|
|
||||||
|
if (!ALLOWED_IPS.includes(ip)) {
|
||||||
|
throw new BadRequestError(`Not allowed to import collections`)
|
||||||
|
}
|
||||||
|
|
||||||
|
e.next()
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
let settings = app.settings()
|
||||||
|
|
||||||
|
settings.meta.appName = "f7b2aa49-1077-4692-8b37-dae2a255116c.app-preview.com"
|
||||||
|
settings.meta.appURL = "https://f7b2aa49-1077-4692-8b37-dae2a255116c.app-preview.com/hcgi/platform"
|
||||||
|
settings.meta.hideControls = true
|
||||||
|
|
||||||
|
settings.logs.maxDays = 7
|
||||||
|
settings.logs.minLevel = 8
|
||||||
|
settings.logs.logIP = true
|
||||||
|
|
||||||
|
settings.trustedProxy.headers = [
|
||||||
|
"X-Real-IP",
|
||||||
|
"X-Forwarded-For",
|
||||||
|
"CF-Connecting-IP",
|
||||||
|
]
|
||||||
|
|
||||||
|
app.save(settings)
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const superusers = app.findCollectionByNameOrId("_superusers")
|
||||||
|
const record = new Record(superusers)
|
||||||
|
|
||||||
|
record.set("email", $os.getenv("PB_SUPERUSER_EMAIL"))
|
||||||
|
record.set("password", $os.getenv("PB_SUPERUSER_PASSWORD"))
|
||||||
|
|
||||||
|
app.save(record)
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const superusers = app.findCollectionByNameOrId("_superusers")
|
||||||
|
|
||||||
|
superusers.authAlert = {
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
app.save(superusers)
|
||||||
|
})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
let settings = app.settings()
|
||||||
|
|
||||||
|
settings.rateLimits = {
|
||||||
|
enabled: true,
|
||||||
|
rules: [
|
||||||
|
// General API limit - 200 requests per 5 minutes
|
||||||
|
{
|
||||||
|
label: "/api",
|
||||||
|
audience: "",
|
||||||
|
duration: 5 * 60, // 5 minutes
|
||||||
|
maxRequests: 200
|
||||||
|
},
|
||||||
|
// Brute force protection for auth attempts - 20 attempts per 5 minutes (guests only)
|
||||||
|
{
|
||||||
|
label: "*:auth",
|
||||||
|
audience: "@guest", // Only for unauthenticated users
|
||||||
|
duration: 5 * 60, // 15 minutes
|
||||||
|
maxRequests: 20
|
||||||
|
},
|
||||||
|
// Password reset - 5 per hour
|
||||||
|
{
|
||||||
|
label: "POST /api/collections/users/request-password-reset",
|
||||||
|
audience: "",
|
||||||
|
duration: 60 * 60, // 1 hour
|
||||||
|
maxRequests: 5
|
||||||
|
},
|
||||||
|
// Email verification - 5 per hour
|
||||||
|
{
|
||||||
|
label: "POST /api/collections/users/request-verification",
|
||||||
|
audience: "",
|
||||||
|
duration: 60 * 60, // 1 hour
|
||||||
|
maxRequests: 5
|
||||||
|
},
|
||||||
|
// Email change - 3 per hour (authenticated only)
|
||||||
|
{
|
||||||
|
label: "POST /api/collections/users/request-email-change",
|
||||||
|
audience: "@auth",
|
||||||
|
duration: 60 * 60, // 1 hour
|
||||||
|
maxRequests: 3
|
||||||
|
},
|
||||||
|
// OTP - 10 per hour
|
||||||
|
{
|
||||||
|
label: "POST /api/collections/users/request-otp",
|
||||||
|
audience: "",
|
||||||
|
duration: 60 * 60, // 1 hour
|
||||||
|
maxRequests: 10
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
app.save(settings)
|
||||||
|
})
|
||||||
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
let usersCollection
|
||||||
|
try {
|
||||||
|
usersCollection = app.findCollectionByNameOrId("users")
|
||||||
|
usersCollection.authAlert = {
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("no rows in result set")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
app.save(usersCollection)
|
||||||
|
})
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text9973129546",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text7224323192",
|
||||||
|
"name": "firstName",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text",
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text8927180967",
|
||||||
|
"name": "lastName",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text",
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "email3000958229",
|
||||||
|
"name": "email",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "email",
|
||||||
|
"exceptDomains": [],
|
||||||
|
"onlyDomains": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json4467574208",
|
||||||
|
"name": "websites",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json",
|
||||||
|
"maxSize": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json7565497650",
|
||||||
|
"name": "apps",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json",
|
||||||
|
"maxSize": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "json2686026609",
|
||||||
|
"name": "systems",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "json",
|
||||||
|
"maxSize": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text2579574645",
|
||||||
|
"name": "industry",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text",
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate2823430259",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate7741794431",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_3506166606",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'",
|
||||||
|
"name": "assessments",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'",
|
||||||
|
"viewRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("Collection name must be unique")) {
|
||||||
|
console.log("Collection already exists, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_3506166606");
|
||||||
|
return app.delete(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("websites");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "websites",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("apps");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "apps",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("systems");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "systems",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("websites");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "websites",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("apps");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "apps",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("systems");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "systems",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
|
||||||
|
const existing = collection.fields.getByName("websites_text");
|
||||||
|
if (existing) {
|
||||||
|
if (existing.type === "text") {
|
||||||
|
return; // field already exists with correct type, skip
|
||||||
|
}
|
||||||
|
collection.fields.removeByName("websites_text"); // exists with wrong type, remove first
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.fields.add(new TextField({
|
||||||
|
name: "websites_text",
|
||||||
|
required: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("websites_text");
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
|
||||||
|
const existing = collection.fields.getByName("apps_text");
|
||||||
|
if (existing) {
|
||||||
|
if (existing.type === "text") {
|
||||||
|
return; // field already exists with correct type, skip
|
||||||
|
}
|
||||||
|
collection.fields.removeByName("apps_text"); // exists with wrong type, remove first
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.fields.add(new TextField({
|
||||||
|
name: "apps_text",
|
||||||
|
required: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("apps_text");
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
|
||||||
|
const existing = collection.fields.getByName("systems_text");
|
||||||
|
if (existing) {
|
||||||
|
if (existing.type === "text") {
|
||||||
|
return; // field already exists with correct type, skip
|
||||||
|
}
|
||||||
|
collection.fields.removeByName("systems_text"); // exists with wrong type, remove first
|
||||||
|
}
|
||||||
|
|
||||||
|
collection.fields.add(new TextField({
|
||||||
|
name: "systems_text",
|
||||||
|
required: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("systems_text");
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("websites");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "websites",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("apps");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "apps",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("systems");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "systems",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("websites");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "websites",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("apps");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "apps",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.removeByName("systems");
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
collection.fields.add(new JSONField({
|
||||||
|
name: "systems",
|
||||||
|
required: false
|
||||||
|
}));
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
return app.delete(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping delete");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, (app) => {
|
||||||
|
// Note: The down migration cannot restore data, only the collection structure
|
||||||
|
// You would need to manually recreate the collection structure here if needed
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
return app.delete(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping delete");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, (app) => {
|
||||||
|
// Note: The down migration cannot restore data, only the collection structure
|
||||||
|
// You would need to manually recreate the collection structure here if needed
|
||||||
|
})
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = new Collection({
|
||||||
|
"createRule": "",
|
||||||
|
"deleteRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"autogeneratePattern": "[a-z0-9]{15}",
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3930877004",
|
||||||
|
"max": 15,
|
||||||
|
"min": 15,
|
||||||
|
"name": "id",
|
||||||
|
"pattern": "^[a-z0-9]+$",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": true,
|
||||||
|
"required": true,
|
||||||
|
"system": true,
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text9496940303",
|
||||||
|
"name": "firstName",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text",
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text6526030650",
|
||||||
|
"name": "lastName",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text",
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1504649217",
|
||||||
|
"name": "email",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text",
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text3766238150",
|
||||||
|
"name": "industry",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text",
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text0206998537",
|
||||||
|
"name": "websites",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text",
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text1142803244",
|
||||||
|
"name": "applications",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text",
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "text6766822077",
|
||||||
|
"name": "systems",
|
||||||
|
"presentable": false,
|
||||||
|
"primaryKey": false,
|
||||||
|
"required": true,
|
||||||
|
"system": false,
|
||||||
|
"type": "text",
|
||||||
|
"autogeneratePattern": "",
|
||||||
|
"max": 0,
|
||||||
|
"min": 0,
|
||||||
|
"pattern": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate1878312857",
|
||||||
|
"name": "created",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": false,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"hidden": false,
|
||||||
|
"id": "autodate5206414872",
|
||||||
|
"name": "updated",
|
||||||
|
"onCreate": true,
|
||||||
|
"onUpdate": true,
|
||||||
|
"presentable": false,
|
||||||
|
"system": false,
|
||||||
|
"type": "autodate"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"id": "pbc_6716256996",
|
||||||
|
"indexes": [],
|
||||||
|
"listRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'",
|
||||||
|
"name": "assessments",
|
||||||
|
"system": false,
|
||||||
|
"type": "base",
|
||||||
|
"updateRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'",
|
||||||
|
"viewRule": "@request.auth.collectionName = 'users' && @request.auth.role = 'admin'"
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("Collection name must be unique")) {
|
||||||
|
console.log("Collection already exists, skipping");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId("pbc_6716256996");
|
||||||
|
return app.delete(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
const field = collection.fields.getByName("websites");
|
||||||
|
field.required = false;
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
const field = collection.fields.getByName("websites");
|
||||||
|
if (!field) { console.log("Field not found, skipping revert"); return; }
|
||||||
|
field.required = true;
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection or field not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
const field = collection.fields.getByName("applications");
|
||||||
|
field.required = false;
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
const field = collection.fields.getByName("applications");
|
||||||
|
if (!field) { console.log("Field not found, skipping revert"); return; }
|
||||||
|
field.required = true;
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection or field not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
const field = collection.fields.getByName("systems");
|
||||||
|
field.required = false;
|
||||||
|
return app.save(collection);
|
||||||
|
}, (app) => {
|
||||||
|
try {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
const field = collection.fields.getByName("systems");
|
||||||
|
if (!field) { console.log("Field not found, skipping revert"); return; }
|
||||||
|
field.required = true;
|
||||||
|
return app.save(collection);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("no rows in result set")) {
|
||||||
|
console.log("Collection or field not found, skipping revert");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/// <reference path="../pb_data/types.d.ts" />
|
||||||
|
migrate((app) => {
|
||||||
|
const collection = app.findCollectionByNameOrId("assessments");
|
||||||
|
|
||||||
|
const record0 = new Record(collection);
|
||||||
|
record0.id = "zlolg0zav7yhepz";
|
||||||
|
record0.set("firstName", "John");
|
||||||
|
record0.set("lastName", "Doe");
|
||||||
|
record0.set("email", "john@example.com");
|
||||||
|
record0.set("industry", "Technology");
|
||||||
|
try {
|
||||||
|
app.save(record0);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.message.includes("Value must be unique")) {
|
||||||
|
console.log("Record with unique value already exists, skipping");
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, (app) => {
|
||||||
|
const seededRecordIds = ["zlolg0zav7yhepz"];
|
||||||
|
for (const seededRecordId of seededRecordIds) {
|
||||||
|
try {
|
||||||
|
app.delete(app.findRecordById("assessments", seededRecordId));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes("no rows in result set")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Frontend — build the Vite app, then serve it with nginx.
|
||||||
|
# Build context must be the REPO ROOT (npm workspaces).
|
||||||
|
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install workspace dependencies first for better layer caching.
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
COPY apps/web/package.json ./apps/web/package.json
|
||||||
|
RUN npm ci --workspace apps/web
|
||||||
|
|
||||||
|
# Build the static site (outputs to /app/dist/apps/web).
|
||||||
|
COPY apps/web ./apps/web
|
||||||
|
WORKDIR /app/apps/web
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
COPY --from=build /app/dist/apps/web /usr/share/nginx/html
|
||||||
|
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": false,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import importPlugin from 'eslint-plugin-import';
|
||||||
|
import react from 'eslint-plugin-react';
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks';
|
||||||
|
import globals from 'globals';
|
||||||
|
import unicodeEscapePlugin from './eslint.unicode-escapes-plugin.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{ ignores: ['node_modules/**', 'dist/**', 'build/**', 'vite.config.js'] },
|
||||||
|
{
|
||||||
|
files: ['**/*.js', '**/*.jsx'],
|
||||||
|
plugins: { react, 'react-hooks': reactHooks, 'import': importPlugin },
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 'latest',
|
||||||
|
sourceType: 'module',
|
||||||
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
|
globals: { ...globals.browser, React: 'readonly', Intl: 'readonly' },
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
'react': { version: 'detect' },
|
||||||
|
'import/extensions': ['.js', '.jsx'],
|
||||||
|
'import/resolver': {
|
||||||
|
node: { extensions: ['.js', '.jsx'] },
|
||||||
|
alias: { map: [['@', './src']], extensions: ['.js', '.jsx'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...react.configs.recommended.rules,
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
...importPlugin.flatConfigs.recommended.rules,
|
||||||
|
|
||||||
|
// Non-critical rules - disabled since code works fine without them
|
||||||
|
'react/prop-types': 'off',
|
||||||
|
'react/no-unescaped-entities': 'off',
|
||||||
|
'react/display-name': 'off', // Non-critical, component works without displayName
|
||||||
|
'react/jsx-uses-react': 'off', // Not needed in React 17+, non-critical
|
||||||
|
'react/react-in-jsx-scope': 'off', // Not needed in React 17+, non-critical
|
||||||
|
'react/jsx-uses-vars': 'off', // Non-critical, code works fine
|
||||||
|
'react/jsx-no-comment-textnodes': 'off', // Non-critical, comments could be visible if put inside the JSX, most cases are just rendering text like '///'
|
||||||
|
|
||||||
|
'no-unused-vars': 'off', // Non-critical, code works fine with unused vars
|
||||||
|
'import/no-named-as-default': 'off', // Can cause runtime import errors, usually fine to leave as is
|
||||||
|
'import/no-named-as-default-member': 'off', // Can cause runtime import errors
|
||||||
|
|
||||||
|
// Critical rules that prevent runtime errors
|
||||||
|
'no-undef': 'error', // Undefined variables cause runtime errors
|
||||||
|
'no-empty': ['error', { allowEmptyCatch: true }], // Empty blocks often signal a bug (e.g. missing body)
|
||||||
|
|
||||||
|
// Override recommended import rules for stricter checking
|
||||||
|
'import/no-self-import': 'error', // Extremely fast rule, breaking results in infinite loop/bundling error
|
||||||
|
|
||||||
|
// Disable expensive rules for performance
|
||||||
|
'import/no-cycle': 'off', // AI rarely makes this error, and the rule is very slow to run
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.jsx'],
|
||||||
|
plugins: { horizons: unicodeEscapePlugin },
|
||||||
|
rules: { 'horizons/no-unicode-escapes-in-jsx': 'warn' },
|
||||||
|
},
|
||||||
|
{ files: ['tools/**/*.js', 'tailwind.config.js'], languageOptions: { globals: globals.node } },
|
||||||
|
];
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const RULE_ID = 'horizons/no-unicode-escapes-in-jsx';
|
||||||
|
const MAX_DETAILED_FINDINGS = 25;
|
||||||
|
const ESCAPE_FROM_MESSAGE_PATTERN = /^Literal Unicode escape "(\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\}))"/;
|
||||||
|
const GUIDANCE = 'Unicode escapes in JSX text or quoted attributes may be displayed verbatim to website visitors. Replace every escape in affected JSX in place with its intended literal character (for example, use "ç", not "\\u00e7"). Do not move the text into variables, objects, or JSX expressions, and do not wrap escapes in JSX braces ({}).';
|
||||||
|
|
||||||
|
function selectFindingsAcrossFiles(findings) {
|
||||||
|
const findingsByFile = new Map();
|
||||||
|
for (const finding of findings) {
|
||||||
|
const fileFindings = findingsByFile.get(finding.filePath) ?? [];
|
||||||
|
fileFindings.push(finding);
|
||||||
|
findingsByFile.set(finding.filePath, fileFindings);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = [];
|
||||||
|
for (let findingIndex = 0; shown.length < MAX_DETAILED_FINDINGS; findingIndex++) {
|
||||||
|
let addedFinding = false;
|
||||||
|
|
||||||
|
for (const fileFindings of findingsByFile.values()) {
|
||||||
|
const finding = fileFindings[findingIndex];
|
||||||
|
if (finding) {
|
||||||
|
shown.push(finding);
|
||||||
|
addedFinding = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shown.length === MAX_DETAILED_FINDINGS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!addedFinding) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return shown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function format(results) {
|
||||||
|
const findings = results.flatMap(result =>
|
||||||
|
result.messages
|
||||||
|
.filter(message => message.ruleId === RULE_ID)
|
||||||
|
.map(message => ({ filePath: result.filePath, ...message })),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (findings.length === 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const shown = selectFindingsAcrossFiles(findings);
|
||||||
|
const lines = [
|
||||||
|
GUIDANCE,
|
||||||
|
...shown.map((finding) => {
|
||||||
|
const escape = finding.message.match(ESCAPE_FROM_MESSAGE_PATTERN)?.[1] ?? 'Unicode escape';
|
||||||
|
return `${finding.filePath}:${finding.line}:${finding.column} warning ${escape} ${RULE_ID}`;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const omitted = findings.length - shown.length;
|
||||||
|
lines.push(`Found ${findings.length} Unicode escapes; ${omitted} additional findings omitted. Replace all escapes in affected JSX. [horizons-unicode-escapes] total=${findings.length} shown=${shown.length} omitted=${omitted}`);
|
||||||
|
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
const CODE_LIKE_ELEMENTS = new Set(['code', 'pre', 'kbd', 'samp']);
|
||||||
|
const UNICODE_ESCAPE_PATTERN = /\\u(?:[0-9a-fA-F]{4}|\{[0-9a-fA-F]{1,6}\})/g;
|
||||||
|
|
||||||
|
function isInsideCodeLikeElement(node) {
|
||||||
|
for (let parent = node.parent; parent; parent = parent.parent) {
|
||||||
|
if (parent.type !== 'JSXElement') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = parent.openingElement.name;
|
||||||
|
if (name.type === 'JSXIdentifier' && CODE_LIKE_ELEMENTS.has(name.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
rules: {
|
||||||
|
'no-unicode-escapes-in-jsx': {
|
||||||
|
meta: {
|
||||||
|
type: 'problem',
|
||||||
|
docs: {
|
||||||
|
description: 'Warn when Unicode escapes in JSX text or quoted attributes would be shown verbatim to website visitors instead of rendering the intended character',
|
||||||
|
},
|
||||||
|
schema: [],
|
||||||
|
messages: {
|
||||||
|
found: 'Literal Unicode escape "{{escape}}" in JSX text or a quoted attribute may be displayed verbatim to users. Replace only the escape in place with the intended literal character (for example, use "ç", not "\\u00e7"). Do not move the text into variables, objects, or JSX expressions, and do not wrap the escape in JSX braces ({}).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
function check(node) {
|
||||||
|
if (isInsideCodeLikeElement(node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = context.sourceCode.getText(node);
|
||||||
|
for (const match of text.matchAll(UNICODE_ESCAPE_PATTERN)) {
|
||||||
|
const startIndex = node.range[0] + match.index;
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
loc: {
|
||||||
|
start: context.sourceCode.getLocFromIndex(startIndex),
|
||||||
|
end: context.sourceCode.getLocFromIndex(startIndex + match[0].length),
|
||||||
|
},
|
||||||
|
messageId: 'found',
|
||||||
|
data: { escape: match[0] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
JSXText: check,
|
||||||
|
JSXAttribute(node) {
|
||||||
|
if (node.value?.type === 'Literal') {
|
||||||
|
check(node.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<!-- Google Analytics gtag.js script - Loaded before any other scripts -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id=G-3DTN0F457M"></script>
|
||||||
|
<script>
|
||||||
|
console.log('GA4 gtag.js script loaded');
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag(){dataLayer.push(arguments);}
|
||||||
|
gtag('js', new Date());
|
||||||
|
// Disable automatic page views to handle them manually via React Router
|
||||||
|
gtag('config', 'G-3DTN0F457M', { send_page_view: false });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#2563EB" />
|
||||||
|
|
||||||
|
<!-- Primary Meta Tags -->
|
||||||
|
<title>Korrith - Secure Infrastructure. Quantum Future.</title>
|
||||||
|
<meta name="title" content="Korrith - Secure Infrastructure. Quantum Future." />
|
||||||
|
<meta name="description" content="Enterprise-grade managed infrastructure, AI-powered security, and quantum readiness. Host, automate, and protect everything in one hardened platform." />
|
||||||
|
<meta name="keywords" content="secure infrastructure, managed hosting, cybersecurity, quantum readiness, AI-powered platform" />
|
||||||
|
<meta name="author" content="Korrith" />
|
||||||
|
|
||||||
|
<!-- Favicon -->
|
||||||
|
<link rel="icon" type="image/png" href="https://horizons-cdn.hostinger.com/f7b2aa49-1077-4692-8b37-dae2a255116c/cc09584db4e246d7aa61f1dfc8694cb1.png" sizes="any" />
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="https://horizons-cdn.hostinger.com/f7b2aa49-1077-4692-8b37-dae2a255116c/cc09584db4e246d7aa61f1dfc8694cb1.png" />
|
||||||
|
<link rel="icon" type="image/png" sizes="64x64" href="https://horizons-cdn.hostinger.com/f7b2aa49-1077-4692-8b37-dae2a255116c/cc09584db4e246d7aa61f1dfc8694cb1.png" />
|
||||||
|
<link rel="apple-touch-icon" href="https://horizons-cdn.hostinger.com/f7b2aa49-1077-4692-8b37-dae2a255116c/cc09584db4e246d7aa61f1dfc8694cb1.png" />
|
||||||
|
|
||||||
|
<!-- Open Graph / Facebook -->
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:url" content="https://korrith.com/" />
|
||||||
|
<meta property="og:title" content="Korrith - Secure Infrastructure. Quantum Future." />
|
||||||
|
<meta property="og:description" content="Enterprise-grade managed infrastructure, AI-powered security, and quantum readiness." />
|
||||||
|
<meta property="og:image" content="https://horizons-cdn.hostinger.com/f7b2aa49-1077-4692-8b37-dae2a255116c/4f9dad66a0ea7b7fc63fbb800ea750c0.png" />
|
||||||
|
<meta property="og:image:width" content="1200" />
|
||||||
|
<meta property="og:image:height" content="630" />
|
||||||
|
|
||||||
|
<!-- Twitter -->
|
||||||
|
<meta property="twitter:card" content="summary_large_image" />
|
||||||
|
<meta property="twitter:url" content="https://korrith.com/" />
|
||||||
|
<meta property="twitter:title" content="Korrith - Secure Infrastructure. Quantum Future." />
|
||||||
|
<meta property="twitter:description" content="Enterprise-grade managed infrastructure, AI-powered security, and quantum readiness." />
|
||||||
|
<meta property="twitter:image" content="https://horizons-cdn.hostinger.com/f7b2aa49-1077-4692-8b37-dae2a255116c/4f9dad66a0ea7b7fc63fbb800ea750c0.png" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Korrith frontend — static SPA + same-origin reverse proxies.
|
||||||
|
# The frontend calls the API and PocketBase on relative paths, so they must
|
||||||
|
# be reachable on the same origin as the site:
|
||||||
|
# /hcgi/api/* -> Express API
|
||||||
|
# /hcgi/platform/* -> PocketBase (API + admin UI)
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
client_max_body_size 20m;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/css application/javascript application/json image/svg+xml font/woff2;
|
||||||
|
|
||||||
|
# Express API
|
||||||
|
location /hcgi/api/ {
|
||||||
|
proxy_pass http://api:3001/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# PocketBase
|
||||||
|
location /hcgi/platform/ {
|
||||||
|
proxy_pass http://pocketbase:8090/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
# Server-sent events (realtime subscriptions)
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Hashed build assets — safe to cache aggressively
|
||||||
|
location /assets/ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback — client-side routing
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"start": "vite preview --outDir ../../dist/apps/web --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 ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-accordion": "^1.2.12",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||||
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
|
"@radix-ui/react-hover-card": "^1.1.15",
|
||||||
|
"@radix-ui/react-label": "^2.1.8",
|
||||||
|
"@radix-ui/react-menubar": "^1.1.16",
|
||||||
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
|
"@radix-ui/react-popover": "^1.1.15",
|
||||||
|
"@radix-ui/react-progress": "^1.1.8",
|
||||||
|
"@radix-ui/react-radio-group": "^1.3.8",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
|
"@radix-ui/react-slider": "^1.3.6",
|
||||||
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"framer-motion": "^11.15.0",
|
||||||
|
"input-otp": "^1.4.2",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"pocketbase": "^0.27.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-day-picker": "^9.14.0",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-helmet": "^6.1.0",
|
||||||
|
"react-hook-form": "^7.71.2",
|
||||||
|
"react-resizable-panels": "^4.7.2",
|
||||||
|
"react-router-dom": "^7.17.0",
|
||||||
|
"recharts": "^2.15.4",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
|
"zod": "^4.3.6"
|
||||||
|
},
|
||||||
|
"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",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"terser": "^5.37.0",
|
||||||
|
"vite": "^7.3.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
|
"eslint-plugin-import": "^2.32.0",
|
||||||
|
"eslint-import-resolver-alias": "^1.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,471 @@
|
|||||||
|
const EVENT = 'session-journal:event';
|
||||||
|
const MAX_TEXT = 2000;
|
||||||
|
const MAX_BATCH = 50;
|
||||||
|
const FLUSH_INTERVAL_MS = 1000;
|
||||||
|
|
||||||
|
let queue = [];
|
||||||
|
let flushTimer = null;
|
||||||
|
let connected = false;
|
||||||
|
|
||||||
|
function flushQueue() {
|
||||||
|
if (flushTimer) {
|
||||||
|
clearTimeout(flushTimer);
|
||||||
|
flushTimer = null;
|
||||||
|
}
|
||||||
|
if (!queue.length || !import.meta.hot || !connected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const batch = queue;
|
||||||
|
queue = [];
|
||||||
|
try {
|
||||||
|
import.meta.hot.send(EVENT, { batch });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(kind, data) {
|
||||||
|
if (!import.meta.hot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queue.push({ kind, t: Date.now(), ...data });
|
||||||
|
if (connected && queue.length >= MAX_BATCH) {
|
||||||
|
flushQueue();
|
||||||
|
} else if (!flushTimer) {
|
||||||
|
flushTimer = setTimeout(flushQueue, FLUSH_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.hot) {
|
||||||
|
import.meta.hot.on('vite:ws:connect', () => {
|
||||||
|
connected = true;
|
||||||
|
flushQueue();
|
||||||
|
});
|
||||||
|
import.meta.hot.on('vite:ws:disconnect', () => {
|
||||||
|
connected = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', () => {
|
||||||
|
if (document.visibilityState === 'hidden') {
|
||||||
|
flushQueue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('pagehide', flushQueue);
|
||||||
|
|
||||||
|
function truncate(value) {
|
||||||
|
if (value == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const s = String(value);
|
||||||
|
return s.length > MAX_TEXT ? s.slice(0, MAX_TEXT) + '...' : s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSensitiveName(name) {
|
||||||
|
return /password|secret|token|apikey|key|auth|credential|cookie/i.test(String(name || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeUrl(value) {
|
||||||
|
try {
|
||||||
|
const url = new URL(String(value), location.href);
|
||||||
|
for (const key of [...url.searchParams.keys()]) {
|
||||||
|
if (isSensitiveName(key)) {
|
||||||
|
url.searchParams.set(key, '[redacted]');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return truncate(url.toString());
|
||||||
|
} catch {
|
||||||
|
return truncate(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeValue(name, value) {
|
||||||
|
if (isSensitiveName(name)) {
|
||||||
|
const length = value == null ? 0 : String(value).length;
|
||||||
|
return '[redacted:length=' + length + ']';
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => summarizeValue(name, item));
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return summarizeObject(value);
|
||||||
|
}
|
||||||
|
return truncate(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeObject(value) {
|
||||||
|
const out = {};
|
||||||
|
for (const [key, val] of Object.entries(value || {})) {
|
||||||
|
out[key] = summarizeValue(key, val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRequestBody(body) {
|
||||||
|
if (body == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (body instanceof FormData) {
|
||||||
|
const out = {};
|
||||||
|
for (const [key, val] of body.entries()) {
|
||||||
|
out[key] = val instanceof File
|
||||||
|
? { fileName: truncate(val.name), type: val.type || null, size: val.size }
|
||||||
|
: summarizeValue(key, val);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (body instanceof URLSearchParams) {
|
||||||
|
const out = {};
|
||||||
|
for (const [key, val] of body.entries()) out[key] = summarizeValue(key, val);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
if (typeof body === 'string') {
|
||||||
|
try {
|
||||||
|
return summarizeObject(JSON.parse(body));
|
||||||
|
} catch {
|
||||||
|
return truncate(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (body && typeof body === 'object' && body.constructor === Object) {
|
||||||
|
return summarizeObject(body);
|
||||||
|
}
|
||||||
|
return '[' + Object.prototype.toString.call(body).slice(8, -1) + ' body]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSensitive(el) {
|
||||||
|
if (!el) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (el.type === 'password') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isSensitiveName(el.name || el.id || el.getAttribute('autocomplete') || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactText(value) {
|
||||||
|
return truncate(String(value || '').replace(/\s+/g, ' ').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFromLabelledBy(el) {
|
||||||
|
const ids = (el.getAttribute('aria-labelledby') || '').split(/\s+/).filter(Boolean);
|
||||||
|
if (!ids.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const parts = ids
|
||||||
|
.map((id) => document.getElementById(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((node) => compactText(node.textContent));
|
||||||
|
return parts.filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFromForAttr(el) {
|
||||||
|
if (!el.id) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const escapedId = window.CSS && CSS.escape ? CSS.escape(el.id) : el.id;
|
||||||
|
const labelEl = document.querySelector('label[for="' + escapedId + '"]');
|
||||||
|
return labelEl ? compactText(labelEl.textContent) : '';
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelFromWrappingLabel(el) {
|
||||||
|
let cur = el.parentElement;
|
||||||
|
for (let i = 0; i < 4 && cur; i++) {
|
||||||
|
if (cur.tagName === 'LABEL') {
|
||||||
|
const clone = cur.cloneNode(true);
|
||||||
|
for (const inner of clone.querySelectorAll('input, textarea, select')) {
|
||||||
|
inner.remove();
|
||||||
|
}
|
||||||
|
return compactText(clone.textContent);
|
||||||
|
}
|
||||||
|
cur = cur.parentElement;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldLabel(el) {
|
||||||
|
if (!el || el.nodeType !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const sources = [
|
||||||
|
el.getAttribute('name'),
|
||||||
|
labelFromLabelledBy(el),
|
||||||
|
el.getAttribute('aria-label'),
|
||||||
|
labelFromForAttr(el),
|
||||||
|
labelFromWrappingLabel(el),
|
||||||
|
el.id,
|
||||||
|
el.getAttribute('placeholder'),
|
||||||
|
];
|
||||||
|
for (const candidate of sources) {
|
||||||
|
const trimmed = candidate ? String(candidate).trim() : '';
|
||||||
|
if (trimmed) {
|
||||||
|
return compactText(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const type = (el.getAttribute('type') || el.tagName || '').toLowerCase();
|
||||||
|
return type ? '[' + type + ']' : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fieldValue(el) {
|
||||||
|
if (!el || !('value' in el)) {
|
||||||
|
return { value: '', length: 0, redacted: false };
|
||||||
|
}
|
||||||
|
const raw = el.value == null ? '' : String(el.value);
|
||||||
|
if (isSensitive(el)) {
|
||||||
|
return { value: '[redacted:length=' + raw.length + ']', length: raw.length, redacted: true };
|
||||||
|
}
|
||||||
|
return { value: truncate(raw), length: raw.length, redacted: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeElement(el) {
|
||||||
|
if (!el || el.nodeType !== 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const role = el.getAttribute('role') || null;
|
||||||
|
const ariaLabel = el.getAttribute('aria-label') || null;
|
||||||
|
const name = el.getAttribute('name') || null;
|
||||||
|
const type = el.getAttribute('type') || null;
|
||||||
|
const id = el.id || null;
|
||||||
|
const placeholder = el.getAttribute('placeholder') || null;
|
||||||
|
const isInput = ['INPUT', 'TEXTAREA', 'SELECT'].includes(el.tagName);
|
||||||
|
const valueInfo = isInput ? fieldValue(el) : null;
|
||||||
|
const text = isSensitive(el) ? '' : truncate(el.textContent || '');
|
||||||
|
return {
|
||||||
|
tag,
|
||||||
|
role,
|
||||||
|
ariaLabel,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
placeholder,
|
||||||
|
label: isInput ? fieldLabel(el) : null,
|
||||||
|
value: valueInfo ? valueInfo.value : null,
|
||||||
|
valueLength: valueInfo ? valueInfo.length : 0,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInteractive(el) {
|
||||||
|
let cur = el;
|
||||||
|
for (let i = 0; i < 6 && cur; i++) {
|
||||||
|
if (cur.nodeType !== 1) { cur = cur.parentNode; continue; }
|
||||||
|
const tag = cur.tagName;
|
||||||
|
if (tag === 'BUTTON' || tag === 'A' || tag === 'INPUT' ||
|
||||||
|
tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'LABEL' ||
|
||||||
|
cur.hasAttribute('role') || cur.hasAttribute('onclick')) {
|
||||||
|
return cur;
|
||||||
|
}
|
||||||
|
cur = cur.parentNode;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
send('click', { element: describeElement(findInteractive(e.target)) });
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener('submit', (e) => {
|
||||||
|
const form = e.target;
|
||||||
|
const fields = [];
|
||||||
|
if (form && form.elements) {
|
||||||
|
for (const el of form.elements) {
|
||||||
|
const label = fieldLabel(el);
|
||||||
|
if (!label || !('value' in el)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const v = fieldValue(el);
|
||||||
|
fields.push({
|
||||||
|
label,
|
||||||
|
type: el.type || el.tagName.toLowerCase(),
|
||||||
|
value: v.value,
|
||||||
|
length: v.length,
|
||||||
|
redacted: v.redacted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
send('submit', { formId: form.id || null, action: form.action || null, fields });
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener('focus', (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send('focus', { element: describeElement(target) });
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener('change', (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.type === 'file') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send('change', { element: describeElement(target) });
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener('blur', (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
if (!target || !['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!('value' in target) || !target.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send('blur', { element: describeElement(target) });
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
const nativeFetch = window.fetch.bind(window);
|
||||||
|
window.fetch = async (input, init = {}) => {
|
||||||
|
const started = performance.now();
|
||||||
|
const url = typeof input === 'string' ? input : input?.url;
|
||||||
|
const method = (init?.method || input?.method || 'GET').toUpperCase();
|
||||||
|
const requestBody = summarizeRequestBody(init?.body);
|
||||||
|
try {
|
||||||
|
const response = await nativeFetch(input, init);
|
||||||
|
const requestUrl = url || response.url;
|
||||||
|
if (!response.ok) {
|
||||||
|
let body = '';
|
||||||
|
try { body = await response.clone().text(); } catch {}
|
||||||
|
send('network.error', {
|
||||||
|
method,
|
||||||
|
url: sanitizeUrl(requestUrl),
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
requestBody,
|
||||||
|
response: truncate(body),
|
||||||
|
durationMs: Math.round(performance.now() - started),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
} catch (err) {
|
||||||
|
send('network.error', {
|
||||||
|
method,
|
||||||
|
url: sanitizeUrl(url || ''),
|
||||||
|
requestBody,
|
||||||
|
message: truncate(err && err.message ? err.message : String(err)),
|
||||||
|
durationMs: Math.round(performance.now() - started),
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const NativeXMLHttpRequest = window.XMLHttpRequest;
|
||||||
|
window.XMLHttpRequest = function SessionJournalXMLHttpRequest() {
|
||||||
|
const xhr = new NativeXMLHttpRequest();
|
||||||
|
let method = 'GET';
|
||||||
|
let url = '';
|
||||||
|
let requestBody = null;
|
||||||
|
let started = 0;
|
||||||
|
|
||||||
|
const originalOpen = xhr.open;
|
||||||
|
xhr.open = function (nextMethod, nextUrl, ...rest) {
|
||||||
|
method = String(nextMethod || 'GET').toUpperCase();
|
||||||
|
url = String(nextUrl || '');
|
||||||
|
return originalOpen.call(xhr, nextMethod, nextUrl, ...rest);
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalSend = xhr.send;
|
||||||
|
xhr.send = function (body) {
|
||||||
|
started = performance.now();
|
||||||
|
requestBody = summarizeRequestBody(body);
|
||||||
|
return originalSend.call(xhr, body);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.addEventListener('loadend', () => {
|
||||||
|
if (xhr.status >= 400 || xhr.status === 0) {
|
||||||
|
let response = '';
|
||||||
|
try { response = xhr.responseText || ''; } catch {}
|
||||||
|
send('network.error', {
|
||||||
|
transport: 'xhr',
|
||||||
|
method,
|
||||||
|
url: sanitizeUrl(url),
|
||||||
|
status: xhr.status,
|
||||||
|
statusText: xhr.statusText,
|
||||||
|
requestBody,
|
||||||
|
response: truncate(response),
|
||||||
|
durationMs: Math.round(performance.now() - started),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return xhr;
|
||||||
|
};
|
||||||
|
window.XMLHttpRequest.prototype = NativeXMLHttpRequest.prototype;
|
||||||
|
|
||||||
|
send('load', { url: location.href, title: document.title });
|
||||||
|
|
||||||
|
const origPush = history.pushState;
|
||||||
|
history.pushState = function (...args) {
|
||||||
|
const result = origPush.apply(this, args);
|
||||||
|
send('navigate', { url: location.href, via: 'pushState' });
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const origReplace = history.replaceState;
|
||||||
|
history.replaceState = function (...args) {
|
||||||
|
const result = origReplace.apply(this, args);
|
||||||
|
send('navigate', { url: location.href, via: 'replaceState' });
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
window.addEventListener('popstate', () => {
|
||||||
|
send('navigate', { url: location.href, via: 'popstate' });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const level of ['error', 'warn']) {
|
||||||
|
const original = console[level];
|
||||||
|
console[level] = (...args) => {
|
||||||
|
try {
|
||||||
|
const text = args.map((arg) => {
|
||||||
|
if (arg instanceof Error) {
|
||||||
|
return arg.stack || arg.message;
|
||||||
|
}
|
||||||
|
if (typeof arg === 'object') {
|
||||||
|
try { return JSON.stringify(arg); } catch { return String(arg); }
|
||||||
|
}
|
||||||
|
return String(arg);
|
||||||
|
}).join(' ');
|
||||||
|
send('console.' + level, { text: truncate(text) });
|
||||||
|
} catch {}
|
||||||
|
original.apply(console, args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
send('window.error', {
|
||||||
|
message: truncate(e.message),
|
||||||
|
source: e.filename || null,
|
||||||
|
line: e.lineno || null,
|
||||||
|
col: e.colno || null,
|
||||||
|
stack: e.error && e.error.stack ? truncate(e.error.stack) : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
const reason = e.reason;
|
||||||
|
send('unhandledrejection', {
|
||||||
|
message: truncate(reason && reason.message ? reason.message : String(reason)),
|
||||||
|
stack: reason && reason.stack ? truncate(reason.stack) : null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const root = document.getElementById('root');
|
||||||
|
if (root) {
|
||||||
|
let blankTimer = null;
|
||||||
|
const check = () => {
|
||||||
|
if (root.children.length === 0) {
|
||||||
|
if (!blankTimer) {
|
||||||
|
blankTimer = setTimeout(() => {
|
||||||
|
send('root.empty', { url: location.href });
|
||||||
|
blankTimer = null;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
} else if (blankTimer) {
|
||||||
|
clearTimeout(blankTimer);
|
||||||
|
blankTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
new MutationObserver(check).observe(root, { childList: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const VIRTUAL_ID = 'virtual:session-journal-client';
|
||||||
|
const RESOLVED_ID = '\0' + VIRTUAL_ID;
|
||||||
|
const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const CLIENT_PATH = path.resolve(PLUGIN_DIR, 'session-journal-client.js');
|
||||||
|
const LOG_PATH = path.resolve(
|
||||||
|
PLUGIN_DIR,
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'vault',
|
||||||
|
'temp',
|
||||||
|
'SESSION_JOURNAL.md',
|
||||||
|
);
|
||||||
|
const MAX_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
const d = new Date(ts || Date.now());
|
||||||
|
return d.toISOString().replace('T', ' ').slice(0, 23) + 'Z';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntry(event) {
|
||||||
|
const { kind, t, ...rest } = event || {};
|
||||||
|
const head = `## ${formatTime(t)} ${kind || 'unknown'}`;
|
||||||
|
const lines = [head];
|
||||||
|
for (const [key, value] of Object.entries(rest)) {
|
||||||
|
if (value == null || value === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
lines.push(`- ${key}: ${JSON.stringify(value)}`);
|
||||||
|
} catch {
|
||||||
|
lines.push(`- ${key}: [unserializable]`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const text = String(value);
|
||||||
|
lines.push(`- ${key}: ${text.includes('\n') ? '\n ' + text.replace(/\n/g, '\n ') : text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lines.join('\n') + '\n\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appendWithRotation(text) {
|
||||||
|
try {
|
||||||
|
await fs.mkdir(path.dirname(LOG_PATH), { recursive: true });
|
||||||
|
await fs.appendFile(LOG_PATH, text);
|
||||||
|
const { size } = await fs.stat(LOG_PATH);
|
||||||
|
if (size > MAX_BYTES) {
|
||||||
|
const content = await fs.readFile(LOG_PATH, 'utf8');
|
||||||
|
const keep = content.slice(-Math.floor(MAX_BYTES / 2));
|
||||||
|
await fs.writeFile(
|
||||||
|
LOG_PATH,
|
||||||
|
'# SESSION_JOURNAL.md (rotated - earlier entries trimmed)\n\n' + keep,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The journal should never break the app dev server.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function sessionJournalPlugin() {
|
||||||
|
return {
|
||||||
|
name: 'session-journal',
|
||||||
|
apply: 'serve',
|
||||||
|
|
||||||
|
resolveId(id) {
|
||||||
|
if (id === VIRTUAL_ID) {
|
||||||
|
return RESOLVED_ID;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
load(id) {
|
||||||
|
if (id === RESOLVED_ID) {
|
||||||
|
return fs.readFile(CLIENT_PATH, 'utf-8');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
transformIndexHtml(html) {
|
||||||
|
return html.replace(
|
||||||
|
'</head>',
|
||||||
|
` <script type="module" src="/@id/${VIRTUAL_ID}"></script>\n</head>`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
configureServer(server) {
|
||||||
|
server.ws.on('session-journal:event', (payload) => {
|
||||||
|
const events = Array.isArray(payload?.batch) ? payload.batch : [payload];
|
||||||
|
let text = '';
|
||||||
|
for (const event of events) {
|
||||||
|
text += formatEntry(event);
|
||||||
|
}
|
||||||
|
if (text) {
|
||||||
|
void appendWithRotation(text);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const SITE_PAGES_ENDPOINT = '/__horizons/site-pages';
|
||||||
|
|
||||||
|
const OUTGOING_SITE_PAGES_MESSAGE = 'sitePages';
|
||||||
|
const INCOMING_REQUEST_SITE_PAGES_MESSAGE = 'request-site-pages';
|
||||||
|
|
||||||
|
const ALLOWED_PARENT_ORIGINS = [
|
||||||
|
'https://horizons.hostinger.com',
|
||||||
|
'https://horizons.hostinger.dev',
|
||||||
|
'https://horizons-frontend-local.hostinger.dev',
|
||||||
|
'http://localhost:4000',
|
||||||
|
];
|
||||||
|
|
||||||
|
function postSitePages(pages) {
|
||||||
|
let parentOrigin = window.location.ancestorOrigins?.[0];
|
||||||
|
if (!parentOrigin && document.referrer) {
|
||||||
|
try {
|
||||||
|
parentOrigin = new URL(document.referrer).origin;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (parentOrigin && ALLOWED_PARENT_ORIGINS.includes(parentOrigin)) {
|
||||||
|
window.parent.postMessage({ type: OUTGOING_SITE_PAGES_MESSAGE, payload: { pages } }, parentOrigin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendSitePagesToParent() {
|
||||||
|
if (window.self === window.top) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(SITE_PAGES_ENDPOINT);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
postSitePages(await response.json());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[site-pages] Failed to send site pages to parent:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.self !== window.top) {
|
||||||
|
window.addEventListener('load', sendSitePagesToParent);
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
if (event.data?.type === INCOMING_REQUEST_SITE_PAGES_MESSAGE) {
|
||||||
|
sendSitePagesToParent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
import traverseBabel from '@babel/traverse';
|
||||||
|
import * as t from '@babel/types';
|
||||||
|
|
||||||
|
const ECOMMERCE_API_RELATIVE_PATH = '../../src/api/EcommerceApi.js';
|
||||||
|
|
||||||
|
const HOME_ROUTE_LABEL = 'Main';
|
||||||
|
const PRODUCT_PAGE_FETCH_LIMIT = 100;
|
||||||
|
const MAX_PRODUCT_PAGES = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the tag name of a JSX element, unwrapping member expressions (e.g. `Foo.Bar` → `Bar`).
|
||||||
|
*
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement['name']} nameNode
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function getJsxElementName(nameNode) {
|
||||||
|
if (t.isJSXIdentifier(nameNode)) {
|
||||||
|
return nameNode.name;
|
||||||
|
}
|
||||||
|
if (t.isJSXMemberExpression(nameNode) && t.isJSXIdentifier(nameNode.property)) {
|
||||||
|
return nameNode.property.name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a string literal from an attribute value, including one wrapped in a JSX expression container.
|
||||||
|
*
|
||||||
|
* @param {import('@babel/types').Node|null|undefined} value
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function getStringLiteral(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
if (t.isStringLiteral(value)) return value.value;
|
||||||
|
if (t.isJSXExpressionContainer(value) && t.isStringLiteral(value.expression)) {
|
||||||
|
return value.expression.value;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a JSX element from an attribute value, including one wrapped in a JSX expression container.
|
||||||
|
*
|
||||||
|
* @param {import('@babel/types').Node|null|undefined} value
|
||||||
|
* @returns {import('@babel/types').JSXElement|null}
|
||||||
|
*/
|
||||||
|
function getJsxElement(value) {
|
||||||
|
if (t.isJSXElement(value)) return value;
|
||||||
|
if (t.isJSXExpressionContainer(value) && t.isJSXElement(value.expression)) {
|
||||||
|
return value.expression;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts the `path` and element component name from a `<Route>` opening element.
|
||||||
|
*
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||||
|
* @returns {{ routePath: string, componentName: string }|null}
|
||||||
|
*/
|
||||||
|
function parseRoute(openingNode) {
|
||||||
|
if (getJsxElementName(openingNode.name) !== 'Route') return null;
|
||||||
|
|
||||||
|
const pathAttr = openingNode.attributes.find((a) => t.isJSXAttribute(a) && a.name?.name === 'path');
|
||||||
|
const elementAttr = openingNode.attributes.find((a) => t.isJSXAttribute(a) && a.name?.name === 'element');
|
||||||
|
|
||||||
|
const routePath = getStringLiteral(pathAttr?.value);
|
||||||
|
if (routePath === null) return null;
|
||||||
|
|
||||||
|
const element = getJsxElement(elementAttr?.value);
|
||||||
|
const componentName = element && getJsxElementName(element.openingElement.name);
|
||||||
|
if (!componentName) return null;
|
||||||
|
|
||||||
|
return { routePath, componentName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps default import local names to their module source for one import declaration.
|
||||||
|
*
|
||||||
|
* @param {import('@babel/types').ImportDeclaration} importNode
|
||||||
|
* @returns {Record<string, string>}
|
||||||
|
*/
|
||||||
|
function parseImports(importNode) {
|
||||||
|
const source = importNode.source.value;
|
||||||
|
return Object.fromEntries(
|
||||||
|
importNode.specifiers
|
||||||
|
.filter(t.isImportDefaultSpecifier)
|
||||||
|
.map((specifier) => [specifier.local.name, source]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the text of the `<title>` inside a `<Helmet>` element, if present.
|
||||||
|
*
|
||||||
|
* @param {import('@babel/types').JSXElement} jsxElement
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function parseHelmetTitle(jsxElement) {
|
||||||
|
if (getJsxElementName(jsxElement.openingElement.name) !== 'Helmet') return null;
|
||||||
|
|
||||||
|
for (const child of jsxElement.children) {
|
||||||
|
if (!t.isJSXElement(child) || getJsxElementName(child.openingElement.name) !== 'title') continue;
|
||||||
|
|
||||||
|
const titleChild = child.children[0];
|
||||||
|
if (t.isJSXText(titleChild)) return titleChild.value.trim();
|
||||||
|
if (t.isJSXExpressionContainer(titleChild) && t.isStringLiteral(titleChild.expression)) {
|
||||||
|
return titleChild.expression.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans one file's AST. Router files yield routes + imports; page files yield helmetTitle.
|
||||||
|
*
|
||||||
|
* @param {import('@babel/types').File} ast
|
||||||
|
* @returns {{ routes: Array<{ routePath: string, componentName: string }>, imports: Record<string, string>, helmetTitle: string|null, usesEcommerceApi: boolean }}
|
||||||
|
*/
|
||||||
|
export function scanFile(ast) {
|
||||||
|
const routes = [];
|
||||||
|
const imports = {};
|
||||||
|
let helmetTitle = null;
|
||||||
|
let usesEcommerceApi = false;
|
||||||
|
|
||||||
|
(traverseBabel.default)(ast, {
|
||||||
|
enter(nodePath) {
|
||||||
|
if (nodePath.isJSXOpeningElement()) {
|
||||||
|
const route = parseRoute(nodePath.node);
|
||||||
|
if (route) routes.push(route);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nodePath.isImportDeclaration()) {
|
||||||
|
Object.assign(imports, parseImports(nodePath.node));
|
||||||
|
if (nodePath.node.source.value.includes('EcommerceApi')) {
|
||||||
|
usesEcommerceApi = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nodePath.isJSXElement()) {
|
||||||
|
const title = parseHelmetTitle(nodePath.node);
|
||||||
|
if (title) helmetTitle = title;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { routes, imports, helmetTitle, usesEcommerceApi };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges one file's scan result into the running index, replacing any previous
|
||||||
|
* entries contributed by that same file.
|
||||||
|
*
|
||||||
|
* @param {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }} index
|
||||||
|
* @param {string} fileId
|
||||||
|
* @param {{ routes: Array<{ routePath: string, componentName: string }>, imports: Record<string, string>, helmetTitle: string|null, usesEcommerceApi: boolean }} scan
|
||||||
|
* @returns {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }}
|
||||||
|
*/
|
||||||
|
export function upsertFile(index, fileId, scan) {
|
||||||
|
const routerEntries = [
|
||||||
|
...index.routerEntries.filter((entry) => entry.routerFileId !== fileId),
|
||||||
|
...scan.routes.map(({ routePath, componentName }) => ({
|
||||||
|
routePath,
|
||||||
|
componentName,
|
||||||
|
routerFileId: fileId,
|
||||||
|
imports: scan.imports,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
const titlesByPageFile = Object.fromEntries(
|
||||||
|
Object.entries(index.titlesByPageFile).filter(([key]) => key !== fileId),
|
||||||
|
);
|
||||||
|
if (scan.helmetTitle) {
|
||||||
|
titlesByPageFile[fileId] = scan.helmetTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ecommercePageFiles = Object.fromEntries(
|
||||||
|
Object.entries(index.ecommercePageFiles).filter(([key]) => key !== fileId),
|
||||||
|
);
|
||||||
|
if (scan.usesEcommerceApi) {
|
||||||
|
ecommercePageFiles[fileId] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { routerEntries, titlesByPageFile, ecommercePageFiles };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chooses a display label for a route: the home label for `/`, otherwise the
|
||||||
|
* helmet title when available, falling back to the component name.
|
||||||
|
*
|
||||||
|
* @param {string} routePath
|
||||||
|
* @param {string} componentName
|
||||||
|
* @param {string|null} helmetTitle
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function resolvePageLabel(routePath, componentName, helmetTitle) {
|
||||||
|
if (routePath === '/') return HOME_ROUTE_LABEL;
|
||||||
|
return helmetTitle ?? componentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitutes the first dynamic segment of a route with a concrete value.
|
||||||
|
* The param name is derived from the route (e.g. ":slug", ":productId"), not
|
||||||
|
* assumed to be ":id", since route params are authored by generated code.
|
||||||
|
*
|
||||||
|
* @param {string} routePath
|
||||||
|
* @param {string} productId
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function buildProductPath(routePath, productId) {
|
||||||
|
const paramToken = routePath.match(/:[^/]+/)?.[0];
|
||||||
|
return paramToken ? routePath.replace(paramToken, productId) : routePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expands a dynamic product route into one concrete page entry per product.
|
||||||
|
*
|
||||||
|
* @param {Array<{ id: string, title: string }>} products
|
||||||
|
* @param {string} routePath
|
||||||
|
* @param {string} componentName
|
||||||
|
* @returns {Array<{ path: string, label: string, helmetTitle: string, component: string, dynamic: true }>}
|
||||||
|
*/
|
||||||
|
export function mapProductsToPages(products, routePath, componentName) {
|
||||||
|
return products.map((product) => ({
|
||||||
|
path: buildProductPath(routePath, product.id),
|
||||||
|
label: product.title,
|
||||||
|
helmetTitle: product.title,
|
||||||
|
component: componentName,
|
||||||
|
dynamic: true,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches every product by paging the store API until a short page is returned,
|
||||||
|
* bounded by MAX_PRODUCT_PAGES. EcommerceApi is imported lazily so non-ecommerce
|
||||||
|
* sites never resolve a module that does not exist for them.
|
||||||
|
*
|
||||||
|
* Import via absolute file URL: after Vite bundles this plugin into
|
||||||
|
* vite.config.js.timestamp-*.mjs, relative imports resolve against the bundle
|
||||||
|
* path instead of the original plugin location.
|
||||||
|
*
|
||||||
|
* @param {string} ecommerceApiPath Absolute filesystem path to EcommerceApi.js
|
||||||
|
* @returns {Promise<Array<{ id: string, title: string }>>}
|
||||||
|
*/
|
||||||
|
async function fetchAllProducts(ecommerceApiPath) {
|
||||||
|
const { getProducts } = await import(pathToFileURL(ecommerceApiPath).href);
|
||||||
|
|
||||||
|
const collected = [];
|
||||||
|
for (let page = 0; page < MAX_PRODUCT_PAGES; page++) {
|
||||||
|
const { products = [] } = await getProducts({
|
||||||
|
limit: PRODUCT_PAGE_FETCH_LIMIT,
|
||||||
|
offset: page * PRODUCT_PAGE_FETCH_LIMIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
collected.push(...products);
|
||||||
|
|
||||||
|
if (products.length < PRODUCT_PAGE_FETCH_LIMIT) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the flat list of site pages from the scanned index, resolving each route's
|
||||||
|
* page module and expanding ecommerce product routes. Returns only routes actually
|
||||||
|
* detected by the scan (no synthetic home fallback).
|
||||||
|
*
|
||||||
|
* @param {{ routerEntries: Array<{ routePath: string, componentName: string, routerFileId: string, imports: Record<string, string> }>, titlesByPageFile: Record<string, string>, ecommercePageFiles: Record<string, boolean> }} index
|
||||||
|
* @param {(specifier: string, importer: string) => Promise<{ id: string }|null>} resolveModule
|
||||||
|
* @param {string} projectRoot
|
||||||
|
* @returns {Promise<Array<{ path: string, label: string, helmetTitle: string|null, component: string|null, file?: string|null, dynamic?: true }>>}
|
||||||
|
*/
|
||||||
|
export async function buildPageList(index, resolveModule, projectRoot) {
|
||||||
|
const groups = await Promise.all(
|
||||||
|
index.routerEntries.map(async ({ routePath, componentName, routerFileId, imports }) => {
|
||||||
|
const moduleSpecifier = imports[componentName];
|
||||||
|
const resolved = moduleSpecifier
|
||||||
|
? await resolveModule(moduleSpecifier, routerFileId)
|
||||||
|
: null;
|
||||||
|
const pageFileId = resolved?.id ?? null;
|
||||||
|
|
||||||
|
if (routePath.includes(':')) {
|
||||||
|
// Only ecommerce product routes are expanded into concrete pages.
|
||||||
|
// Other dynamic routes (e.g. blog/CMS detail pages) are intentionally
|
||||||
|
// skipped since we have no data source to enumerate their instances.
|
||||||
|
if (!pageFileId || !index.ecommercePageFiles[pageFileId]) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ecommerceApiPath = fileURLToPath(new URL(ECOMMERCE_API_RELATIVE_PATH, import.meta.url));
|
||||||
|
if (!existsSync(ecommerceApiPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const products = await fetchAllProducts(ecommerceApiPath);
|
||||||
|
|
||||||
|
return mapProductsToPages(products, routePath, componentName);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const helmetTitle = pageFileId ? (index.titlesByPageFile[pageFileId] ?? null) : null;
|
||||||
|
|
||||||
|
return [{
|
||||||
|
path: routePath,
|
||||||
|
label: resolvePageLabel(routePath, componentName, helmetTitle),
|
||||||
|
helmetTitle,
|
||||||
|
component: componentName,
|
||||||
|
file: pageFileId
|
||||||
|
? path.relative(projectRoot, pageFileId).split(path.sep).join('/')
|
||||||
|
: null,
|
||||||
|
}];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return groups.flat();
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import generate from '@babel/generator';
|
||||||
|
import { parse } from '@babel/parser';
|
||||||
|
import traverseBabel from '@babel/traverse';
|
||||||
|
import {
|
||||||
|
isJSXIdentifier,
|
||||||
|
isJSXMemberExpression,
|
||||||
|
} from '@babel/types';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const VITE_PROJECT_ROOT = path.resolve(__dirname, '../../../..');
|
||||||
|
|
||||||
|
// Blacklist of components that should not be extracted (utility/non-visual components)
|
||||||
|
const COMPONENT_BLACKLIST = new Set([
|
||||||
|
'Helmet',
|
||||||
|
'HelmetProvider',
|
||||||
|
'Head',
|
||||||
|
'head',
|
||||||
|
'Meta',
|
||||||
|
'meta',
|
||||||
|
'Script',
|
||||||
|
'script',
|
||||||
|
'NoScript',
|
||||||
|
'noscript',
|
||||||
|
'Style',
|
||||||
|
'style',
|
||||||
|
'title',
|
||||||
|
'Title',
|
||||||
|
'link',
|
||||||
|
'Link',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a file path is safe to access
|
||||||
|
* @param {string} filePath - Relative file path
|
||||||
|
* @returns {{ isValid: boolean, absolutePath?: string, error?: string }} - Object containing validation result
|
||||||
|
*/
|
||||||
|
export function validateFilePath(filePath) {
|
||||||
|
if (!filePath) {
|
||||||
|
return { isValid: false, error: 'Missing filePath' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteFilePath = path.resolve(VITE_PROJECT_ROOT, filePath);
|
||||||
|
|
||||||
|
if (filePath.includes('..')
|
||||||
|
|| !absoluteFilePath.startsWith(VITE_PROJECT_ROOT)
|
||||||
|
|| absoluteFilePath.includes('node_modules')) {
|
||||||
|
return { isValid: false, error: 'Invalid path' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(absoluteFilePath)) {
|
||||||
|
return { isValid: false, error: 'File not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isValid: true, absolutePath: absoluteFilePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a file into a Babel AST
|
||||||
|
* @param {string} absoluteFilePath - Absolute path to file
|
||||||
|
* @returns {object} Babel AST
|
||||||
|
*/
|
||||||
|
export function parseFileToAST(absoluteFilePath) {
|
||||||
|
const content = fs.readFileSync(absoluteFilePath, 'utf-8');
|
||||||
|
|
||||||
|
return parse(content, {
|
||||||
|
sourceType: 'module',
|
||||||
|
plugins: ['jsx', 'typescript'],
|
||||||
|
errorRecovery: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a JSX opening element at a specific line and column
|
||||||
|
* @param {object} ast - Babel AST
|
||||||
|
* @param {number} line - Line number (1-indexed)
|
||||||
|
* @param {number} column - Column number (0-indexed for get-code-block, 1-indexed for apply-edit)
|
||||||
|
* @returns {object | null} Babel path to the JSX opening element
|
||||||
|
*/
|
||||||
|
export function findJSXElementAtPosition(ast, line, column) {
|
||||||
|
let targetNodePath = null;
|
||||||
|
let closestNodePath = null;
|
||||||
|
let closestDistance = Infinity;
|
||||||
|
const allNodesOnLine = [];
|
||||||
|
|
||||||
|
const visitor = {
|
||||||
|
JSXOpeningElement(path) {
|
||||||
|
const node = path.node;
|
||||||
|
if (node.loc) {
|
||||||
|
// Exact match (with tolerance for off-by-one column differences)
|
||||||
|
if (node.loc.start.line === line
|
||||||
|
&& Math.abs(node.loc.start.column - column) <= 1) {
|
||||||
|
targetNodePath = path;
|
||||||
|
path.stop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track all nodes on the same line
|
||||||
|
if (node.loc.start.line === line) {
|
||||||
|
allNodesOnLine.push({
|
||||||
|
path,
|
||||||
|
column: node.loc.start.column,
|
||||||
|
distance: Math.abs(node.loc.start.column - column),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track closest match on the same line for fallback
|
||||||
|
if (node.loc.start.line === line) {
|
||||||
|
const distance = Math.abs(node.loc.start.column - column);
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closestNodePath = path;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Also check JSXElement nodes that contain the position
|
||||||
|
JSXElement(path) {
|
||||||
|
const node = path.node;
|
||||||
|
if (!node.loc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this element spans the target line (for multi-line elements)
|
||||||
|
if (node.loc.start.line > line || node.loc.end.line < line) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're inside this element's range, consider its opening element
|
||||||
|
if (!path.node.openingElement?.loc) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const openingLine = path.node.openingElement.loc.start.line;
|
||||||
|
const openingCol = path.node.openingElement.loc.start.column;
|
||||||
|
|
||||||
|
// Prefer elements that start on the exact line
|
||||||
|
if (openingLine === line) {
|
||||||
|
const distance = Math.abs(openingCol - column);
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closestNodePath = path.get('openingElement');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle elements that start before the target line
|
||||||
|
if (openingLine < line) {
|
||||||
|
const distance = (line - openingLine) * 100; // Penalize by line distance
|
||||||
|
if (distance < closestDistance) {
|
||||||
|
closestDistance = distance;
|
||||||
|
closestNodePath = path.get('openingElement');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const traverseFunction = traverseBabel.default || traverseBabel;
|
||||||
|
traverseFunction(ast, visitor);
|
||||||
|
|
||||||
|
// Return exact match if found, otherwise return closest match if within reasonable distance
|
||||||
|
// Use larger threshold (50 chars) for same-line elements, 5 lines for multi-line elements
|
||||||
|
const threshold = closestDistance < 100 ? 50 : 500;
|
||||||
|
return targetNodePath || (closestDistance <= threshold ? closestNodePath : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a JSX element name is blacklisted
|
||||||
|
* @param {object} jsxOpeningElement - Babel JSX opening element node
|
||||||
|
* @returns {boolean} True if blacklisted
|
||||||
|
*/
|
||||||
|
function isBlacklistedComponent(jsxOpeningElement) {
|
||||||
|
if (!jsxOpeningElement || !jsxOpeningElement.name) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle JSXIdentifier (e.g., <Helmet>)
|
||||||
|
if (isJSXIdentifier(jsxOpeningElement.name)) {
|
||||||
|
return COMPONENT_BLACKLIST.has(jsxOpeningElement.name.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle JSXMemberExpression (e.g., <React.Fragment>)
|
||||||
|
if (isJSXMemberExpression(jsxOpeningElement.name)) {
|
||||||
|
let current = jsxOpeningElement.name;
|
||||||
|
while (isJSXMemberExpression(current)) {
|
||||||
|
current = current.property;
|
||||||
|
}
|
||||||
|
if (isJSXIdentifier(current)) {
|
||||||
|
return COMPONENT_BLACKLIST.has(current.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates code from an AST node
|
||||||
|
* @param {object} node - Babel AST node
|
||||||
|
* @param {object} options - Generator options
|
||||||
|
* @returns {string} Generated code
|
||||||
|
*/
|
||||||
|
export function generateCode(node, options = {}) {
|
||||||
|
const generateFunction = generate.default || generate;
|
||||||
|
const output = generateFunction(node, options);
|
||||||
|
return output.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates a full source file from AST with source maps
|
||||||
|
* @param {object} ast - Babel AST
|
||||||
|
* @param {string} sourceFileName - Source file name for source map
|
||||||
|
* @param {string} originalCode - Original source code
|
||||||
|
* @returns {{code: string, map: object}} - Object containing generated code and source map
|
||||||
|
*/
|
||||||
|
export function generateSourceWithMap(ast, sourceFileName, originalCode) {
|
||||||
|
const generateFunction = generate.default || generate;
|
||||||
|
return generateFunction(ast, {
|
||||||
|
sourceMaps: true,
|
||||||
|
sourceFileName,
|
||||||
|
}, originalCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts code blocks from a JSX element at a specific location
|
||||||
|
* @param {string} filePath - Relative file path
|
||||||
|
* @param {number} line - Line number
|
||||||
|
* @param {number} column - Column number
|
||||||
|
* @param {object} [domContext] - Optional DOM context to return on failure
|
||||||
|
* @returns {{success: boolean, filePath?: string, specificLine?: string, error?: string, domContext?: object}} - Object with metadata for LLM
|
||||||
|
*/
|
||||||
|
export function extractCodeBlocks(filePath, line, column, domContext) {
|
||||||
|
try {
|
||||||
|
// Validate file path
|
||||||
|
const validation = validateFilePath(filePath);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
return { success: false, error: validation.error, domContext };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse AST
|
||||||
|
const ast = parseFileToAST(validation.absolutePath);
|
||||||
|
|
||||||
|
// Find target node
|
||||||
|
const targetNodePath = findJSXElementAtPosition(ast, line, column);
|
||||||
|
|
||||||
|
if (!targetNodePath) {
|
||||||
|
return { success: false, error: 'Target node not found at specified line/column', domContext };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the target node is a blacklisted component
|
||||||
|
const isBlacklisted = isBlacklistedComponent(targetNodePath.node);
|
||||||
|
|
||||||
|
if (isBlacklisted) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
specificLine: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get specific line code
|
||||||
|
const specificLine = generateCode(targetNodePath.parentPath?.node || targetNodePath.node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
filePath,
|
||||||
|
specificLine,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ast-utils] Error extracting code blocks:', error);
|
||||||
|
return { success: false, error: 'Failed to extract code blocks', domContext };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Project root path
|
||||||
|
*/
|
||||||
|
export { VITE_PROJECT_ROOT };
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Whether a dev-server API request came from the preview page itself.
|
||||||
|
*
|
||||||
|
* This is a cross-origin/CSRF control, NOT authorization. `Sec-Fetch-Site` is
|
||||||
|
* client-supplied and the preview URL needs no credentials, so a crafted request
|
||||||
|
* that sets the header still gets through. What it does stop: browser-driven
|
||||||
|
* cross-origin writes, and clients that send no fetch metadata at all.
|
||||||
|
*
|
||||||
|
* Browsers label the preview's own same-origin `fetch` calls
|
||||||
|
* `Sec-Fetch-Site: same-origin`. Hosts are compared rather than full origins
|
||||||
|
* because the request reaches Vite over plain HTTP behind the proxy, which
|
||||||
|
* forwards the original `Host`.
|
||||||
|
*
|
||||||
|
* @param {import('node:http').IncomingMessage} request
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isSameOriginRequest(request) {
|
||||||
|
const site = request.headers['sec-fetch-site'];
|
||||||
|
if (site) {
|
||||||
|
return site === 'same-origin';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-16.4 Safari omits fetch metadata but still sends `Origin` on POST.
|
||||||
|
const origin = request.headers.origin;
|
||||||
|
if (!origin) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return new URL(origin).host === request.headers.host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { exportDraftSnapshot, importDraftSnapshot, getEditState } from "../state/history-state.js";
|
||||||
|
import { exportAnnotationMarkers, importAnnotationMarkers } from "../ui/annotation-panel/annotation-markers.js";
|
||||||
|
import { ParentMessage } from "../constants/messages.js";
|
||||||
|
import { postToParent } from "../utils/parent-frame.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full draft snapshot for parent-frame persistence (history + annotations).
|
||||||
|
* @returns {{ history: ReturnType<typeof exportDraftSnapshot>, annotations: ReturnType<typeof exportAnnotationMarkers> }}
|
||||||
|
*/
|
||||||
|
export function buildDraftSnapshot() {
|
||||||
|
return {
|
||||||
|
history: exportDraftSnapshot(),
|
||||||
|
annotations: exportAnnotationMarkers(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restores a previously persisted draft into the live editor session.
|
||||||
|
* @param {{ history?: object, annotations?: object[] }|null|undefined} snapshot
|
||||||
|
* @param {{ onMarkerClick: (comment: object, clientX: number, clientY: number) => void }} options
|
||||||
|
*/
|
||||||
|
export function applyDraftSnapshot(snapshot, { onMarkerClick }) {
|
||||||
|
if (!snapshot || typeof snapshot !== "object") return;
|
||||||
|
|
||||||
|
importDraftSnapshot(snapshot.history);
|
||||||
|
importAnnotationMarkers(snapshot.annotations, onMarkerClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates parent with the draft on user action
|
||||||
|
* @param {string} [type] - A {@link ParentMessage} value.
|
||||||
|
* @param {object} [extraPayload]
|
||||||
|
*/
|
||||||
|
export function notifyDraftStateChanged(type = ParentMessage.EDIT_STATE_CHANGED, extraPayload) {
|
||||||
|
const { pathname, search, hash } = window.location;
|
||||||
|
|
||||||
|
postToParent(type, {
|
||||||
|
...getEditState(),
|
||||||
|
...extraPayload,
|
||||||
|
snapshot: buildDraftSnapshot(),
|
||||||
|
route: `${pathname}${search}${hash}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { ParentMessage } from "../constants/messages.js";
|
||||||
|
import { postToParent } from "../utils/parent-frame.js";
|
||||||
|
import { getEditing } from "../state/editing-state.js";
|
||||||
|
import { getCurrentEdits, clearHistory, getEditState, getActionPointer } from "../state/history-state.js";
|
||||||
|
import { applySessionUndo } from "../utils/session-history-command.js";
|
||||||
|
import { commitCurrentEdit } from "../ui/inline-edit/edit-action.js";
|
||||||
|
import { getComments, setComments } from "../state/annotation-state.js";
|
||||||
|
import { getEditId } from "../constants/selectors.js";
|
||||||
|
import { clearAllAnnotationMarkers } from "../ui/annotation-panel/annotation-markers.js";
|
||||||
|
import { hideAnnotationPanel } from "../ui/annotation-panel/panel.js";
|
||||||
|
import { resetMultiSelectState } from "../state/multi-select-state.js";
|
||||||
|
import { unlockHoverOutline } from "../ui/overlays/hover-outline.js";
|
||||||
|
|
||||||
|
/** Relative URL used to persist draft edits to the server. */
|
||||||
|
const DRAFT_SAVE_API_URL = "/api/draft-save";
|
||||||
|
/** Minimum duration (ms) the parent loading indicator stays visible during a direct-only draft save. */
|
||||||
|
const MIN_LOADING_DISPLAY_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls back every committed edit in the history stack and clears it, returning
|
||||||
|
* the document to its pre-edit state.
|
||||||
|
*/
|
||||||
|
export function discardAllEdits() {
|
||||||
|
while (getActionPointer() >= 0) {
|
||||||
|
applySessionUndo();
|
||||||
|
}
|
||||||
|
clearHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commits any active inline edit, rolls back all edits in the history stack,
|
||||||
|
* clears annotations, and notifies the parent frame that the draft was discarded.
|
||||||
|
*/
|
||||||
|
export function handleDraftDiscard() {
|
||||||
|
if (getEditing()?.targetElement?.hasAttribute("contenteditable")) {
|
||||||
|
commitCurrentEdit();
|
||||||
|
}
|
||||||
|
discardAllEdits();
|
||||||
|
hideAnnotationPanel();
|
||||||
|
clearAllAnnotationMarkers();
|
||||||
|
setComments([]);
|
||||||
|
resetMultiSelectState();
|
||||||
|
unlockHoverOutline();
|
||||||
|
postToParent(ParentMessage.DRAFT_DISCARDED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the current draft edits to the server.
|
||||||
|
*
|
||||||
|
* Commits any active contenteditable edit first, then POSTs the direct edits to
|
||||||
|
* `DRAFT_SAVE_API_URL`. Assisted edits and annotations are applied by the AI
|
||||||
|
* during the stream that follows, so a draft holding only those skips the
|
||||||
|
* request entirely.
|
||||||
|
*
|
||||||
|
* Parent frame messages emitted:
|
||||||
|
* - `DRAFT_SAVE_STARTED` — immediately on call
|
||||||
|
* - `DRAFT_APPLIED` — on success, includes server results and current edit state
|
||||||
|
* - `DRAFT_SAVE_FAILED` — on server error or network failure, includes error message
|
||||||
|
* - `DRAFT_SAVE_FINISHED` — always, delayed by `MIN_LOADING_DISPLAY_MS` for direct-only saves
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function handleDraftSave() {
|
||||||
|
if (getEditing()?.targetElement?.hasAttribute("contenteditable")) {
|
||||||
|
commitCurrentEdit();
|
||||||
|
}
|
||||||
|
postToParent(ParentMessage.DRAFT_SAVE_STARTED);
|
||||||
|
const startTime = Date.now();
|
||||||
|
let hasAssistedEdits = false;
|
||||||
|
try {
|
||||||
|
const allEdits = getCurrentEdits();
|
||||||
|
|
||||||
|
const draft = Object.fromEntries(
|
||||||
|
Object.entries(allEdits)
|
||||||
|
.filter(([, edit]) => !edit.isAssisted)
|
||||||
|
.map(([key, edit]) => {
|
||||||
|
const sourceEdit = { ...edit };
|
||||||
|
delete sourceEdit.editId;
|
||||||
|
return [key, sourceEdit];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const visualEdits = [
|
||||||
|
...Object.values(allEdits).map(edit => ({
|
||||||
|
editId: edit.editId,
|
||||||
|
selectionModes: [edit.selectionMode],
|
||||||
|
instruction: edit.instruction,
|
||||||
|
isAssisted: edit.isAssisted,
|
||||||
|
})),
|
||||||
|
...getComments().map(({ text, elements, selections, attachments }) => ({
|
||||||
|
editId: elements.length === 1 ? getEditId(elements[0]) || undefined : undefined,
|
||||||
|
selectionModes: selections,
|
||||||
|
instruction: {
|
||||||
|
changes: [],
|
||||||
|
annotation: text,
|
||||||
|
...(attachments?.length && {
|
||||||
|
attachments: attachments.map(({ url }) => ({ type: 'image', image: url })),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
isAssisted: true,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
hasAssistedEdits = visualEdits.some(edit => edit.isAssisted);
|
||||||
|
|
||||||
|
let result = { success: true, results: [] };
|
||||||
|
|
||||||
|
if (Object.keys(draft).length) {
|
||||||
|
const response = await fetch(DRAFT_SAVE_API_URL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ draft }),
|
||||||
|
});
|
||||||
|
|
||||||
|
result = await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
clearHistory();
|
||||||
|
hideAnnotationPanel();
|
||||||
|
clearAllAnnotationMarkers();
|
||||||
|
setComments([]);
|
||||||
|
resetMultiSelectState();
|
||||||
|
unlockHoverOutline();
|
||||||
|
postToParent(ParentMessage.DRAFT_APPLIED, {
|
||||||
|
results: result.results,
|
||||||
|
visualEdits,
|
||||||
|
...getEditState(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
postToParent(ParentMessage.DRAFT_SAVE_FAILED, { error: result.error });
|
||||||
|
console.error(`[vite][visual-editor] Error saving draft: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
postToParent(ParentMessage.DRAFT_SAVE_FAILED, { error: error.message });
|
||||||
|
console.error(`[vite][visual-editor] Error during draft save:`, error);
|
||||||
|
} finally {
|
||||||
|
if (!hasAssistedEdits) {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
const remaining = MIN_LOADING_DISPLAY_MS - elapsed;
|
||||||
|
if (remaining > 0) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, remaining));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
postToParent(ParentMessage.DRAFT_SAVE_FINISHED);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/** Small chevron-down icon for compact dropdown triggers. */
|
||||||
|
export const ICON_CHEVRON_DOWN_SMALL = `
|
||||||
|
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" aria-hidden="true">
|
||||||
|
<path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Chevron-down icon for standard dropdown triggers. */
|
||||||
|
export const ICON_CHEVRON_DOWN = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M12.0303 7.03033C12.3232 6.73744 12.3232 6.26256 12.0303 5.96967C11.7374 5.67678 11.2626 5.67678 10.9697 5.96967L8 8.93934L5.03033 5.96967C4.73744 5.67678 4.26256 5.67678 3.96967 5.96967C3.67678 6.26256 3.67678 6.73744 3.96967 7.03033L7.46967 10.5303C7.76256 10.8232 8.23744 10.8232 8.53033 10.5303L12.0303 7.03033Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Bold text-format toolbar icon. */
|
||||||
|
export const ICON_BOLD = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="10" viewBox="0 0 8 10" fill="none">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.03125 0.0146484C6.41781 0.155626 7.5 1.32626 7.5 2.75L7.48633 3.03125C7.42559 3.62924 7.17072 4.16858 6.78906 4.59082C7.52541 5.13749 8.00391 6.01245 8.00391 7L8 7.1543C7.92229 8.68818 6.69202 9.91814 5.1582 9.99609L5.00391 10H0.861328L0.773438 9.99512C0.370065 9.95414 0.0497858 9.63383 0.00878906 9.23047L0.00390625 9.14258V5.5H0V0.803711C2.4579e-05 0.387669 0.316487 0.045031 0.72168 0.00390625L0.803711 0H4.75L5.03125 0.0146484ZM1.50391 8.5H5.00391C5.83211 8.49974 6.50391 7.82826 6.50391 7C6.50391 6.17174 5.83211 5.50026 5.00391 5.5H1.50391V8.5ZM1.5 4H4.75C5.39701 3.99981 5.9301 3.5081 5.99414 2.87793L6 2.75C6 2.05977 5.44019 1.5002 4.75 1.5H1.5V4Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Italic text-format toolbar icon. */
|
||||||
|
export const ICON_ITALIC = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M8.45775 5C8.92563 5 9.27389 5.43265 9.17357 5.88965L7.62865 12.9248C7.55475 13.2606 7.25764 13.4999 6.9138 13.5C6.44592 13.5 6.09766 13.0673 6.19798 12.6104L7.7429 5.5752C7.8168 5.23941 8.11391 5.0001 8.45775 5Z" fill="white"/>
|
||||||
|
<path d="M9.00072 2.5C9.4148 2.50015 9.75072 2.83588 9.75072 3.25C9.75072 3.66412 9.4148 3.99985 9.00072 4C8.5865 4 8.25072 3.66421 8.25072 3.25C8.25072 2.83579 8.5865 2.5 9.00072 2.5Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Underline text-format toolbar icon. */
|
||||||
|
export const ICON_UNDERLINE = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M12.5771 12.5039C12.9551 12.5425 13.25 12.8618 13.25 13.25C13.25 13.6382 12.9551 13.9575 12.5771 13.9961L12.5 14H3.5C3.08579 14 2.75 13.6642 2.75 13.25C2.75 12.8358 3.08579 12.5 3.5 12.5H12.5L12.5771 12.5039Z" fill="white"/>
|
||||||
|
<path d="M4.75 2.74316C5.16421 2.74316 5.5 3.07895 5.5 3.49316V7.74609L5.5127 8.00098C5.64042 9.2608 6.70376 10.2445 7.99707 10.2461L8.25293 10.2334C9.43054 10.1151 10.3676 9.17942 10.4873 8.00195L10.5 7.74609V3.50098C10.5 3.08676 10.8358 2.75098 11.25 2.75098C11.6642 2.75098 12 3.08676 12 3.50098V7.74609L11.9951 7.95215C11.8912 9.99912 10.2483 11.6398 8.20117 11.7412L7.99512 11.7461C5.85703 11.7435 4.11219 10.0635 4.00488 7.95215L4 7.74609V3.49316C4 3.07895 4.33579 2.74316 4.75 2.74316Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Align-left text-format toolbar icon. */
|
||||||
|
export const ICON_ALIGN_LEFT = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M10.5771 11.2539C10.9551 11.2925 11.25 11.6118 11.25 12C11.25 12.3882 10.9551 12.7075 10.5771 12.7461L10.5 12.75H3.5C3.08579 12.75 2.75 12.4142 2.75 12C2.75 11.5858 3.08579 11.25 3.5 11.25H10.5L10.5771 11.2539Z" fill="white"/>
|
||||||
|
<path d="M7.5 7.25C7.91421 7.25 8.25 7.58579 8.25 8C8.25 8.41421 7.91421 8.75 7.5 8.75H3.5C3.08579 8.75 2.75 8.41421 2.75 8C2.75 7.58579 3.08579 7.25 3.5 7.25H7.5Z" fill="white"/>
|
||||||
|
<path d="M13.5 3.25C13.9142 3.25007 14.25 3.58583 14.25 4C14.25 4.41417 13.9142 4.74993 13.5 4.75H3.5C3.08579 4.75 2.75 4.41421 2.75 4C2.75 3.58579 3.08579 3.25 3.5 3.25H13.5Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** “More options” toolbar icon (line height / extras). */
|
||||||
|
export const ICON_MORE = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M12.5 12.5C12.9142 12.5 13.25 12.8358 13.25 13.25C13.25 13.6642 12.9142 14 12.5 14H3.5C3.08579 14 2.75 13.6642 2.75 13.25C2.75 12.8358 3.08579 12.5 3.5 12.5H12.5Z" fill="white"/>
|
||||||
|
<path d="M8.03027 1.60755C8.334 1.60858 8.60681 1.79314 8.72168 2.07435L12.1943 10.5743C12.3507 10.9577 12.1665 11.3953 11.7832 11.5519C11.3998 11.7083 10.9623 11.5241 10.8057 11.1408L8.02148 4.32728L5.19238 11.1447C5.03363 11.5272 4.59541 11.7086 4.21289 11.5499C3.83039 11.3912 3.64899 10.953 3.80762 10.5704L7.33496 2.07044C7.45146 1.78974 7.72636 1.6067 8.03027 1.60755Z" fill="white"/>
|
||||||
|
<path d="M10.0863 7.25C10.5004 7.25013 10.8363 7.58587 10.8363 8C10.8363 8.41413 10.5004 8.74987 10.0863 8.75H5.87732C5.4631 8.75 5.12732 8.41421 5.12732 8C5.12732 7.58579 5.4631 7.25 5.87732 7.25H10.0863Z" fill="white"/>
|
||||||
|
<path d="M12.7811 1.19129C12.6805 0.936238 12.3195 0.936238 12.2189 1.19129L11.6936 2.52336C11.6629 2.60122 11.6012 2.66286 11.5234 2.69357L10.1913 3.21893C9.93624 3.31952 9.93624 3.68048 10.1913 3.78107L11.5234 4.30643C11.6012 4.33714 11.6629 4.39878 11.6936 4.47664L12.2189 5.80871C12.3195 6.06376 12.6805 6.06376 12.7811 5.80872L13.3064 4.47664C13.3371 4.39878 13.3988 4.33714 13.4766 4.30643L14.8087 3.78107C15.0638 3.68048 15.0638 3.31952 14.8087 3.21893L13.4766 2.69357C13.3988 2.66286 13.3371 2.60122 13.3064 2.52336L12.7811 1.19129Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Link insertion toolbar icon. */
|
||||||
|
export const ICON_LINK = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.82058 2.51974C9.38336 0.956963 11.9171 0.956963 13.4799 2.51974C15.0427 4.08251 15.0427 6.61627 13.4799 8.17904L12.2356 9.42334C11.9427 9.71623 11.4678 9.71623 11.1749 9.42334C10.882 9.13044 10.882 8.65557 11.1749 8.36268L12.4192 7.11838C13.3962 6.1414 13.3962 4.55738 12.4192 3.5804C11.4422 2.60341 9.85823 2.60341 8.88124 3.5804L7.63695 4.82469C7.34406 5.11758 6.86918 5.11758 6.57629 4.82469C6.2834 4.5318 6.2834 4.05692 6.57629 3.76403L7.82058 2.51974ZM4.80405 6.59693C5.09695 6.88982 5.09695 7.3647 4.80405 7.65759L3.5804 8.88124C2.60341 9.85823 2.60341 11.4422 3.5804 12.4192C4.55739 13.3962 6.1414 13.3962 7.11838 12.4192L8.34204 11.1956C8.63493 10.9027 9.10981 10.9027 9.4027 11.1956C9.69559 11.4885 9.69559 11.9633 9.4027 12.2562L8.17904 13.4799C6.61627 15.0427 4.08251 15.0427 2.51974 13.4799C0.956964 11.9171 0.956962 9.38336 2.51974 7.82058L3.74339 6.59693C4.03629 6.30404 4.51116 6.30403 4.80405 6.59693Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.6946 5.36582C10.9875 5.65871 10.9875 6.13358 10.6946 6.42648L6.12342 10.9976C5.83053 11.2905 5.35566 11.2905 5.06276 10.9976C4.76987 10.7047 4.76987 10.2299 5.06276 9.93698L9.63393 5.36582C9.92682 5.07292 10.4017 5.07292 10.6946 5.36582Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Delete/trash icon for removing edits. */
|
||||||
|
export const ICON_DELETE = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<g transform="translate(1.85 1.085)">
|
||||||
|
<path d="M5.55 5.25C5.55 4.83579 5.21421 4.5 4.8 4.5C4.38579 4.5 4.05 4.83579 4.05 5.25V10.2C4.05 10.6142 4.38579 10.95 4.8 10.95C5.21421 10.95 5.55 10.6142 5.55 10.2V5.25Z" fill="white"/>
|
||||||
|
<path d="M7.5 4.5C7.91421 4.5 8.25 4.83579 8.25 5.25V10.2C8.25 10.6142 7.91421 10.95 7.5 10.95C7.08579 10.95 6.75 10.6142 6.75 10.2V5.25C6.75 4.83579 7.08579 4.5 7.5 4.5Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.35623 0C4.73126 0 4.15992 0.353103 3.88043 0.912097L3.39085 1.89125L0.750363 1.88998C0.336149 1.88978 0.000200302 2.2254 8.95287e-08 2.63962C-0.000200122 3.05383 0.335424 3.38978 0.749637 3.38998L1.20364 3.3902L1.74954 12.2812C1.80298 13.1515 2.52443 13.83 3.39644 13.83H8.90357C9.77557 13.83 10.497 13.1515 10.5505 12.2812L11.0962 3.39498L11.5496 3.3952C11.9639 3.3954 12.2998 3.05977 12.3 2.64556C12.3002 2.23135 11.9646 1.8954 11.5504 1.8952L8.91049 1.89392L8.41957 0.912098C8.14008 0.353104 7.56874 0 6.94377 0H5.35623ZM5.22207 1.58292C5.24748 1.5321 5.29941 1.5 5.35623 1.5H6.94377C7.00059 1.5 7.05253 1.5321 7.07793 1.58292L7.18647 1.8H5.11353L5.22207 1.58292ZM2.70651 3.39092L9.59338 3.39425L9.05329 12.1892C9.04843 12.2684 8.98284 12.33 8.90357 12.33H3.39644C3.31716 12.33 3.25158 12.2684 3.24672 12.1892L2.70651 3.39092Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Close/dismiss icon for panels and popovers. */
|
||||||
|
export const ICON_CLOSE = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path transform="translate(3.75 3.75)" d="M0.219671 1.28033C-0.0732222 0.987437 -0.0732222 0.512563 0.219671 0.21967C0.512564 -0.0732233 0.987438 -0.0732233 1.28033 0.21967L4.25 3.18934L7.21967 0.21967C7.51256 -0.0732232 7.98744 -0.0732232 8.28033 0.21967C8.57322 0.512563 8.57322 0.987437 8.28033 1.28033L5.31066 4.25L8.28033 7.21967C8.57322 7.51256 8.57322 7.98744 8.28033 8.28033C7.98744 8.57322 7.51256 8.57322 7.21967 8.28033L4.25 5.31066L1.28033 8.28033C0.987437 8.57322 0.512563 8.57322 0.21967 8.28033C-0.0732233 7.98744 -0.0732233 7.51256 0.21967 7.21967L3.18934 4.25L0.219671 1.28033Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Image icon for attaching reference images to an annotation. Mirrors the chat prompt's `ic-image-16`. */
|
||||||
|
export const ICON_IMAGE = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.5 2.5H12.5C13.0523 2.5 13.5 2.94772 13.5 3.5V12.0818L10.8974 8.39082C10.5672 7.92252 10.4021 7.68836 10.1967 7.60571C10.017 7.53339 9.81632 7.53339 9.63663 7.60571C9.43128 7.68836 9.26618 7.92252 8.93596 8.39082L6.83975 11.3636L6.02303 10.2054C5.73684 9.79951 5.59374 9.59658 5.41578 9.52495C5.26005 9.46227 5.08611 9.46227 4.93038 9.52495C4.75241 9.59658 4.60932 9.79951 4.32313 10.2054L2.5308 12.7472C2.51069 12.6682 2.5 12.5853 2.5 12.5V3.5C2.5 2.94772 2.94772 2.5 3.5 2.5ZM1 3.5C1 2.11929 2.11929 1 3.5 1H12.5C13.8807 1 15 2.11929 15 3.5V12.5C15 13.8807 13.8807 15 12.5 15H3.5C2.11929 15 1 13.8807 1 12.5V3.5ZM5.5 7C6.32843 7 7 6.32843 7 5.5C7 4.67157 6.32843 4 5.5 4C4.67157 4 4 4.67157 4 5.5C4 6.32843 4.67157 7 5.5 7Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Cursor icon shown while actively editing text (used as a CSS `cursor: url(...)` value, must stay on one line). */
|
||||||
|
export const ICON_TEXT_EDIT_CURSOR = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M14.6855 4.01657C15.1185 3.98715 15.5827 4.0007 15.9206 4.02768L16.0189 6.02799C15.4981 6.00166 15.1549 5.99184 14.856 6.00975C14.2838 6.13443 14.022 6.27355 13.7212 6.48238C13.5144 6.6249 13.1758 7.08781 13.0328 7.40688V11.9466H14.0308V13.9306H13.0328V17.5075C13.1757 17.8261 13.5144 18.2881 13.7256 18.4331C14.0074 18.6295 14.253 18.7705 14.4705 18.8452C15.3967 18.9569 15.3319 18.8892 15.8593 18.906L16.1008 20.8832C15.4621 20.9149 15.0617 20.9214 14.6848 20.8955C14.3728 20.8741 14.1113 20.8311 13.9107 20.7663C13.4939 20.6366 13.0581 20.4058 12.5878 20.0778C12.4192 19.9621 12.2307 19.7872 12.0449 19.5797C11.8745 19.7815 11.7053 19.9522 11.561 20.0652C11.1569 20.3791 10.6947 20.6094 10.2013 20.7643C9.98962 20.8325 9.72877 20.8751 9.41772 20.8965C8.98322 20.9264 8.5177 20.9128 8.17962 20.8857L8.0827 18.8854C8.60041 18.9118 8.94438 18.9217 9.2438 18.9036C9.86247 18.7641 10.1206 18.6516 10.3311 18.4881C10.5282 18.3338 10.9156 17.8079 11.0328 17.5043L11.0451 13.9306H10.0448V11.9466H11.0468V7.41344C10.9148 7.10644 10.5303 6.57954 10.3355 6.42776C10.1342 6.27198 9.89676 6.15407 9.63388 6.06667C8.70613 5.95562 8.77053 6.02289 8.24302 6.00632L8 4.02903C8.64058 3.99743 9.04059 3.99092 9.41699 4.01654C9.72766 4.03768 9.98817 4.07993 10.1966 4.1466C10.6967 4.30508 11.1568 4.53443 11.5622 4.84814C11.7056 4.95991 11.8748 5.13053 12.0455 5.33267C12.23 5.12617 12.417 4.95226 12.5834 4.83756C13.0581 4.508 13.4916 4.27789 13.9024 4.14775C14.1137 4.07998 14.3744 4.0377 14.6855 4.01657Z" fill="black" stroke="white" stroke-linejoin="round"/></svg>`;
|
||||||
|
|
||||||
|
/** Sparkles icon (`ic-sparkles`) for AI edit affordances: the toolbar button and annotation markers. 12x13 glyph in a 16x16 box. */
|
||||||
|
export const ICON_SPARKLES = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<g transform="translate(2 1)">
|
||||||
|
<path d="M3.89349 3.7678C3.75267 3.41073 3.24733 3.41073 3.10651 3.7678L2.371 5.6327C2.32801 5.74171 2.24171 5.82801 2.1327 5.871L0.2678 6.60651C-0.0892668 6.74733 -0.0892668 7.25267 0.2678 7.39349L2.1327 8.129C2.24171 8.17199 2.32801 8.25829 2.371 8.3673L3.1065 10.2322C3.24733 10.5893 3.75267 10.5893 3.89349 10.2322L4.629 8.3673C4.67199 8.25829 4.75829 8.17199 4.8673 8.129L6.7322 7.39349C7.08927 7.25267 7.08927 6.74733 6.7322 6.60651L4.8673 5.871C4.75829 5.82801 4.67199 5.74171 4.629 5.6327L3.89349 3.7678Z" fill="white"/>
|
||||||
|
<path d="M9.78107 0.191286C9.68048 -0.0637618 9.31952 -0.0637621 9.21893 0.191286L8.69357 1.52336C8.66286 1.60122 8.60122 1.66286 8.52336 1.69357L7.19129 2.21893C6.93624 2.31952 6.93624 2.68048 7.19129 2.78107L8.52336 3.30643C8.60122 3.33714 8.66286 3.39878 8.69357 3.47664L9.21893 4.80871C9.31952 5.06376 9.68048 5.06376 9.78107 4.80872L10.3064 3.47664C10.3371 3.39878 10.3988 3.33714 10.4766 3.30643L11.8087 2.78107C12.0638 2.68048 12.0638 2.31952 11.8087 2.21893L10.4766 1.69357C10.3988 1.66286 10.3371 1.60122 10.3064 1.52336L9.78107 0.191286Z" fill="white"/>
|
||||||
|
<path d="M9.66864 10.1148C9.60829 9.96174 9.39171 9.96174 9.33136 10.1148L9.01614 10.914C8.99772 10.9607 8.96073 10.9977 8.91401 11.0161L8.11477 11.3314C7.96174 11.3917 7.96174 11.6083 8.11477 11.6686L8.91401 11.9839C8.96073 12.0023 8.99772 12.0393 9.01614 12.086L9.33136 12.8852C9.39171 13.0383 9.60829 13.0383 9.66864 12.8852L9.98386 12.086C10.0023 12.0393 10.0393 12.0023 10.086 11.9839L10.8852 11.6686C11.0383 11.6083 11.0383 11.3917 10.8852 11.3314L10.086 11.0161C10.0393 10.9977 10.0023 10.9607 9.98386 10.914L9.66864 10.1148Z" fill="white"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
/** Label icon shown on elements with assisted (AI) edits. */
|
||||||
|
export const ASSISTED_EDIT_LABEL = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="17" viewBox="0 0 16 17" fill="none">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.9707 8.64551C13.1721 8.57718 13.3909 8.5738 13.5938 8.6377C13.8245 8.71052 14.0256 8.91063 14.4277 9.30859L14.8369 9.71387C15.1919 10.0638 15.3707 10.2388 15.4482 10.4375C15.537 10.6652 15.5388 10.9183 15.4541 11.1475C15.38 11.3472 15.2046 11.5261 14.8564 11.8818L11.5137 15.2969C11.4057 15.4072 11.3511 15.463 11.29 15.5088C11.2359 15.5494 11.1774 15.5854 11.1162 15.6143L11 15.6582C10.9543 15.6732 10.8984 15.691 10.8242 15.7129L9.78418 16.0195L9.42383 16.125C9.10857 16.2124 8.91655 16.2452 8.76367 16.1973L8.63574 16.1426C8.51494 16.0759 8.41463 15.9741 8.35059 15.8496C8.27735 15.7071 8.27841 15.5121 8.31152 15.1865L8.35449 14.8145L8.50781 13.5195C8.5317 13.3189 8.54362 13.217 8.57422 13.123C8.58781 13.0815 8.60516 13.0403 8.62402 13.001L8.68848 12.8857C8.71549 12.8446 8.74705 12.8049 8.78906 12.7588L8.95605 12.5869L11.6582 9.84277L12.1504 9.33301C12.5436 8.92657 12.7417 8.72344 12.9707 8.64551ZM13.2295 10.376L12.7363 10.8857L12.7275 10.8945L10.0254 13.6377C10.0103 13.6531 10.0009 13.6736 9.99821 13.6949C9.99815 13.6954 9.9981 13.6958 9.99805 13.6963L9.95695 14.0409C9.93571 14.2191 10.1034 14.3609 10.2755 14.3104L10.3984 14.2744C10.4146 14.2696 10.4292 14.2605 10.441 14.2485C10.4411 14.2483 10.4413 14.2482 10.4414 14.248L13.7842 10.833C13.7981 10.8188 13.7972 10.7954 13.7831 10.7814L13.3719 10.3742C13.3322 10.3349 13.2684 10.3358 13.2295 10.376Z" fill="#18181A"/>
|
||||||
|
<path d="M7 0C7.41421 0 7.75 0.335786 7.75 0.75V2.40039C7.75 2.4554 7.7946 2.5 7.84961 2.5C8.95782 2.5 9.84974 2.49916 10.5605 2.57617C11.2852 2.6547 11.9107 2.82112 12.4541 3.21582C12.7725 3.44712 13.0529 3.72754 13.2842 4.0459C13.6789 4.58927 13.8453 5.21477 13.9238 5.93945C13.9538 6.21579 13.9716 6.5195 13.9825 6.85264C13.988 7.02115 13.8262 7.14461 13.6595 7.11978C13.3798 7.07813 13.0964 7.08423 12.8213 7.13676C12.6552 7.16849 12.4881 7.05113 12.4826 6.88211C12.4729 6.58977 12.4576 6.33246 12.4326 6.10156C12.3679 5.50448 12.2483 5.17286 12.0703 4.92773C11.9315 4.73672 11.7633 4.56847 11.5723 4.42969C11.3271 4.25165 10.9955 4.13207 10.3984 4.06738C9.78723 4.0012 8.99098 4 7.84961 4H6.15039C5.00902 4 4.21277 4.0012 3.60156 4.06738C3.00448 4.13207 2.67286 4.25165 2.42773 4.42969C2.23672 4.56847 2.06847 4.73672 1.92969 4.92773C1.75165 5.17286 1.63207 5.50448 1.56738 6.10156C1.5012 6.71277 1.5 7.50902 1.5 8.65039V8.84961C1.5 9.99098 1.5012 10.7872 1.56738 11.3984C1.63207 11.9955 1.75166 12.3271 1.92969 12.5723C2.06847 12.7633 2.23672 12.9315 2.42773 13.0703C2.67286 13.2483 3.00449 13.3679 3.60156 13.4326C4.21277 13.4988 5.00902 13.5 6.15039 13.5H6.71864C6.86837 13.5 6.98452 13.6307 6.9669 13.7794L6.86523 14.6377L6.86426 14.6426L6.84866 14.7785C6.83418 14.9047 6.72733 15 6.60029 15H6.15039C5.04218 15 4.15026 15.0008 3.43945 14.9238C2.71477 14.8453 2.08927 14.6789 1.5459 14.2842C1.22754 14.0529 0.94712 13.7725 0.715821 13.4541C0.321123 12.9107 0.154699 12.2852 0.0761723 11.5605C-0.000839256 10.8497 3.82375e-07 9.95782 3.82375e-07 8.84961V8.65039C3.82375e-07 7.54218 -0.000839256 6.65026 0.0761723 5.93945C0.154699 5.21477 0.321123 4.58927 0.715821 4.0459C0.947119 3.72754 1.22754 3.44712 1.5459 3.21582C2.08927 2.82112 2.71477 2.6547 3.43945 2.57617C4.15026 2.49916 5.04218 2.5 6.15039 2.5C6.2054 2.5 6.25 2.4554 6.25 2.40039V0.75C6.25 0.335787 6.58579 2.89917e-07 7 0Z" fill="#18181A"/>
|
||||||
|
<path d="M4.5 7.5C5.05229 7.5 5.5 7.94772 5.5 8.5C5.5 9.05229 5.05229 9.5 4.5 9.5C3.94772 9.5 3.5 9.05229 3.5 8.5C3.5 7.94772 3.94772 7.5 4.5 7.5Z" fill="#18181A"/>
|
||||||
|
<path d="M9.5 7.5C10.0523 7.5 10.5 7.94772 10.5 8.5C10.5 9.05229 10.0523 9.5 9.5 9.5C8.94771 9.5 8.5 9.05229 8.5 8.5C8.5 7.94772 8.94771 7.5 9.5 7.5Z" fill="#18181A"/>
|
||||||
|
</svg>
|
||||||
|
`.trim();
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/** Numeric spacing and sizing values (px) used to position editor UI. */
|
||||||
|
|
||||||
|
/** Gap (px) between a panel and its anchor element. */
|
||||||
|
export const PANEL_GAP = 4;
|
||||||
|
/** Minimum margin (px) from the viewport edge when positioning panels. */
|
||||||
|
export const PANEL_MARGIN = 8;
|
||||||
|
/** Reserved height (px) of the parent Horizons toolbar when positioning iframe UI. */
|
||||||
|
export const PARENT_TOOLBAR_HEIGHT = 68;
|
||||||
|
/** Offset (px) from the cursor to the type tooltip's top-left corner. */
|
||||||
|
export const TYPE_TOOLTIP_CURSOR_OFFSET = 12;
|
||||||
|
/** Minimum margin (px) from the viewport edge when clamping the type tooltip. */
|
||||||
|
export const TOOLTIP_VIEWPORT_MARGIN = 4;
|
||||||
|
/** Border width (px) of the dashed hover outline drawn around the hovered element. */
|
||||||
|
export const HOVER_OUTLINE_STROKE_WIDTH = 1;
|
||||||
|
/** Width (px) of the annotation panel. */
|
||||||
|
export const ANNOTATION_PANEL_WIDTH = 320;
|
||||||
|
/** Estimated height (px) of the annotation panel for positioning calculations. */
|
||||||
|
export const ANNOTATION_PANEL_ESTIMATED_HEIGHT = 184;
|
||||||
|
/** Pointer travel (px) from mousedown before a click becomes a drag-select. */
|
||||||
|
export const DRAG_SELECT_THRESHOLD = 4;
|
||||||
|
/** Smallest side (px) an element may have to be worth drag-selecting. */
|
||||||
|
export const MIN_SELECTION_CANDIDATE_SIZE = 8;
|
||||||
|
/** Elements one annotation may target. Matches the backend's cap so a drag can never build a payload the API rejects.*/
|
||||||
|
export const MAX_SELECTED_ELEMENTS = 20;
|
||||||
|
/** Reference images across all annotations in one visual-editor turn. */
|
||||||
|
export const MAX_ANNOTATION_ATTACHMENTS = 10;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/** Message types sent from the iframe to the parent Horizons frame. */
|
||||||
|
export const ParentMessage = {
|
||||||
|
EDIT_APPLIED: 'editApplied',
|
||||||
|
EDIT_CANCEL: 'editCancel',
|
||||||
|
EDIT_ENTER: 'editEnter',
|
||||||
|
IMAGE_EDIT_ENTER: 'imageEditEnter',
|
||||||
|
EDIT_STATE_CHANGED: 'editStateChanged',
|
||||||
|
EDIT_UNDO_APPLIED: 'undoApplied',
|
||||||
|
EDIT_REDO_APPLIED: 'redoApplied',
|
||||||
|
DRAFT_SAVE_STARTED: 'draftSaveStarted',
|
||||||
|
DRAFT_APPLIED: 'draftApplied',
|
||||||
|
DRAFT_SAVE_FAILED: 'draftSaveFailed',
|
||||||
|
DRAFT_SAVE_FINISHED: 'draftSaveFinished',
|
||||||
|
DRAFT_DISCARDED: 'draftDiscarded',
|
||||||
|
ANNOTATION_PANEL_OPENED: 'annotationPanelOpened',
|
||||||
|
ANNOTATION_IMAGE_ATTACH_REQUESTED: 'annotationImageAttachRequested',
|
||||||
|
ANNOTATION_IMAGE_REMOVED: 'annotationImageRemoved',
|
||||||
|
ANNOTATION_IMAGE_PREVIEW_REQUESTED: 'annotationImagePreviewRequested',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message types sent from the parent Horizons frame to the iframe. */
|
||||||
|
export const ChildMessage = {
|
||||||
|
ENABLE_EDIT_MODE: 'enable-edit-mode',
|
||||||
|
DISABLE_EDIT_MODE: 'disable-edit-mode',
|
||||||
|
EDIT_SAVE: 'edit-save',
|
||||||
|
DRAFT_SAVE: 'draft-save',
|
||||||
|
DRAFT_DISCARD: 'draft-discard',
|
||||||
|
DRAFT_RESTORE: 'draft-restore',
|
||||||
|
DRAFT_SNAPSHOT_REQUEST: 'draft-snapshot-request',
|
||||||
|
EDIT_UNDO: 'edit-undo',
|
||||||
|
EDIT_REDO: 'edit-redo',
|
||||||
|
EDIT_CANCEL: 'edit-cancel',
|
||||||
|
ANNOTATION_OPEN_FOR_IMAGE: 'annotation-open-for-image',
|
||||||
|
ANNOTATION_IMAGE_ATTACHED: 'annotation-image-attached',
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/** CSS properties captured in DOM context payloads sent to the parent frame. */
|
||||||
|
export const IMPORTANT_STYLES = [
|
||||||
|
'display',
|
||||||
|
'position',
|
||||||
|
'flex-direction',
|
||||||
|
'justify-content',
|
||||||
|
'align-items',
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'padding',
|
||||||
|
'margin',
|
||||||
|
'border',
|
||||||
|
'background-color',
|
||||||
|
'color',
|
||||||
|
'font-size',
|
||||||
|
'font-weight',
|
||||||
|
'font-family',
|
||||||
|
'border-radius',
|
||||||
|
'box-shadow',
|
||||||
|
'gap',
|
||||||
|
'grid-template-columns',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Maps logical element types to tag names (lowercase HTML and PascalCase components). */
|
||||||
|
export const ELEMENT_TYPE_MAP = {
|
||||||
|
text: ['a', 'Link', 'p', 'span', 'time', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'label', 'Label', 'strong', 'em', 'b', 'i', 'u'],
|
||||||
|
button: ['button', 'Button'],
|
||||||
|
image: ['img'],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hover type tooltip label maps to its translation key and default English label. */
|
||||||
|
export const ELEMENT_TYPE_TRANSLATIONS = {
|
||||||
|
text: { key: 'elementTypeText', defaultLabel: 'Text' },
|
||||||
|
button: { key: 'elementTypeButton', defaultLabel: 'Button' },
|
||||||
|
image: { key: 'elementTypeImage', defaultLabel: 'Image' },
|
||||||
|
block: { key: 'elementTypeBlock', defaultLabel: 'Block' },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Selector for ancestors that use fixed/sticky positioning (affects overlay coordinates). */
|
||||||
|
export const FIXED_CONTEXT_SELECTOR = 'nav, header, [style*="position: fixed"], [style*="position:fixed"], [style*="position: sticky"], [style*="position:sticky"]';
|
||||||
|
|
||||||
|
/** CSS selector matching the floating text-format toolbar and its action panels. */
|
||||||
|
export const TOOLBAR_UI_SELECTOR = '#text-format-toolbar, #text-format-link-action, #text-format-size-action, #text-format-font-action, #text-format-color-action';
|
||||||
|
|
||||||
|
/** DOM id of the drag-select band drawn while rubber-band selecting. */
|
||||||
|
export const SELECTION_RECT_ID = 'selection-mode-select-rect';
|
||||||
|
|
||||||
|
/** CSS selector matching the annotation panel, its pin markers and the drag-select band. */
|
||||||
|
export const ANNOTATION_UI_SELECTOR = `#selection-mode-annotation-panel, .selection-mode-annotation-marker, #${SELECTION_RECT_ID}`;
|
||||||
|
|
||||||
|
/** All floating editor UI (toolbar, annotation panel, pin markers). */
|
||||||
|
export const EDITOR_UI_SELECTOR = `${TOOLBAR_UI_SELECTOR}, ${ANNOTATION_UI_SELECTOR}`;
|
||||||
|
|
||||||
|
/** CSS selector matching any editable target (static or assisted). */
|
||||||
|
export const EDIT_TARGET_SELECTOR = '[data-edit-id], [data-edit-assisted-id]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whichever edit-id attribute is present on the element.
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function getEditId(element) {
|
||||||
|
return element?.getAttribute('data-edit-id') || element?.getAttribute('data-edit-assisted-id') || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Selector for actionable elements (pointer-events disabled in edit mode, see `EDIT_MODE_STYLES`). */
|
||||||
|
export const ACTIONABLE_SELECTOR = 'a[href], button, [role="button"], [type="submit"]';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True while edit mode is active on the app root.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isEditModeEnabled() {
|
||||||
|
return document.getElementById('root')?.getAttribute('data-edit-mode-enabled') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when the event target sits inside any floating editor UI (toolbar or action panels).
|
||||||
|
* @param {EventTarget|null} target
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isInsideEditorUi(target) {
|
||||||
|
return !!target?.closest?.(EDITOR_UI_SELECTOR);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/** Visual design tokens (colors, z-index, shadows) for the editor UI. */
|
||||||
|
|
||||||
|
/** Background color for floating panels and toolbars. */
|
||||||
|
export const PANEL_BG = '#1D1E20';
|
||||||
|
/** Background color for inset input surfaces inside panels (textareas, fields). */
|
||||||
|
export const PANEL_INPUT_BG = '#262831';
|
||||||
|
/** Border color for panel borders and dividers. */
|
||||||
|
export const BORDER_COLOR = '#3B3D4A';
|
||||||
|
/** Translucent white border (#fff at 20% opacity) for subtle on-dark outlines. */
|
||||||
|
export const BORDER_COLOR_TRANSLUCENT = '#ffffff33';
|
||||||
|
/** Border color for focused inputs: a touch brighter than the default, not white. */
|
||||||
|
export const BORDER_COLOR_FOCUS = '#6B6E7E';
|
||||||
|
/** Subtle hover background for interactive panel controls. */
|
||||||
|
export const HOVER_BG = '#ffffff0d';
|
||||||
|
/** Active/pressed background for panel controls. */
|
||||||
|
export const ACTIVE_BG = '#2a2b2f';
|
||||||
|
/** Default foreground color on dark panels. */
|
||||||
|
export const COLOR_WHITE = '#ffffff';
|
||||||
|
/** Secondary foreground on dark panels (#fff at 50% opacity) for labels and placeholders. */
|
||||||
|
export const TEXT_MUTED = '#ffffff80';
|
||||||
|
/** Foreground color for light (white-filled) buttons. */
|
||||||
|
export const BUTTON_LIGHT_TEXT = '#161718';
|
||||||
|
/** Hover background for light (white-filled) buttons. */
|
||||||
|
export const BUTTON_LIGHT_BG_HOVER = '#E5E5E7';
|
||||||
|
/** Outline color for editable targets in edit mode. */
|
||||||
|
export const EDIT_BORDER_COLOR = '#673DE6';
|
||||||
|
/** Hover background for editable targets in edit mode. */
|
||||||
|
export const EDIT_BACKGROUND_COLOR = '#673DE61A';
|
||||||
|
/** Accent color for annotation panel buttons and markers. */
|
||||||
|
export const ANNOTATION_COLOR = '#673DE6';
|
||||||
|
/** Hover state for annotation buttons and markers. */
|
||||||
|
export const ANNOTATION_COLOR_HOVER = '#5025D1';
|
||||||
|
/** Placeholder text color in panel inputs. */
|
||||||
|
export const PLACEHOLDER_COLOR = '#D1D5DC';
|
||||||
|
/** Scrollbar thumb on dark panel surfaces. Matches the chat's `--scrollbar-thumb`. */
|
||||||
|
export const SCROLLBAR_THUMB = 'rgba(255, 255, 255, 0.28)';
|
||||||
|
/** Hovered scrollbar thumb. Matches the chat's `--scrollbar-thumb-hover`. */
|
||||||
|
export const SCROLLBAR_THUMB_HOVER = 'rgba(255, 255, 255, 0.42)';
|
||||||
|
|
||||||
|
/** Browser int32 max; toolbar, dropdowns, and annotation panel. */
|
||||||
|
export const Z_INDEX_EDITOR_PANEL = 2147483647;
|
||||||
|
/** Overlays: element-type tooltip and drag-select band. */
|
||||||
|
export const Z_INDEX_EDITOR_OVERLAY = Z_INDEX_EDITOR_PANEL - 1;
|
||||||
|
/** Annotation markers. */
|
||||||
|
export const Z_INDEX_EDITOR_MARKER = Z_INDEX_EDITOR_PANEL - 2;
|
||||||
|
|
||||||
|
/** Box shadow for dropdown panels. */
|
||||||
|
export const BOX_SHADOW_DROPDOWN = '0 4px 16px 0 rgba(0, 0, 0, 0.30)';
|
||||||
|
/** Box shadow for floating editor surfaces: inline toolbars and panels. */
|
||||||
|
export const BOX_SHADOW_TOOLBAR = '0 1px 4px 0 rgba(0, 0, 0, 0.10)';
|
||||||
@@ -0,0 +1,812 @@
|
|||||||
|
import { startInlineEdit, commitCurrentEdit, placeCursorAtPoint } from "./ui/inline-edit/edit-action.js";
|
||||||
|
import { getEditing } from "./state/editing-state.js";
|
||||||
|
import { getElementType, isInFixedContext, isolateEditorUiEvents, getOpenModals } from "./utils/dom-utils.js";
|
||||||
|
import { postToParent, ALLOWED_PARENT_ORIGINS } from "./utils/parent-frame.js";
|
||||||
|
import { ParentMessage, ChildMessage } from "./constants/messages.js";
|
||||||
|
import { isEditModeEnabled, isInsideEditorUi, ACTIONABLE_SELECTOR, EDIT_TARGET_SELECTOR, getEditId } from "./constants/selectors.js";
|
||||||
|
import { recordEdit, reapplyCommittedEdits, getEditState } from "./state/history-state.js";
|
||||||
|
import { applySessionUndo, applySessionRedo } from "./utils/session-history-command.js";
|
||||||
|
import { handleDraftSave, handleDraftDiscard, discardAllEdits } from "./api/draft.js";
|
||||||
|
import { applyDraftSnapshot, notifyDraftStateChanged } from "./api/draft-snapshot.js";
|
||||||
|
import { patchReactDomMutations } from "./utils/html-utils.js";
|
||||||
|
import { showTypeTooltip, hideTypeTooltip } from "./ui/overlays/type-tooltip.js";
|
||||||
|
import { openOrCreateAnnotation, getAnnotationPanelEl, hideAnnotationPanel, openPanelForEdit, addPendingAttachments } from "./ui/annotation-panel/panel.js";
|
||||||
|
import { clearAllAnnotationMarkers, refreshAnnotationMarkers } from "./ui/annotation-panel/annotation-markers.js";
|
||||||
|
import { setEditorTranslations, setComments, getPendingElements } from "./state/annotation-state.js";
|
||||||
|
import { captureElementMetadata } from "./utils/selection-mode-metadata.js";
|
||||||
|
import { hideTextFormatToolbar } from "./ui/text-format/toolbar/toolbar.js";
|
||||||
|
import { hideAllPanels } from "./state/panel-state.js";
|
||||||
|
import { lockHoverOutline, unlockHoverOutline } from "./ui/overlays/hover-outline.js";
|
||||||
|
import {
|
||||||
|
DRAG_SELECT_THRESHOLD, MAX_SELECTED_ELEMENTS,
|
||||||
|
ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT, PANEL_GAP,
|
||||||
|
} from "./constants/layout.js";
|
||||||
|
import {
|
||||||
|
getSelectedElements, setSelectedElements, clearSelectedElements,
|
||||||
|
getDragStartX, getDragStartY, setDragStart, clearDragStart,
|
||||||
|
getIsDragging, setIsDragging,
|
||||||
|
getHadDrag, setHadDrag,
|
||||||
|
isDragPending, resetMultiSelectState, toggleSelectedElement, orderSelection,
|
||||||
|
pruneDetachedSelection,
|
||||||
|
} from "./state/multi-select-state.js";
|
||||||
|
import {
|
||||||
|
getSelectionRect, collectSelection, NO_TEXT_SELECT_CLASS,
|
||||||
|
showSelectionRect, hideSelectionRect, destroySelectionRect,
|
||||||
|
} from "./ui/overlays/selection-rect.js";
|
||||||
|
|
||||||
|
let globalClickHandler = null;
|
||||||
|
|
||||||
|
/** Tracks the element behind the parent image picker (back-compat `edit-save` flow). */
|
||||||
|
let currentImageEdit = null;
|
||||||
|
|
||||||
|
let lastCursorClientX = 0;
|
||||||
|
let lastCursorClientY = 0;
|
||||||
|
let typeTooltipAnimationFrameId = null;
|
||||||
|
/** True right after a mousedown until the next genuine mousemove, so a drag doesn't drag the tooltip too. */
|
||||||
|
let suppressTypeTooltipUntilMove = false;
|
||||||
|
/** Caches the last raw `elementFromPoint` hit to skip the pointer-events workaround below when unchanged. */
|
||||||
|
let lastRawHitTestElement = null;
|
||||||
|
let lastResolvedTypeTooltipTarget = null;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Editable target lookup
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function findEditableElementAtPoint(event) {
|
||||||
|
const direct = event.target.closest(EDIT_TARGET_SELECTOR);
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
if (isInFixedContext(event.target)) return null;
|
||||||
|
|
||||||
|
const elementsAtPoint = document.elementsFromPoint(event.clientX, event.clientY);
|
||||||
|
const found = elementsAtPoint.find(
|
||||||
|
(element) => element !== event.target && (element.hasAttribute("data-edit-id") || element.hasAttribute("data-edit-assisted-id"))
|
||||||
|
);
|
||||||
|
return found || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Image edit (parent picker) — kept as the existing message contract
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function startImageEdit(targetElement, editId) {
|
||||||
|
currentImageEdit = { editId, targetElement };
|
||||||
|
lockHoverOutline(targetElement);
|
||||||
|
postToParent(ParentMessage.IMAGE_EDIT_ENTER, {
|
||||||
|
currentText: targetElement.getAttribute('src') || '',
|
||||||
|
elementType: 'img',
|
||||||
|
isDirectlyEditable: targetElement.hasAttribute('data-edit-id'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the in-progress image edit and drops its locked outline. */
|
||||||
|
function clearImageEdit() {
|
||||||
|
if (!currentImageEdit) return;
|
||||||
|
currentImageEdit = null;
|
||||||
|
unlockHoverOutline();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dismisses an active image selection and tells the parent to close the image
|
||||||
|
* picker popup (e.g. when the user clicks away to another element or empty space).
|
||||||
|
*/
|
||||||
|
function cancelImageEdit() {
|
||||||
|
if (!currentImageEdit) return;
|
||||||
|
clearImageEdit();
|
||||||
|
postToParent(ParentMessage.EDIT_CANCEL, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parent image picker drives image replacement via `edit-save`. The change
|
||||||
|
* is applied to the live DOM and recorded in history; it is written to source
|
||||||
|
* only on an explicit draft save.
|
||||||
|
*/
|
||||||
|
function handleImageEditSave(newText) {
|
||||||
|
if (!currentImageEdit) return;
|
||||||
|
const { editId, targetElement } = currentImageEdit;
|
||||||
|
const beforeContent = targetElement.getAttribute('src');
|
||||||
|
const afterContent = newText ?? "";
|
||||||
|
const isAssisted = targetElement.hasAttribute('data-edit-assisted-id');
|
||||||
|
const selectionMode = captureElementMetadata(targetElement);
|
||||||
|
targetElement.setAttribute('src', afterContent);
|
||||||
|
recordEdit(editId, { beforeContent, afterContent }, {
|
||||||
|
attribute: 'src',
|
||||||
|
element: targetElement,
|
||||||
|
isAssisted,
|
||||||
|
selectionMode,
|
||||||
|
});
|
||||||
|
clearImageEdit();
|
||||||
|
notifyDraftStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Group selection (drag band + shift+click)
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
let dragPreviewFrameId = null;
|
||||||
|
|
||||||
|
/** Drops the group and the outlines marking it. */
|
||||||
|
function clearMultiSelection() {
|
||||||
|
clearSelectedElements();
|
||||||
|
unlockHoverOutline();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Element under the cursor that a group may target, or null for empty space. */
|
||||||
|
function resolveGroupTarget(event) {
|
||||||
|
const rawTarget = document.elementFromPoint(event.clientX, event.clientY);
|
||||||
|
if (!rawTarget || isPageRoot(rawTarget) || isInsideEditorUi(rawTarget)) return null;
|
||||||
|
|
||||||
|
const resolved = resolveHoverTarget(rawTarget, event.clientX, event.clientY);
|
||||||
|
return resolved && !isPageRoot(resolved) ? resolved : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when the click landed on a member of the group (or inside one). */
|
||||||
|
function isGroupMember(group, target) {
|
||||||
|
return !!target && group.some(member => member === target || member.contains(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A drag takes over the whole surface, so every other floating affordance —
|
||||||
|
* toolbar dropdowns, the text toolbar, an open annotation panel, the image
|
||||||
|
* picker — gets out of the way before the band is drawn.
|
||||||
|
*/
|
||||||
|
function beginDragSelect(event) {
|
||||||
|
setIsDragging(true);
|
||||||
|
hideAllPanels();
|
||||||
|
hideTextFormatToolbar();
|
||||||
|
// A shift+drag extends the group, so the panel must not take it down with it.
|
||||||
|
hideAnnotationPanel({ restoreFocus: false, keepSelection: event.shiftKey });
|
||||||
|
if (getEditing()) commitCurrentEdit();
|
||||||
|
hideTypeTooltipImmediately();
|
||||||
|
cancelImageEdit();
|
||||||
|
if (!event.shiftKey) clearMultiSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the gesture and tears the band down, whether it completed or was abandoned. */
|
||||||
|
function endDragSelect() {
|
||||||
|
clearDragStart();
|
||||||
|
setIsDragging(false);
|
||||||
|
if (dragPreviewFrameId !== null) {
|
||||||
|
cancelAnimationFrame(dragPreviewFrameId);
|
||||||
|
dragPreviewFrameId = null;
|
||||||
|
}
|
||||||
|
hideSelectionRect();
|
||||||
|
document.body.classList.remove(NO_TEXT_SELECT_CLASS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDragSelect() {
|
||||||
|
if (!isDragPending()) return;
|
||||||
|
endDragSelect();
|
||||||
|
clearMultiSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
if (document.visibilityState === "hidden" && getEditing()) {
|
||||||
|
commitCurrentEdit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectMouseDown(event) {
|
||||||
|
setHadDrag(false);
|
||||||
|
if (!isEditModeEnabled()) return;
|
||||||
|
if (event.button !== 0 || isInsideEditorUi(event.target)) return;
|
||||||
|
// Text selection inside the active inline edit stays native.
|
||||||
|
if (getEditing()?.targetElement?.contains(event.target)) return;
|
||||||
|
|
||||||
|
// Suppress all native pointerdown side-effects (focus, text selection, form
|
||||||
|
// validation) so the host app never reacts to clicks during edit mode.
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
document.body.classList.add(NO_TEXT_SELECT_CLASS);
|
||||||
|
if (event.shiftKey) {
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDragStart(event.clientX, event.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recomputes the previewed group at most once per frame — `collectSelection`
|
||||||
|
* walks the page and measures every candidate, which is far too heavy to run on
|
||||||
|
* every mousemove of a drag.
|
||||||
|
*/
|
||||||
|
function scheduleDragPreview(clientX, clientY, additive) {
|
||||||
|
if (dragPreviewFrameId !== null) return;
|
||||||
|
dragPreviewFrameId = requestAnimationFrame(() => {
|
||||||
|
dragPreviewFrameId = null;
|
||||||
|
if (!getIsDragging()) return;
|
||||||
|
|
||||||
|
const preview = collectSelection(getSelectionRect(getDragStartX(), getDragStartY(), clientX, clientY));
|
||||||
|
lockHoverOutline(orderSelection(additive ? [...getSelectedElements(), ...preview] : preview));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectMouseMove(event) {
|
||||||
|
if (!isDragPending()) return;
|
||||||
|
|
||||||
|
const startX = getDragStartX();
|
||||||
|
const startY = getDragStartY();
|
||||||
|
|
||||||
|
if (!getIsDragging()) {
|
||||||
|
const movedFarEnough = Math.abs(event.clientX - startX) > DRAG_SELECT_THRESHOLD
|
||||||
|
|| Math.abs(event.clientY - startY) > DRAG_SELECT_THRESHOLD;
|
||||||
|
if (!movedFarEnough) return;
|
||||||
|
beginDragSelect(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
showSelectionRect(startX, startY, event.clientX, event.clientY);
|
||||||
|
scheduleDragPreview(event.clientX, event.clientY, event.shiftKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectMouseUp(event) {
|
||||||
|
if (!isDragPending()) return;
|
||||||
|
|
||||||
|
const startX = getDragStartX();
|
||||||
|
const startY = getDragStartY();
|
||||||
|
const wasDragging = getIsDragging();
|
||||||
|
endDragSelect();
|
||||||
|
if (!wasDragging) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
// The browser still delivers a click for this gesture; handleClick drops it.
|
||||||
|
setHadDrag(true);
|
||||||
|
|
||||||
|
const picked = collectSelection(getSelectionRect(startX, startY, event.clientX, event.clientY));
|
||||||
|
const group = orderSelection(event.shiftKey ? [...getSelectedElements(), ...picked] : picked)
|
||||||
|
.slice(0, MAX_SELECTED_ELEMENTS);
|
||||||
|
setSelectedElements(group);
|
||||||
|
|
||||||
|
if (!group.length) {
|
||||||
|
clearMultiSelection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openOrCreateAnnotation(group, event.clientX, event.clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Escape drops a group that no panel is holding; an open panel owns the key itself. */
|
||||||
|
function handleSelectionKeydown(event) {
|
||||||
|
if (event.key !== 'Escape' || !getSelectedElements().length) return;
|
||||||
|
if (getAnnotationPanelEl()?.classList.contains('active')) return;
|
||||||
|
clearMultiSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Click routing
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function handleClick(event) {
|
||||||
|
if (!isEditModeEnabled()) return;
|
||||||
|
|
||||||
|
if (event.target.closest?.("a[href]")) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInsideEditorUi(event.target)) {
|
||||||
|
cancelImageEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action: drop the click a completed drag leaves behind.
|
||||||
|
if (getHadDrag()) {
|
||||||
|
setHadDrag(false);
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action: shift+click adds or removes one element from the group. The panel
|
||||||
|
// stays shut — it opens on the following plain click on a member.
|
||||||
|
if (event.shiftKey) {
|
||||||
|
const target = resolveGroupTarget(event);
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
hideTypeTooltipImmediately();
|
||||||
|
cancelImageEdit();
|
||||||
|
if (getEditing()) {
|
||||||
|
hideTextFormatToolbar();
|
||||||
|
commitCurrentEdit();
|
||||||
|
}
|
||||||
|
hideAnnotationPanel({ restoreFocus: false, keepSelection: true });
|
||||||
|
|
||||||
|
// At the cap, removing still works but adding is ignored — silently
|
||||||
|
// dropping some other member to make room would be worse.
|
||||||
|
const selected = getSelectedElements();
|
||||||
|
if (selected.includes(target) || selected.length < MAX_SELECTED_ELEMENTS) {
|
||||||
|
toggleSelectedElement(target);
|
||||||
|
}
|
||||||
|
lockHoverOutline(getSelectedElements());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action: open the group's panel when the click lands on one of its members;
|
||||||
|
// anything else drops the group and falls through to single-element routing.
|
||||||
|
const group = getSelectedElements();
|
||||||
|
if (group.length) {
|
||||||
|
if (isGroupMember(group, resolveGroupTarget(event))) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
hideTypeTooltipImmediately();
|
||||||
|
cancelImageEdit();
|
||||||
|
if (getEditing()) commitCurrentEdit();
|
||||||
|
hideAnnotationPanel({ restoreFocus: false, keepSelection: true });
|
||||||
|
|
||||||
|
openOrCreateAnnotation(group, event.clientX, event.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clearMultiSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action: dismiss annotation panel on outside click.
|
||||||
|
if (getAnnotationPanelEl()?.classList.contains('active')) {
|
||||||
|
hideAnnotationPanel();
|
||||||
|
hideTextFormatToolbar();
|
||||||
|
if (getEditing()) {
|
||||||
|
commitCurrentEdit();
|
||||||
|
}
|
||||||
|
if (!findEditableElementAtPoint(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const editing = getEditing();
|
||||||
|
|
||||||
|
// Action: place cursor inside the active contenteditable on click.
|
||||||
|
if (editing?.targetElement?.hasAttribute("contenteditable") && editing.targetElement.contains(event.target)) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
const link = event.target.closest?.("a");
|
||||||
|
if (link && editing.targetElement.contains(link)) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.detail <= 1) {
|
||||||
|
placeCursorAtPoint(event.clientX, event.clientY, editing.targetElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action: start inline edit or image edit on an editable element.
|
||||||
|
const editable = findEditableElementAtPoint(event);
|
||||||
|
|
||||||
|
if (editable) {
|
||||||
|
if (editing && editing.targetElement === editable) {
|
||||||
|
commitCurrentEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
const editId = getEditId(editable);
|
||||||
|
if (!editId) return;
|
||||||
|
|
||||||
|
hideTypeTooltipImmediately();
|
||||||
|
|
||||||
|
if (editable?.tagName?.toLowerCase() === 'img') {
|
||||||
|
if (editing) commitCurrentEdit();
|
||||||
|
startImageEdit(editable, editId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
commitCurrentEdit();
|
||||||
|
}
|
||||||
|
clearImageEdit();
|
||||||
|
startInlineEdit(editable, editId, editable.innerHTML || "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action: commit any active inline edit and stop.
|
||||||
|
if (editing) {
|
||||||
|
commitCurrentEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action: open annotation panel for non-editable elements (blocks, icon-only buttons/links, etc.).
|
||||||
|
const rawTarget = document.elementFromPoint(event.clientX, event.clientY);
|
||||||
|
if (rawTarget && !isPageRoot(rawTarget)) {
|
||||||
|
const hoverTarget = resolveHoverTarget(rawTarget, event.clientX, event.clientY);
|
||||||
|
const hoverType = hoverTarget && getElementType(hoverTarget);
|
||||||
|
if (hoverTarget && !isPageRoot(hoverTarget) && (hoverType === 'block' || hoverType === 'button')) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
hideTypeTooltipImmediately();
|
||||||
|
cancelImageEdit();
|
||||||
|
|
||||||
|
postToParent(ParentMessage.ANNOTATION_PANEL_OPENED, { elementType: hoverTarget.tagName.toLowerCase() });
|
||||||
|
openOrCreateAnnotation(hoverTarget, event.clientX, event.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action: close the image picker when clicking empty space.
|
||||||
|
cancelImageEdit();
|
||||||
|
|
||||||
|
if (event.target.closest("a[href], button, [role='button'], [type='submit']")) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Hover type tooltip (element type near the cursor)
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** Finds the deepest actionable descendant of `container` still under `(clientX, clientY)`, or `container` itself. */
|
||||||
|
function resolveActionableDescendantAtPoint(container, clientX, clientY) {
|
||||||
|
let deepest = container;
|
||||||
|
for (const candidate of container.querySelectorAll(ACTIONABLE_SELECTOR)) {
|
||||||
|
const bounds = candidate.getBoundingClientRect();
|
||||||
|
if (clientX >= bounds.left && clientX <= bounds.right && clientY >= bounds.top && clientY <= bounds.bottom) {
|
||||||
|
deepest = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deepest;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hides the type tooltip and drops any frame scheduled to redisplay it. */
|
||||||
|
function hideTypeTooltipImmediately() {
|
||||||
|
if (typeTooltipAnimationFrameId !== null) {
|
||||||
|
cancelAnimationFrame(typeTooltipAnimationFrameId);
|
||||||
|
typeTooltipAnimationFrameId = null;
|
||||||
|
}
|
||||||
|
hideTypeTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves the element under the cursor to highlight:
|
||||||
|
* an editable or `<svg>` ancestor wins, else the raw target. */
|
||||||
|
function resolveHoverTarget(rawTarget, clientX, clientY) {
|
||||||
|
const editableAncestor = rawTarget.closest(EDIT_TARGET_SELECTOR);
|
||||||
|
if (editableAncestor) return resolveActionableDescendantAtPoint(editableAncestor, clientX, clientY);
|
||||||
|
|
||||||
|
const svgAncestor = rawTarget.closest('svg');
|
||||||
|
if (svgAncestor) return svgAncestor;
|
||||||
|
|
||||||
|
return resolveActionableDescendantAtPoint(rawTarget, clientX, clientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for the page's own root containers, which are too broad to be a meaningful hover target. */
|
||||||
|
function isPageRoot(element) {
|
||||||
|
return element === document.body || element === document.documentElement || element.id === 'root';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTypeTooltip() {
|
||||||
|
// A drag keeps firing mousemove, which would otherwise clear the
|
||||||
|
// suppress-until-move flag and flash the tooltip over the band.
|
||||||
|
if (!isEditModeEnabled() || suppressTypeTooltipUntilMove || getIsDragging()) {
|
||||||
|
hideTypeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawTarget = document.elementFromPoint(lastCursorClientX, lastCursorClientY);
|
||||||
|
if (!rawTarget || isInsideEditorUi(rawTarget) || isPageRoot(rawTarget)) {
|
||||||
|
hideTypeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const editing = getEditing();
|
||||||
|
if (editing?.targetElement && (editing.targetElement === rawTarget || editing.targetElement.contains(rawTarget))) {
|
||||||
|
hideTypeTooltip();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawTarget !== lastRawHitTestElement) {
|
||||||
|
lastRawHitTestElement = rawTarget;
|
||||||
|
lastResolvedTypeTooltipTarget = resolveHoverTarget(rawTarget, lastCursorClientX, lastCursorClientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
showTypeTooltip(lastResolvedTypeTooltipTarget, lastCursorClientX, lastCursorClientY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throttles type-tooltip recomputation to at most once per animation frame. */
|
||||||
|
function scheduleTypeTooltipUpdate() {
|
||||||
|
if (typeTooltipAnimationFrameId !== null) return;
|
||||||
|
typeTooltipAnimationFrameId = requestAnimationFrame(() => {
|
||||||
|
typeTooltipAnimationFrameId = null;
|
||||||
|
updateTypeTooltip();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTypeTooltipMouseMove(event) {
|
||||||
|
lastCursorClientX = event.clientX;
|
||||||
|
lastCursorClientY = event.clientY;
|
||||||
|
suppressTypeTooltipUntilMove = false;
|
||||||
|
scheduleTypeTooltipUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wheel-scroll fires no `mousemove`, so recompute from the last known cursor position. */
|
||||||
|
function handleTypeTooltipScroll() {
|
||||||
|
if (!isEditModeEnabled()) return;
|
||||||
|
scheduleTypeTooltipUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Suppresses the tooltip for the duration of a text-selection or element drag. */
|
||||||
|
function handleTypeTooltipMouseDown() {
|
||||||
|
suppressTypeTooltipUntilMove = true;
|
||||||
|
hideTypeTooltipImmediately();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Annotation ↔ DOM sync (client-side route changes, re-renders)
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
let annotationDomObserver = null;
|
||||||
|
let annotationDomSyncFrameId = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-anchors markers to their (re-mounted) elements and hides the ones whose
|
||||||
|
* element left the page, so a comment made on one route stays on that route.
|
||||||
|
* Also re-applies unsaved edits to any element a route change or React
|
||||||
|
* re-render replaced, so they don't visually revert to the unedited source.
|
||||||
|
*/
|
||||||
|
function syncAnnotationsWithDom() {
|
||||||
|
annotationDomSyncFrameId = null;
|
||||||
|
reapplyCommittedEdits();
|
||||||
|
refreshAnnotationMarkers();
|
||||||
|
|
||||||
|
// A group only ever spans one page: navigating away unmounts its members, and
|
||||||
|
// an outline can't be drawn for an element that is no longer there.
|
||||||
|
if (pruneDetachedSelection()) {
|
||||||
|
const group = getSelectedElements();
|
||||||
|
if (group.length) lockHoverOutline(group);
|
||||||
|
else clearMultiSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingElements = getPendingElements();
|
||||||
|
if (pendingElements.length
|
||||||
|
&& pendingElements.every(element => !element.isConnected)
|
||||||
|
&& getAnnotationPanelEl()?.classList.contains('active')) {
|
||||||
|
hideAnnotationPanel({ restoreFocus: false });
|
||||||
|
hideTextFormatToolbar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throttles the annotation sync to at most once per animation frame. */
|
||||||
|
function scheduleAnnotationDomSync() {
|
||||||
|
if (annotationDomSyncFrameId !== null) return;
|
||||||
|
annotationDomSyncFrameId = requestAnimationFrame(syncAnnotationsWithDom);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Undo / redo keyboard shortcuts
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/** True for native text-editing surfaces where the browser should own undo. */
|
||||||
|
function isNativeEditingContext(target) {
|
||||||
|
if (!target) return false;
|
||||||
|
const tag = target.tagName?.toLowerCase();
|
||||||
|
return tag === "input" || tag === "textarea" || !!target.isContentEditable;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleHistoryKeydown(event) {
|
||||||
|
if (!isEditModeEnabled()) return;
|
||||||
|
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "z") return;
|
||||||
|
|
||||||
|
// While an inline edit is active, let the browser handle native text undo
|
||||||
|
// inside the contenteditable region instead of stepping the edit history.
|
||||||
|
if (getEditing()?.targetElement?.hasAttribute("contenteditable") || isNativeEditingContext(event.target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
if (event.shiftKey) {
|
||||||
|
applySessionRedo();
|
||||||
|
} else {
|
||||||
|
applySessionUndo();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keeps host-page modals open and host-page key handlers quiet in edit mode. */
|
||||||
|
|
||||||
|
function handleModalPointerDownGuard(event) {
|
||||||
|
if (isInsideEditorUi(event.target)) return;
|
||||||
|
|
||||||
|
const openModals = getOpenModals();
|
||||||
|
if (!openModals.length) return;
|
||||||
|
if (openModals.some((modal) => modal.contains(event.target))) return;
|
||||||
|
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registered on window capture after the editor's own key handlers: `stopPropagation`
|
||||||
|
* still lets those sibling window listeners run, but nothing on document or below
|
||||||
|
* (host shortcuts, dismiss-on-Escape) ever sees the key.
|
||||||
|
*/
|
||||||
|
function handleHostKeyGuard(event) {
|
||||||
|
if (isInsideEditorUi(event.target) || getEditing()?.targetElement?.contains(event.target)) {
|
||||||
|
// Dismissable layers honour `defaultPrevented`, so an Escape aimed at the editor stays with it.
|
||||||
|
if (event.key === "Escape") event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditorFocusGuard(event) {
|
||||||
|
if (isInsideEditorUi(event.target) || isInsideEditorUi(event.relatedTarget)) {
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Enable / disable edit mode
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function enableEditMode() {
|
||||||
|
// Guard React reconciliation before any inline edit rewrites DOM subtrees.
|
||||||
|
patchReactDomMutations();
|
||||||
|
|
||||||
|
document.getElementById("root")?.setAttribute("data-edit-mode-enabled", "true");
|
||||||
|
|
||||||
|
if (!globalClickHandler) {
|
||||||
|
globalClickHandler = handleClick;
|
||||||
|
document.addEventListener("click", globalClickHandler, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleHistoryKeydown, true);
|
||||||
|
window.addEventListener("keydown", handleSelectionKeydown, true);
|
||||||
|
window.addEventListener("keydown", handleHostKeyGuard, true);
|
||||||
|
window.addEventListener("keyup", handleHostKeyGuard, true);
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", handleTypeTooltipMouseMove, true);
|
||||||
|
document.addEventListener("mousedown", handleTypeTooltipMouseDown, true);
|
||||||
|
document.addEventListener("dragstart", hideTypeTooltipImmediately, true);
|
||||||
|
document.addEventListener("mouseleave", hideTypeTooltipImmediately);
|
||||||
|
document.addEventListener("scroll", handleTypeTooltipScroll, true);
|
||||||
|
window.addEventListener("blur", hideTypeTooltipImmediately);
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleSelectMouseDown, true);
|
||||||
|
document.addEventListener("mousemove", handleSelectMouseMove, true);
|
||||||
|
document.addEventListener("mouseup", handleSelectMouseUp, true);
|
||||||
|
// A mouseup outside the iframe never reaches us, so the band would stay up.
|
||||||
|
window.addEventListener("blur", cancelDragSelect);
|
||||||
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
|
||||||
|
window.addEventListener("pointerdown", handleModalPointerDownGuard, true);
|
||||||
|
window.addEventListener("pointerup", handleModalPointerDownGuard, true);
|
||||||
|
window.addEventListener("focusin", handleEditorFocusGuard, true);
|
||||||
|
window.addEventListener("focusout", handleEditorFocusGuard, true);
|
||||||
|
|
||||||
|
if (!annotationDomObserver && typeof MutationObserver !== "undefined") {
|
||||||
|
annotationDomObserver = new MutationObserver(scheduleAnnotationDomSync);
|
||||||
|
annotationDomObserver.observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableEditMode() {
|
||||||
|
const editing = getEditing();
|
||||||
|
if (editing?.targetElement?.hasAttribute("contenteditable")) {
|
||||||
|
commitCurrentEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("root")?.removeAttribute("data-edit-mode-enabled");
|
||||||
|
|
||||||
|
currentImageEdit = null;
|
||||||
|
hideAnnotationPanel();
|
||||||
|
annotationDomObserver?.disconnect();
|
||||||
|
annotationDomObserver = null;
|
||||||
|
if (annotationDomSyncFrameId !== null) {
|
||||||
|
cancelAnimationFrame(annotationDomSyncFrameId);
|
||||||
|
annotationDomSyncFrameId = null;
|
||||||
|
}
|
||||||
|
clearAllAnnotationMarkers();
|
||||||
|
setComments([]);
|
||||||
|
|
||||||
|
if (globalClickHandler) {
|
||||||
|
document.removeEventListener("click", globalClickHandler, true);
|
||||||
|
globalClickHandler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener("keydown", handleHistoryKeydown, true);
|
||||||
|
window.removeEventListener("keydown", handleSelectionKeydown, true);
|
||||||
|
window.removeEventListener("keydown", handleHostKeyGuard, true);
|
||||||
|
window.removeEventListener("keyup", handleHostKeyGuard, true);
|
||||||
|
|
||||||
|
document.removeEventListener("mousemove", handleTypeTooltipMouseMove, true);
|
||||||
|
document.removeEventListener("mousedown", handleTypeTooltipMouseDown, true);
|
||||||
|
document.removeEventListener("dragstart", hideTypeTooltipImmediately, true);
|
||||||
|
document.removeEventListener("mouseleave", hideTypeTooltipImmediately);
|
||||||
|
document.removeEventListener("scroll", handleTypeTooltipScroll, true);
|
||||||
|
window.removeEventListener("blur", hideTypeTooltipImmediately);
|
||||||
|
|
||||||
|
document.removeEventListener("mousedown", handleSelectMouseDown, true);
|
||||||
|
document.removeEventListener("mousemove", handleSelectMouseMove, true);
|
||||||
|
document.removeEventListener("mouseup", handleSelectMouseUp, true);
|
||||||
|
window.removeEventListener("blur", cancelDragSelect);
|
||||||
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||||
|
window.removeEventListener("pointerdown", handleModalPointerDownGuard, true);
|
||||||
|
window.removeEventListener("pointerup", handleModalPointerDownGuard, true);
|
||||||
|
window.removeEventListener("focusin", handleEditorFocusGuard, true);
|
||||||
|
window.removeEventListener("focusout", handleEditorFocusGuard, true);
|
||||||
|
endDragSelect();
|
||||||
|
destroySelectionRect();
|
||||||
|
resetMultiSelectState();
|
||||||
|
unlockHoverOutline();
|
||||||
|
hideTypeTooltipImmediately();
|
||||||
|
suppressTypeTooltipUntilMove = false;
|
||||||
|
lastRawHitTestElement = null;
|
||||||
|
lastResolvedTypeTooltipTarget = null;
|
||||||
|
|
||||||
|
discardAllEdits();
|
||||||
|
postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState() });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ *
|
||||||
|
* Parent <-> iframe messaging
|
||||||
|
* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
window.addEventListener("message", function (event) {
|
||||||
|
if (!ALLOWED_PARENT_ORIGINS.includes(event.origin)) return;
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.ENABLE_EDIT_MODE) {
|
||||||
|
if (event.data?.translations) {
|
||||||
|
setEditorTranslations(event.data.translations);
|
||||||
|
}
|
||||||
|
enableEditMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.DISABLE_EDIT_MODE) {
|
||||||
|
disableEditMode();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text editing now happens in-iframe via contenteditable; `edit-save` only
|
||||||
|
// remains to keep the existing parent-driven image picker working.
|
||||||
|
if (event.data?.type === ChildMessage.EDIT_SAVE) {
|
||||||
|
handleImageEditSave(event.data?.payload?.newText);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.EDIT_CANCEL) {
|
||||||
|
clearImageEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.DRAFT_SAVE) {
|
||||||
|
handleDraftSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.DRAFT_DISCARD) {
|
||||||
|
handleDraftDiscard();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.DRAFT_RESTORE) {
|
||||||
|
applyDraftSnapshot(event.data?.payload?.snapshot, { onMarkerClick: openPanelForEdit });
|
||||||
|
postToParent(ParentMessage.EDIT_STATE_CHANGED, { ...getEditState(), isDraftRestored: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.DRAFT_SNAPSHOT_REQUEST) {
|
||||||
|
notifyDraftStateChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.EDIT_UNDO) {
|
||||||
|
applySessionUndo();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.EDIT_REDO) {
|
||||||
|
applySessionRedo();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.ANNOTATION_IMAGE_ATTACHED) {
|
||||||
|
addPendingAttachments(event.data?.payload?.attachments);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data?.type === ChildMessage.ANNOTATION_OPEN_FOR_IMAGE) {
|
||||||
|
if (!currentImageEdit) return;
|
||||||
|
const { targetElement } = currentImageEdit;
|
||||||
|
clearImageEdit();
|
||||||
|
|
||||||
|
const clickX = (window.innerWidth - ANNOTATION_PANEL_WIDTH) / 2;
|
||||||
|
const clickY = (window.innerHeight - ANNOTATION_PANEL_ESTIMATED_HEIGHT) / 2 - PANEL_GAP;
|
||||||
|
openOrCreateAnnotation(targetElement, clickX, clickY);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,423 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import traverseBabel from '@babel/traverse';
|
||||||
|
import * as t from '@babel/types';
|
||||||
|
import {
|
||||||
|
validateFilePath,
|
||||||
|
findJSXElementAtPosition,
|
||||||
|
generateSourceWithMap,
|
||||||
|
VITE_PROJECT_ROOT,
|
||||||
|
} from '../../utils/ast-utils.js';
|
||||||
|
import { decodeHtmlEntities, cssPropToCamel, toJsxAttributeName } from '../utils/html-utils.js';
|
||||||
|
|
||||||
|
const WRITABLE_ATTRIBUTE_TARGETS = new Set(['src', 'href', 'to']);
|
||||||
|
const JSX_STYLE_PROPERTY_PATTERN = /^[A-Za-z_$][\w$]*$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses `filePath:line:column` edit IDs produced by the transform plugin.
|
||||||
|
*
|
||||||
|
* @param {string} editId
|
||||||
|
* @returns {{ filePath: string, line: number, column: number }|null}
|
||||||
|
*/
|
||||||
|
function parseEditId(editId) {
|
||||||
|
const segments = editId.split(':');
|
||||||
|
if (segments.length < 3) return null;
|
||||||
|
const column = parseInt(segments.at(-1), 10);
|
||||||
|
const line = parseInt(segments.at(-2), 10);
|
||||||
|
const filePath = segments.slice(0, -2).join(':');
|
||||||
|
if (!filePath || isNaN(line) || isNaN(column)) return null;
|
||||||
|
return { filePath, line, column };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an HTML `style="..."` string into a Babel JSX style object expression.
|
||||||
|
*
|
||||||
|
* @param {string} styleString
|
||||||
|
* @returns {import('@babel/types').JSXExpressionContainer}
|
||||||
|
*/
|
||||||
|
function parseStyleString(styleString) {
|
||||||
|
const decoded = decodeHtmlEntities(styleString);
|
||||||
|
const properties = [];
|
||||||
|
for (const declaration of decoded.split(';')) {
|
||||||
|
const colonIndex = declaration.indexOf(':');
|
||||||
|
if (colonIndex < 0) continue;
|
||||||
|
const key = declaration.slice(0, colonIndex).trim();
|
||||||
|
const value = declaration.slice(colonIndex + 1).trim();
|
||||||
|
if (!key || !value) continue;
|
||||||
|
properties.push(
|
||||||
|
t.objectProperty(t.identifier(cssPropToCamel(key)), t.stringLiteral(value))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return t.jsxExpressionContainer(t.objectExpression(properties));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses HTML attribute string from saved innerHTML into JSX attribute nodes.
|
||||||
|
* Skips `data-edit-*` attributes; maps names via {@link toJsxAttributeName}.
|
||||||
|
*
|
||||||
|
* @param {string} rawAttributes
|
||||||
|
* @returns {import('@babel/types').JSXAttribute[]}
|
||||||
|
*/
|
||||||
|
function parseHtmlAttributesToJsx(rawAttributes) {
|
||||||
|
if (!rawAttributes || !rawAttributes.trim()) return [];
|
||||||
|
const attributes = [];
|
||||||
|
const ATTRIBUTE_REGEX = /([\w-]+)\s*=\s*"([^"]*)"/g;
|
||||||
|
let match;
|
||||||
|
while ((match = ATTRIBUTE_REGEX.exec(rawAttributes)) !== null) {
|
||||||
|
const [, rawName, value] = match;
|
||||||
|
// data-edit-* are injected by the visual-editor transform; never write them to source
|
||||||
|
if (rawName.startsWith('data-edit-')) continue;
|
||||||
|
const name = toJsxAttributeName(rawName);
|
||||||
|
if (name === 'style') {
|
||||||
|
attributes.push(t.jsxAttribute(t.jsxIdentifier(name), parseStyleString(value)));
|
||||||
|
} else {
|
||||||
|
attributes.push(t.jsxAttribute(t.jsxIdentifier(name), t.stringLiteral(decodeHtmlEntities(value))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds JSX children from saved innerHTML, reconstructing every tag structurally.
|
||||||
|
* Rendered `<svg>` blocks are matched back, in order, to `iconQueue` (original icon nodes).
|
||||||
|
*
|
||||||
|
* @param {string} html
|
||||||
|
* @param {import('@babel/types').JSXElement[]} iconQueue - consumed as rendered `<svg>` blocks are matched.
|
||||||
|
* @returns {import('@babel/types').JSXChild[]}
|
||||||
|
*/
|
||||||
|
function buildChildrenFromText(html, iconQueue) {
|
||||||
|
if (!html || html.trim() === '') return [];
|
||||||
|
|
||||||
|
// Decode </> back to literal tags (not ", which TAG_REGEX relies on as a delimiter).
|
||||||
|
html = html.replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
const TAG_REGEX = /<(\/?)(\w+)((?:\s+[\w-]+(?:\s*=\s*"[^"]*")?)*)\s*(\/?)>/g;
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
const stack = [{ children: result }];
|
||||||
|
let lastIndex = 0;
|
||||||
|
/** Depth inside a rendered `<svg>` block being skipped because it was matched to a preserved icon. */
|
||||||
|
let skippedSvgDepth = 0;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = TAG_REGEX.exec(html)) !== null) {
|
||||||
|
const [full, closing, tagName, rawAttributes, selfClose] = match;
|
||||||
|
const lower = tagName.toLowerCase();
|
||||||
|
|
||||||
|
if (skippedSvgDepth > 0) {
|
||||||
|
if (lower === 'svg' && selfClose !== '/') {
|
||||||
|
skippedSvgDepth += closing === '/' ? -1 : 1;
|
||||||
|
}
|
||||||
|
lastIndex = match.index + full.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
const text = html.slice(lastIndex, match.index);
|
||||||
|
if (text) stack[stack.length - 1].children.push(t.jsxText(text));
|
||||||
|
}
|
||||||
|
lastIndex = match.index + full.length;
|
||||||
|
|
||||||
|
if (closing === '/') {
|
||||||
|
if (stack.length > 1 && stack[stack.length - 1].tag === lower) {
|
||||||
|
const frame = stack.pop();
|
||||||
|
const attributes = parseHtmlAttributesToJsx(frame.rawAttrs);
|
||||||
|
stack[stack.length - 1].children.push(
|
||||||
|
t.jsxElement(
|
||||||
|
t.jsxOpeningElement(t.jsxIdentifier(frame.tag), attributes),
|
||||||
|
t.jsxClosingElement(t.jsxIdentifier(frame.tag)),
|
||||||
|
frame.children
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lower === 'svg' && selfClose !== '/' && iconQueue.length > 0) {
|
||||||
|
stack[stack.length - 1].children.push(iconQueue.shift());
|
||||||
|
skippedSvgDepth = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Void elements (img, br, ...) never get a closing tag in serialized HTML; others always do.
|
||||||
|
const hasClosingTag = new RegExp(`</${lower}\\s*>`, 'i').test(html.slice(lastIndex));
|
||||||
|
if (selfClose === '/' || !hasClosingTag) {
|
||||||
|
const attributes = parseHtmlAttributesToJsx(rawAttributes);
|
||||||
|
stack[stack.length - 1].children.push(
|
||||||
|
t.jsxElement(t.jsxOpeningElement(t.jsxIdentifier(lower), attributes, true), null, [], true)
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.push({ tag: lower, children: [], rawAttrs: rawAttributes || '' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < html.length) {
|
||||||
|
const text = html.slice(lastIndex);
|
||||||
|
if (text) stack[stack.length - 1].children.push(t.jsxText(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-close any unclosed tags (defensive: malformed/truncated captured HTML).
|
||||||
|
while (stack.length > 1) {
|
||||||
|
const frame = stack.pop();
|
||||||
|
const attributes = parseHtmlAttributesToJsx(frame.rawAttrs);
|
||||||
|
stack[stack.length - 1].children.push(
|
||||||
|
t.jsxElement(
|
||||||
|
t.jsxOpeningElement(t.jsxIdentifier(frame.tag), attributes),
|
||||||
|
t.jsxClosingElement(t.jsxIdentifier(frame.tag)),
|
||||||
|
frame.children
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges or creates a JSX `style={{ ... }}` object on the opening element.
|
||||||
|
* An empty-string value means the editor removed the inline property, so the
|
||||||
|
* matching JSX style entry is dropped instead of written as `prop: ""`.
|
||||||
|
*
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingElement
|
||||||
|
* @param {Record<string, string>} styleObject
|
||||||
|
* @returns {boolean} Whether the style shape was safe to update.
|
||||||
|
*/
|
||||||
|
function setJsxStyleProperty(openingElement, styleObject) {
|
||||||
|
const existing = openingElement.attributes.find(attribute =>
|
||||||
|
t.isJSXAttribute(attribute) && attribute.name?.name === 'style'
|
||||||
|
);
|
||||||
|
if (existing && t.isJSXExpressionContainer(existing.value) && t.isObjectExpression(existing.value.expression)) {
|
||||||
|
const properties = existing.value.expression.properties;
|
||||||
|
for (const [key, value] of Object.entries(styleObject)) {
|
||||||
|
const propertyIndex = properties.findIndex(property =>
|
||||||
|
t.isObjectProperty(property) && t.isIdentifier(property.key) && property.key.name === key
|
||||||
|
);
|
||||||
|
if (!value) {
|
||||||
|
if (propertyIndex !== -1) properties.splice(propertyIndex, 1);
|
||||||
|
} else if (propertyIndex !== -1) {
|
||||||
|
properties[propertyIndex].value = t.stringLiteral(value);
|
||||||
|
} else {
|
||||||
|
properties.push(t.objectProperty(t.identifier(key), t.stringLiteral(value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else if (!existing) {
|
||||||
|
const properties = Object.entries(styleObject)
|
||||||
|
.filter(([, value]) => !!value)
|
||||||
|
.map(([key, value]) => t.objectProperty(t.identifier(key), t.stringLiteral(value)));
|
||||||
|
if (properties.length === 0) return true;
|
||||||
|
openingElement.attributes.push(
|
||||||
|
t.jsxAttribute(t.jsxIdentifier('style'), t.jsxExpressionContainer(t.objectExpression(properties)))
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets or removes a static JSX attribute.
|
||||||
|
*
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingElement
|
||||||
|
* @param {string} attributeName
|
||||||
|
* @param {string|null} value - `null` removes the attribute.
|
||||||
|
* @returns {{ modified: true }|{ modified: false, error: string }}
|
||||||
|
*/
|
||||||
|
function setJsxAttribute(openingElement, attributeName, value) {
|
||||||
|
const attributeIndex = openingElement.attributes.findIndex(attribute =>
|
||||||
|
t.isJSXAttribute(attribute) && attribute.name?.name === attributeName
|
||||||
|
);
|
||||||
|
const existingAttribute = openingElement.attributes[attributeIndex];
|
||||||
|
|
||||||
|
if (value === null) {
|
||||||
|
if (attributeIndex !== -1) openingElement.attributes.splice(attributeIndex, 1);
|
||||||
|
return { modified: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const newLiteral = t.stringLiteral(value);
|
||||||
|
if (!existingAttribute) {
|
||||||
|
openingElement.attributes.push(t.jsxAttribute(t.jsxIdentifier(attributeName), newLiteral));
|
||||||
|
return { modified: true };
|
||||||
|
}
|
||||||
|
if (t.isStringLiteral(existingAttribute.value)) {
|
||||||
|
existingAttribute.value = newLiteral;
|
||||||
|
return { modified: true };
|
||||||
|
}
|
||||||
|
if (t.isJSXExpressionContainer(existingAttribute.value) && t.isStringLiteral(existingAttribute.value.expression)) {
|
||||||
|
existingAttribute.value = newLiteral;
|
||||||
|
return { modified: true };
|
||||||
|
}
|
||||||
|
return { modified: false, error: `Cannot write '${attributeName}' — source value is not a static string literal` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse + validate every draftKey; group valid edits by absolute file path;
|
||||||
|
* push error entries for invalid ones.
|
||||||
|
* @param {Record<string, object>} draft
|
||||||
|
* @returns {{ editsByFile: Map<string, object[]>, results: object[] }}
|
||||||
|
*/
|
||||||
|
export function groupEditsByFile(draft) {
|
||||||
|
const results = [];
|
||||||
|
const editsByFile = new Map();
|
||||||
|
|
||||||
|
for (const [draftKey, edit] of Object.entries(draft)) {
|
||||||
|
let editId = draftKey;
|
||||||
|
const atIndex = editId.indexOf('@');
|
||||||
|
if (atIndex >= 0) editId = editId.slice(0, atIndex);
|
||||||
|
|
||||||
|
const parsedId = parseEditId(editId);
|
||||||
|
if (!parsedId) {
|
||||||
|
results.push({ draftKey, success: false, error: 'Invalid editId format' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validation = validateFilePath(parsedId.filePath);
|
||||||
|
if (!validation.isValid) {
|
||||||
|
results.push({ draftKey, success: false, error: validation.error });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!editsByFile.has(validation.absolutePath)) {
|
||||||
|
editsByFile.set(validation.absolutePath, []);
|
||||||
|
}
|
||||||
|
editsByFile.get(validation.absolutePath).push({ draftKey, editId, edit, parsedId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { editsByFile, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the JSX opening element and apply its atomic content, attribute, and style changes.
|
||||||
|
* @param {object} babelAst
|
||||||
|
* @param {object} edit
|
||||||
|
* @param {{ filePath: string, line: number, column: number }} parsedId
|
||||||
|
* @returns {{ modified: true }|{ modified: false, error: string }}
|
||||||
|
*/
|
||||||
|
export function applyElementEdit(babelAst, edit, parsedId) {
|
||||||
|
const targetNodePath = findJSXElementAtPosition(babelAst, parsedId.line, parsedId.column + 1);
|
||||||
|
if (!targetNodePath) return { modified: false, error: 'Target node not found' };
|
||||||
|
|
||||||
|
const targetOpeningElement = targetNodePath.node;
|
||||||
|
const parentElementNode = targetNodePath.parentPath?.node;
|
||||||
|
const changes = edit.instruction?.changes;
|
||||||
|
if (!Array.isArray(changes) || changes.length === 0) {
|
||||||
|
return { modified: false, error: 'Edit has no changes' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentChanges = changes.filter(change => change.kind === 'content');
|
||||||
|
const attributeChanges = changes.filter(change => change.kind === 'attribute');
|
||||||
|
const styleChanges = changes.filter(change => change.kind === 'style');
|
||||||
|
if (contentChanges.length + attributeChanges.length + styleChanges.length !== changes.length) {
|
||||||
|
return { modified: false, error: 'Unsupported visual edit change kind' };
|
||||||
|
}
|
||||||
|
if (contentChanges.length > 1) {
|
||||||
|
return { modified: false, error: 'Edit contains multiple content changes' };
|
||||||
|
}
|
||||||
|
if (changes.some(change =>
|
||||||
|
(change.before !== null && typeof change.before !== 'string')
|
||||||
|
|| (change.after !== null && typeof change.after !== 'string')
|
||||||
|
)) {
|
||||||
|
return { modified: false, error: 'Visual edit change values must be strings or null' };
|
||||||
|
}
|
||||||
|
if (contentChanges.some(change => change.target !== null)) {
|
||||||
|
return { modified: false, error: 'Content changes must have a null target' };
|
||||||
|
}
|
||||||
|
if (contentChanges.length && (!parentElementNode || !t.isJSXElement(parentElementNode))) {
|
||||||
|
return { modified: false, error: 'Could not apply content change to AST' };
|
||||||
|
}
|
||||||
|
if ([...attributeChanges, ...styleChanges].some(change => typeof change.target !== 'string' || !change.target)) {
|
||||||
|
return { modified: false, error: 'Attribute and style changes require a target' };
|
||||||
|
}
|
||||||
|
if (attributeChanges.some(change => !WRITABLE_ATTRIBUTE_TARGETS.has(change.target))) {
|
||||||
|
return { modified: false, error: 'Unsupported visual edit attribute target' };
|
||||||
|
}
|
||||||
|
if (styleChanges.some(change => !JSX_STYLE_PROPERTY_PATTERN.test(change.target))) {
|
||||||
|
return { modified: false, error: 'Unsupported visual edit style target' };
|
||||||
|
}
|
||||||
|
if (styleChanges.length) {
|
||||||
|
const styleAttribute = targetOpeningElement.attributes.find(attribute =>
|
||||||
|
t.isJSXAttribute(attribute) && attribute.name?.name === 'style'
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
styleAttribute
|
||||||
|
&& !(
|
||||||
|
t.isJSXExpressionContainer(styleAttribute.value)
|
||||||
|
&& t.isObjectExpression(styleAttribute.value.expression)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return { modified: false, error: 'Cannot write style — source value is not a static object literal' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate every attribute before mutating the AST so a later unsupported
|
||||||
|
// dynamic value cannot leave an earlier change partially applied.
|
||||||
|
for (const change of attributeChanges) {
|
||||||
|
const existingAttribute = targetOpeningElement.attributes.find(attribute =>
|
||||||
|
t.isJSXAttribute(attribute) && attribute.name?.name === change.target
|
||||||
|
);
|
||||||
|
const isWritable = !existingAttribute
|
||||||
|
|| t.isStringLiteral(existingAttribute.value)
|
||||||
|
|| (
|
||||||
|
t.isJSXExpressionContainer(existingAttribute.value)
|
||||||
|
&& t.isStringLiteral(existingAttribute.value.expression)
|
||||||
|
);
|
||||||
|
if (!isWritable) {
|
||||||
|
return {
|
||||||
|
modified: false,
|
||||||
|
error: `Cannot write '${change.target}' — source value is not a static string literal`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const change of attributeChanges) {
|
||||||
|
const result = setJsxAttribute(targetOpeningElement, change.target, change.after);
|
||||||
|
if (!result.modified) return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentChanges.length) {
|
||||||
|
// Icon components can't be rebuilt from rendered HTML, so their original nodes pass through.
|
||||||
|
const preservedIcons = parentElementNode.children.filter(child =>
|
||||||
|
t.isJSXElement(child)
|
||||||
|
&& child.openingElement.selfClosing
|
||||||
|
&& child.openingElement.name?.name
|
||||||
|
&& /^[A-Z]/.test(child.openingElement.name.name)
|
||||||
|
);
|
||||||
|
parentElementNode.children = buildChildrenFromText(contentChanges[0].after ?? '', preservedIcons);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styleChanges.length) {
|
||||||
|
const style = Object.fromEntries(styleChanges.map(change => [change.target, change.after ?? '']));
|
||||||
|
if (!setJsxStyleProperty(targetOpeningElement, style)) {
|
||||||
|
return { modified: false, error: 'Cannot write style — source value is not a static object literal' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { modified: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip any lingering data-edit-* attributes from the AST and generate the new
|
||||||
|
* source, returning the result entry for the API response.
|
||||||
|
*
|
||||||
|
* Must never write to disk: `/api/draft-save` is reachable unauthenticated from
|
||||||
|
* the public preview origin, so persisting is the authenticated backend's job
|
||||||
|
* via `POST /api/websites/:id/visual-edits`.
|
||||||
|
*
|
||||||
|
* @param {object} babelAst
|
||||||
|
* @param {string} absoluteFilePath
|
||||||
|
* @param {string} originalContent
|
||||||
|
* @returns {{ filePath: string, newFileContent: string }}
|
||||||
|
*/
|
||||||
|
export function renderEditedSource(babelAst, absoluteFilePath, originalContent) {
|
||||||
|
// Strip any data-edit-* attributes before generating source.
|
||||||
|
const traverseFunction = traverseBabel.default || traverseBabel;
|
||||||
|
traverseFunction(babelAst, {
|
||||||
|
JSXOpeningElement(openingElementPath) {
|
||||||
|
openingElementPath.node.attributes = openingElementPath.node.attributes.filter(attribute =>
|
||||||
|
!(t.isJSXAttribute(attribute) && typeof attribute.name?.name === 'string'
|
||||||
|
&& attribute.name.name.startsWith('data-edit-'))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const webRelativeFilePath = path.relative(VITE_PROJECT_ROOT, absoluteFilePath).split(path.sep).join('/');
|
||||||
|
const output = generateSourceWithMap(babelAst, webRelativeFilePath, originalContent);
|
||||||
|
return { filePath: webRelativeFilePath, newFileContent: output.code };
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* A reference attachment the user added to a comment.
|
||||||
|
* @typedef {{ url: string, fileName: string, size: number, type: 'image' }} AnnotationAttachment
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A comment always targets a group: `elements` and `selections` are parallel,
|
||||||
|
* one entry per annotated element. A single annotation is just a group of one.
|
||||||
|
* @typedef {{
|
||||||
|
* elements: HTMLElement[],
|
||||||
|
* selections: object[],
|
||||||
|
* text: string,
|
||||||
|
* attachments: AnnotationAttachment[],
|
||||||
|
* clickX: number,
|
||||||
|
* clickY: number,
|
||||||
|
* fixed: boolean,
|
||||||
|
* marker?: HTMLElement,
|
||||||
|
* }} AnnotationComment
|
||||||
|
*/
|
||||||
|
|
||||||
|
let _editorTranslations = {};
|
||||||
|
/** @type {AnnotationComment[]} */
|
||||||
|
let _comments = [];
|
||||||
|
let _panelMode = 'create';
|
||||||
|
let _editingComment = null;
|
||||||
|
/** @type {HTMLElement[]} */
|
||||||
|
let _pendingElements = [];
|
||||||
|
/** @type {object[]} */
|
||||||
|
let _pendingSelections = [];
|
||||||
|
/** @type {AnnotationAttachment[]} */
|
||||||
|
let _pendingAttachments = [];
|
||||||
|
let _annotationClickX = 0;
|
||||||
|
let _annotationClickY = 0;
|
||||||
|
|
||||||
|
export function getComments() { return _comments; }
|
||||||
|
export function setComments(value) { _comments = value; }
|
||||||
|
|
||||||
|
export function getPanelMode() { return _panelMode; }
|
||||||
|
export function setPanelMode(value) { _panelMode = value; }
|
||||||
|
|
||||||
|
export function getEditingComment() { return _editingComment; }
|
||||||
|
export function setEditingComment(value) { _editingComment = value; }
|
||||||
|
|
||||||
|
export function getPendingElements() { return _pendingElements; }
|
||||||
|
export function setPendingElements(value) { _pendingElements = value ?? []; }
|
||||||
|
|
||||||
|
export function getPendingSelections() { return _pendingSelections; }
|
||||||
|
export function setPendingSelections(value) { _pendingSelections = value ?? []; }
|
||||||
|
|
||||||
|
export function getPendingAttachments() { return _pendingAttachments; }
|
||||||
|
export function setPendingAttachments(value) { _pendingAttachments = value ?? []; }
|
||||||
|
|
||||||
|
export function getAnnotationClickX() { return _annotationClickX; }
|
||||||
|
export function setAnnotationClickX(value) { _annotationClickX = value; }
|
||||||
|
|
||||||
|
export function getAnnotationClickY() { return _annotationClickY; }
|
||||||
|
export function setAnnotationClickY(value) { _annotationClickY = value; }
|
||||||
|
|
||||||
|
export function getEditorTranslations() { return _editorTranslations; }
|
||||||
|
export function setEditorTranslations(value) { _editorTranslations = value; }
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/** Active inline edit session; `null` when no element is being edited. */
|
||||||
|
let current = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{ editId: string, targetElement: HTMLElement, sessionId: number, originalContent: string,
|
||||||
|
* originalTextAlign: string, originalInlineStyles: object,
|
||||||
|
* savedChildren: Array, hasSoftSaved?: boolean }|null}
|
||||||
|
*/
|
||||||
|
export function getEditing() {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ editId: string, targetElement: HTMLElement, originalContent: string,
|
||||||
|
* originalTextAlign: string, originalInlineStyles: object,
|
||||||
|
* savedChildren: Array }} info
|
||||||
|
*/
|
||||||
|
export function setEditing(info) {
|
||||||
|
current = info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the active edit session (called on commit or discard). */
|
||||||
|
export function clearEditing() {
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import { sanitizeText, patchRemoveChild } from "../utils/html-utils.js";
|
||||||
|
import { getComments } from "./annotation-state.js";
|
||||||
|
import { getEditId } from "../constants/selectors.js";
|
||||||
|
|
||||||
|
/** Maximum undo/redo entries; oldest entry is dropped when the log exceeds this. */
|
||||||
|
const MAX_HISTORY = 25;
|
||||||
|
|
||||||
|
/** Committed and redoable edit actions for the current session. */
|
||||||
|
const actionLog = [];
|
||||||
|
|
||||||
|
/** Index of the last committed entry in `actionLog`; -1 when nothing is committed. */
|
||||||
|
let actionPointer = -1;
|
||||||
|
|
||||||
|
/** Monotonic id assigned at the start of each inline edit session. */
|
||||||
|
let nextEditSessionId = 1;
|
||||||
|
|
||||||
|
/** @returns {number} A new session id for the next inline edit burst. */
|
||||||
|
export function beginEditSession() {
|
||||||
|
return nextEditSessionId++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {number} index of the last committed entry; -1 if nothing committed */
|
||||||
|
export function getActionPointer() {
|
||||||
|
return actionPointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {number} total entries in the log, including undone future entries */
|
||||||
|
export function getActionLogLength() {
|
||||||
|
return actionLog.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wipes all undo/redo history (e.g. on discard or new session). */
|
||||||
|
export function clearHistory() {
|
||||||
|
actionLog.length = 0;
|
||||||
|
actionPointer = -1;
|
||||||
|
nextEditSessionId = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable deduplication key for an action.
|
||||||
|
* Direct edits target one source location (AST writer), so they key by editId only.
|
||||||
|
* Assisted edits can target separate rendered instances of the same JSX expression,
|
||||||
|
* so they also include the captured selector.
|
||||||
|
* @param {{ editId: string, attribute?: string|null, isAssisted?: boolean, selectionMode?: object|null }} action
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getActionKey(action) {
|
||||||
|
const attributeSuffix = action.attribute ? `@${action.attribute}` : '';
|
||||||
|
const selector = action.isAssisted ? action.selectionMode.selector : null;
|
||||||
|
const instanceSuffix = selector ? `@selector:${selector}` : '';
|
||||||
|
return `${action.editId}${attributeSuffix}${instanceSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain text from a stored HTML snapshot (beforeContent), for matching mapped
|
||||||
|
* siblings that share one edit id.
|
||||||
|
* @param {string|null|undefined} html
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
function plainTextFromStoredHtml(html) {
|
||||||
|
if (typeof html !== "string" || !html) return null;
|
||||||
|
const text = html.replace(/<[^>]*>/g, "").trim();
|
||||||
|
return text || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the DOM element for an action. Prefers the stored element reference
|
||||||
|
* (survives HMR position shifts). When several nodes share one JSX edit id
|
||||||
|
* (e.g. letter spans from `.map()`), disambiguates with selectionMode text /
|
||||||
|
* beforeContent — same approach as annotation `resolveByEditId`. Never falls
|
||||||
|
* back to the first querySelector match when duplicates exist.
|
||||||
|
* @param {object} action
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
|
*/
|
||||||
|
function resolveElement(action) {
|
||||||
|
if (action.element?.isConnected) return action.element;
|
||||||
|
|
||||||
|
const matches = Array.from(document.querySelectorAll(
|
||||||
|
`[data-edit-id="${action.editId}"], [data-edit-assisted-id="${action.editId}"]`,
|
||||||
|
));
|
||||||
|
if (matches.length <= 1) return matches[0] ?? null;
|
||||||
|
|
||||||
|
const text = action.selectionMode?.textContent?.trim()
|
||||||
|
|| plainTextFromStoredHtml(action.instruction?.beforeContent);
|
||||||
|
if (!text) return null;
|
||||||
|
|
||||||
|
return matches.find((match) => match.textContent?.trim() === text) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the DOM element for an edit and writes a value to it (used by undo/redo).
|
||||||
|
* @param {object} action - The history action entry (has element ref + editId).
|
||||||
|
* @param {string} value
|
||||||
|
*/
|
||||||
|
function applyValueToElement(action, value) {
|
||||||
|
const element = resolveElement(action);
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
if (action.attribute) {
|
||||||
|
const domAttribute = action.attribute === 'to' ? 'href' : action.attribute;
|
||||||
|
if (value === null) {
|
||||||
|
element.removeAttribute(domAttribute);
|
||||||
|
} else {
|
||||||
|
element.setAttribute(domAttribute, value);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element.tagName.toLowerCase() === "img") {
|
||||||
|
element.setAttribute("src", value);
|
||||||
|
} else {
|
||||||
|
patchRemoveChild(element);
|
||||||
|
element.innerHTML = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies element-level inline style props for undo/redo. Setting a prop to ''
|
||||||
|
* removes the inline value so CSS-class styling takes over again.
|
||||||
|
* @param {object} action - The history action entry.
|
||||||
|
* @param {object|null} styleProperties - camelCase property → value (or '' to clear)
|
||||||
|
*/
|
||||||
|
function applyStyleToElement(action, styleProperties) {
|
||||||
|
if (!styleProperties) return;
|
||||||
|
const element = resolveElement(action);
|
||||||
|
if (!element) return;
|
||||||
|
for (const [key, value] of Object.entries(styleProperties)) {
|
||||||
|
element.style[key] = value || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records an edit, coalescing same-session changes to the same element into one
|
||||||
|
* undo/redo step. Attribute edits (e.g. href) are keyed separately via {@link getActionKey}.
|
||||||
|
*
|
||||||
|
* @param {string} editId
|
||||||
|
* @param {{ beforeContent: string|null, afterContent: string|null }} instruction - Internal undo/redo snapshot.
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {object|null} [options.style] - Net element-level style props applied.
|
||||||
|
* @param {object|null} [options.oldStyle] - Previous inline values of those props ('' = unset).
|
||||||
|
* @param {string|null} [options.attribute]
|
||||||
|
* @param {HTMLElement|null} [options.element]
|
||||||
|
* @param {number|null} [options.sessionId] - Inline edit session; coalesces only when equal.
|
||||||
|
* @param {boolean} [options.isAssisted] - True when the source-writer can't safely apply this edit and the AI must.
|
||||||
|
* @param {object|null} [options.selectionMode] - Rich DOM context captured before mutation (see `captureElementMetadata`).
|
||||||
|
*/
|
||||||
|
export function recordEdit(editId, instruction, { style = null, oldStyle = null, attribute = null, element = null, sessionId = null, isAssisted = false, selectionMode = null } = {}) {
|
||||||
|
const composite = getActionKey({ editId, attribute, isAssisted, selectionMode });
|
||||||
|
const lastForEdit = [...actionLog].slice(0, actionPointer + 1).findLast(action => getActionKey(action) === composite);
|
||||||
|
if (lastForEdit && lastForEdit.instruction.afterContent === instruction.afterContent && !style) return;
|
||||||
|
|
||||||
|
if (lastForEdit
|
||||||
|
&& actionLog.lastIndexOf(lastForEdit) === actionPointer
|
||||||
|
&& lastForEdit.sessionId === sessionId) {
|
||||||
|
lastForEdit.instruction = { ...lastForEdit.instruction, afterContent: instruction.afterContent };
|
||||||
|
if (style) {
|
||||||
|
lastForEdit.style = { ...lastForEdit.style, ...style };
|
||||||
|
lastForEdit.oldStyle = { ...oldStyle, ...lastForEdit.oldStyle };
|
||||||
|
}
|
||||||
|
if (element) lastForEdit.element = element;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
actionLog.length = actionPointer + 1;
|
||||||
|
actionLog.push({ editId, instruction, style, oldStyle, attribute, element, sessionId, isAssisted, selectionMode });
|
||||||
|
|
||||||
|
if (actionLog.length > MAX_HISTORY) {
|
||||||
|
actionLog.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
actionPointer = actionLog.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-applies every committed edit's content/attribute/style to its element,
|
||||||
|
* for entries whose stored element reference went stale (a route change or
|
||||||
|
* React re-render replaced it with a freshly-mounted, un-edited node).
|
||||||
|
*/
|
||||||
|
export function reapplyCommittedEdits() {
|
||||||
|
for (let index = 0; index <= actionPointer; index++) {
|
||||||
|
const action = actionLog[index];
|
||||||
|
if (action.element?.isConnected) continue;
|
||||||
|
|
||||||
|
const element = resolveElement(action);
|
||||||
|
if (!element) continue;
|
||||||
|
|
||||||
|
action.element = element;
|
||||||
|
applyValueToElement(action, action.instruction.afterContent);
|
||||||
|
if (action.style) applyStyleToElement(action, action.style);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverts the most recent committed edit (content and element-level styles)
|
||||||
|
* and moves the pointer back.
|
||||||
|
* @returns {{ editId: string, instruction: { beforeContent: string, afterContent: string } }|null}
|
||||||
|
*/
|
||||||
|
export function undo() {
|
||||||
|
if (actionPointer < 0) return null;
|
||||||
|
const action = actionLog[actionPointer];
|
||||||
|
applyValueToElement(action, action.instruction.beforeContent);
|
||||||
|
if (action.style) {
|
||||||
|
const revert = {};
|
||||||
|
for (const key of Object.keys(action.style)) {
|
||||||
|
revert[key] = action.oldStyle?.[key] ?? '';
|
||||||
|
}
|
||||||
|
applyStyleToElement(action, revert);
|
||||||
|
}
|
||||||
|
actionPointer--;
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-applies the next undone edit (content and element-level styles) and
|
||||||
|
* advances the pointer.
|
||||||
|
* @returns {{ editId: string, instruction: { beforeContent: string, afterContent: string } }|null}
|
||||||
|
*/
|
||||||
|
export function redo() {
|
||||||
|
if (actionPointer >= actionLog.length - 1) return null;
|
||||||
|
actionPointer++;
|
||||||
|
const action = actionLog[actionPointer];
|
||||||
|
applyValueToElement(action, action.instruction.afterContent);
|
||||||
|
applyStyleToElement(action, action.style);
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregates the committed log into one draft per target: the first entry for a key
|
||||||
|
* captures the original instruction/style, later entries update `afterContent`/style.
|
||||||
|
* @returns {Map<string, { editId: string, attribute: string|null, instruction: { beforeContent: string, afterContent: string }, originalStyle: Record<string, string>, currentStyle: Record<string, string>, isAssisted: boolean, selectionMode: object|null }>}
|
||||||
|
*/
|
||||||
|
function collectEditDrafts() {
|
||||||
|
const draftsByKey = new Map();
|
||||||
|
for (let index = 0; index <= actionPointer; index++) {
|
||||||
|
const action = actionLog[index];
|
||||||
|
const key = getActionKey(action);
|
||||||
|
let draft = draftsByKey.get(key);
|
||||||
|
if (!draft) {
|
||||||
|
draft = {
|
||||||
|
editId: action.editId,
|
||||||
|
attribute: action.attribute ?? null,
|
||||||
|
instruction: {
|
||||||
|
beforeContent: action.instruction.beforeContent,
|
||||||
|
afterContent: action.instruction.afterContent,
|
||||||
|
},
|
||||||
|
originalStyle: {},
|
||||||
|
currentStyle: {},
|
||||||
|
isAssisted: action.isAssisted ?? false,
|
||||||
|
selectionMode: action.selectionMode ?? null,
|
||||||
|
};
|
||||||
|
draftsByKey.set(key, draft);
|
||||||
|
}
|
||||||
|
draft.instruction.afterContent = action.instruction.afterContent;
|
||||||
|
for (const [property, value] of Object.entries(action.style ?? {})) {
|
||||||
|
if (!(property in draft.originalStyle)) {
|
||||||
|
draft.originalStyle[property] = action.oldStyle?.[property] ?? '';
|
||||||
|
}
|
||||||
|
draft.currentStyle[property] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return draftsByKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collapses the committed log into one net edit per target, shaped like the wire
|
||||||
|
* `VisualEditItem`, keyed separately for source persistence and history aggregation.
|
||||||
|
* Content, attribute, and individual style-property changes are emitted atomically
|
||||||
|
* so downstream consumers do not have to infer what a before/after pair represents.
|
||||||
|
* @returns {Record<string, { editId: string, instruction: { changes: Array<{ kind: string, target: string|null, before: string|null, after: string|null }> }, isAssisted: boolean, selectionMode: object|null }>}
|
||||||
|
*/
|
||||||
|
export function getCurrentEdits() {
|
||||||
|
const edits = {};
|
||||||
|
for (const [key, draft] of collectEditDrafts()) {
|
||||||
|
const changes = [];
|
||||||
|
const beforeContent = draft.attribute
|
||||||
|
? draft.instruction.beforeContent ?? null
|
||||||
|
: sanitizeText(draft.instruction.beforeContent);
|
||||||
|
const afterContent = draft.attribute
|
||||||
|
? draft.instruction.afterContent ?? null
|
||||||
|
: sanitizeText(draft.instruction.afterContent);
|
||||||
|
|
||||||
|
if (beforeContent !== afterContent) {
|
||||||
|
changes.push({
|
||||||
|
kind: draft.attribute ? 'attribute' : 'content',
|
||||||
|
target: draft.attribute,
|
||||||
|
before: beforeContent,
|
||||||
|
after: afterContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const property of Object.keys(draft.currentStyle)) {
|
||||||
|
if ((draft.currentStyle[property] ?? '') !== (draft.originalStyle[property] ?? '')) {
|
||||||
|
changes.push({
|
||||||
|
kind: 'style',
|
||||||
|
target: property,
|
||||||
|
before: draft.originalStyle[property] || null,
|
||||||
|
after: draft.currentStyle[property] || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!changes.length) continue;
|
||||||
|
|
||||||
|
const edit = {
|
||||||
|
editId: draft.editId,
|
||||||
|
instruction: { changes },
|
||||||
|
isAssisted: !!draft.isAssisted,
|
||||||
|
selectionMode: draft.selectionMode,
|
||||||
|
};
|
||||||
|
edits[key] = edit;
|
||||||
|
}
|
||||||
|
return edits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorizes the current, uncommitted edit set — used to report which kinds of
|
||||||
|
* changes a save actually carries, as opposed to which edit surfaces were merely
|
||||||
|
* opened during the session (a text/image edit entered and then reverted leaves
|
||||||
|
* no trace here).
|
||||||
|
* @param {ReturnType<typeof getCurrentEdits>} edits
|
||||||
|
* @param {ReturnType<typeof getComments>} annotations
|
||||||
|
* @returns {string[]} distinct categories: "text" | "image" | "annotation"
|
||||||
|
*/
|
||||||
|
function getEditTypes(edits, annotations) {
|
||||||
|
const editTypes = new Set();
|
||||||
|
for (const edit of Object.values(edits)) {
|
||||||
|
const isImageEdit = edit.instruction.changes.some(change => change.kind === 'attribute' && change.target === 'src');
|
||||||
|
editTypes.add(isImageEdit ? 'image' : 'text');
|
||||||
|
}
|
||||||
|
if (annotations.length) editTypes.add('annotation');
|
||||||
|
return [...editTypes];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo/redo availability plus the count of distinct elements that differ from their original,
|
||||||
|
* including elements that only carry an annotation (no history edit).
|
||||||
|
* @returns {{ canUndo: boolean, canRedo: boolean, editedElementsCount: number, editTypes: string[] }}
|
||||||
|
*/
|
||||||
|
export function getEditState() {
|
||||||
|
const edits = getCurrentEdits();
|
||||||
|
const annotations = getComments();
|
||||||
|
|
||||||
|
const changedElementIds = new Set(
|
||||||
|
Object.values(edits).map(edit => getActionKey({ ...edit, attribute: null })),
|
||||||
|
);
|
||||||
|
|
||||||
|
annotations.forEach((annotation, annotationIndex) => {
|
||||||
|
if (!annotation.elements.length) {
|
||||||
|
changedElementIds.add(`annotation-${annotationIndex}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
annotation.elements.forEach((element, elementIndex) => {
|
||||||
|
changedElementIds.add(getEditId(element) || `annotation-${annotationIndex}-${elementIndex}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
canUndo: actionPointer >= 0,
|
||||||
|
canRedo: actionPointer < actionLog.length - 1,
|
||||||
|
editedElementsCount: changedElementIds.size,
|
||||||
|
editTypes: getEditTypes(edits, annotations),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes the full undo/redo stack for persistence across reloads.
|
||||||
|
* Strips non-serializable DOM element refs.
|
||||||
|
* @returns {{ actionLog: object[], actionPointer: number, nextEditSessionId: number }}
|
||||||
|
*/
|
||||||
|
export function exportDraftSnapshot() {
|
||||||
|
return {
|
||||||
|
actionLog: actionLog.map(({ element: _element, ...action }) => ({
|
||||||
|
...action,
|
||||||
|
instruction: { ...action.instruction },
|
||||||
|
style: action.style ? { ...action.style } : null,
|
||||||
|
oldStyle: action.oldStyle ? { ...action.oldStyle } : null,
|
||||||
|
selectionMode: action.selectionMode ? structuredClone(action.selectionMode) : null,
|
||||||
|
})),
|
||||||
|
actionPointer,
|
||||||
|
nextEditSessionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds the undo/redo stack from a persisted snapshot and re-applies committed
|
||||||
|
* edits to the current DOM. Skips actions whose elements cannot be resolved.
|
||||||
|
* @param {{ actionLog?: object[], actionPointer?: number, nextEditSessionId?: number }|null|undefined} snapshot
|
||||||
|
* @returns {{ restoredCount: number, skippedCount: number }}
|
||||||
|
*/
|
||||||
|
export function importDraftSnapshot(snapshot) {
|
||||||
|
clearHistory();
|
||||||
|
|
||||||
|
if (!snapshot || !Array.isArray(snapshot.actionLog)) {
|
||||||
|
return { restoredCount: 0, skippedCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let restoredCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
for (const action of snapshot.actionLog) {
|
||||||
|
if (!action?.editId || !action?.instruction) {
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
actionLog.push({
|
||||||
|
editId: action.editId,
|
||||||
|
instruction: {
|
||||||
|
beforeContent: action.instruction.beforeContent ?? null,
|
||||||
|
afterContent: action.instruction.afterContent ?? null,
|
||||||
|
},
|
||||||
|
style: action.style ?? null,
|
||||||
|
oldStyle: action.oldStyle ?? null,
|
||||||
|
attribute: action.attribute ?? null,
|
||||||
|
element: null,
|
||||||
|
sessionId: action.sessionId ?? null,
|
||||||
|
isAssisted: !!action.isAssisted,
|
||||||
|
selectionMode: action.selectionMode ?? null,
|
||||||
|
});
|
||||||
|
restoredCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxPointer = actionLog.length - 1;
|
||||||
|
const requestedPointer = typeof snapshot.actionPointer === 'number' ? snapshot.actionPointer : maxPointer;
|
||||||
|
actionPointer = Math.min(Math.max(requestedPointer, -1), maxPointer);
|
||||||
|
nextEditSessionId = typeof snapshot.nextEditSessionId === 'number' && snapshot.nextEditSessionId > 0
|
||||||
|
? snapshot.nextEditSessionId
|
||||||
|
: 1;
|
||||||
|
|
||||||
|
reapplyCommittedEdits();
|
||||||
|
|
||||||
|
for (let index = 0; index <= actionPointer; index++) {
|
||||||
|
if (!actionLog[index].element) {
|
||||||
|
skippedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { restoredCount, skippedCount };
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Group selection built by a rubber-band drag or shift+click. The committed
|
||||||
|
* group is document-ordered and never nested; `_drag*` lives for one gesture.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {HTMLElement[]} */
|
||||||
|
let _selectedElements = [];
|
||||||
|
let _dragStartX = null;
|
||||||
|
let _dragStartY = null;
|
||||||
|
let _isDragging = false;
|
||||||
|
/** Set on mouseup so the synthetic click a drag leaves behind is swallowed. */
|
||||||
|
let _hadDrag = false;
|
||||||
|
|
||||||
|
export function getSelectedElements() { return _selectedElements; }
|
||||||
|
export function setSelectedElements(value) { _selectedElements = value; }
|
||||||
|
|
||||||
|
export function getDragStartX() { return _dragStartX; }
|
||||||
|
export function getDragStartY() { return _dragStartY; }
|
||||||
|
export function setDragStart(x, y) { _dragStartX = x; _dragStartY = y; }
|
||||||
|
export function clearDragStart() { _dragStartX = null; _dragStartY = null; }
|
||||||
|
|
||||||
|
export function getIsDragging() { return _isDragging; }
|
||||||
|
export function setIsDragging(value) { _isDragging = value; }
|
||||||
|
|
||||||
|
export function getHadDrag() { return _hadDrag; }
|
||||||
|
export function setHadDrag(value) { _hadDrag = value; }
|
||||||
|
|
||||||
|
/** True when a mousedown is pending and could still become a drag. */
|
||||||
|
export function isDragPending() { return _dragStartX !== null; }
|
||||||
|
|
||||||
|
/** Drops the current group without touching an in-flight gesture. */
|
||||||
|
export function clearSelectedElements() { _selectedElements = []; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drops members that have left the page, so a group never outlives the route it
|
||||||
|
* was made on or holds nodes a re-render replaced.
|
||||||
|
* @returns {boolean} True when the group changed.
|
||||||
|
*/
|
||||||
|
export function pruneDetachedSelection() {
|
||||||
|
const connected = _selectedElements.filter(element => element.isConnected);
|
||||||
|
if (connected.length === _selectedElements.length) return false;
|
||||||
|
|
||||||
|
_selectedElements = connected;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets everything, including any gesture in progress. */
|
||||||
|
export function resetMultiSelectState() {
|
||||||
|
_selectedElements = [];
|
||||||
|
_dragStartX = null;
|
||||||
|
_dragStartY = null;
|
||||||
|
_isDragging = false;
|
||||||
|
_hadDrag = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedupes and puts the group in document order, which fixes the marker's anchor
|
||||||
|
* element and the order selections reach the AI.
|
||||||
|
* @param {HTMLElement[]} elements
|
||||||
|
* @returns {HTMLElement[]}
|
||||||
|
*/
|
||||||
|
export function orderSelection(elements) {
|
||||||
|
return [...new Set(elements)]
|
||||||
|
.sort((a, b) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the element to the group, or removes it when already a member. Nesting is
|
||||||
|
* allowed: picking a parent on top of its children is an explicit choice.
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @returns {HTMLElement[]} The new group.
|
||||||
|
*/
|
||||||
|
export function toggleSelectedElement(element) {
|
||||||
|
_selectedElements = _selectedElements.includes(element)
|
||||||
|
? _selectedElements.filter(member => member !== element)
|
||||||
|
: orderSelection([..._selectedElements, element]);
|
||||||
|
return _selectedElements;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/** @enum {string} Unique identifier for each action panel. */
|
||||||
|
export const PanelId = Object.freeze({
|
||||||
|
COLOR: 'color',
|
||||||
|
FONT: 'font',
|
||||||
|
SIZE: 'size',
|
||||||
|
LINK: 'link',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks whether each panel is currently open.
|
||||||
|
* @type {Map<string, boolean>}
|
||||||
|
*/
|
||||||
|
const visibility = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hide function supplied by each panel at registration time.
|
||||||
|
* @type {Map<string, () => void>}
|
||||||
|
*/
|
||||||
|
const hideFunctions = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an action panel so it participates in bulk show/hide operations.
|
||||||
|
* Called once at module load time by each panel.
|
||||||
|
* @param {{ id: string, hide: () => void }} panel
|
||||||
|
*/
|
||||||
|
export function registerPanel({ id, hide }) {
|
||||||
|
hideFunctions.set(id, hide);
|
||||||
|
visibility.set(id, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the visibility state for a panel.
|
||||||
|
* Called by the panel's own show/hide functions.
|
||||||
|
* @param {string} id - A {@link PanelId} value.
|
||||||
|
* @param {boolean} visible
|
||||||
|
*/
|
||||||
|
export function setPanelVisible(id, visible) {
|
||||||
|
visibility.set(id, visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id - A {@link PanelId} value.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function isPanelVisible(id) {
|
||||||
|
return visibility.get(id) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hide a single panel by ID.
|
||||||
|
* @param {string} id - A {@link PanelId} value.
|
||||||
|
*/
|
||||||
|
export function hidePanelById(id) {
|
||||||
|
hideFunctions.get(id)?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hide every registered panel. */
|
||||||
|
export function hideAllPanels() {
|
||||||
|
for (const id of hideFunctions.keys()) hidePanelById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @returns {boolean} True if at least one panel is currently visible. */
|
||||||
|
export function anyPanelVisible() {
|
||||||
|
return [...visibility.values()].some(Boolean);
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
import * as t from '@babel/types';
|
||||||
|
import { ELEMENT_TYPE_MAP } from '../constants/selectors.js';
|
||||||
|
|
||||||
|
export const EDITABLE_HTML_TAGS = ["a", "Link", "button", "Button", "p", "span", "time", "h1", "h2", "h3", "h4", "h5", "h6", "label", "Label", "img"];
|
||||||
|
|
||||||
|
export const INLINE_EDIT_HTML_TAGS = ["br", "strong", "em", "b", "i", "u", "span", "a"];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingElementNode
|
||||||
|
* @param {string[]} editableTagsList
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function checkTagNameEditable(openingElementNode, editableTagsList) {
|
||||||
|
if (!openingElementNode || !openingElementNode.name) return false;
|
||||||
|
const nameNode = openingElementNode.name;
|
||||||
|
|
||||||
|
if (nameNode.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nameNode.type === 'JSXMemberExpression' && nameNode.property && nameNode.property.type === 'JSXIdentifier' && editableTagsList.includes(nameNode.property.name)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||||
|
* @returns {{ isValid: boolean, reason: string|null }}
|
||||||
|
*/
|
||||||
|
export function validateImageSrc(openingNode) {
|
||||||
|
if (!openingNode || !openingNode.name || (openingNode.name.name !== 'img' && openingNode.name.property?.name !== 'img')) {
|
||||||
|
return { isValid: true, reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPropsSpread = openingNode.attributes.some(attribute =>
|
||||||
|
t.isJSXSpreadAttribute(attribute) &&
|
||||||
|
attribute.argument &&
|
||||||
|
t.isIdentifier(attribute.argument) &&
|
||||||
|
attribute.argument.name === 'props'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasPropsSpread) return { isValid: false, reason: 'props-spread' };
|
||||||
|
|
||||||
|
const sourceAttribute = openingNode.attributes.find(attribute =>
|
||||||
|
t.isJSXAttribute(attribute) && attribute.name && attribute.name.name === 'src'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sourceAttribute) return { isValid: false, reason: 'missing-src' };
|
||||||
|
if (!t.isStringLiteral(sourceAttribute.value)) return { isValid: false, reason: 'dynamic-src' };
|
||||||
|
if (!sourceAttribute.value.value || sourceAttribute.value.value.trim() === '') return { isValid: false, reason: 'empty-src' };
|
||||||
|
|
||||||
|
return { isValid: true, reason: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// `children` and capitalised identifiers hold rendered nodes, not text, so an
|
||||||
|
// element whose only content is one of those is a layout slot rather than a target.
|
||||||
|
function isNodeSlotExpression(expression) {
|
||||||
|
if (t.isIdentifier(expression)) return expression.name === 'children' || /^[A-Z]/.test(expression.name);
|
||||||
|
return t.isMemberExpression(expression) && expression.property?.name === 'children';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLikelyTextExpression(expression) {
|
||||||
|
if (isNodeSlotExpression(expression)) return false;
|
||||||
|
// A ternary or `&&` still renders text through whichever branch wins. Without
|
||||||
|
// this, `{ready ? `${n} entries` : 'Loading'}` reads as an empty element and
|
||||||
|
// the span is skipped entirely instead of being marked assisted.
|
||||||
|
if (t.isConditionalExpression(expression)) {
|
||||||
|
return isLikelyTextExpression(expression.consequent) || isLikelyTextExpression(expression.alternate);
|
||||||
|
}
|
||||||
|
if (t.isLogicalExpression(expression)) {
|
||||||
|
return isLikelyTextExpression(expression.right);
|
||||||
|
}
|
||||||
|
return t.isIdentifier(expression)
|
||||||
|
|| t.isMemberExpression(expression)
|
||||||
|
|| t.isStringLiteral(expression)
|
||||||
|
|| t.isNumericLiteral(expression)
|
||||||
|
|| t.isTemplateLiteral(expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when an element has direct text children worth editing
|
||||||
|
* @param {import('@babel/types').JSXElement} elementNode
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function hasMeaningfulTextContent(elementNode) {
|
||||||
|
if (!t.isJSXElement(elementNode) || !elementNode.children?.length) return false;
|
||||||
|
for (const child of elementNode.children) {
|
||||||
|
if (t.isJSXText(child) && child.value.trim().length > 0) return true;
|
||||||
|
if (t.isJSXExpressionContainer(child)
|
||||||
|
&& !t.isJSXEmptyExpression(child.expression)
|
||||||
|
&& isLikelyTextExpression(child.expression)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link hasMeaningfulTextContent} but walks the full subtree.
|
||||||
|
* Used to distinguish decorative inline children (color swatches, icons)
|
||||||
|
* from ones that carry actual text.
|
||||||
|
*/
|
||||||
|
function subtreeHasText(elementNode) {
|
||||||
|
if (!t.isJSXElement(elementNode)) return false;
|
||||||
|
for (const child of elementNode.children ?? []) {
|
||||||
|
if (t.isJSXText(child) && child.value.trim().length > 0) return true;
|
||||||
|
if (t.isJSXExpressionContainer(child)
|
||||||
|
&& !t.isJSXEmptyExpression(child.expression)
|
||||||
|
&& isLikelyTextExpression(child.expression)) return true;
|
||||||
|
if (t.isJSXElement(child) && subtreeHasText(child)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if the element should receive an assisted-edit id due to spread
|
||||||
|
* props or dynamic children that contain text. Icon-only buttons are excluded.
|
||||||
|
* @param {import('@babel/types').JSXElement} elementNode
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function shouldDisableDueToChildren(elementNode, openingNode) {
|
||||||
|
if (!t.isJSXElement(elementNode) || !elementNode.children) return false;
|
||||||
|
|
||||||
|
const hasPropsSpread = openingNode.attributes.some(attribute =>
|
||||||
|
t.isJSXSpreadAttribute(attribute)
|
||||||
|
&& attribute.argument
|
||||||
|
&& t.isIdentifier(attribute.argument)
|
||||||
|
&& attribute.argument.name === 'props'
|
||||||
|
);
|
||||||
|
if (hasPropsSpread) return true;
|
||||||
|
|
||||||
|
const hasDynamicChild = elementNode.children.some(child => t.isJSXExpressionContainer(child));
|
||||||
|
if (hasDynamicChild) {
|
||||||
|
const hasStaticText = elementNode.children.some(
|
||||||
|
child => t.isJSXText(child) && child.value.trim().length > 0
|
||||||
|
);
|
||||||
|
if (hasStaticText) return true;
|
||||||
|
|
||||||
|
const hasTextLikeExpression = elementNode.children.some(child =>
|
||||||
|
t.isJSXExpressionContainer(child)
|
||||||
|
&& !t.isJSXEmptyExpression(child.expression)
|
||||||
|
&& isLikelyTextExpression(child.expression)
|
||||||
|
);
|
||||||
|
if (hasTextLikeExpression) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyses child composition to decide whether the element can be edited.
|
||||||
|
* @param {import('@babel/types').JSXElement} elementNode
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||||
|
* @returns {'skip'|'disabled'|'allow'}
|
||||||
|
*/
|
||||||
|
export function evalChildComposition(elementNode, openingNode) {
|
||||||
|
if (!t.isJSXElement(elementNode)) return 'allow';
|
||||||
|
if (!elementNode.children?.length) {
|
||||||
|
const name = openingNode?.name?.name || openingNode?.name?.property?.name || '';
|
||||||
|
return name === 'img' ? 'allow' : 'skip';
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasTextContent = false;
|
||||||
|
let hasNonEditableJsxChild = false;
|
||||||
|
let hasNonSelfClosingChild = false;
|
||||||
|
let hasInlineTagChild = false;
|
||||||
|
let hasIdIneligibleInlineChild = false;
|
||||||
|
|
||||||
|
for (const child of elementNode.children) {
|
||||||
|
if (t.isJSXText(child)) {
|
||||||
|
if (child.value.trim().length > 0) hasTextContent = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t.isJSXExpressionContainer(child)) {
|
||||||
|
if (!t.isJSXEmptyExpression(child.expression) && isLikelyTextExpression(child.expression)) {
|
||||||
|
hasTextContent = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (t.isJSXElement(child)) {
|
||||||
|
const childNode = child.openingElement;
|
||||||
|
if (childNode.selfClosing) {
|
||||||
|
const childName = childNode.name?.name || '';
|
||||||
|
if (!/^[A-Z]/.test(childName) && !checkTagNameEditable(childNode, EDITABLE_HTML_TAGS) && !INLINE_EDIT_HTML_TAGS.includes(childName)) {
|
||||||
|
hasNonEditableJsxChild = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const childTagName = child.openingElement.name?.name;
|
||||||
|
if (INLINE_EDIT_HTML_TAGS.includes(childTagName)) {
|
||||||
|
if (!subtreeHasText(child)) {
|
||||||
|
hasNonEditableJsxChild = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hasInlineTagChild = true;
|
||||||
|
// `strong`/`em`/… are inline-editable inside a parent but cannot
|
||||||
|
// receive their own edit id (Condition 2 returns early). Only
|
||||||
|
// skip the parent when every inline child could own an id.
|
||||||
|
if (!EDITABLE_HTML_TAGS.includes(childTagName)) {
|
||||||
|
hasIdIneligibleInlineChild = true;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
hasNonSelfClosingChild = true;
|
||||||
|
if (!checkTagNameEditable(childNode, EDITABLE_HTML_TAGS)) {
|
||||||
|
hasNonEditableJsxChild = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasTextContent && !hasNonSelfClosingChild && !hasInlineTagChild) return 'skip';
|
||||||
|
if (hasNonEditableJsxChild) {
|
||||||
|
if (!hasTextContent && !hasInlineTagChild) return 'skip';
|
||||||
|
return 'disabled';
|
||||||
|
}
|
||||||
|
if (hasNonSelfClosingChild && !hasTextContent && !hasInlineTagChild) {
|
||||||
|
if (openingNode && checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS)) return 'allow';
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
// Layout wrappers that only nest id-eligible inline tags (span/a) skip so
|
||||||
|
// the child receives the id. Keep 'allow' when an inline child cannot own
|
||||||
|
// an id (`<p><strong>text</strong></p>`), or when the parent is itself an
|
||||||
|
// editable tag (h1, p, a, button, …) that should own the edit target.
|
||||||
|
if (!hasTextContent && hasInlineTagChild && !hasIdIneligibleInlineChild) {
|
||||||
|
const parentName = openingNode?.name?.name || openingNode?.name?.property?.name || '';
|
||||||
|
const isFormattingWrapper = INLINE_EDIT_HTML_TAGS.includes(parentName) && parentName !== 'a';
|
||||||
|
if (openingNode && checkTagNameEditable(openingNode, EDITABLE_HTML_TAGS)
|
||||||
|
&& !isFormattingWrapper) return 'allow';
|
||||||
|
return 'skip';
|
||||||
|
}
|
||||||
|
return 'allow';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively checks whether an element or any of its descendants contain
|
||||||
|
* dynamic content, spread props, or conditional expressions that a direct
|
||||||
|
* inline edit would silently overwrite. When true the element must be routed
|
||||||
|
* through AI-assisted editing instead.
|
||||||
|
* @param {import('@babel/types').JSXElement} elementNode
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function subtreeRequiresAssistance(elementNode) {
|
||||||
|
if (!t.isJSXElement(elementNode)) return false;
|
||||||
|
|
||||||
|
const opening = elementNode.openingElement;
|
||||||
|
if (opening.attributes.some(attribute => t.isJSXSpreadAttribute(attribute))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of elementNode.children ?? []) {
|
||||||
|
if (t.isJSXExpressionContainer(child) && !t.isJSXEmptyExpression(child.expression)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.isJSXElement(child) && subtreeRequiresAssistance(child)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns true if an ancestor JSXElement already received a target attribute.
|
||||||
|
* @param {import('@babel/traverse').NodePath<import('@babel/types').JSXOpeningElement>} openingPath
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function hasEditableAncestor(openingPath) {
|
||||||
|
const selfElementPath = openingPath.parentPath;
|
||||||
|
if (!selfElementPath?.isJSXElement()) return false;
|
||||||
|
|
||||||
|
let ancestorJsxElementPath = selfElementPath.findParent(path => path.isJSXElement());
|
||||||
|
while (ancestorJsxElementPath) {
|
||||||
|
const ancestorOpening = ancestorJsxElementPath.node.openingElement;
|
||||||
|
if (ancestorOpening.attributes.some(attribute =>
|
||||||
|
t.isJSXAttribute(attribute) && (attribute.name?.name === 'data-edit-id' || attribute.name?.name === 'data-edit-assisted-id')
|
||||||
|
)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
ancestorJsxElementPath = ancestorJsxElementPath.findParent(path => path.isJSXElement());
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps a JSX opening element tag name to a semantic edit type.
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function getElementTypeFromNode(openingNode) {
|
||||||
|
if (!openingNode || !openingNode.name) return null;
|
||||||
|
const name = openingNode.name?.name || openingNode.name?.property?.name || '';
|
||||||
|
if (!name) return null;
|
||||||
|
|
||||||
|
for (const [type, tags] of Object.entries(ELEMENT_TYPE_MAP)) {
|
||||||
|
if (tags.includes(name)) return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import * as t from '@babel/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For an editable `<a>` / `<Link>` / `<NavLink>`, returns the name of the URL
|
||||||
|
* attribute (`href` or `to`) when it holds a static string literal.
|
||||||
|
* Returns `null` for non-link tags or dynamic/expression URLs.
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function detectUrlAttribute(openingNode) {
|
||||||
|
const tagName = openingNode.name?.name || openingNode.name?.property?.name || '';
|
||||||
|
let attributeName = null;
|
||||||
|
if (tagName === 'a') attributeName = 'href';
|
||||||
|
else if (tagName === 'Link' || tagName === 'NavLink') attributeName = 'to';
|
||||||
|
if (!attributeName) return null;
|
||||||
|
|
||||||
|
const matchedAttribute = openingNode.attributes.find(attribute =>
|
||||||
|
t.isJSXAttribute(attribute) && attribute.name?.name === attributeName
|
||||||
|
);
|
||||||
|
if (!matchedAttribute || !matchedAttribute.value) return null;
|
||||||
|
|
||||||
|
if (t.isStringLiteral(matchedAttribute.value)) return attributeName;
|
||||||
|
if (t.isJSXExpressionContainer(matchedAttribute.value) && t.isStringLiteral(matchedAttribute.value.expression)) return attributeName;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes `data-edit-id` (and optionally `data-edit-url-attr`) onto the element.
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||||
|
* @param {string} editId
|
||||||
|
* @param {string|null} urlAttributeName
|
||||||
|
*/
|
||||||
|
export function attachEditAttributes(openingNode, editId, urlAttributeName) {
|
||||||
|
openingNode.attributes.push(
|
||||||
|
t.jsxAttribute(t.jsxIdentifier('data-edit-id'), t.stringLiteral(editId))
|
||||||
|
);
|
||||||
|
if (urlAttributeName) {
|
||||||
|
openingNode.attributes.push(
|
||||||
|
t.jsxAttribute(t.jsxIdentifier('data-edit-url-attr'), t.stringLiteral(urlAttributeName))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes only `data-edit-assisted-id` onto the element.
|
||||||
|
* @param {import('@babel/types').JSXOpeningElement} openingNode
|
||||||
|
* @param {string} editId
|
||||||
|
*/
|
||||||
|
export function attachAssistedEditId(openingNode, editId) {
|
||||||
|
openingNode.attributes.push(
|
||||||
|
t.jsxAttribute(t.jsxIdentifier('data-edit-assisted-id'), t.stringLiteral(editId))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
import { ICON_SPARKLES } from '../../constants/icons.js';
|
||||||
|
import { ANNOTATION_MARKER_STYLES } from './styles.js';
|
||||||
|
import { getEditId } from '../../constants/selectors.js';
|
||||||
|
import { setComments } from '../../state/annotation-state.js';
|
||||||
|
import { getAppRoot, isolateEditorUiEvents } from '../../utils/dom-utils.js';
|
||||||
|
import {
|
||||||
|
showHoverOutline, holdHoverOutline, releaseHoverOutline, lockHoverOutline,
|
||||||
|
HOVER_OUTLINE_CLASS, LOCKED_OUTLINE_CLASS,
|
||||||
|
} from '../overlays/hover-outline.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `class` attribute with our own hover/locked outline classes removed, so an
|
||||||
|
* anchor captured while the panel had the element locked never bakes in a
|
||||||
|
* class the element won't carry once re-mounted on a route round-trip.
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function stableClassName(element) {
|
||||||
|
return Array.from(element.classList)
|
||||||
|
.filter(className => className !== HOVER_OUTLINE_CLASS && className !== LOCKED_OUTLINE_CLASS)
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
const _markers = [];
|
||||||
|
let _scrollListenerActive = false;
|
||||||
|
let _stylesInjected = false;
|
||||||
|
|
||||||
|
function ensureStyles() {
|
||||||
|
if (_stylesInjected) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'annotation-marker-styles';
|
||||||
|
style.textContent = ANNOTATION_MARKER_STYLES;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
_stylesInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Route an annotation belongs to; it is never re-anchored on any other one. */
|
||||||
|
function currentRoute() {
|
||||||
|
return location.pathname + location.hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structural anchor: the element's child-index path from the app root plus the
|
||||||
|
* tag and classes expected there. Its captured `selector` is unusable for
|
||||||
|
* re-querying — Tailwind variants (`md:py-32`) make it invalid CSS.
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @returns {{path: number[], tagName: string, className: string}|null}
|
||||||
|
*/
|
||||||
|
function captureAnchor(element) {
|
||||||
|
const root = getAppRoot();
|
||||||
|
const path = [];
|
||||||
|
let node = element;
|
||||||
|
|
||||||
|
while (node && node !== root) {
|
||||||
|
const parent = node.parentElement;
|
||||||
|
if (!parent) return null;
|
||||||
|
path.unshift(Array.prototype.indexOf.call(parent.children, node));
|
||||||
|
node = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const anchor = { path, tagName: element.tagName, className: stableClassName(element) };
|
||||||
|
|
||||||
|
return anchor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corrects for a reordered list: items rendered from one `.map()` are identical
|
||||||
|
* in tag and classes, so only their text tells them apart. When no sibling
|
||||||
|
* matches either, the text was edited since and the position stands.
|
||||||
|
* @param {HTMLElement} candidate
|
||||||
|
* @param {object|null} selection - `captureElementMetadata` output.
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
function disambiguateSiblings(candidate, selection) {
|
||||||
|
const text = selection?.textContent;
|
||||||
|
if (!text || candidate.textContent?.trim() === text) return candidate;
|
||||||
|
|
||||||
|
const siblings = Array.from(candidate.parentElement?.children ?? []);
|
||||||
|
return siblings.find(sibling =>
|
||||||
|
sibling.tagName === candidate.tagName
|
||||||
|
&& stableClassName(sibling) === stableClassName(candidate)
|
||||||
|
&& sibling.textContent?.trim() === text) ?? candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node currently sitting at the anchor's tree position, or null when the
|
||||||
|
* position is empty or now holds a different element.
|
||||||
|
* @param {{path: number[], tagName: string, className: string}|null} anchor
|
||||||
|
* @param {object|null} selection - `captureElementMetadata` output.
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
|
*/
|
||||||
|
function resolveByAnchor(anchor, selection) {
|
||||||
|
if (!anchor) return null;
|
||||||
|
|
||||||
|
let candidate = getAppRoot();
|
||||||
|
for (const index of anchor.path) {
|
||||||
|
candidate = candidate?.children[index];
|
||||||
|
}
|
||||||
|
if (!candidate) return null;
|
||||||
|
|
||||||
|
const actualClassName = stableClassName(candidate);
|
||||||
|
const sameElement = candidate.tagName === anchor.tagName && actualClassName === anchor.className;
|
||||||
|
|
||||||
|
return sameElement ? disambiguateSiblings(candidate, selection) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An edit id is a JSX source location, so every node rendered from one `.map()`
|
||||||
|
* call site carries the same one; the anchor picks the annotated instance.
|
||||||
|
* @param {{editId: string, anchor: object|null, selection: object|null}} target
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
|
*/
|
||||||
|
function resolveByEditId(target) {
|
||||||
|
const matches = Array.from(document.querySelectorAll(`[data-edit-id="${target.editId}"], [data-edit-assisted-id="${target.editId}"]`));
|
||||||
|
if (matches.length <= 1) return matches[0] ?? null;
|
||||||
|
|
||||||
|
const anchored = resolveByAnchor(target.anchor, target.selection);
|
||||||
|
if (anchored && matches.includes(anchored)) return anchored;
|
||||||
|
|
||||||
|
const text = target.selection?.textContent;
|
||||||
|
return (text && matches.find(match => match.textContent?.trim() === text)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Element one annotated target currently points at, re-resolved when the stored
|
||||||
|
* node was unmounted (re-render, route change); null when it is gone.
|
||||||
|
* @param {object} target
|
||||||
|
* @param {string} route - Route the annotation was made on.
|
||||||
|
* @returns {HTMLElement|null}
|
||||||
|
*/
|
||||||
|
function resolveTarget(target, route) {
|
||||||
|
if (target.element?.isConnected) return target.element;
|
||||||
|
if (currentRoute() !== route) return null;
|
||||||
|
|
||||||
|
const element = target.editId ? resolveByEditId(target) : resolveByAnchor(target.anchor, target.selection);
|
||||||
|
if (element) target.element = element;
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Elements a marker's annotation still points at. A group keeps its pin as long
|
||||||
|
* as at least one member survives, so a partial re-render doesn't drop it.
|
||||||
|
* @param {object} entry
|
||||||
|
* @returns {HTMLElement[]}
|
||||||
|
*/
|
||||||
|
function resolveMarkerElements(entry) {
|
||||||
|
const elements = entry.targets
|
||||||
|
.map(target => resolveTarget(target, entry.route))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
entry.elements = elements;
|
||||||
|
if (elements.length) entry.comment.elements = elements;
|
||||||
|
return elements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places one marker over its annotation's current elements.
|
||||||
|
* @param {object} entry
|
||||||
|
* @param {HTMLElement[]} elements - `resolveMarkerElements` output, never empty.
|
||||||
|
*/
|
||||||
|
function positionMarker(entry, elements) {
|
||||||
|
const anchorElement = elements[0];
|
||||||
|
const rect = anchorElement.getBoundingClientRect();
|
||||||
|
const fixed = entry.fixed;
|
||||||
|
|
||||||
|
const anchored = elements.length === 1
|
||||||
|
&& Array.from(anchorElement.attributes || []).some(attr => attr.name.startsWith('data-edit-'));
|
||||||
|
const relativeX = anchored ? 0 : entry.relativeX;
|
||||||
|
const relativeY = anchored ? rect.height / 2 : entry.relativeY;
|
||||||
|
|
||||||
|
const scrollX = fixed ? 0 : window.scrollX;
|
||||||
|
const scrollY = fixed ? 0 : window.scrollY;
|
||||||
|
entry.marker.style.left = `${rect.left + relativeX + scrollX}px`;
|
||||||
|
entry.marker.style.top = `${rect.top + relativeY + scrollY}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repositions every marker over its annotation, hiding the ones whose elements
|
||||||
|
* are no longer on the page so annotations never bleed onto another route. This
|
||||||
|
* is also what anchors a restored annotation, once its route renders.
|
||||||
|
*/
|
||||||
|
export function refreshAnnotationMarkers() {
|
||||||
|
for (const entry of _markers) {
|
||||||
|
const elements = resolveMarkerElements(entry);
|
||||||
|
if (!elements.length) {
|
||||||
|
entry.marker.style.display = 'none';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.marker.style.display = '';
|
||||||
|
positionMarker(entry, elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureScrollListener() {
|
||||||
|
if (_scrollListenerActive) return;
|
||||||
|
_scrollListenerActive = true;
|
||||||
|
window.addEventListener('scroll', refreshAnnotationMarkers, true);
|
||||||
|
window.addEventListener('resize', refreshAnnotationMarkers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds and registers the pin of one annotation. `targets` may describe elements
|
||||||
|
* that are not on the page: the pin stays hidden until a refresh resolves them.
|
||||||
|
* @param {{
|
||||||
|
* comment: import('../../state/annotation-state.js').AnnotationComment,
|
||||||
|
* targets: Array<{element: HTMLElement|null, editId: string|null, anchor: object|null, selection: object|null}>,
|
||||||
|
* relativeX: number,
|
||||||
|
* relativeY: number,
|
||||||
|
* fixed: boolean,
|
||||||
|
* route: string,
|
||||||
|
* onMarkerClick: (comment: object, clientX: number, clientY: number) => void,
|
||||||
|
* }} options
|
||||||
|
* @returns {object} The marker entry.
|
||||||
|
*/
|
||||||
|
function createMarkerEntry({ comment, targets, relativeX, relativeY, fixed, route, onMarkerClick }) {
|
||||||
|
ensureStyles();
|
||||||
|
ensureScrollListener();
|
||||||
|
|
||||||
|
const marker = document.createElement('div');
|
||||||
|
marker.className = 'selection-mode-annotation-marker';
|
||||||
|
marker.style.position = fixed ? 'fixed' : 'absolute';
|
||||||
|
marker.style.display = 'none';
|
||||||
|
marker.innerHTML = ICON_SPARKLES;
|
||||||
|
|
||||||
|
// The outline and panel target `entry.elements`, not the captured ones:
|
||||||
|
// re-resolution after a re-render swaps in the freshly mounted nodes.
|
||||||
|
const entry = {
|
||||||
|
marker,
|
||||||
|
elements: targets.map(target => target.element).filter(Boolean),
|
||||||
|
relativeX,
|
||||||
|
relativeY,
|
||||||
|
fixed,
|
||||||
|
comment,
|
||||||
|
targets,
|
||||||
|
route,
|
||||||
|
};
|
||||||
|
|
||||||
|
marker.addEventListener('mouseenter', () => {
|
||||||
|
holdHoverOutline();
|
||||||
|
showHoverOutline(entry.elements);
|
||||||
|
});
|
||||||
|
|
||||||
|
marker.addEventListener('mouseleave', () => {
|
||||||
|
releaseHoverOutline();
|
||||||
|
});
|
||||||
|
|
||||||
|
marker.addEventListener('click', (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
lockHoverOutline(entry.elements);
|
||||||
|
onMarkerClick(comment, event.clientX, event.clientY);
|
||||||
|
});
|
||||||
|
|
||||||
|
isolateEditorUiEvents(marker);
|
||||||
|
document.body.appendChild(marker);
|
||||||
|
|
||||||
|
_markers.push(entry);
|
||||||
|
comment.marker = marker;
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the single pin that represents one annotation, tracked against the first of its elements
|
||||||
|
* @param {import('../../state/annotation-state.js').AnnotationComment} comment
|
||||||
|
* @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick
|
||||||
|
*/
|
||||||
|
export function addAnnotationMarker(comment, onMarkerClick) {
|
||||||
|
const { elements, selections, clickX, clickY, fixed } = comment;
|
||||||
|
const anchorElement = elements[0];
|
||||||
|
const rect = anchorElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const anchorToLeftBorder = elements.length === 1
|
||||||
|
&& (anchorElement.hasAttribute('data-edit-id') || anchorElement.hasAttribute('data-edit-assisted-id'));
|
||||||
|
|
||||||
|
const entry = createMarkerEntry({
|
||||||
|
comment,
|
||||||
|
targets: elements.map((element, index) => ({
|
||||||
|
element,
|
||||||
|
editId: getEditId(element),
|
||||||
|
anchor: captureAnchor(element),
|
||||||
|
selection: selections[index] ?? null,
|
||||||
|
})),
|
||||||
|
relativeX: anchorToLeftBorder ? 0 : clickX - rect.left,
|
||||||
|
relativeY: anchorToLeftBorder ? rect.height / 2 : clickY - rect.top,
|
||||||
|
fixed,
|
||||||
|
route: currentRoute(),
|
||||||
|
onMarkerClick,
|
||||||
|
});
|
||||||
|
|
||||||
|
entry.marker.style.display = '';
|
||||||
|
positionMarker(entry, elements);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recreates the pin of a persisted annotation
|
||||||
|
* @param {import('../../state/annotation-state.js').AnnotationComment} comment
|
||||||
|
* @param {ReturnType<typeof exportAnnotationMarkers>[number]} serialized
|
||||||
|
* @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick
|
||||||
|
*/
|
||||||
|
function addRestoredAnnotationMarker(comment, serialized, onMarkerClick) {
|
||||||
|
createMarkerEntry({
|
||||||
|
comment,
|
||||||
|
targets: (serialized.targets ?? []).map(target => ({
|
||||||
|
element: null,
|
||||||
|
editId: target.editId ?? null,
|
||||||
|
anchor: target.anchor ?? null,
|
||||||
|
selection: target.selection ?? null,
|
||||||
|
})),
|
||||||
|
relativeX: serialized.relativeX ?? 0,
|
||||||
|
relativeY: serialized.relativeY ?? 0,
|
||||||
|
fixed: !!serialized.fixed,
|
||||||
|
route: serialized.route ?? currentRoute(),
|
||||||
|
onMarkerClick,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rebuilds annotation comments and their pins from a snapshot. Nothing is
|
||||||
|
* resolved here: the refresh pass anchors each annotation as its route renders
|
||||||
|
* @param {ReturnType<typeof exportAnnotationMarkers>|null|undefined} serialized
|
||||||
|
* @param {(comment: object, clientX: number, clientY: number) => void} onMarkerClick
|
||||||
|
*/
|
||||||
|
export function importAnnotationMarkers(serialized, onMarkerClick) {
|
||||||
|
clearAllAnnotationMarkers();
|
||||||
|
setComments([]);
|
||||||
|
|
||||||
|
if (!Array.isArray(serialized)) return;
|
||||||
|
|
||||||
|
// Without targets an annotation can never be anchored again, so it would sit
|
||||||
|
// in the draft as a change the user cannot see, reach or remove.
|
||||||
|
const annotations = serialized.filter(annotation => annotation?.targets?.length);
|
||||||
|
if (!annotations.length) return;
|
||||||
|
|
||||||
|
const comments = annotations.map(annotation => ({
|
||||||
|
elements: [],
|
||||||
|
selections: annotation.targets.map(target => target.selection).filter(Boolean),
|
||||||
|
text: annotation.text ?? '',
|
||||||
|
attachments: Array.isArray(annotation.attachments) ? annotation.attachments : [],
|
||||||
|
clickX: annotation.clickX ?? 0,
|
||||||
|
clickY: annotation.clickY ?? 0,
|
||||||
|
fixed: !!annotation.fixed,
|
||||||
|
}));
|
||||||
|
|
||||||
|
setComments(comments);
|
||||||
|
comments.forEach((comment, index) => addRestoredAnnotationMarker(comment, annotations[index], onMarkerClick));
|
||||||
|
refreshAnnotationMarkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes every annotation for persistence
|
||||||
|
* @returns {Array<{
|
||||||
|
* targets: Array<{editId: string|null, anchor: object|null, selection: object|null}>,
|
||||||
|
* text: string, clickX: number, clickY: number,
|
||||||
|
* relativeX: number, relativeY: number, fixed: boolean, route: string,
|
||||||
|
* attachments: Array<{url: string, fileName: string, size: number}>,
|
||||||
|
* }>}
|
||||||
|
*/
|
||||||
|
export function exportAnnotationMarkers() {
|
||||||
|
return _markers.map(entry => ({
|
||||||
|
targets: entry.targets.map(({ editId, anchor, selection }) => ({ editId, anchor, selection })),
|
||||||
|
text: entry.comment.text ?? '',
|
||||||
|
clickX: entry.comment.clickX ?? 0,
|
||||||
|
clickY: entry.comment.clickY ?? 0,
|
||||||
|
relativeX: entry.relativeX,
|
||||||
|
relativeY: entry.relativeY,
|
||||||
|
fixed: entry.fixed,
|
||||||
|
route: entry.route,
|
||||||
|
attachments: entry.comment.attachments ?? [],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeMarker(comment) {
|
||||||
|
const index = _markers.findIndex(e => e.comment === comment);
|
||||||
|
if (index === -1) return;
|
||||||
|
const entry = _markers[index];
|
||||||
|
entry.marker.remove();
|
||||||
|
_markers.splice(index, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Highlights the pin of whichever annotation covers the element, group or not. */
|
||||||
|
export function highlightMarkerForElement(element) {
|
||||||
|
for (const entry of _markers) {
|
||||||
|
if (entry.elements.includes(element)) {
|
||||||
|
entry.marker.classList.add('highlighted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unhighlightAllMarkers() {
|
||||||
|
for (const entry of _markers) {
|
||||||
|
entry.marker.classList.remove('highlighted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAllAnnotationMarkers() {
|
||||||
|
for (const entry of _markers) {
|
||||||
|
entry.marker.remove();
|
||||||
|
}
|
||||||
|
_markers.length = 0;
|
||||||
|
window.removeEventListener('scroll', refreshAnnotationMarkers, true);
|
||||||
|
window.removeEventListener('resize', refreshAnnotationMarkers);
|
||||||
|
_scrollListenerActive = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
import { ANNOTATION_PANEL_HTML } from './template.js';
|
||||||
|
import { ANNOTATION_PANEL_STYLES } from './styles.js';
|
||||||
|
import { addAnnotationMarker, removeMarker, unhighlightAllMarkers } from './annotation-markers.js';
|
||||||
|
import { lockHoverOutline, unlockHoverOutline } from '../overlays/hover-outline.js';
|
||||||
|
import { isInFixedContext, getElementType, isolateEditorUiEvents } from '../../utils/dom-utils.js';
|
||||||
|
import { captureElementMetadata } from '../../utils/selection-mode-metadata.js';
|
||||||
|
import { PANEL_GAP, PANEL_MARGIN, PARENT_TOOLBAR_HEIGHT, ANNOTATION_PANEL_WIDTH, ANNOTATION_PANEL_ESTIMATED_HEIGHT, MAX_ANNOTATION_ATTACHMENTS, TOOLTIP_VIEWPORT_MARGIN } from '../../constants/layout.js';
|
||||||
|
import {
|
||||||
|
getComments, setComments,
|
||||||
|
getPanelMode, setPanelMode,
|
||||||
|
getEditingComment, setEditingComment,
|
||||||
|
getPendingElements, setPendingElements,
|
||||||
|
getPendingSelections, setPendingSelections,
|
||||||
|
getPendingAttachments, setPendingAttachments,
|
||||||
|
getAnnotationClickX, setAnnotationClickX,
|
||||||
|
getAnnotationClickY, setAnnotationClickY,
|
||||||
|
getEditorTranslations,
|
||||||
|
} from '../../state/annotation-state.js';
|
||||||
|
import { ICON_DELETE, ICON_IMAGE } from '../../constants/icons.js';
|
||||||
|
import { ParentMessage } from '../../constants/messages.js';
|
||||||
|
import { postToParent } from '../../utils/parent-frame.js';
|
||||||
|
import { clearSelectedElements } from '../../state/multi-select-state.js';
|
||||||
|
import { notifyDraftStateChanged } from '../../api/draft-snapshot.js';
|
||||||
|
import { getEditing } from '../../state/editing-state.js';
|
||||||
|
import { startInlineEdit, commitCurrentEdit } from '../inline-edit/edit-action.js';
|
||||||
|
import { showTextFormatToolbar, hideTextFormatToolbar, getToolbarEl } from '../text-format/toolbar/toolbar.js';
|
||||||
|
|
||||||
|
const DEFAULT_LABELS = {
|
||||||
|
annotationAddComment: 'Add comment',
|
||||||
|
annotationSaveComment: 'Save comment',
|
||||||
|
annotationPlaceholder: 'Tell AI what to change',
|
||||||
|
annotationElementSelected: '{count} element selected',
|
||||||
|
annotationElementsSelected: '{count} elements selected',
|
||||||
|
annotationAttachImage: 'Add images',
|
||||||
|
annotationRemoveImage: 'Remove image',
|
||||||
|
annotationDeleteComment: 'Delete comment',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Gap (px) between a panel button and the tooltip floating above it. */
|
||||||
|
const BUTTON_TOOLTIP_GAP = 8;
|
||||||
|
|
||||||
|
function label(key, replacements = {}) {
|
||||||
|
const raw = getEditorTranslations()[key] || DEFAULT_LABELS[key] || key;
|
||||||
|
return Object.entries(replacements).reduce(
|
||||||
|
(text, [token, value]) => text.replace(`{${token}}`, value), raw
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _annotationPanelEl = null;
|
||||||
|
let _buttonTooltipEl = null;
|
||||||
|
let _stylesInjected = false;
|
||||||
|
let _panelKeydownHandler = null;
|
||||||
|
let _isAttachmentUploadInProgress = false;
|
||||||
|
// Attachments already stored on the comment when the panel opened, so cancelling
|
||||||
|
// can tell apart images added this session from ones the user saved earlier.
|
||||||
|
let _savedAttachments = [];
|
||||||
|
|
||||||
|
function selectionLabel(elements) {
|
||||||
|
const count = elements.length;
|
||||||
|
const key = count === 1 ? 'annotationElementSelected' : 'annotationElementsSelected';
|
||||||
|
return label(key, { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureStyles() {
|
||||||
|
if (_stylesInjected) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'annotation-panel-styles';
|
||||||
|
style.textContent = ANNOTATION_PANEL_STYLES;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
_stylesInjected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensurePanelElement() {
|
||||||
|
if (_annotationPanelEl) return _annotationPanelEl;
|
||||||
|
ensureStyles();
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.innerHTML = ANNOTATION_PANEL_HTML;
|
||||||
|
_annotationPanelEl = wrapper.firstElementChild;
|
||||||
|
document.body.appendChild(_annotationPanelEl);
|
||||||
|
|
||||||
|
const textarea = _annotationPanelEl.querySelector('#selection-mode-annotation-textarea');
|
||||||
|
const commentBtn = _annotationPanelEl.querySelector('#selection-mode-comment-btn');
|
||||||
|
const discardBtn = _annotationPanelEl.querySelector('#selection-mode-discard-btn');
|
||||||
|
const deleteBtn = _annotationPanelEl.querySelector('#selection-mode-delete-btn');
|
||||||
|
const attachImageBtn = _annotationPanelEl.querySelector('#selection-mode-attach-image-btn');
|
||||||
|
|
||||||
|
textarea.placeholder = label('annotationPlaceholder');
|
||||||
|
commentBtn.textContent = label('annotationAddComment');
|
||||||
|
attachImageBtn.setAttribute('aria-label', label('annotationAttachImage'));
|
||||||
|
deleteBtn.setAttribute('aria-label', label('annotationDeleteComment'));
|
||||||
|
bindButtonTooltip(attachImageBtn);
|
||||||
|
bindButtonTooltip(deleteBtn);
|
||||||
|
|
||||||
|
textarea.addEventListener('input', () => {
|
||||||
|
commentBtn.disabled = !textarea.value.trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
commentBtn.addEventListener('click', handleCommentClick);
|
||||||
|
discardBtn.addEventListener('click', handleDiscardClick);
|
||||||
|
deleteBtn.addEventListener('click', handleDeleteClick);
|
||||||
|
attachImageBtn.addEventListener('click', () => {
|
||||||
|
if (attachImageBtn.disabled) return;
|
||||||
|
setAttachmentUploadInProgress(true);
|
||||||
|
postToParent(ParentMessage.ANNOTATION_IMAGE_ATTACH_REQUESTED);
|
||||||
|
});
|
||||||
|
|
||||||
|
isolateEditorUiEvents(_annotationPanelEl);
|
||||||
|
|
||||||
|
return _annotationPanelEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shows the editor's hover tooltip centred above a panel button, reusing the
|
||||||
|
* `element-type-tooltip` look. The tooltip is a child of the panel: the panel
|
||||||
|
* already carries the highest z-index the editor has, so a tooltip anywhere
|
||||||
|
* else on the page could never paint over it. Offsets are therefore measured
|
||||||
|
* against the panel's own box rather than the viewport.
|
||||||
|
* @param {HTMLButtonElement} button
|
||||||
|
*/
|
||||||
|
function showButtonTooltip(button) {
|
||||||
|
const text = button.getAttribute('aria-label');
|
||||||
|
if (!text || !_annotationPanelEl) return;
|
||||||
|
|
||||||
|
if (!_buttonTooltipEl) {
|
||||||
|
_buttonTooltipEl = document.createElement('div');
|
||||||
|
_buttonTooltipEl.className = 'element-type-tooltip selection-mode-button-tooltip';
|
||||||
|
_annotationPanelEl.appendChild(_buttonTooltipEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
_buttonTooltipEl.textContent = text;
|
||||||
|
_buttonTooltipEl.classList.add('active');
|
||||||
|
|
||||||
|
const panelRect = _annotationPanelEl.getBoundingClientRect();
|
||||||
|
const buttonRect = button.getBoundingClientRect();
|
||||||
|
const { width, height } = _buttonTooltipEl.getBoundingClientRect();
|
||||||
|
const centeredLeft = buttonRect.left + (buttonRect.width - width) / 2;
|
||||||
|
const clampedLeft = Math.min(
|
||||||
|
Math.max(centeredLeft, TOOLTIP_VIEWPORT_MARGIN),
|
||||||
|
Math.max(TOOLTIP_VIEWPORT_MARGIN, window.innerWidth - width - TOOLTIP_VIEWPORT_MARGIN)
|
||||||
|
);
|
||||||
|
|
||||||
|
_buttonTooltipEl.style.left = `${clampedLeft - panelRect.left}px`;
|
||||||
|
_buttonTooltipEl.style.top = `${buttonRect.top - panelRect.top - height - BUTTON_TOOLTIP_GAP}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideButtonTooltip() {
|
||||||
|
_buttonTooltipEl?.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindButtonTooltip(button) {
|
||||||
|
button.addEventListener('mouseenter', () => {
|
||||||
|
if (button.disabled) return;
|
||||||
|
showButtonTooltip(button);
|
||||||
|
});
|
||||||
|
button.addEventListener('mouseleave', hideButtonTooltip);
|
||||||
|
button.addEventListener('blur', hideButtonTooltip);
|
||||||
|
button.addEventListener('click', hideButtonTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAttachmentUploadInProgress(isUploading) {
|
||||||
|
_isAttachmentUploadInProgress = Boolean(isUploading);
|
||||||
|
updateAttachImageButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseAttachments(attachments) {
|
||||||
|
attachments.forEach(({ url }) => postToParent(ParentMessage.ANNOTATION_IMAGE_REMOVED, { url }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachmentsNotIn(attachments, others) {
|
||||||
|
const urls = new Set(others.map(({ url }) => url));
|
||||||
|
return attachments.filter(({ url }) => !urls.has(url));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds attachments the parent frame finished uploading to the open panel.
|
||||||
|
* Clears the paperclip loading state started when attach was requested.
|
||||||
|
* @param {import('../../state/annotation-state.js').AnnotationAttachment[]} attachments
|
||||||
|
*/
|
||||||
|
export function addPendingAttachments(attachments) {
|
||||||
|
setAttachmentUploadInProgress(false);
|
||||||
|
|
||||||
|
if (!attachments?.length) return;
|
||||||
|
|
||||||
|
if (!_annotationPanelEl?.classList.contains('active')) {
|
||||||
|
releaseAttachments(attachments);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingAttachments([...getPendingAttachments(), ...attachments]);
|
||||||
|
renderAttachments();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePendingAttachment(url) {
|
||||||
|
const removed = getPendingAttachments().filter(attachment => attachment.url === url);
|
||||||
|
setPendingAttachments(getPendingAttachments().filter(attachment => attachment.url !== url));
|
||||||
|
renderAttachments();
|
||||||
|
|
||||||
|
// An image already stored on the comment is only released once Save confirms
|
||||||
|
// the removal, so cancelling the panel can bring it back.
|
||||||
|
if (!_savedAttachments.some(attachment => attachment.url === url)) {
|
||||||
|
releaseAttachments(removed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisualEditorAttachmentCount() {
|
||||||
|
const editing = getEditingComment();
|
||||||
|
const savedAttachmentsCount = getComments().reduce((total, comment) => {
|
||||||
|
if (comment === editing) {
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
return total + (comment.attachments?.length ?? 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return savedAttachmentsCount + getPendingAttachments().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAttachImageButtonState() {
|
||||||
|
const attachImageBtn = _annotationPanelEl?.querySelector('#selection-mode-attach-image-btn');
|
||||||
|
if (!attachImageBtn) return;
|
||||||
|
|
||||||
|
const isAtLimit = getVisualEditorAttachmentCount() >= MAX_ANNOTATION_ATTACHMENTS;
|
||||||
|
const isUploading = _isAttachmentUploadInProgress;
|
||||||
|
|
||||||
|
attachImageBtn.disabled = isAtLimit || isUploading;
|
||||||
|
attachImageBtn.classList.toggle('is-loading', isUploading);
|
||||||
|
attachImageBtn.innerHTML = isUploading
|
||||||
|
? '<span class="selection-mode-attach-spinner" aria-hidden="true"></span>'
|
||||||
|
: ICON_IMAGE;
|
||||||
|
if (attachImageBtn.disabled) hideButtonTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachments() {
|
||||||
|
const container = _annotationPanelEl?.querySelector('#selection-mode-annotation-attachments');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const attachments = getPendingAttachments();
|
||||||
|
container.textContent = '';
|
||||||
|
container.classList.toggle('active', attachments.length > 0);
|
||||||
|
|
||||||
|
attachments.forEach(({ url, fileName }) => {
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.className = 'selection-mode-annotation-attachment';
|
||||||
|
|
||||||
|
const thumbnail = document.createElement('img');
|
||||||
|
thumbnail.className = 'selection-mode-annotation-attachment-thumb';
|
||||||
|
thumbnail.src = url;
|
||||||
|
thumbnail.alt = fileName;
|
||||||
|
thumbnail.title = fileName;
|
||||||
|
thumbnail.loading = 'lazy';
|
||||||
|
thumbnail.decoding = 'async';
|
||||||
|
thumbnail.addEventListener('click', () => {
|
||||||
|
const allAttachments = getPendingAttachments().map(({ url: attachmentUrl, fileName: attachmentName }) => ({
|
||||||
|
url: attachmentUrl,
|
||||||
|
fileName: attachmentName,
|
||||||
|
}));
|
||||||
|
postToParent(ParentMessage.ANNOTATION_IMAGE_PREVIEW_REQUESTED, { url, attachments: allAttachments });
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.className = 'selection-mode-annotation-attachment-remove';
|
||||||
|
removeBtn.title = label('annotationRemoveImage');
|
||||||
|
removeBtn.innerHTML = ICON_DELETE;
|
||||||
|
removeBtn.addEventListener('click', () => removePendingAttachment(url));
|
||||||
|
|
||||||
|
chip.append(thumbnail, removeBtn);
|
||||||
|
container.appendChild(chip);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateAttachImageButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCommentClick() {
|
||||||
|
const textarea = _annotationPanelEl.querySelector('#selection-mode-annotation-textarea');
|
||||||
|
const text = textarea.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
if (getPanelMode() === 'edit') {
|
||||||
|
const editing = getEditingComment();
|
||||||
|
if (editing) {
|
||||||
|
editing.text = text;
|
||||||
|
editing.attachments = getPendingAttachments();
|
||||||
|
notifyDraftStateChanged();
|
||||||
|
}
|
||||||
|
hideAnnotationPanel({ commitPendingAttachments: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const elements = getPendingElements();
|
||||||
|
const selections = getPendingSelections();
|
||||||
|
const clickX = getAnnotationClickX();
|
||||||
|
const clickY = getAnnotationClickY();
|
||||||
|
|
||||||
|
const comment = {
|
||||||
|
elements,
|
||||||
|
selections,
|
||||||
|
text,
|
||||||
|
attachments: getPendingAttachments(),
|
||||||
|
clickX,
|
||||||
|
clickY,
|
||||||
|
fixed: elements.length ? isInFixedContext(elements[0]) : false,
|
||||||
|
};
|
||||||
|
setComments([...getComments(), comment]);
|
||||||
|
addAnnotationMarker(comment, openPanelForEdit);
|
||||||
|
notifyDraftStateChanged();
|
||||||
|
hideAnnotationPanel({ commitPendingAttachments: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDiscardClick() {
|
||||||
|
hideAnnotationPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteClick() {
|
||||||
|
const editing = getEditingComment();
|
||||||
|
if (!editing) return;
|
||||||
|
|
||||||
|
releaseAttachments(editing.attachments ?? []);
|
||||||
|
|
||||||
|
removeMarker(editing);
|
||||||
|
setComments(getComments().filter(c => c !== editing));
|
||||||
|
notifyDraftStateChanged();
|
||||||
|
hideAnnotationPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionPanel(clickX, clickY, elements, anchorTop = clickY) {
|
||||||
|
const panel = _annotationPanelEl;
|
||||||
|
const fixed = elements.length ? isInFixedContext(elements[0]) : false;
|
||||||
|
panel.style.position = fixed ? 'fixed' : 'absolute';
|
||||||
|
|
||||||
|
const scrollX = fixed ? 0 : window.scrollX;
|
||||||
|
const scrollY = fixed ? 0 : window.scrollY;
|
||||||
|
|
||||||
|
let left = clickX;
|
||||||
|
let top = clickY + PANEL_GAP;
|
||||||
|
|
||||||
|
if (left + ANNOTATION_PANEL_WIDTH > window.innerWidth - PANEL_MARGIN) {
|
||||||
|
left = window.innerWidth - ANNOTATION_PANEL_WIDTH - PANEL_MARGIN;
|
||||||
|
}
|
||||||
|
if (left < PANEL_MARGIN) left = PANEL_MARGIN;
|
||||||
|
|
||||||
|
if (top + ANNOTATION_PANEL_ESTIMATED_HEIGHT > window.innerHeight - PARENT_TOOLBAR_HEIGHT - PANEL_MARGIN) {
|
||||||
|
// anchorTop is the anchor's top edge (equals clickY for a plain click
|
||||||
|
// point, but the toolbar's top when anchored to it) so the panel
|
||||||
|
// flips fully above the anchor instead of overlapping its bottom half.
|
||||||
|
top = anchorTop - ANNOTATION_PANEL_ESTIMATED_HEIGHT - PANEL_GAP;
|
||||||
|
}
|
||||||
|
if (top < PANEL_MARGIN) top = PANEL_MARGIN;
|
||||||
|
|
||||||
|
panel.style.left = `${left + scrollX}px`;
|
||||||
|
panel.style.top = `${top + scrollY}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the panel in create mode for a group of one or more elements.
|
||||||
|
* @param {HTMLElement[]} elements
|
||||||
|
* @param {object[]} selections - `captureElementMetadata` output, one per element.
|
||||||
|
*/
|
||||||
|
export function showAnnotationPanel(elements, selections, clickX, clickY, anchorTop = clickY) {
|
||||||
|
const panel = ensurePanelElement();
|
||||||
|
|
||||||
|
setPendingElements(elements);
|
||||||
|
setPendingSelections(selections);
|
||||||
|
setPendingAttachments([]);
|
||||||
|
_savedAttachments = [];
|
||||||
|
setAnnotationClickX(clickX);
|
||||||
|
setAnnotationClickY(clickY);
|
||||||
|
setPanelMode('create');
|
||||||
|
setEditingComment(null);
|
||||||
|
|
||||||
|
const textarea = panel.querySelector('#selection-mode-annotation-textarea');
|
||||||
|
const commentBtn = panel.querySelector('#selection-mode-comment-btn');
|
||||||
|
const deleteWrapper = panel.querySelector('#selection-mode-delete-wrapper');
|
||||||
|
const countEl = panel.querySelector('#selection-mode-annotation-count');
|
||||||
|
|
||||||
|
textarea.value = '';
|
||||||
|
commentBtn.disabled = true;
|
||||||
|
commentBtn.textContent = label('annotationAddComment');
|
||||||
|
deleteWrapper.style.display = 'none';
|
||||||
|
countEl.textContent = selectionLabel(elements);
|
||||||
|
renderAttachments();
|
||||||
|
|
||||||
|
lockHoverOutline(elements);
|
||||||
|
positionPanel(clickX, clickY, elements, anchorTop);
|
||||||
|
panel.classList.add('active');
|
||||||
|
textarea.focus();
|
||||||
|
|
||||||
|
attachPanelKeydown();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openPanelForEdit(comment, clickX, clickY) {
|
||||||
|
const panel = ensurePanelElement();
|
||||||
|
const elements = comment.elements;
|
||||||
|
const single = elements.length === 1 ? elements[0] : null;
|
||||||
|
|
||||||
|
const currentEditing = getEditing();
|
||||||
|
if (currentEditing && currentEditing.targetElement !== single) {
|
||||||
|
hideTextFormatToolbar();
|
||||||
|
commitCurrentEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementType = single ? getElementType(single) : null;
|
||||||
|
const openToolbar = !!single
|
||||||
|
&& (elementType === 'text' || elementType === 'button')
|
||||||
|
&& (single.hasAttribute('data-edit-id') || single.hasAttribute('data-edit-assisted-id'));
|
||||||
|
|
||||||
|
setPanelMode('edit');
|
||||||
|
setEditingComment(comment);
|
||||||
|
setPendingElements(elements);
|
||||||
|
setPendingSelections(comment.selections);
|
||||||
|
setPendingAttachments(comment.attachments ? [...comment.attachments] : []);
|
||||||
|
_savedAttachments = comment.attachments ? [...comment.attachments] : [];
|
||||||
|
setAnnotationClickX(clickX);
|
||||||
|
setAnnotationClickY(clickY);
|
||||||
|
|
||||||
|
const textarea = panel.querySelector('#selection-mode-annotation-textarea');
|
||||||
|
const commentBtn = panel.querySelector('#selection-mode-comment-btn');
|
||||||
|
const deleteWrapper = panel.querySelector('#selection-mode-delete-wrapper');
|
||||||
|
const countEl = panel.querySelector('#selection-mode-annotation-count');
|
||||||
|
|
||||||
|
countEl.textContent = selectionLabel(elements);
|
||||||
|
textarea.value = comment.text;
|
||||||
|
commentBtn.disabled = !comment.text.trim();
|
||||||
|
commentBtn.textContent = label('annotationSaveComment');
|
||||||
|
deleteWrapper.style.display = 'flex';
|
||||||
|
renderAttachments();
|
||||||
|
|
||||||
|
let anchorTop = clickY;
|
||||||
|
if (openToolbar) {
|
||||||
|
if (!getEditing() || getEditing().targetElement !== single) {
|
||||||
|
const editId = single.getAttribute('data-edit-id') || single.getAttribute('data-edit-assisted-id');
|
||||||
|
if (editId) {
|
||||||
|
startInlineEdit(single, editId, single.innerHTML || "", { focusTarget: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
showTextFormatToolbar(single);
|
||||||
|
const toolbarRect = getToolbarEl()?.getBoundingClientRect();
|
||||||
|
if (toolbarRect) {
|
||||||
|
clickX = toolbarRect.left;
|
||||||
|
clickY = toolbarRect.bottom;
|
||||||
|
anchorTop = toolbarRect.top;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
panel.classList.add('active');
|
||||||
|
lockHoverOutline(elements);
|
||||||
|
unhighlightAllMarkers();
|
||||||
|
if (comment.marker) comment.marker.classList.add('highlighted');
|
||||||
|
positionPanel(clickX, clickY, elements, anchorTop);
|
||||||
|
|
||||||
|
textarea.focus();
|
||||||
|
attachPanelKeydown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `restoreFocus: false` skips re-focusing the inline edit, whose `selectionchange`
|
||||||
|
* would close a panel the caller opens next; `keepSelection` keeps the group.
|
||||||
|
* `commitPendingAttachments` keeps the images the caller just saved onto a comment
|
||||||
|
* and releases the ones it dropped; without it the panel is cancelling, so images
|
||||||
|
* added during this session are released and previously saved ones are kept.
|
||||||
|
* @param {{ restoreFocus?: boolean, keepSelection?: boolean, commitPendingAttachments?: boolean }} [options]
|
||||||
|
*/
|
||||||
|
export function hideAnnotationPanel({ restoreFocus = true, keepSelection = false, commitPendingAttachments = false } = {}) {
|
||||||
|
if (!_annotationPanelEl?.classList.contains('active')) return;
|
||||||
|
|
||||||
|
releaseAttachments(commitPendingAttachments
|
||||||
|
? attachmentsNotIn(_savedAttachments, getPendingAttachments())
|
||||||
|
: attachmentsNotIn(getPendingAttachments(), _savedAttachments));
|
||||||
|
|
||||||
|
_annotationPanelEl.classList.remove('active');
|
||||||
|
hideButtonTooltip();
|
||||||
|
setAttachmentUploadInProgress(false);
|
||||||
|
|
||||||
|
setPendingElements([]);
|
||||||
|
setPendingSelections([]);
|
||||||
|
setPendingAttachments([]);
|
||||||
|
_savedAttachments = [];
|
||||||
|
setEditingComment(null);
|
||||||
|
setPanelMode('create');
|
||||||
|
if (!keepSelection) clearSelectedElements();
|
||||||
|
|
||||||
|
unlockHoverOutline();
|
||||||
|
unhighlightAllMarkers();
|
||||||
|
|
||||||
|
const editing = getEditing();
|
||||||
|
if (restoreFocus && editing?.targetElement?.hasAttribute('contenteditable')) {
|
||||||
|
editing.targetElement.focus({ preventScroll: true });
|
||||||
|
const selection = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(editing.targetElement);
|
||||||
|
range.collapse(false);
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
detachPanelKeydown();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAnnotationPanelEl() {
|
||||||
|
return _annotationPanelEl;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function attachPanelKeydown() {
|
||||||
|
detachPanelKeydown();
|
||||||
|
_panelKeydownHandler = (event) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
hideAnnotationPanel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', _panelKeydownHandler, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detachPanelKeydown() {
|
||||||
|
if (_panelKeydownHandler) {
|
||||||
|
window.removeEventListener('keydown', _panelKeydownHandler, true);
|
||||||
|
_panelKeydownHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the panel for one element or a group, resuming the existing comment when
|
||||||
|
* the same target is already annotated and capturing fresh metadata otherwise.
|
||||||
|
* @param {HTMLElement|HTMLElement[]} target
|
||||||
|
* @param {number} clickX
|
||||||
|
* @param {number} clickY
|
||||||
|
* @param {number} [anchorTop] - Top edge of the anchor (e.g. a toolbar) used
|
||||||
|
* when flipping the panel above; defaults to clickY for a plain click point.
|
||||||
|
*/
|
||||||
|
export function openOrCreateAnnotation(target, clickX, clickY, anchorTop = clickY) {
|
||||||
|
const elements = (Array.isArray(target) ? target : [target]).filter(Boolean);
|
||||||
|
if (!elements.length) return;
|
||||||
|
|
||||||
|
const existing = getComments().find(comment => comment.elements.length === elements.length
|
||||||
|
&& comment.elements.every(element => elements.includes(element)));
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
openPanelForEdit(existing, existing.clickX, existing.clickY);
|
||||||
|
} else {
|
||||||
|
showAnnotationPanel(elements, elements.map(captureElementMetadata), clickX, clickY, anchorTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { ANNOTATION_COLOR, PANEL_BG, PANEL_INPUT_BG, BORDER_COLOR, HOVER_BG, TEXT_MUTED, COLOR_WHITE, BUTTON_LIGHT_TEXT, BUTTON_LIGHT_BG_HOVER, BOX_SHADOW_TOOLBAR, SCROLLBAR_THUMB, SCROLLBAR_THUMB_HOVER, Z_INDEX_EDITOR_PANEL, Z_INDEX_EDITOR_MARKER } from '../../constants/theme.js';
|
||||||
|
|
||||||
|
export const ANNOTATION_PANEL_STYLES = `
|
||||||
|
#selection-mode-annotation-panel {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
position: fixed;
|
||||||
|
background: ${PANEL_BG};
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid ${BORDER_COLOR};
|
||||||
|
padding: 8px;
|
||||||
|
width: 320px;
|
||||||
|
box-shadow: ${BOX_SHADOW_TOOLBAR};
|
||||||
|
pointer-events: all;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: DM Sans, sans-serif;
|
||||||
|
z-index: ${Z_INDEX_EDITOR_PANEL};
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-annotation-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-annotation-count {
|
||||||
|
flex: 1 0 0;
|
||||||
|
min-width: 0;
|
||||||
|
color: ${TEXT_MUTED};
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 400;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-annotation-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: ${PANEL_INPUT_BG};
|
||||||
|
border: 1px solid ${BORDER_COLOR};
|
||||||
|
border-radius: 8px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-annotation-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88px;
|
||||||
|
max-height: 160px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
padding: 0 4px;
|
||||||
|
color: ${COLOR_WHITE};
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
caret-color: ${COLOR_WHITE};
|
||||||
|
line-height: 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-annotation-textarea::placeholder {
|
||||||
|
color: ${TEXT_MUTED};
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-annotation-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mirrors the chat file previews: chips scroll sideways instead of stacking into rows. */
|
||||||
|
#selection-mode-annotation-attachments {
|
||||||
|
display: none;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-top: 4px;
|
||||||
|
margin-top: -4px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: ${SCROLLBAR_THUMB} transparent;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: ${SCROLLBAR_THUMB};
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: ${SCROLLBAR_THUMB_HOVER};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-mode-annotation-attachment {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-mode-annotation-attachment-thumb {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border: 1px solid ${BORDER_COLOR};
|
||||||
|
border-radius: 8px;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-mode-annotation-attachment-remove {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: 8px;
|
||||||
|
background: ${PANEL_BG};
|
||||||
|
border: 1px solid ${BORDER_COLOR};
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||||
|
color: ${COLOR_WHITE};
|
||||||
|
|
||||||
|
& svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: ${BORDER_COLOR};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-annotation-actions,
|
||||||
|
#selection-mode-annotation-primary-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overrides the hover tooltip's fixed positioning; the id keeps this winning
|
||||||
|
no matter which of the two stylesheets is injected last. */
|
||||||
|
#selection-mode-annotation-panel .selection-mode-button-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-discard-btn,
|
||||||
|
#selection-mode-attach-image-btn,
|
||||||
|
#selection-mode-delete-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-discard-btn:hover,
|
||||||
|
#selection-mode-attach-image-btn:hover:not(:disabled),
|
||||||
|
#selection-mode-delete-btn:hover {
|
||||||
|
background: ${HOVER_BG};
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-attach-image-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-attach-image-btn.is-loading:disabled {
|
||||||
|
opacity: 1;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mirrors horizons-frontend Spinner.vue (w-4 border-1 border-light border-t-transparent, 2s spin). */
|
||||||
|
.selection-mode-attach-spinner {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid ${COLOR_WHITE};
|
||||||
|
border-top-color: transparent;
|
||||||
|
animation: selection-mode-attach-spin 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes selection-mode-attach-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-delete-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-comment-btn {
|
||||||
|
background: ${COLOR_WHITE};
|
||||||
|
border: none;
|
||||||
|
color: ${BUTTON_LIGHT_TEXT};
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-comment-btn:hover {
|
||||||
|
background: ${BUTTON_LIGHT_BG_HOVER};
|
||||||
|
}
|
||||||
|
|
||||||
|
#selection-mode-comment-btn:disabled {
|
||||||
|
opacity: 0.2;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background: ${COLOR_WHITE};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const ANNOTATION_MARKER_STYLES = `
|
||||||
|
.selection-mode-annotation-marker > * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-mode-annotation-marker {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: ${ANNOTATION_COLOR};
|
||||||
|
border: 1px solid ${COLOR_WHITE}25;
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
transition: transform 0.15s ease;
|
||||||
|
z-index: ${Z_INDEX_EDITOR_MARKER};
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-mode-annotation-marker:hover,
|
||||||
|
.selection-mode-annotation-marker.highlighted {
|
||||||
|
transform: translate(-50%, -50%) scale(1.1);
|
||||||
|
}
|
||||||
|
`;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ICON_DELETE, ICON_CLOSE, ICON_IMAGE } from '../../constants/icons.js';
|
||||||
|
|
||||||
|
export const ANNOTATION_PANEL_HTML = `
|
||||||
|
<div id="selection-mode-annotation-panel">
|
||||||
|
<div id="selection-mode-annotation-header">
|
||||||
|
<span id="selection-mode-annotation-count"></span>
|
||||||
|
<button id="selection-mode-discard-btn" title="Close">${ICON_CLOSE}</button>
|
||||||
|
</div>
|
||||||
|
<div id="selection-mode-annotation-input-wrapper">
|
||||||
|
<div id="selection-mode-annotation-attachments"></div>
|
||||||
|
<textarea id="selection-mode-annotation-textarea" placeholder="Tell AI what to change" rows="1"></textarea>
|
||||||
|
</div>
|
||||||
|
<div id="selection-mode-annotation-buttons">
|
||||||
|
<div id="selection-mode-annotation-actions">
|
||||||
|
<button id="selection-mode-attach-image-btn" aria-label="Add images">${ICON_IMAGE}</button>
|
||||||
|
</div>
|
||||||
|
<div id="selection-mode-annotation-primary-actions">
|
||||||
|
<div id="selection-mode-delete-wrapper">
|
||||||
|
<button id="selection-mode-delete-btn" aria-label="Delete comment">${ICON_DELETE}</button>
|
||||||
|
</div>
|
||||||
|
<button id="selection-mode-comment-btn" disabled>Add comment</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { getEditing } from "../../state/editing-state.js";
|
||||||
|
import { stopFontSizeEdit, updateTextFormatToolbar, scheduleRepositionEditOverlays } from "../text-format/toolbar/toolbar.js";
|
||||||
|
import { hideAllPanels } from "../../state/panel-state.js";
|
||||||
|
import { resolveInlineStylesAtRange } from "../../utils/inline-style-state.js";
|
||||||
|
import { EDITOR_UI_SELECTOR } from "../../constants/selectors.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
* @returns {{ value: string, isGradient: boolean }}
|
||||||
|
*/
|
||||||
|
function resolveTextColor(element, container) {
|
||||||
|
const color = getComputedStyle(element).color;
|
||||||
|
|
||||||
|
if (color !== 'rgba(0, 0, 0, 0)' && color !== 'transparent') {
|
||||||
|
return { value: color, isGradient: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = element;
|
||||||
|
while (current && current !== container.parentElement) {
|
||||||
|
const computedStyle = getComputedStyle(current);
|
||||||
|
const backgroundImage = computedStyle.backgroundImage;
|
||||||
|
const backgroundClip = computedStyle.backgroundClip || computedStyle['webkitBackgroundClip'];
|
||||||
|
|
||||||
|
if (backgroundImage && backgroundImage !== 'none' && backgroundImage.includes('gradient') && backgroundClip === 'text') {
|
||||||
|
return { value: backgroundImage, isGradient: true };
|
||||||
|
}
|
||||||
|
current = current.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { value: color, isGradient: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves caret/selection presentation state for the formatting toolbar.
|
||||||
|
* Bold, italic, and underline are detected independently via ancestor walks
|
||||||
|
* so nested markup (`<em><u><strong>…</strong></u></em>`) reports all active styles.
|
||||||
|
* @returns {object|null}
|
||||||
|
*/
|
||||||
|
export function getStylesAtCursor() {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (!selection || selection.rangeCount === 0) return null;
|
||||||
|
|
||||||
|
const range = selection.getRangeAt(0);
|
||||||
|
let node = range.startContainer;
|
||||||
|
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
const child = node.childNodes[range.startOffset];
|
||||||
|
if (child) node = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
node = node.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
|
||||||
|
node = getEditing()?.targetElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
const container = getEditing()?.targetElement || node;
|
||||||
|
const presentation = resolveInlineStylesAtRange(range, container);
|
||||||
|
const styles = getComputedStyle(node);
|
||||||
|
const containerStyles = getComputedStyle(container);
|
||||||
|
const resolvedColor = resolveTextColor(node, container);
|
||||||
|
|
||||||
|
return {
|
||||||
|
color: resolvedColor.value,
|
||||||
|
isGradient: resolvedColor.isGradient,
|
||||||
|
fontFamily: styles.fontFamily,
|
||||||
|
fontSize: styles.fontSize,
|
||||||
|
textAlign: containerStyles.textAlign,
|
||||||
|
...presentation,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates toolbar and parent when the selection moves during inline edit. */
|
||||||
|
export function onSelectionChange(force = false) {
|
||||||
|
const editing = getEditing();
|
||||||
|
if (!editing?.targetElement?.hasAttribute("contenteditable")) return;
|
||||||
|
if (!force && document.activeElement?.closest(EDITOR_UI_SELECTOR)) return;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.rangeCount > 0 && !editing.targetElement.contains(selection.getRangeAt(0).commonAncestorContainer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopFontSizeEdit();
|
||||||
|
hideAllPanels();
|
||||||
|
const styles = getStylesAtCursor();
|
||||||
|
if (!styles) return;
|
||||||
|
updateTextFormatToolbar(styles);
|
||||||
|
scheduleRepositionEditOverlays();
|
||||||
|
}
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import { getEditing, setEditing, clearEditing } from "../../state/editing-state.js";
|
||||||
|
import { normalizeContentEditableHtml, patchRemoveChild, serializeContentEditableHtml } from "../../utils/html-utils.js";
|
||||||
|
import { postToParent } from "../../utils/parent-frame.js";
|
||||||
|
import { ParentMessage } from "../../constants/messages.js";
|
||||||
|
import { beginEditSession, recordEdit } from "../../state/history-state.js";
|
||||||
|
import { notifyDraftStateChanged } from "../../api/draft-snapshot.js";
|
||||||
|
import { showTextFormatToolbar, hideTextFormatToolbar, scheduleRepositionEditOverlays } from "../text-format/toolbar/toolbar.js";
|
||||||
|
import { onSelectionChange } from "./cursor-styles.js";
|
||||||
|
import { captureElementMetadata } from "../../utils/selection-mode-metadata.js";
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a CSS property name to its camelCase JS form
|
||||||
|
* (e.g. `background-clip` → `backgroundClip`), matching the keys used by the
|
||||||
|
* JSX style objects in the source write-back.
|
||||||
|
*/
|
||||||
|
function cssPropertyToCamelCase(cssProperty) {
|
||||||
|
return cssProperty.replace(/^-/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRANSPARENT_TEXT_CARET_COLOR = "#6B7280";
|
||||||
|
|
||||||
|
function isTransparentColor(color) {
|
||||||
|
if (!color || color === "transparent") return true;
|
||||||
|
const match = color.replace(/\s+/g, "").match(/^rgba?\(([^)]+)\)$/i);
|
||||||
|
if (!match) return false;
|
||||||
|
const segments = match[1].split(",");
|
||||||
|
return segments.length === 4 && parseFloat(segments[3]) === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reports whether the element or any descendant renders transparent text
|
||||||
|
* (e.g. gradient text via `background-clip: text` + `color: transparent`),
|
||||||
|
* where the caret would otherwise be invisible.
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
function hasTransparentText(element) {
|
||||||
|
if (isTransparentColor(getComputedStyle(element).color)) return true;
|
||||||
|
return Array.from(element.querySelectorAll("*")).some(
|
||||||
|
descendant => isTransparentColor(getComputedStyle(descendant).color)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `caret-color: currentColor` is invisible on transparent/gradient text, so use a visible caret there. */
|
||||||
|
function applyEditingCaretColor(element) {
|
||||||
|
if (hasTransparentText(element)) {
|
||||||
|
element.style.caretColor = TRANSPARENT_TEXT_CARET_COLOR;
|
||||||
|
} else {
|
||||||
|
element.style.removeProperty("caret-color");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshots every style property set inline on the element itself.
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @returns {Record<string, string>} camelCase property → value
|
||||||
|
*/
|
||||||
|
function captureInlineStyles(element) {
|
||||||
|
const styles = {};
|
||||||
|
for (let index = 0; index < element.style.length; index++) {
|
||||||
|
const cssProperty = element.style.item(index);
|
||||||
|
styles[cssPropertyToCamelCase(cssProperty)] = element.style.getPropertyValue(cssProperty);
|
||||||
|
}
|
||||||
|
return styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diffs the element's current inline styles against a snapshot, covering any
|
||||||
|
* panel action that styles the element itself ('' marks a removed property).
|
||||||
|
* `textAlign` is excluded here — it is compared against computed style instead,
|
||||||
|
* so re-applying an alignment inherited from CSS is not logged as a change.
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @param {Record<string, string>} original
|
||||||
|
* @returns {{ changed: Record<string, string>, previous: Record<string, string> }|null}
|
||||||
|
*/
|
||||||
|
function getInlineStyleDiff(element, original) {
|
||||||
|
const current = captureInlineStyles(element);
|
||||||
|
const changed = {};
|
||||||
|
const previous = {};
|
||||||
|
for (const key of new Set([...Object.keys(current), ...Object.keys(original)])) {
|
||||||
|
if (key === 'textAlign') continue;
|
||||||
|
// Transient editing caret-color must never be written back to source.
|
||||||
|
if (key === 'caretColor') continue;
|
||||||
|
if ((current[key] ?? '') !== (original[key] ?? '')) {
|
||||||
|
changed[key] = current[key] ?? '';
|
||||||
|
previous[key] = original[key] ?? '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Object.keys(changed).length > 0 ? { changed, previous } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes element-level style changes (alignment plus any other inline style
|
||||||
|
* the panel set on the element) since the edit session's last snapshot.
|
||||||
|
* @param {object} editing - Current editing state.
|
||||||
|
* @returns {{ style: object|null }}
|
||||||
|
*/
|
||||||
|
function buildStyleDiff(editing) {
|
||||||
|
const { targetElement, originalTextAlign, originalInlineTextAlign, originalInlineStyles } = editing;
|
||||||
|
const alignChanged = getComputedStyle(targetElement).textAlign !== originalTextAlign;
|
||||||
|
const inlineDiff = getInlineStyleDiff(targetElement, originalInlineStyles);
|
||||||
|
if (!alignChanged && !inlineDiff) return { style: null, oldStyle: null };
|
||||||
|
|
||||||
|
const style = {};
|
||||||
|
const oldStyle = {};
|
||||||
|
if (alignChanged) {
|
||||||
|
style.textAlign = targetElement.style.textAlign;
|
||||||
|
// Keep previous inline value around so re-applying the inherited default still serializes.
|
||||||
|
if (!style.textAlign) style.textAlign = originalInlineTextAlign ?? '';
|
||||||
|
oldStyle.textAlign = originalInlineTextAlign ?? '';
|
||||||
|
}
|
||||||
|
if (inlineDiff) {
|
||||||
|
Object.assign(style, inlineDiff.changed);
|
||||||
|
Object.assign(oldStyle, inlineDiff.previous);
|
||||||
|
}
|
||||||
|
return { style, oldStyle };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-snapshots the editing state's style baselines after a soft-save. */
|
||||||
|
function refreshStyleSnapshots(editing) {
|
||||||
|
const { targetElement } = editing;
|
||||||
|
editing.originalTextAlign = getComputedStyle(targetElement).textAlign;
|
||||||
|
editing.originalInlineTextAlign = targetElement.style.textAlign || '';
|
||||||
|
editing.originalInlineStyles = captureInlineStyles(targetElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Snapshots child nodes so DOM mutations during edit can be restored if needed.
|
||||||
|
* @param {HTMLElement} element
|
||||||
|
* @returns {Array<{ node: Node, value: string|null }>}
|
||||||
|
*/
|
||||||
|
export function saveChildNodes(element) {
|
||||||
|
const saved = [];
|
||||||
|
for (const child of element.childNodes) {
|
||||||
|
saved.push({
|
||||||
|
node: child,
|
||||||
|
value: child.nodeType === Node.TEXT_NODE ? child.nodeValue : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places a collapsed caret at the end of the editable container's contents.
|
||||||
|
* @param {HTMLElement} container
|
||||||
|
*/
|
||||||
|
export function placeCursorAtEnd(container) {
|
||||||
|
if (!container.childNodes.length) {
|
||||||
|
container.appendChild(document.createTextNode(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = container;
|
||||||
|
while (target.lastChild) {
|
||||||
|
target = target.lastChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
if (target.nodeType === Node.TEXT_NODE) {
|
||||||
|
range.setStart(target, target.textContent.length);
|
||||||
|
} else {
|
||||||
|
range.selectNodeContents(target);
|
||||||
|
}
|
||||||
|
range.collapse(false);
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places the text caret at viewport coordinates, clamped to the editable container.
|
||||||
|
* @param {number} x
|
||||||
|
* @param {number} y
|
||||||
|
* @param {HTMLElement} [container]
|
||||||
|
*/
|
||||||
|
export function placeCursorAtPoint(x, y, container) {
|
||||||
|
let range = null;
|
||||||
|
if (document.caretPositionFromPoint) {
|
||||||
|
const position = document.caretPositionFromPoint(x, y);
|
||||||
|
if (position) {
|
||||||
|
range = document.createRange();
|
||||||
|
range.setStart(position.offsetNode, position.offset);
|
||||||
|
range.collapse(true);
|
||||||
|
}
|
||||||
|
} else if (document.caretRangeFromPoint) {
|
||||||
|
range = document.caretRangeFromPoint(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the caret resolved outside our editable target (e.g. an absolute overlay
|
||||||
|
// sat on top of the click point), fall back to placing it at the end of the
|
||||||
|
// target so typing actually goes into the contenteditable.
|
||||||
|
if (container && (!range || !container.contains(range.startContainer))) {
|
||||||
|
range = document.createRange();
|
||||||
|
range.selectNodeContents(container);
|
||||||
|
range.collapse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!range) return;
|
||||||
|
const selection = window.getSelection();
|
||||||
|
selection.removeAllRanges();
|
||||||
|
selection.addRange(range);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enables contenteditable on the target and wires edit listeners and toolbar.
|
||||||
|
* @param {HTMLElement} targetElement
|
||||||
|
* @param {string} editId
|
||||||
|
* @param {string} currentContent - Initial HTML content.
|
||||||
|
* @param {{ focusTarget?: boolean }} [options] - Pass `focusTarget: false` when the caller focuses its own UI instead.
|
||||||
|
*/
|
||||||
|
export function startInlineEdit(targetElement, editId, currentContent, { focusTarget = true } = {}) {
|
||||||
|
if (getEditing()?.targetElement === targetElement) return;
|
||||||
|
|
||||||
|
const savedChildren = saveChildNodes(targetElement);
|
||||||
|
const normalizedOriginal = normalizeContentEditableHtml(currentContent);
|
||||||
|
const sessionId = beginEditSession();
|
||||||
|
const selectionMode = captureElementMetadata(targetElement);
|
||||||
|
setEditing({
|
||||||
|
editId,
|
||||||
|
targetElement,
|
||||||
|
sessionId,
|
||||||
|
originalContent: normalizedOriginal,
|
||||||
|
originalTextAlign: getComputedStyle(targetElement).textAlign,
|
||||||
|
originalInlineTextAlign: targetElement.style.textAlign || '',
|
||||||
|
originalInlineStyles: captureInlineStyles(targetElement),
|
||||||
|
savedChildren,
|
||||||
|
selectionMode,
|
||||||
|
});
|
||||||
|
document.getElementById("root")?.setAttribute("data-inline-editing", "true");
|
||||||
|
targetElement.setAttribute("data-editing", "true");
|
||||||
|
patchRemoveChild(targetElement);
|
||||||
|
|
||||||
|
targetElement.setAttribute("contenteditable", "true");
|
||||||
|
applyEditingCaretColor(targetElement);
|
||||||
|
|
||||||
|
if (focusTarget) {
|
||||||
|
targetElement.focus();
|
||||||
|
placeCursorAtEnd(targetElement);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (getEditing()?.targetElement !== targetElement) return;
|
||||||
|
placeCursorAtEnd(targetElement);
|
||||||
|
targetElement.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
postToParent(ParentMessage.EDIT_ENTER, {
|
||||||
|
currentText: currentContent,
|
||||||
|
elementType: targetElement.tagName.toLowerCase(),
|
||||||
|
isDirectlyEditable: targetElement.hasAttribute('data-edit-id'),
|
||||||
|
});
|
||||||
|
showTextFormatToolbar(targetElement);
|
||||||
|
targetElement.addEventListener("keydown", onInlineEditKeyDown);
|
||||||
|
targetElement.addEventListener("beforeinput", onInlineEditBeforeInput);
|
||||||
|
targetElement.addEventListener("input", onInlineEditInput);
|
||||||
|
targetElement.addEventListener("tft-color-commit", saveCurrentEdit);
|
||||||
|
document.addEventListener("selectionchange", onSelectionChange);
|
||||||
|
setTimeout(onSelectionChange, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Soft-saves the current edit without tearing down contenteditable (e.g. color commit),
|
||||||
|
* recording it into the in-iframe edit history so it can be undone/redone and
|
||||||
|
* later batch-persisted on draft save.
|
||||||
|
*/
|
||||||
|
export function saveCurrentEdit() {
|
||||||
|
const editing = getEditing();
|
||||||
|
if (!editing) return;
|
||||||
|
|
||||||
|
const { editId, targetElement, originalContent, sessionId, selectionMode } = editing;
|
||||||
|
const newHTML = serializeContentEditableHtml(targetElement);
|
||||||
|
const { style, oldStyle } = buildStyleDiff(editing);
|
||||||
|
const isAssisted = targetElement.hasAttribute('data-edit-assisted-id');
|
||||||
|
|
||||||
|
if (newHTML !== originalContent || style) {
|
||||||
|
recordEdit(editId, { beforeContent: originalContent, afterContent: newHTML }, { style, oldStyle, element: targetElement, sessionId, isAssisted, selectionMode });
|
||||||
|
notifyDraftStateChanged();
|
||||||
|
editing.originalContent = newHTML;
|
||||||
|
editing.hasSoftSaved = true;
|
||||||
|
refreshStyleSnapshots(editing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Finalizes the active inline edit, records it into history, and clears editing state. */
|
||||||
|
export function commitCurrentEdit() {
|
||||||
|
const editing = getEditing();
|
||||||
|
if (!editing) return;
|
||||||
|
|
||||||
|
const { editId, targetElement, originalContent, sessionId, selectionMode } = editing;
|
||||||
|
const newHTML = serializeContentEditableHtml(targetElement);
|
||||||
|
const { style, oldStyle } = buildStyleDiff(editing);
|
||||||
|
const isAssisted = targetElement.hasAttribute('data-edit-assisted-id');
|
||||||
|
|
||||||
|
targetElement.removeAttribute("contenteditable");
|
||||||
|
targetElement.removeAttribute("data-editing");
|
||||||
|
targetElement.style.removeProperty("caret-color");
|
||||||
|
document.getElementById("root")?.removeAttribute("data-inline-editing");
|
||||||
|
targetElement.removeEventListener("keydown", onInlineEditKeyDown);
|
||||||
|
targetElement.removeEventListener("beforeinput", onInlineEditBeforeInput);
|
||||||
|
targetElement.removeEventListener("input", onInlineEditInput);
|
||||||
|
targetElement.removeEventListener("tft-color-commit", saveCurrentEdit);
|
||||||
|
document.removeEventListener("selectionchange", onSelectionChange);
|
||||||
|
hideTextFormatToolbar();
|
||||||
|
|
||||||
|
patchRemoveChild(targetElement);
|
||||||
|
targetElement.innerHTML = newHTML;
|
||||||
|
|
||||||
|
const { hasSoftSaved } = editing;
|
||||||
|
|
||||||
|
if (newHTML !== originalContent || style) {
|
||||||
|
recordEdit(editId, { beforeContent: originalContent, afterContent: newHTML }, { style, oldStyle, element: targetElement, sessionId, isAssisted, selectionMode });
|
||||||
|
notifyDraftStateChanged();
|
||||||
|
} else if (!hasSoftSaved) {
|
||||||
|
postToParent(ParentMessage.EDIT_CANCEL, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearEditing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocks browser replacement-text input during inline edit.
|
||||||
|
* @param {InputEvent} e
|
||||||
|
*/
|
||||||
|
export function onInlineEditBeforeInput(event) {
|
||||||
|
if (event.inputType === "insertReplacementText") {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scheduleRepositionEditOverlays();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repositions the toolbar as the editable content grows/shrinks. */
|
||||||
|
export function onInlineEditInput() {
|
||||||
|
if (!getEditing()) return;
|
||||||
|
saveCurrentEdit();
|
||||||
|
scheduleRepositionEditOverlays();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles Escape/Enter to commit and stops propagation for other keys.
|
||||||
|
* @param {KeyboardEvent} e
|
||||||
|
*/
|
||||||
|
export function onInlineEditKeyDown(event) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
commitCurrentEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Enter") {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
commitCurrentEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === " " && event.currentTarget.tagName.toLowerCase() === "button") {
|
||||||
|
// Native <button> treats Space as activation, not text input, so the
|
||||||
|
// browser never inserts a character. Prevent the activation and insert
|
||||||
|
// the space manually (execCommand keeps the input event).
|
||||||
|
event.preventDefault();
|
||||||
|
document.execCommand("insertText", false, " ");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { EDIT_BORDER_COLOR, EDIT_BACKGROUND_COLOR, PANEL_BG, BORDER_COLOR, COLOR_WHITE, Z_INDEX_EDITOR_OVERLAY } from '../../constants/theme.js';
|
||||||
|
import { ICON_TEXT_EDIT_CURSOR } from '../../constants/icons.js';
|
||||||
|
import { HOVER_OUTLINE_STROKE_WIDTH } from '../../constants/layout.js';
|
||||||
|
import { EDITOR_UI_SELECTOR } from '../../constants/selectors.js';
|
||||||
|
|
||||||
|
/** Injected CSS for edit-mode hover outlines, contenteditable affordances, and the element-type tooltip. */
|
||||||
|
export const EDIT_MODE_STYLES = `
|
||||||
|
#root[data-edit-mode-enabled="true"] [data-edit-id],
|
||||||
|
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id] {
|
||||||
|
cursor: default !important;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
#root[data-edit-mode-enabled="true"] [data-edit-id][contenteditable="true"],
|
||||||
|
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id][contenteditable="true"] {
|
||||||
|
outline: 1px solid ${EDIT_BORDER_COLOR};
|
||||||
|
caret-color: currentColor;
|
||||||
|
user-select: text;
|
||||||
|
-webkit-user-select: text;
|
||||||
|
cursor: url('data:image/svg+xml,${ICON_TEXT_EDIT_CURSOR}') 12 12, text !important;
|
||||||
|
}
|
||||||
|
#root[data-edit-mode-enabled="true"] [data-edit-id][contenteditable="true"] *,
|
||||||
|
#root[data-edit-mode-enabled="true"] [data-edit-assisted-id][contenteditable="true"] * {
|
||||||
|
cursor: url('data:image/svg+xml,${ICON_TEXT_EDIT_CURSOR}') 12 12, text !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Suppress native cursor on actionable elements while editing. */
|
||||||
|
#root[data-edit-mode-enabled="true"] a,
|
||||||
|
#root[data-edit-mode-enabled="true"] button,
|
||||||
|
#root[data-edit-mode-enabled="true"] [role="button"],
|
||||||
|
#root[data-edit-mode-enabled="true"] [type="submit"] {
|
||||||
|
cursor: default !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-editor-hover-outline,
|
||||||
|
.inline-editor-locked-outline {
|
||||||
|
outline-offset: -${HOVER_OUTLINE_STROKE_WIDTH}px;
|
||||||
|
box-shadow: inset 0 0 0 999px ${EDIT_BACKGROUND_COLOR};
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-editor-hover-outline {
|
||||||
|
outline: ${HOVER_OUTLINE_STROKE_WIDTH}px dashed ${EDIT_BORDER_COLOR};
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-editor-locked-outline {
|
||||||
|
outline: ${HOVER_OUTLINE_STROKE_WIDTH}px solid ${EDIT_BORDER_COLOR};
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes element-type-tooltip-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-type-tooltip {
|
||||||
|
display: none;
|
||||||
|
opacity: 0;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
background-color: ${PANEL_BG};
|
||||||
|
color: ${COLOR_WHITE};
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: DM Sans, sans-serif;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 20px;
|
||||||
|
border: 1px solid ${BORDER_COLOR};
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: ${Z_INDEX_EDITOR_OVERLAY};
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-type-tooltip.active {
|
||||||
|
display: block;
|
||||||
|
animation: element-type-tooltip-fade-in 0.2s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
${EDITOR_UI_SELECTOR} {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
`;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user