Add MindSpace visual page editor and Plaza local Tunnel deployment.
In-preview HTML editing with undo/redo sync replaces Agent patch auto-save; Plaza runs on local Mac via Cloudflare Tunnel with embed height and CSP fixes. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+9
-14
@@ -118,22 +118,17 @@ VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
|
|||||||
# 历史列表每次展示/加载条数(默认 25,与 desktop 一致)
|
# 历史列表每次展示/加载条数(默认 25,与 desktop 一致)
|
||||||
# VITE_SESSION_PAGE_SIZE=25
|
# VITE_SESSION_PAGE_SIZE=25
|
||||||
|
|
||||||
# Plaza 发现广场(Sprint 1)
|
# Plaza 发现广场(本机为主,105 已停用)
|
||||||
# PLAZA_AUTO_APPROVE=true
|
# PLAZA_AUTO_APPROVE=true
|
||||||
# 本地 Plaza(无端口,与线上一致):
|
# 文档:docs/plaza-local.md
|
||||||
# sudo pnpm setup:plaza-dns # hosts + /etc/resolver(覆盖 Tailscale fake-ip)
|
# 首次:sudo pnpm setup:plaza-dns # 仅纯本地 HTTPS 时需要;走 Tunnel 请用 setup:plaza-dns:remove
|
||||||
# pnpm dev:plaza-dns # 本地 DNS :5533(dev:plaza 会自动启动)
|
# 日常:pnpm dev:plaza | 浏览器:pnpm open:plaza | 诊断:pnpm check:plaza
|
||||||
# pnpm dev:plaza # 仅 Plaza + Portal
|
# 公网 plaza.tkmind.cn 经 Cloudflare Tunnel → 本机 :3001(需 dev:plaza 常驻)
|
||||||
# sudo pnpm dev:plaza-proxy # :443 -> :3001
|
|
||||||
# 若浏览器仍走 Cloudflare:关闭「安全 DNS / Secure DNS」后重开浏览器
|
|
||||||
# PLAZA_LOCAL_HOST=plaza.tkmind.cn
|
# PLAZA_LOCAL_HOST=plaza.tkmind.cn
|
||||||
# PLAZA_PUBLIC_PORT=80
|
# PLAZA_PUBLIC_BASE=https://plaza.tkmind.cn
|
||||||
# PLAZA_PUBLIC_BASE=http://plaza.tkmind.cn
|
# VITE_PLAZA_BASE=https://plaza.tkmind.cn
|
||||||
# VITE_PLAZA_BASE=http://plaza.tkmind.cn
|
# CLOUDFLARE_TUNNEL_CONFIG=/Users/john/Project/ollama/cloudflare/config.yml
|
||||||
# 105 部署(Plaza 与 H5 分离):
|
# CLOUDFLARE_TUNNEL_NAME=ollama-tkmind
|
||||||
# pnpm deploy:105 # Memind H5 → 105(Plaza API + MindSpace + 登录)
|
|
||||||
# pnpm deploy:plaza-105 # Plaza Next.js → 105 /root/plaza/web(独立前端)
|
|
||||||
# 配置:deploy/plaza-105/plaza-105.env.example → plaza-105.env
|
|
||||||
|
|
||||||
# MindSpace(默认启用;子功能需按需开启)
|
# MindSpace(默认启用;子功能需按需开启)
|
||||||
# MINDSPACE_ENABLED=false
|
# MINDSPACE_ENABLED=false
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Memind (TKMind H5)
|
||||||
|
|
||||||
|
Memind 主应用与 Plaza 发现广场。**当前以本机 Mac 为主服务**,105 服务器已停用。
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
cp .env.example .env # 按需填写
|
||||||
|
pnpm dev # MindSpace + Plaza + Ops
|
||||||
|
```
|
||||||
|
|
||||||
|
仅 Plaza:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm start:plaza # 本机正式模式(Tunnel 回源)
|
||||||
|
pnpm dev:plaza # 开发热更新
|
||||||
|
pnpm open:plaza # 浏览器打开 https://plaza.tkmind.cn/plaza
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plaza 本机为主
|
||||||
|
|
||||||
|
完整说明见 **[docs/plaza-local.md](docs/plaza-local.md)**,摘要如下:
|
||||||
|
|
||||||
|
| 步骤 | 命令 |
|
||||||
|
|------|------|
|
||||||
|
| 首次 DNS | `sudo pnpm setup:plaza-dns` |
|
||||||
|
| 首次 TLS(可选) | `sudo pnpm setup:plaza-local` |
|
||||||
|
| 公网 Tunnel | `pnpm setup:plaza-tunnel` |
|
||||||
|
| 日常正式 | `pnpm start:plaza` |
|
||||||
|
| 开发调试 | `pnpm dev:plaza` |
|
||||||
|
| 诊断 | `pnpm check:plaza` |
|
||||||
|
|
||||||
|
公网 `https://plaza.tkmind.cn` 经 Cloudflare Tunnel 回源本机 `:3001`,需保持 `pnpm start:plaza` 运行。
|
||||||
|
|
||||||
|
## 常用脚本
|
||||||
|
|
||||||
|
| 命令 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `pnpm dev` | 全栈开发 |
|
||||||
|
| `pnpm start:plaza` | Plaza 正式模式 + Portal |
|
||||||
|
| `pnpm dev:plaza` | Plaza 开发模式 + Portal |
|
||||||
|
| `pnpm open:plaza` | 浏览器强制本地解析 |
|
||||||
|
| `pnpm check:plaza` | 本地 Plaza 诊断 |
|
||||||
|
| `pnpm test` | 单元测试 |
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [Plaza 本机部署与 Tunnel](docs/plaza-local.md)
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Plaza 本机为主
|
||||||
|
|
||||||
|
105 服务器已停用。Plaza 与 Memind H5 均在本机 Mac 运行;公网 `plaza.tkmind.cn` 经 **Cloudflare Tunnel** 回源到本机。
|
||||||
|
|
||||||
|
## 架构
|
||||||
|
|
||||||
|
```
|
||||||
|
浏览器 / 微信
|
||||||
|
↓
|
||||||
|
Cloudflare(plaza.tkmind.cn)
|
||||||
|
↓ Cloudflare Tunnel
|
||||||
|
本机 Mac
|
||||||
|
├─ :3001 Plaza Next.js(/plaza、/_next)
|
||||||
|
│ └─ rewrites → :8081(/api、/auth、/u)
|
||||||
|
└─ :8081 Memind Portal(server.mjs)
|
||||||
|
```
|
||||||
|
|
||||||
|
Next.js 通过 `PLAZA_API_PROXY` 把 API 请求转发到 Portal,无需单独 nginx。
|
||||||
|
|
||||||
|
## 首次配置(一次性)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 本地 DNS(覆盖 Tailscale / Clash fake-ip)
|
||||||
|
sudo pnpm setup:plaza-dns
|
||||||
|
|
||||||
|
# 2. 本地 HTTPS 证书(可选,供本机 https://plaza.tkmind.cn)
|
||||||
|
sudo pnpm setup:plaza-local
|
||||||
|
|
||||||
|
# 3. 公网隧道(plaza.tkmind.cn → 本机 :3001)
|
||||||
|
pnpm setup:plaza-tunnel
|
||||||
|
```
|
||||||
|
|
||||||
|
## 日常启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 本机正式模式(next build + start,供 Tunnel 回源,推荐公网)
|
||||||
|
pnpm start:plaza
|
||||||
|
|
||||||
|
# 开发模式(热更新,仅本地调试)
|
||||||
|
pnpm dev:plaza
|
||||||
|
|
||||||
|
# 另开终端:本机 HTTPS 入口(可选,纯本地 HTTPS 时用)
|
||||||
|
sudo pnpm dev:plaza-proxy
|
||||||
|
|
||||||
|
# 或全栈(MindSpace + Plaza + Ops)
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 访问方式
|
||||||
|
|
||||||
|
| 场景 | 地址 |
|
||||||
|
|------|------|
|
||||||
|
| 本机直连 | http://127.0.0.1:3001/plaza |
|
||||||
|
| 本机域名(需 proxy) | https://plaza.tkmind.cn/plaza |
|
||||||
|
| 浏览器强制本地 | `pnpm open:plaza` |
|
||||||
|
| 公网(需 dev:plaza + 隧道) | https://plaza.tkmind.cn/plaza |
|
||||||
|
|
||||||
|
### 浏览器提示「连接不是私密连接」?
|
||||||
|
|
||||||
|
本机曾用 `setup:plaza-dns` 把域名指到 `127.0.0.1`,HTTPS 走自签证书。若已配置 Cloudflare Tunnel,应 **去掉本地劫持**,走公网证书(仍回源本机):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo pnpm setup:plaza-dns:remove
|
||||||
|
```
|
||||||
|
|
||||||
|
然后刷新 https://plaza.tkmind.cn/ 即可。
|
||||||
|
|
||||||
|
### 浏览器打不开?
|
||||||
|
|
||||||
|
Chrome / Cursor 内置浏览器默认走 **Secure DNS**,会绕过 `/etc/hosts` 连到 Cloudflare(105 停服时显示 502)。
|
||||||
|
|
||||||
|
解决:
|
||||||
|
|
||||||
|
1. `pnpm open:plaza`(推荐)
|
||||||
|
2. 关闭浏览器「安全 DNS / Secure DNS」
|
||||||
|
3. `pnpm check:plaza` 查看诊断
|
||||||
|
|
||||||
|
## 公网 Tunnel
|
||||||
|
|
||||||
|
隧道配置写在 `ollama-tkmind` 的 cloudflared config(默认 `~/Project/ollama/cloudflare/config.yml`)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm setup:plaza-tunnel # 写入 ingress + DNS 路由 + 重启 cloudflared
|
||||||
|
```
|
||||||
|
|
||||||
|
**前提:** `pnpm start:plaza`(或 `pnpm dev:plaza`)已在跑(:3001 可访问)。Mac 休眠或服务停掉时,外网会 502/503。
|
||||||
|
|
||||||
|
自定义:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CLOUDFLARE_TUNNEL_CONFIG=/path/to/config.yml \
|
||||||
|
CLOUDFLARE_TUNNEL_NAME=ollama-tkmind \
|
||||||
|
PLAZA_TUNNEL_PORT=3001 \
|
||||||
|
pnpm setup:plaza-tunnel
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
见 `.env.example` 中 Plaza 相关注释。常用:
|
||||||
|
|
||||||
|
| 变量 | 默认 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `PLAZA_PORT` | 3001 | Plaza Next.js 端口 |
|
||||||
|
| `H5_PORT` | 8081 | Portal / API 端口 |
|
||||||
|
| `PLAZA_LOCAL_HOST` | plaza.tkmind.cn | 本地域名 |
|
||||||
|
| `PLAZA_PUBLIC_BASE` | https://plaza.tkmind.cn | 公开 URL 前缀 |
|
||||||
|
|
||||||
|
## 已弃用
|
||||||
|
|
||||||
|
- `pnpm deploy:105`、`pnpm deploy:plaza-105` — 105 不再作为主服务
|
||||||
|
- 105 上 nginx / goose-h5 / goose-plaza-web — 已停服,公网不再回源 105
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
MINDSPACE_PAGE_CONTENT_MESSAGE,
|
||||||
|
buildEditablePreviewDocument,
|
||||||
|
} from './src/utils/mindspaceVisualEditor.ts';
|
||||||
|
|
||||||
|
test('buildEditablePreviewDocument injects visual editor into body', () => {
|
||||||
|
const html = '<!doctype html><html><head></head><body><p>Hi</p></body></html>';
|
||||||
|
const result = buildEditablePreviewDocument(html);
|
||||||
|
|
||||||
|
assert.match(result, /mindspace-visual-editor-style/);
|
||||||
|
assert.match(result, /mindspace-visual-editor-script/);
|
||||||
|
assert.match(result, /data-mindspace-editing/);
|
||||||
|
assert.match(result, /<p>Hi<\/p>/);
|
||||||
|
assert.match(result, new RegExp(MINDSPACE_PAGE_CONTENT_MESSAGE.replace(':', '\\:')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildEditablePreviewDocument wraps fragment html', () => {
|
||||||
|
const result = buildEditablePreviewDocument('<section>块</section>');
|
||||||
|
assert.match(result, /<section>块<\/section>/);
|
||||||
|
assert.match(result, /<\/body>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildEditablePreviewDocument provides blank fallback', () => {
|
||||||
|
const result = buildEditablePreviewDocument('');
|
||||||
|
assert.match(result, /空白页面/);
|
||||||
|
});
|
||||||
+4
-1
@@ -6,12 +6,15 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node scripts/dev.mjs",
|
"dev": "node scripts/dev.mjs",
|
||||||
"dev:plaza": "node scripts/dev-plaza.mjs",
|
"dev:plaza": "node scripts/dev-plaza.mjs",
|
||||||
|
"start:plaza": "node scripts/start-plaza-prod.mjs",
|
||||||
"dev:plaza-proxy": "node scripts/plaza-local-proxy.mjs",
|
"dev:plaza-proxy": "node scripts/plaza-local-proxy.mjs",
|
||||||
"setup:plaza-dns": "node scripts/setup-plaza-local-dns.mjs",
|
"setup:plaza-dns": "node scripts/setup-plaza-local-dns.mjs",
|
||||||
|
"setup:plaza-dns:remove": "node scripts/setup-plaza-local-dns.mjs --remove",
|
||||||
"dev:plaza-dns": "node scripts/plaza-local-dns-server.mjs",
|
"dev:plaza-dns": "node scripts/plaza-local-dns-server.mjs",
|
||||||
"open:plaza": "node scripts/open-plaza-local.mjs",
|
"open:plaza": "node scripts/open-plaza-local.mjs",
|
||||||
"check:plaza": "node scripts/check-plaza-local.mjs",
|
"check:plaza": "node scripts/check-plaza-local.mjs",
|
||||||
"setup:plaza-local": "node scripts/setup-plaza-local.mjs",
|
"setup:plaza-local": "node scripts/setup-plaza-local.mjs",
|
||||||
|
"setup:plaza-tunnel": "bash scripts/install-plaza-tunnel.sh",
|
||||||
"setup:plaza-tls": "node scripts/plaza-local-tls.mjs",
|
"setup:plaza-tls": "node scripts/plaza-local-tls.mjs",
|
||||||
"dev:vite": "vite",
|
"dev:vite": "vite",
|
||||||
"dev:server": "node server.mjs",
|
"dev:server": "node server.mjs",
|
||||||
@@ -22,7 +25,7 @@
|
|||||||
"seed:plaza-covers": "node scripts/seed-plaza-covers.mjs",
|
"seed:plaza-covers": "node scripts/seed-plaza-covers.mjs",
|
||||||
"enrich:plaza": "node scripts/enrich-plaza-posts.mjs",
|
"enrich:plaza": "node scripts/enrich-plaza-posts.mjs",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs",
|
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs",
|
||||||
"test:mindspace-e2e": "node scripts/mindspace-e2e.mjs",
|
"test:mindspace-e2e": "node scripts/mindspace-e2e.mjs",
|
||||||
"test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs",
|
"test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs",
|
||||||
"test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs",
|
"test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs",
|
||||||
|
|||||||
+33
-1
@@ -27,7 +27,19 @@ const EMBED_BOOTSTRAP = `<script id="plaza-embed-bootstrap">(function(){
|
|||||||
return out.length?out:[d.body];
|
return out.length?out:[d.body];
|
||||||
}
|
}
|
||||||
function fullHeight(){
|
function fullHeight(){
|
||||||
return Math.min(d.documentElement.scrollHeight,16000);
|
var body=d.body;
|
||||||
|
var measured=0;
|
||||||
|
var els=blocks();
|
||||||
|
for(var i=0;i<els.length;i++){
|
||||||
|
var el=els[i];
|
||||||
|
measured=Math.max(measured,el.offsetTop+el.offsetHeight);
|
||||||
|
}
|
||||||
|
if(body&&body.lastElementChild){
|
||||||
|
var last=body.lastElementChild;
|
||||||
|
measured=Math.max(measured,last.offsetTop+last.offsetHeight);
|
||||||
|
}
|
||||||
|
measured=Math.max(measured,d.documentElement.scrollHeight,body?body.scrollHeight:0,420);
|
||||||
|
return Math.min(measured,16000);
|
||||||
}
|
}
|
||||||
function restoreBlock(block){
|
function restoreBlock(block){
|
||||||
if(!block)return;
|
if(!block)return;
|
||||||
@@ -81,6 +93,26 @@ const EMBED_BOOTSTRAP = `<script id="plaza-embed-bootstrap">(function(){
|
|||||||
new ResizeObserver(function(){reportHeight();}).observe(d.documentElement);
|
new ResizeObserver(function(){reportHeight();}).observe(d.documentElement);
|
||||||
})();</script>`;
|
})();</script>`;
|
||||||
|
|
||||||
|
const EMBED_LAYOUT_OVERRIDES =
|
||||||
|
'<style id="plaza-embed-overrides">.section,.quote-section,.footer{content-visibility:visible!important;contain-intrinsic-size:auto!important}</style>';
|
||||||
|
|
||||||
|
/** Remove inline CSP meta tags that block plaza-embed-bootstrap (HTTP CSP header applies instead). */
|
||||||
|
export function stripPublicationHtmlCspMeta(html) {
|
||||||
|
return String(html ?? '').replace(
|
||||||
|
/<meta\s+http-equiv\s*=\s*["']Content-Security-Policy["'][^>]*>/gi,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prepare stored publication HTML for Plaza iframe embed. */
|
||||||
|
export function preparePublicationHtmlForEmbed(html) {
|
||||||
|
let source = stripPublicationHtmlCspMeta(html);
|
||||||
|
if (!source.includes('id="plaza-embed-overrides"') && /<head[^>]*>/i.test(source)) {
|
||||||
|
source = source.replace(/<head([^>]*)>/i, `<head$1>${EMBED_LAYOUT_OVERRIDES}`);
|
||||||
|
}
|
||||||
|
return injectPlazaEmbedBootstrap(source);
|
||||||
|
}
|
||||||
|
|
||||||
export function injectPlazaEmbedBootstrap(html) {
|
export function injectPlazaEmbedBootstrap(html) {
|
||||||
const source = String(html ?? '');
|
const source = String(html ?? '');
|
||||||
if (source.includes('id="plaza-embed-bootstrap"')) return source;
|
if (source.includes('id="plaza-embed-bootstrap"')) return source;
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import {
|
|||||||
appendPlazaEmbedQuery,
|
appendPlazaEmbedQuery,
|
||||||
injectPlazaEmbedBootstrap,
|
injectPlazaEmbedBootstrap,
|
||||||
isPlazaEmbedRequest,
|
isPlazaEmbedRequest,
|
||||||
|
preparePublicationHtmlForEmbed,
|
||||||
publishedPageCspForEmbed,
|
publishedPageCspForEmbed,
|
||||||
|
stripPublicationHtmlCspMeta,
|
||||||
} from './plaza-embed.mjs';
|
} from './plaza-embed.mjs';
|
||||||
|
|
||||||
test('isPlazaEmbedRequest detects embed=plaza', () => {
|
test('isPlazaEmbedRequest detects embed=plaza', () => {
|
||||||
@@ -34,3 +36,20 @@ test('injectPlazaEmbedBootstrap adds script before closing body', () => {
|
|||||||
test('publishedPageCspForEmbed allows inline script', () => {
|
test('publishedPageCspForEmbed allows inline script', () => {
|
||||||
assert.match(publishedPageCspForEmbed(true), /script-src 'unsafe-inline'/);
|
assert.match(publishedPageCspForEmbed(true), /script-src 'unsafe-inline'/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stripPublicationHtmlCspMeta removes inline CSP meta tags', () => {
|
||||||
|
const html =
|
||||||
|
'<html><head><meta http-equiv="Content-Security-Policy" content="script-src \'none\'"></head><body></body></html>';
|
||||||
|
const out = stripPublicationHtmlCspMeta(html);
|
||||||
|
assert.doesNotMatch(out, /Content-Security-Policy/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preparePublicationHtmlForEmbed strips CSP and injects bootstrap', () => {
|
||||||
|
const html = `<!doctype html><html><head>
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="script-src 'none'">
|
||||||
|
</head><body><section class="section">Hi</section></body></html>`;
|
||||||
|
const out = preparePublicationHtmlForEmbed(html);
|
||||||
|
assert.doesNotMatch(out, /http-equiv="Content-Security-Policy"/);
|
||||||
|
assert.match(out, /plaza-embed-bootstrap/);
|
||||||
|
assert.match(out, /plaza-embed-overrides/);
|
||||||
|
});
|
||||||
|
|||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 将 plaza.tkmind.cn 经 Cloudflare Tunnel 指到本机 Plaza(:3001)
|
||||||
|
#
|
||||||
|
# 前置:
|
||||||
|
# 1. cloudflared 已安装且 ollama-tkmind 隧道在跑
|
||||||
|
# 2. pnpm dev:plaza 或 next start 监听 :3001
|
||||||
|
#
|
||||||
|
# 用法:pnpm setup:plaza-tunnel
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
CF_CONFIG="${CLOUDFLARE_TUNNEL_CONFIG:-/Users/john/Project/ollama/cloudflare/config.yml}"
|
||||||
|
TUNNEL_NAME="${CLOUDFLARE_TUNNEL_NAME:-ollama-tkmind}"
|
||||||
|
PLAZA_HOST="${PLAZA_LOCAL_HOST:-plaza.tkmind.cn}"
|
||||||
|
PLAZA_PORT="${PLAZA_TUNNEL_PORT:-${PLAZA_PORT:-3001}}"
|
||||||
|
GUI="gui/$(id -u)"
|
||||||
|
|
||||||
|
chmod +x "${ROOT}/scripts/patch-plaza-tunnel-config.mjs"
|
||||||
|
|
||||||
|
echo "==> 更新 cloudflared ingress(${PLAZA_HOST} → 127.0.0.1:${PLAZA_PORT})"
|
||||||
|
CLOUDFLARE_TUNNEL_CONFIG="${CF_CONFIG}" PLAZA_TUNNEL_PORT="${PLAZA_PORT}" \
|
||||||
|
node "${ROOT}/scripts/patch-plaza-tunnel-config.mjs"
|
||||||
|
|
||||||
|
echo "==> 注册 DNS 路由(${PLAZA_HOST} → 隧道 ${TUNNEL_NAME})"
|
||||||
|
if cloudflared tunnel route dns list 2>/dev/null | grep -q "${PLAZA_HOST}"; then
|
||||||
|
echo "DNS 路由已存在,跳过"
|
||||||
|
else
|
||||||
|
cloudflared tunnel route dns "${TUNNEL_NAME}" "${PLAZA_HOST}" || {
|
||||||
|
echo "⚠ DNS 路由失败(可能已在 Cloudflare 控制台手动配置)。继续重启 cloudflared…" >&2
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> 重启 cloudflared"
|
||||||
|
launchctl kickstart -k "${GUI}/com.cloudflare.cloudflared" 2>/dev/null || {
|
||||||
|
echo "⚠ 未找到 LaunchAgent com.cloudflare.cloudflared,请手动:cloudflared tunnel --config ${CF_CONFIG} run" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
echo ""
|
||||||
|
echo "=== Plaza 隧道状态 ==="
|
||||||
|
curl -s -o /dev/null -w "本机 Plaza :${PLAZA_PORT}/plaza → %{http_code}\n" \
|
||||||
|
"http://127.0.0.1:${PLAZA_PORT}/plaza" 2>/dev/null || echo "本机 Plaza 未运行(请先 pnpm dev:plaza)"
|
||||||
|
curl -s -o /dev/null -w "外网 ${PLAZA_HOST}/plaza → %{http_code}\n" \
|
||||||
|
"https://${PLAZA_HOST}/plaza" 2>/dev/null || true
|
||||||
|
echo ""
|
||||||
|
echo "日志: ~/Library/Logs/com.cloudflare.cloudflared.log(或 launchctl 配置的路径)"
|
||||||
|
echo "配置: ${CF_CONFIG}"
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Add plaza.tkmind.cn ingress to cloudflared config (before catch-all 404).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/patch-plaza-tunnel-config.mjs
|
||||||
|
* CLOUDFLARE_TUNNEL_CONFIG=/path/to/config.yml PLAZA_TUNNEL_PORT=3001 node ...
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
|
||||||
|
const configPath =
|
||||||
|
process.env.CLOUDFLARE_TUNNEL_CONFIG ??
|
||||||
|
'/Users/john/Project/ollama/cloudflare/config.yml';
|
||||||
|
const plazaPort = Number(process.env.PLAZA_TUNNEL_PORT ?? process.env.PLAZA_PORT ?? 3001);
|
||||||
|
const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
|
||||||
|
|
||||||
|
if (!fs.existsSync(configPath)) {
|
||||||
|
console.error(`错误: 未找到 cloudflared 配置:${configPath}`);
|
||||||
|
console.error('请设置 CLOUDFLARE_TUNNEL_CONFIG');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = fs.readFileSync(configPath, 'utf8');
|
||||||
|
|
||||||
|
if (content.includes(`hostname: ${plazaHost}`)) {
|
||||||
|
const current = content.match(
|
||||||
|
new RegExp(`hostname: ${plazaHost.replace('.', '\\.')}[\\s\\S]*?service: (http://[^\\n]+)`),
|
||||||
|
);
|
||||||
|
const target = `http://127.0.0.1:${plazaPort}`;
|
||||||
|
if (current?.[1] === target) {
|
||||||
|
console.log(`cloudflared 已配置 ${plazaHost} → ${target}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
content = content.replace(
|
||||||
|
new RegExp(
|
||||||
|
`(hostname: ${plazaHost.replace('.', '\\.')}[\\s\\S]*?service: )http://127\\.0\\.0\\.1:\\d+`,
|
||||||
|
),
|
||||||
|
`$1${target}`,
|
||||||
|
);
|
||||||
|
fs.writeFileSync(configPath, content);
|
||||||
|
console.log(`已更新 ${plazaHost} → ${target}`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = ` - hostname: ${plazaHost}
|
||||||
|
service: http://127.0.0.1:${plazaPort}
|
||||||
|
originRequest:
|
||||||
|
connectTimeout: 30s
|
||||||
|
keepAliveTimeout: 90s
|
||||||
|
disableChunkedEncoding: false
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!content.includes('- service: http_status:404')) {
|
||||||
|
console.error('错误: 配置中未找到 catch-all http_status:404 规则');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
content = content.replace(/\n - service: http_status:404/, `\n${block}\n - service: http_status:404`);
|
||||||
|
fs.writeFileSync(configPath, content);
|
||||||
|
console.log(`已添加 ${plazaHost} → http://127.0.0.1:${plazaPort}`);
|
||||||
@@ -183,7 +183,7 @@ export function buildJohnPage(opts) {
|
|||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
box-shadow: 0 8px 28px ${accent}44;
|
box-shadow: 0 8px 28px ${accent}44;
|
||||||
}
|
}
|
||||||
.section { max-width: 1100px; margin: 0 auto; padding: 72px 24px; content-visibility: auto; contain-intrinsic-size: auto 360px; }
|
.section { max-width: 1100px; margin: 0 auto; padding: 72px 24px; }
|
||||||
.section h2 { font-size: 26px; font-weight: 700; margin-bottom: 8px; }
|
.section h2 { font-size: 26px; font-weight: 700; margin-bottom: 8px; }
|
||||||
.section .hint { color: rgba(255,255,255,0.38); font-size: 15px; margin-bottom: 36px; }
|
.section .hint { color: rgba(255,255,255,0.38); font-size: 15px; margin-bottom: 36px; }
|
||||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; }
|
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; }
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Plaza 本机正式模式:Portal API + Next.js build/start(供 Cloudflare Tunnel 回源)。
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm start:plaza
|
||||||
|
* pnpm start:plaza -- --skip-build # 跳过 next build(已有 .next 产物时)
|
||||||
|
*/
|
||||||
|
import { spawn, execSync } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const plazaDir = process.env.PLAZA_APP_DIR ?? path.join(root, '../tkmind_go/ui/plaza');
|
||||||
|
const portalPort = Number(process.env.H5_PORT ?? 8081);
|
||||||
|
const plazaPort = Number(process.env.PLAZA_PORT ?? 3001);
|
||||||
|
const plazaHost = process.env.PLAZA_LOCAL_HOST ?? 'plaza.tkmind.cn';
|
||||||
|
const plazaPublicBase = (
|
||||||
|
process.env.PLAZA_PUBLIC_BASE ?? 'https://plaza.tkmind.cn'
|
||||||
|
).replace(/\/$/, '');
|
||||||
|
const portalUrl = `http://127.0.0.1:${portalPort}`;
|
||||||
|
const skipBuild = process.argv.includes('--skip-build');
|
||||||
|
|
||||||
|
function loadEnvFile(filePath) {
|
||||||
|
if (!fs.existsSync(filePath)) return;
|
||||||
|
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||||
|
const eq = trimmed.indexOf('=');
|
||||||
|
if (eq < 0) continue;
|
||||||
|
const key = trimmed.slice(0, eq).trim();
|
||||||
|
const value = trimmed.slice(eq + 1).trim();
|
||||||
|
if (!process.env[key]) process.env[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadEnvFile(path.join(root, '../../.env.local'));
|
||||||
|
loadEnvFile(path.join(root, '.env'));
|
||||||
|
|
||||||
|
function freePort(port) {
|
||||||
|
try {
|
||||||
|
execSync(`lsof -ti TCP:${port} -sTCP:LISTEN | xargs kill -9`, { stdio: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
// already free
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function portOpen(port) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${port}/auth/status`);
|
||||||
|
return res.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(check, label, retries = 120) {
|
||||||
|
for (let i = 0; i < retries; i += 1) {
|
||||||
|
try {
|
||||||
|
if (await check()) return;
|
||||||
|
} catch {
|
||||||
|
// still starting
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
throw new Error(`${label} 启动超时`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let portal;
|
||||||
|
let plaza;
|
||||||
|
let stopping = false;
|
||||||
|
|
||||||
|
function spawnChild(command, args, label, cwd = root, extraEnv = {}) {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
env: { ...process.env, NODE_ENV: 'production', ...extraEnv },
|
||||||
|
stdio: 'inherit',
|
||||||
|
});
|
||||||
|
child.on('exit', (code, signal) => {
|
||||||
|
if (signal || stopping) return;
|
||||||
|
if (code && code !== 0) {
|
||||||
|
console.error(`[${label}] exited with code ${code}`);
|
||||||
|
shutdown(code ?? 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shutdown(code = 0) {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
portal?.kill('SIGTERM');
|
||||||
|
plaza?.kill('SIGTERM');
|
||||||
|
setTimeout(() => process.exit(code), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGINT', () => shutdown(0));
|
||||||
|
process.on('SIGTERM', () => shutdown(0));
|
||||||
|
|
||||||
|
if (!fs.existsSync(path.join(plazaDir, 'package.json'))) {
|
||||||
|
console.error(`未找到 Plaza Next.js:${plazaDir}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mindSpacePublicBase = (
|
||||||
|
process.env.H5_PUBLIC_BASE_URL ??
|
||||||
|
process.env.VITE_MINDSPACE_BASE ??
|
||||||
|
'https://g2.tkmind.cn'
|
||||||
|
).replace(/\/$/, '');
|
||||||
|
|
||||||
|
const plazaEnv = {
|
||||||
|
PLAZA_API_PROXY: portalUrl,
|
||||||
|
PLAZA_API_BASE: portalUrl,
|
||||||
|
PLAZA_PUBLIC_BASE: plazaPublicBase,
|
||||||
|
NEXT_PUBLIC_API_BASE: plazaPublicBase,
|
||||||
|
NEXT_PUBLIC_SITE_BASE: plazaPublicBase,
|
||||||
|
NEXT_PUBLIC_MINDSPACE_BASE: mindSpacePublicBase,
|
||||||
|
NEXT_PUBLIC_PLAZA_BASE: '/plaza',
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('==> Plaza 本机正式模式(next build + start)');
|
||||||
|
console.log(` 公开地址 ${plazaPublicBase}/plaza`);
|
||||||
|
console.log(` 内部端口 ${plazaPort}`);
|
||||||
|
|
||||||
|
freePort(plazaPort);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!(await portOpen(portalPort))) {
|
||||||
|
console.log(`==> 启动 Portal @ ${portalUrl}`);
|
||||||
|
portal = spawnChild('node', ['server.mjs'], 'portal');
|
||||||
|
await waitFor(() => portOpen(portalPort), 'Portal');
|
||||||
|
} else {
|
||||||
|
console.log(`==> Portal 已在运行: ${portalUrl}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skipBuild) {
|
||||||
|
console.log('==> 构建 Plaza Next.js(生产)…');
|
||||||
|
execSync('npm run build', { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv } });
|
||||||
|
} else {
|
||||||
|
console.log('==> 跳过 next build(--skip-build)');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`==> 启动 Plaza Next.js 正式服务 (0.0.0.0:${plazaPort})`);
|
||||||
|
plaza = spawnChild(
|
||||||
|
'npm',
|
||||||
|
['run', 'start', '--', '-H', '0.0.0.0', '-p', String(plazaPort)],
|
||||||
|
'plaza',
|
||||||
|
plazaDir,
|
||||||
|
plazaEnv,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(async () => {
|
||||||
|
const res = await fetch(`http://127.0.0.1:${plazaPort}/plaza`, {
|
||||||
|
headers: { Host: plazaHost },
|
||||||
|
});
|
||||||
|
return res.ok || res.status === 307 || res.status === 308;
|
||||||
|
}, 'Plaza Next.js');
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log('Plaza 正式模式已就绪:');
|
||||||
|
console.log(` 本机 http://127.0.0.1:${plazaPort}/plaza`);
|
||||||
|
console.log(` 公网 ${plazaPublicBase}/plaza (需 Cloudflare Tunnel 回源 :${plazaPort})`);
|
||||||
|
console.log('');
|
||||||
|
console.log('隧道未配置或外网 502 时:pnpm setup:plaza-tunnel');
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err instanceof Error ? err.message : err);
|
||||||
|
shutdown(1);
|
||||||
|
}
|
||||||
+2
-2
@@ -46,7 +46,7 @@ import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs';
|
|||||||
import { createPlazaSeoService } from './plaza-seo.mjs';
|
import { createPlazaSeoService } from './plaza-seo.mjs';
|
||||||
import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs';
|
import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs';
|
||||||
import {
|
import {
|
||||||
injectPlazaEmbedBootstrap,
|
preparePublicationHtmlForEmbed,
|
||||||
isPlazaEmbedRequest,
|
isPlazaEmbedRequest,
|
||||||
publishedPageCspForEmbed,
|
publishedPageCspForEmbed,
|
||||||
} from './plaza-embed.mjs';
|
} from './plaza-embed.mjs';
|
||||||
@@ -3213,7 +3213,7 @@ function publishedPageCsp(html, { embed = false } = {}) {
|
|||||||
function sendPublishedPage(res, result, { embed = false } = {}) {
|
function sendPublishedPage(res, result, { embed = false } = {}) {
|
||||||
let html = result.html;
|
let html = result.html;
|
||||||
if (embed) {
|
if (embed) {
|
||||||
html = injectPlazaEmbedBootstrap(html);
|
html = preparePublicationHtmlForEmbed(html);
|
||||||
}
|
}
|
||||||
res.set('Content-Type', 'text/html; charset=utf-8');
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
||||||
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
|
res.set('Content-Security-Policy', publishedPageCsp(html, { embed }));
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ import {
|
|||||||
} from './MindSpacePageFullscreenPreview';
|
} from './MindSpacePageFullscreenPreview';
|
||||||
import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
|
import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
|
||||||
import { MindSpaceModal } from './MindSpaceModal';
|
import { MindSpaceModal } from './MindSpaceModal';
|
||||||
import { mergeMindSpacePagePatch, type MindSpacePagePatch } from '../utils/mindspacePagePatch';
|
|
||||||
|
|
||||||
type AccessMode = MindSpacePublishCheck['accessMode'];
|
type AccessMode = MindSpacePublishCheck['accessMode'];
|
||||||
|
|
||||||
@@ -97,8 +96,6 @@ export function MindSpacePageDetail({
|
|||||||
onContextUpdate,
|
onContextUpdate,
|
||||||
autoOpenPlaza = false,
|
autoOpenPlaza = false,
|
||||||
refreshTrigger = 0,
|
refreshTrigger = 0,
|
||||||
incomingPagePatch = null,
|
|
||||||
onPagePatchHandled,
|
|
||||||
overlayChat = null,
|
overlayChat = null,
|
||||||
onFullscreenPreviewChange,
|
onFullscreenPreviewChange,
|
||||||
}: {
|
}: {
|
||||||
@@ -113,8 +110,6 @@ export function MindSpacePageDetail({
|
|||||||
}) => void;
|
}) => void;
|
||||||
autoOpenPlaza?: boolean;
|
autoOpenPlaza?: boolean;
|
||||||
refreshTrigger?: number;
|
refreshTrigger?: number;
|
||||||
incomingPagePatch?: { patch: MindSpacePagePatch; sourceMessageId: string } | null;
|
|
||||||
onPagePatchHandled?: (sourceMessageId: string) => void;
|
|
||||||
overlayChat?: MindSpacePageFullscreenPreviewChat | null;
|
overlayChat?: MindSpacePageFullscreenPreviewChat | null;
|
||||||
onFullscreenPreviewChange?: (open: boolean) => void;
|
onFullscreenPreviewChange?: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
@@ -134,6 +129,8 @@ export function MindSpacePageDetail({
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [previewKey, setPreviewKey] = useState(0);
|
const [previewKey, setPreviewKey] = useState(0);
|
||||||
|
const [previewContent, setPreviewContent] = useState('');
|
||||||
|
const [previewRefreshPending, setPreviewRefreshPending] = useState(false);
|
||||||
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
|
||||||
const [changeHighlight, setChangeHighlight] = useState<MindSpacePreviewChangeHighlight | null>(
|
const [changeHighlight, setChangeHighlight] = useState<MindSpacePreviewChangeHighlight | null>(
|
||||||
null,
|
null,
|
||||||
@@ -162,8 +159,6 @@ export function MindSpacePageDetail({
|
|||||||
const [plazaPost, setPlazaPost] = useState<PlazaPostBrief | null>(null);
|
const [plazaPost, setPlazaPost] = useState<PlazaPostBrief | null>(null);
|
||||||
const [pushToPlaza, setPushToPlaza] = useState(false);
|
const [pushToPlaza, setPushToPlaza] = useState(false);
|
||||||
const [fixNotice, setFixNotice] = useState<MindSpaceRedactionChange[] | null>(null);
|
const [fixNotice, setFixNotice] = useState<MindSpaceRedactionChange[] | null>(null);
|
||||||
const [patchNotice, setPatchNotice] = useState<string | null>(null);
|
|
||||||
const [patchBusy, setPatchBusy] = useState(false);
|
|
||||||
|
|
||||||
const applyPageRecord = useCallback(
|
const applyPageRecord = useCallback(
|
||||||
(next: MindSpacePage, options?: { recordHistory?: boolean }) => {
|
(next: MindSpacePage, options?: { recordHistory?: boolean }) => {
|
||||||
@@ -192,6 +187,9 @@ export function MindSpacePageDetail({
|
|||||||
summary: next.summary,
|
summary: next.summary,
|
||||||
content: next.content ?? '',
|
content: next.content ?? '',
|
||||||
});
|
});
|
||||||
|
setPreviewContent(next.content ?? '');
|
||||||
|
setPreviewRefreshPending(false);
|
||||||
|
setPreviewKey((value) => value + 1);
|
||||||
setTemplateId(next.templateId);
|
setTemplateId(next.templateId);
|
||||||
setUrlSlug(next.publication?.urlSlug ?? slugFromTitle(next.title, next.id));
|
setUrlSlug(next.publication?.urlSlug ?? slugFromTitle(next.title, next.id));
|
||||||
setAccessMode(next.publication?.accessMode ?? 'public');
|
setAccessMode(next.publication?.accessMode ?? 'public');
|
||||||
@@ -419,6 +417,34 @@ export function MindSpacePageDetail({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const markPreviewRefreshPending = useCallback(() => {
|
||||||
|
setPreviewRefreshPending(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleManualPreviewRefresh = useCallback((nextContent?: string) => {
|
||||||
|
const html = nextContent ?? content;
|
||||||
|
setPreviewContent(html);
|
||||||
|
setPreviewRefreshPending(false);
|
||||||
|
setPreviewKey((value) => value + 1);
|
||||||
|
}, [content]);
|
||||||
|
|
||||||
|
const syncPreviewAfterHistory = useCallback(
|
||||||
|
(snapshot: PageDraftSnapshot) => {
|
||||||
|
if (previewRefreshPending) return;
|
||||||
|
setPreviewContent(snapshot.content);
|
||||||
|
setPreviewKey((value) => value + 1);
|
||||||
|
},
|
||||||
|
[previewRefreshPending],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handlePreviewContentChange = useCallback(
|
||||||
|
(html: string) => {
|
||||||
|
setPreviewContent(html);
|
||||||
|
replaceDraft({ ...draft, content: html }, { recordHistory: true });
|
||||||
|
},
|
||||||
|
[draft, replaceDraft],
|
||||||
|
);
|
||||||
|
|
||||||
const refreshFromServer = useCallback(async () => {
|
const refreshFromServer = useCallback(async () => {
|
||||||
const currentPage = page;
|
const currentPage = page;
|
||||||
if (!currentPage) return;
|
if (!currentPage) return;
|
||||||
@@ -438,52 +464,12 @@ export function MindSpacePageDetail({
|
|||||||
}
|
}
|
||||||
applyPageRecord(next, { recordHistory: true });
|
applyPageRecord(next, { recordHistory: true });
|
||||||
if (draftChanged) {
|
if (draftChanged) {
|
||||||
setPreviewKey((value) => value + 1);
|
markPreviewRefreshPending();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '页面刷新失败');
|
setError(err instanceof Error ? err.message : '页面刷新失败');
|
||||||
}
|
}
|
||||||
}, [applyPageRecord, content, page, pageId, summary, title, triggerChangeHighlight]);
|
}, [applyPageRecord, content, markPreviewRefreshPending, page, pageId, summary, title, triggerChangeHighlight]);
|
||||||
|
|
||||||
const applyIncomingPatch = useCallback(
|
|
||||||
async (patch: MindSpacePagePatch) => {
|
|
||||||
if (!page) return;
|
|
||||||
const merged = mergeMindSpacePagePatch({ title, summary, content }, patch);
|
|
||||||
triggerChangeHighlight({ title, summary, content });
|
|
||||||
setPatchBusy(true);
|
|
||||||
setPatchNotice(null);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const updated = await updateMindSpacePage(page.id, {
|
|
||||||
expectedVersion: page.versionNo,
|
|
||||||
title: merged.title,
|
|
||||||
summary: merged.summary,
|
|
||||||
content: merged.content,
|
|
||||||
templateId,
|
|
||||||
changeNote: 'Agent 对话修改',
|
|
||||||
});
|
|
||||||
applyPageRecord(updated, { recordHistory: true });
|
|
||||||
setPreviewKey((value) => value + 1);
|
|
||||||
setPatchNotice('Agent 修改已同步到预览');
|
|
||||||
} catch (err) {
|
|
||||||
applyPageRecord(
|
|
||||||
{
|
|
||||||
...page,
|
|
||||||
title: merged.title,
|
|
||||||
summary: merged.summary,
|
|
||||||
content: merged.content,
|
|
||||||
},
|
|
||||||
{ recordHistory: true },
|
|
||||||
);
|
|
||||||
setPreviewKey((value) => value + 1);
|
|
||||||
setPatchNotice('预览已更新(保存到服务器失败,请稍后手动保存)');
|
|
||||||
setError(err instanceof Error ? err.message : '保存 Agent 修改失败');
|
|
||||||
} finally {
|
|
||||||
setPatchBusy(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[applyPageRecord, content, page, summary, templateId, title, triggerChangeHighlight],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyOneClickFix = async () => {
|
const applyOneClickFix = async () => {
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
@@ -519,24 +505,6 @@ export function MindSpacePageDetail({
|
|||||||
void refreshFromServer();
|
void refreshFromServer();
|
||||||
}, [refreshTrigger, refreshFromServer]);
|
}, [refreshTrigger, refreshFromServer]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!incomingPagePatch || !page || patchBusy) return;
|
|
||||||
let cancelled = false;
|
|
||||||
void applyIncomingPatch(incomingPagePatch.patch).finally(() => {
|
|
||||||
if (!cancelled) onPagePatchHandled?.(incomingPagePatch.sourceMessageId);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
incomingPagePatch?.sourceMessageId,
|
|
||||||
incomingPagePatch?.patch,
|
|
||||||
page?.id,
|
|
||||||
patchBusy,
|
|
||||||
applyIncomingPatch,
|
|
||||||
onPagePatchHandled,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (autoOpenPlaza && page?.publication && !plazaOpen) {
|
if (autoOpenPlaza && page?.publication && !plazaOpen) {
|
||||||
void openPlazaPanel();
|
void openPlazaPanel();
|
||||||
@@ -556,16 +524,24 @@ export function MindSpacePageDetail({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key.toLowerCase() === 'z' && event.shiftKey) {
|
if (event.key.toLowerCase() === 'z' && event.shiftKey) {
|
||||||
if (redo()) event.preventDefault();
|
const next = redo();
|
||||||
|
if (next) {
|
||||||
|
syncPreviewAfterHistory(next);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key.toLowerCase() === 'z') {
|
if (event.key.toLowerCase() === 'z') {
|
||||||
if (undo()) event.preventDefault();
|
const previous = undo();
|
||||||
|
if (previous) {
|
||||||
|
syncPreviewAfterHistory(previous);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [redo, undo]);
|
}, [redo, syncPreviewAfterHistory, undo]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!page || !onContextUpdate) return;
|
if (!page || !onContextUpdate) return;
|
||||||
@@ -581,17 +557,23 @@ export function MindSpacePageDetail({
|
|||||||
const closeFullscreenPreview = () => {
|
const closeFullscreenPreview = () => {
|
||||||
setFullscreenPreviewOpen(false);
|
setFullscreenPreviewOpen(false);
|
||||||
onFullscreenPreviewChange?.(false);
|
onFullscreenPreviewChange?.(false);
|
||||||
|
setPreviewKey((value) => value + 1);
|
||||||
|
setPreviewRefreshPending(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviewUndo = () => {
|
const handlePreviewUndo = () => {
|
||||||
if (!undo()) return false;
|
const previous = undo();
|
||||||
setPreviewKey((value) => value + 1);
|
if (!previous) return false;
|
||||||
|
if (previewRefreshPending) return true;
|
||||||
|
handleManualPreviewRefresh(previous.content);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviewRedo = () => {
|
const handlePreviewRedo = () => {
|
||||||
if (!redo()) return false;
|
const next = redo();
|
||||||
setPreviewKey((value) => value + 1);
|
if (!next) return false;
|
||||||
|
if (previewRefreshPending) return true;
|
||||||
|
handleManualPreviewRefresh(next.content);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -649,7 +631,6 @@ export function MindSpacePageDetail({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && !deletePreviewOpen && <div className="mindspace-security-note">{error}</div>}
|
{error && !deletePreviewOpen && <div className="mindspace-security-note">{error}</div>}
|
||||||
{patchNotice ? <div className="mindspace-fix-notice">{patchNotice}</div> : null}
|
|
||||||
|
|
||||||
<MindSpaceModal
|
<MindSpaceModal
|
||||||
open={deletePreviewOpen}
|
open={deletePreviewOpen}
|
||||||
@@ -1072,25 +1053,35 @@ export function MindSpacePageDetail({
|
|||||||
|
|
||||||
<div className="mindspace-page-preview">
|
<div className="mindspace-page-preview">
|
||||||
<div className="mindspace-page-preview-bar">
|
<div className="mindspace-page-preview-bar">
|
||||||
<span>{page.contentFormat === 'html' ? '页面预览' : '安全预览'}</span>
|
<span>{page.contentFormat === 'html' ? '页面预览编辑' : '安全预览'}</span>
|
||||||
<div className="mindspace-page-preview-actions">
|
<div className="mindspace-page-preview-actions">
|
||||||
{patchBusy ? <span className="mindspace-page-preview-sync">同步 Agent 修改…</span> : null}
|
<button type="button" onClick={() => {
|
||||||
<button type="button" onClick={() => undo()} disabled={!canUndo} title="撤销 (⌘Z)">
|
const previous = undo();
|
||||||
|
if (previous) syncPreviewAfterHistory(previous);
|
||||||
|
}} disabled={!canUndo} title="撤销 (⌘Z)">
|
||||||
撤销
|
撤销
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => redo()} disabled={!canRedo} title="重做 (⌘⇧Z)">
|
<button type="button" onClick={() => {
|
||||||
|
const next = redo();
|
||||||
|
if (next) syncPreviewAfterHistory(next);
|
||||||
|
}} disabled={!canRedo} title="重做 (⌘⇧Z)">
|
||||||
重做
|
重做
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openFullscreenPreview}
|
onClick={openFullscreenPreview}
|
||||||
disabled={page.contentFormat !== 'html' || !content.trim()}
|
disabled={page.contentFormat !== 'html' || !content.trim()}
|
||||||
title={page.contentFormat === 'html' ? '全屏预览并对话改页' : '仅 HTML 页面支持全屏编辑'}
|
title={page.contentFormat === 'html' ? '全屏可视化编辑,可配合 Agent 改页' : '仅 HTML 页面支持全屏编辑'}
|
||||||
>
|
>
|
||||||
全屏预览
|
全屏预览编辑
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => setPreviewKey((value) => value + 1)}>
|
<button
|
||||||
刷新
|
type="button"
|
||||||
|
className={previewRefreshPending ? 'is-refresh-pending' : undefined}
|
||||||
|
onClick={handleManualPreviewRefresh}
|
||||||
|
title={previewRefreshPending ? '草稿已有新内容,点击刷新预览' : '将当前草稿同步到预览'}
|
||||||
|
>
|
||||||
|
刷新预览{previewRefreshPending ? ' · 有新修改' : ''}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1099,11 +1090,9 @@ export function MindSpacePageDetail({
|
|||||||
pageId={page.id}
|
pageId={page.id}
|
||||||
title={title}
|
title={title}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
content={content}
|
content={previewContent}
|
||||||
reloadKey={previewKey}
|
reloadKey={previewKey}
|
||||||
onContentChange={(html) =>
|
onContentChange={handlePreviewContentChange}
|
||||||
replaceDraft({ ...draft, content: html }, { recordHistory: true })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MindSpacePageDraftPreviewFrame
|
<MindSpacePageDraftPreviewFrame
|
||||||
@@ -1114,6 +1103,7 @@ export function MindSpacePageDetail({
|
|||||||
templateId={templateId}
|
templateId={templateId}
|
||||||
reloadKey={previewKey}
|
reloadKey={previewKey}
|
||||||
minHeight={680}
|
minHeight={680}
|
||||||
|
maxHeight={12000}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page.contentFormat === 'html' && (
|
{page.contentFormat === 'html' && (
|
||||||
@@ -1123,7 +1113,13 @@ export function MindSpacePageDetail({
|
|||||||
className="mindspace-page-content-input"
|
className="mindspace-page-content-input"
|
||||||
value={content}
|
value={content}
|
||||||
rows={22}
|
rows={22}
|
||||||
onChange={(event) => updateDraft({ ...draft, content: event.target.value })}
|
onChange={(event) => {
|
||||||
|
const nextContent = event.target.value;
|
||||||
|
updateDraft({ ...draft, content: nextContent });
|
||||||
|
if (!previewRefreshPending) {
|
||||||
|
setPreviewContent(nextContent);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
@@ -1164,26 +1160,19 @@ export function MindSpacePageDetail({
|
|||||||
pageTitle={title || page.title}
|
pageTitle={title || page.title}
|
||||||
title={title}
|
title={title}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
content={content}
|
content={previewContent}
|
||||||
templateId={templateId}
|
templateId={templateId}
|
||||||
reloadKey={previewKey}
|
reloadKey={previewKey}
|
||||||
patchBusy={patchBusy}
|
|
||||||
changeHighlight={changeHighlight}
|
changeHighlight={changeHighlight}
|
||||||
canUndo={canUndo}
|
canUndo={canUndo}
|
||||||
canRedo={canRedo}
|
canRedo={canRedo}
|
||||||
onUndo={handlePreviewUndo}
|
onUndo={handlePreviewUndo}
|
||||||
onRedo={handlePreviewRedo}
|
onRedo={handlePreviewRedo}
|
||||||
onClose={closeFullscreenPreview}
|
onClose={closeFullscreenPreview}
|
||||||
onRefresh={() => setPreviewKey((value) => value + 1)}
|
onRefresh={() => handleManualPreviewRefresh()}
|
||||||
onAgentIdleSync={() => void refreshFromServer()}
|
refreshPending={previewRefreshPending}
|
||||||
chat={
|
onContentChange={handlePreviewContentChange}
|
||||||
overlayChat
|
chat={overlayChat}
|
||||||
? {
|
|
||||||
...overlayChat,
|
|
||||||
onPagePatch: (patch) => void applyIncomingPatch(patch),
|
|
||||||
}
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { fetchMindSpacePageDraftPreview } from '../api/client';
|
import { fetchMindSpacePageDraftPreview } from '../api/client';
|
||||||
|
import { bindIframeDocumentHeightSync } from '../utils/iframeContentHeight';
|
||||||
import { applyPageChangeHighlight } from '../utils/mindspacePageChangeHighlight';
|
import { applyPageChangeHighlight } from '../utils/mindspacePageChangeHighlight';
|
||||||
|
|
||||||
export type MindSpacePreviewChangeHighlight = {
|
export type MindSpacePreviewChangeHighlight = {
|
||||||
@@ -15,8 +16,9 @@ export function MindSpacePageDraftPreviewFrame({
|
|||||||
content,
|
content,
|
||||||
templateId,
|
templateId,
|
||||||
reloadKey = 0,
|
reloadKey = 0,
|
||||||
|
liveUpdate = true,
|
||||||
minHeight = 680,
|
minHeight = 680,
|
||||||
maxHeight = 4800,
|
maxHeight = 12000,
|
||||||
className = 'mindspace-page-preview-frame',
|
className = 'mindspace-page-preview-frame',
|
||||||
changeHighlight = null,
|
changeHighlight = null,
|
||||||
}: {
|
}: {
|
||||||
@@ -26,6 +28,7 @@ export function MindSpacePageDraftPreviewFrame({
|
|||||||
content: string;
|
content: string;
|
||||||
templateId: string;
|
templateId: string;
|
||||||
reloadKey?: number;
|
reloadKey?: number;
|
||||||
|
liveUpdate?: boolean;
|
||||||
minHeight?: number;
|
minHeight?: number;
|
||||||
maxHeight?: number;
|
maxHeight?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -62,7 +65,9 @@ export function MindSpacePageDraftPreviewFrame({
|
|||||||
}, hasRenderedRef.current ? 450 : 120);
|
}, hasRenderedRef.current ? 450 : 120);
|
||||||
|
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [pageId, title, summary, content, templateId, reloadKey]);
|
}, liveUpdate
|
||||||
|
? [pageId, title, summary, content, templateId, reloadKey]
|
||||||
|
: [pageId, reloadKey, templateId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHeight(minHeight);
|
setHeight(minHeight);
|
||||||
@@ -72,27 +77,16 @@ export function MindSpacePageDraftPreviewFrame({
|
|||||||
const iframe = iframeRef.current;
|
const iframe = iframeRef.current;
|
||||||
if (!iframe || !srcdoc) return;
|
if (!iframe || !srcdoc) return;
|
||||||
|
|
||||||
const syncHeight = () => {
|
const applyHeight = (nextHeight: number) => {
|
||||||
try {
|
setHeight(nextHeight);
|
||||||
const doc = iframe.contentDocument;
|
|
||||||
if (!doc) return;
|
|
||||||
const body = doc.body;
|
|
||||||
const root = doc.documentElement;
|
|
||||||
const next = Math.ceil(
|
|
||||||
Math.max(
|
|
||||||
body?.scrollHeight ?? 0,
|
|
||||||
body?.offsetHeight ?? 0,
|
|
||||||
root?.scrollHeight ?? 0,
|
|
||||||
root?.offsetHeight ?? 0,
|
|
||||||
minHeight,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setHeight(Math.min(next, maxHeight));
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
changeHighlight &&
|
changeHighlight &&
|
||||||
appliedHighlightRevisionRef.current !== changeHighlight.revision
|
appliedHighlightRevisionRef.current !== changeHighlight.revision
|
||||||
) {
|
) {
|
||||||
|
try {
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
appliedHighlightRevisionRef.current = changeHighlight.revision;
|
appliedHighlightRevisionRef.current = changeHighlight.revision;
|
||||||
applyPageChangeHighlight(doc, {
|
applyPageChangeHighlight(doc, {
|
||||||
previousTitle: changeHighlight.previousTitle,
|
previousTitle: changeHighlight.previousTitle,
|
||||||
@@ -100,14 +94,17 @@ export function MindSpacePageDraftPreviewFrame({
|
|||||||
previousContent: changeHighlight.previousContent,
|
previousContent: changeHighlight.previousContent,
|
||||||
nextContent: content,
|
nextContent: content,
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore highlight errors in sandboxed preview
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
setHeight(minHeight);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
iframe.addEventListener('load', syncHeight);
|
return bindIframeDocumentHeightSync(iframe, {
|
||||||
return () => iframe.removeEventListener('load', syncHeight);
|
minHeight,
|
||||||
|
maxHeight,
|
||||||
|
onHeight: applyHeight,
|
||||||
|
});
|
||||||
}, [changeHighlight, content, maxHeight, minHeight, srcdoc, title]);
|
}, [changeHighlight, content, maxHeight, minHeight, srcdoc, title]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { bindIframeDocumentHeightSync, measureDocumentHeight } from '../utils/iframeContentHeight';
|
||||||
|
import {
|
||||||
|
buildEditablePreviewDocument,
|
||||||
|
MINDSPACE_PAGE_CONTENT_MESSAGE,
|
||||||
|
} from '../utils/mindspaceVisualEditor';
|
||||||
|
|
||||||
|
export function MindSpacePageEditablePreviewFrame({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
reloadKey,
|
||||||
|
minHeight = 680,
|
||||||
|
maxHeight = 12000,
|
||||||
|
className = 'mindspace-page-preview-frame',
|
||||||
|
showHint = true,
|
||||||
|
onContentChange,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
reloadKey: number;
|
||||||
|
minHeight?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
className?: string;
|
||||||
|
showHint?: boolean;
|
||||||
|
onContentChange: (html: string) => void;
|
||||||
|
}) {
|
||||||
|
const [previewFailed, setPreviewFailed] = useState(false);
|
||||||
|
const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content));
|
||||||
|
const [height, setHeight] = useState(minHeight);
|
||||||
|
const iframeContentRef = useRef(content);
|
||||||
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
|
const syncHeightRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
iframeContentRef.current = content;
|
||||||
|
setSrcdoc(buildEditablePreviewDocument(content));
|
||||||
|
setPreviewFailed(false);
|
||||||
|
setHeight(minHeight);
|
||||||
|
}, [reloadKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (content === iframeContentRef.current) return;
|
||||||
|
iframeContentRef.current = content;
|
||||||
|
setSrcdoc(buildEditablePreviewDocument(content));
|
||||||
|
setPreviewFailed(false);
|
||||||
|
setHeight(minHeight);
|
||||||
|
}, [content, minHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMessage = (event: MessageEvent) => {
|
||||||
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||||
|
if (event.data?.type !== MINDSPACE_PAGE_CONTENT_MESSAGE) return;
|
||||||
|
const html = String(event.data.html ?? '');
|
||||||
|
if (!html.trim()) return;
|
||||||
|
iframeContentRef.current = html;
|
||||||
|
onContentChange(html);
|
||||||
|
syncHeightRef.current?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', onMessage);
|
||||||
|
return () => window.removeEventListener('message', onMessage);
|
||||||
|
}, [onContentChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const iframe = iframeRef.current;
|
||||||
|
if (!iframe || !srcdoc) return;
|
||||||
|
|
||||||
|
const applyHeight = (nextHeight: number) => setHeight(nextHeight);
|
||||||
|
const unbind = bindIframeDocumentHeightSync(iframe, {
|
||||||
|
minHeight,
|
||||||
|
maxHeight,
|
||||||
|
onHeight: applyHeight,
|
||||||
|
});
|
||||||
|
syncHeightRef.current = () => {
|
||||||
|
try {
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
applyHeight(Math.min(measureDocumentHeight(doc, minHeight), maxHeight));
|
||||||
|
} catch {
|
||||||
|
applyHeight(minHeight);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
syncHeightRef.current = null;
|
||||||
|
unbind();
|
||||||
|
};
|
||||||
|
}, [maxHeight, minHeight, srcdoc]);
|
||||||
|
|
||||||
|
if (previewFailed) {
|
||||||
|
return <div className="mindspace-state mindspace-error">页面预览加载失败</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showHint ? (
|
||||||
|
<p className="mindspace-page-editable-hint">
|
||||||
|
文字直接改 · 图片/音视频点击换 · 背景点空白区 · 链接双击改地址
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<iframe
|
||||||
|
ref={iframeRef}
|
||||||
|
title={`${title || '页面'} 预览`}
|
||||||
|
srcDoc={srcdoc}
|
||||||
|
sandbox="allow-same-origin allow-scripts"
|
||||||
|
scrolling="no"
|
||||||
|
className={className}
|
||||||
|
style={{ height: `${height}px` }}
|
||||||
|
onError={() => setPreviewFailed(true)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { usePageEditSubChat } from '../hooks/usePageEditSubChat';
|
import { usePageEditSubChat } from '../hooks/usePageEditSubChat';
|
||||||
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
|
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
|
||||||
import type { MindSpacePagePatch } from '../utils/mindspacePagePatch';
|
import { MindSpacePageEditablePreviewFrame } from './MindSpacePageEditablePreviewFrame';
|
||||||
import {
|
import type { MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame';
|
||||||
MindSpacePageDraftPreviewFrame,
|
|
||||||
type MindSpacePreviewChangeHighlight,
|
|
||||||
} from './MindSpacePageDraftPreviewFrame';
|
|
||||||
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
|
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
|
||||||
|
|
||||||
export type MindSpacePageFullscreenPreviewChat = {
|
export type MindSpacePageFullscreenPreviewChat = {
|
||||||
@@ -20,7 +17,6 @@ export type MindSpacePageFullscreenPreviewChat = {
|
|||||||
categoryCode?: MindSpaceSaveCategory;
|
categoryCode?: MindSpaceSaveCategory;
|
||||||
}) => void;
|
}) => void;
|
||||||
onSubSessionChange?: (sessionId: string | null) => void;
|
onSubSessionChange?: (sessionId: string | null) => void;
|
||||||
onPagePatch?: (patch: MindSpacePagePatch) => void;
|
|
||||||
onForkUnavailable?: () => void;
|
onForkUnavailable?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,19 +24,19 @@ export function MindSpacePageFullscreenPreview({
|
|||||||
pageId,
|
pageId,
|
||||||
pageTitle,
|
pageTitle,
|
||||||
title,
|
title,
|
||||||
summary,
|
summary: _summary,
|
||||||
content,
|
content,
|
||||||
templateId,
|
templateId: _templateId,
|
||||||
reloadKey,
|
reloadKey,
|
||||||
patchBusy = false,
|
changeHighlight: _changeHighlight = null,
|
||||||
changeHighlight = null,
|
|
||||||
canUndo = false,
|
canUndo = false,
|
||||||
canRedo = false,
|
canRedo = false,
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
onClose,
|
onClose,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onAgentIdleSync,
|
refreshPending = false,
|
||||||
|
onContentChange,
|
||||||
chat,
|
chat,
|
||||||
}: {
|
}: {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -50,7 +46,6 @@ export function MindSpacePageFullscreenPreview({
|
|||||||
content: string;
|
content: string;
|
||||||
templateId: string;
|
templateId: string;
|
||||||
reloadKey: number;
|
reloadKey: number;
|
||||||
patchBusy?: boolean;
|
|
||||||
changeHighlight?: MindSpacePreviewChangeHighlight | null;
|
changeHighlight?: MindSpacePreviewChangeHighlight | null;
|
||||||
canUndo?: boolean;
|
canUndo?: boolean;
|
||||||
canRedo?: boolean;
|
canRedo?: boolean;
|
||||||
@@ -58,7 +53,8 @@ export function MindSpacePageFullscreenPreview({
|
|||||||
onRedo?: () => boolean;
|
onRedo?: () => boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onAgentIdleSync?: () => void;
|
refreshPending?: boolean;
|
||||||
|
onContentChange: (html: string) => void;
|
||||||
chat?: MindSpacePageFullscreenPreviewChat | null;
|
chat?: MindSpacePageFullscreenPreviewChat | null;
|
||||||
}) {
|
}) {
|
||||||
const [chatOpen, setChatOpen] = useState(true);
|
const [chatOpen, setChatOpen] = useState(true);
|
||||||
@@ -69,8 +65,6 @@ export function MindSpacePageFullscreenPreview({
|
|||||||
setSubChatFallback(false);
|
setSubChatFallback(false);
|
||||||
}, [pageId, chat?.parentSessionId]);
|
}, [pageId, chat?.parentSessionId]);
|
||||||
|
|
||||||
const prevSubChatStateRef = useRef(subChatEnabled ? 'loading' : 'idle');
|
|
||||||
|
|
||||||
const subChat = usePageEditSubChat({
|
const subChat = usePageEditSubChat({
|
||||||
pageId,
|
pageId,
|
||||||
pageTitle: pageTitle || title,
|
pageTitle: pageTitle || title,
|
||||||
@@ -79,7 +73,6 @@ export function MindSpacePageFullscreenPreview({
|
|||||||
user: chat?.user,
|
user: chat?.user,
|
||||||
enabled: subChatEnabled && !subChatFallback,
|
enabled: subChatEnabled && !subChatFallback,
|
||||||
onSessionChange: chat?.onSubSessionChange,
|
onSessionChange: chat?.onSubSessionChange,
|
||||||
onPagePatch: chat?.onPagePatch,
|
|
||||||
onForkUnavailable: () => {
|
onForkUnavailable: () => {
|
||||||
setSubChatFallback(true);
|
setSubChatFallback(true);
|
||||||
chat?.onForkUnavailable?.();
|
chat?.onForkUnavailable?.();
|
||||||
@@ -133,27 +126,17 @@ export function MindSpacePageFullscreenPreview({
|
|||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [canRedo, canUndo, chatOpen, onClose, onRedo, onUndo]);
|
}, [canRedo, canUndo, chatOpen, onClose, onRedo, onUndo]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!onAgentIdleSync || !subChatEnabled) return;
|
|
||||||
const previous = prevSubChatStateRef.current;
|
|
||||||
prevSubChatStateRef.current = subChat.chatState;
|
|
||||||
const wasBusy = previous === 'streaming' || previous === 'waiting';
|
|
||||||
if (wasBusy && subChat.chatState === 'idle') {
|
|
||||||
onAgentIdleSync();
|
|
||||||
}
|
|
||||||
}, [onAgentIdleSync, subChat.chatState, subChatEnabled]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mindspace-page-fullscreen-preview" role="dialog" aria-modal="true" aria-label="页面全屏预览">
|
<div className="mindspace-page-fullscreen-preview" role="dialog" aria-modal="true" aria-label="页面全屏预览编辑">
|
||||||
<header className="mindspace-page-fullscreen-preview-toolbar">
|
<header className="mindspace-page-fullscreen-preview-toolbar">
|
||||||
<div className="mindspace-page-fullscreen-preview-title">
|
<div className="mindspace-page-fullscreen-preview-title">
|
||||||
<span className="mindspace-eyebrow">PREVIEW · EDIT</span>
|
<span className="mindspace-eyebrow">VISUAL EDIT</span>
|
||||||
<strong>{pageTitle || '页面预览'}</strong>
|
<strong>{pageTitle || '全屏预览编辑'}</strong>
|
||||||
|
<span className="mindspace-page-fullscreen-preview-hint">
|
||||||
|
文字直接改 · 图片/音视频点击换 · 背景点空白区 · 链接双击改地址
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mindspace-page-fullscreen-preview-actions">
|
<div className="mindspace-page-fullscreen-preview-actions">
|
||||||
{patchBusy ? (
|
|
||||||
<span className="mindspace-page-preview-sync">同步 Agent 修改…</span>
|
|
||||||
) : null}
|
|
||||||
{subChatEnabled && !subChatFallback && subChat.chatState === 'loading' ? (
|
{subChatEnabled && !subChatFallback && subChat.chatState === 'loading' ? (
|
||||||
<span className="mindspace-page-preview-sync">启动编辑 Agent…</span>
|
<span className="mindspace-page-preview-sync">启动编辑 Agent…</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -166,27 +149,30 @@ export function MindSpacePageFullscreenPreview({
|
|||||||
<button type="button" onClick={onRedo} disabled={!canRedo} title="重做 (⌘⇧Z)">
|
<button type="button" onClick={onRedo} disabled={!canRedo} title="重做 (⌘⇧Z)">
|
||||||
重做
|
重做
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={onRefresh}>
|
<button
|
||||||
刷新
|
type="button"
|
||||||
|
className={refreshPending ? 'is-refresh-pending' : undefined}
|
||||||
|
onClick={onRefresh}
|
||||||
|
title={refreshPending ? '草稿已有新内容,点击刷新预览' : '将当前草稿同步到预览'}
|
||||||
|
>
|
||||||
|
刷新预览{refreshPending ? ' · 有新修改' : ''}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="mindspace-primary" onClick={onClose}>
|
<button type="button" className="mindspace-primary" onClick={onClose}>
|
||||||
退出预览
|
退出编辑
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="mindspace-page-fullscreen-preview-body">
|
<div className="mindspace-page-fullscreen-preview-body">
|
||||||
<MindSpacePageDraftPreviewFrame
|
<MindSpacePageEditablePreviewFrame
|
||||||
pageId={pageId}
|
|
||||||
title={title}
|
title={title}
|
||||||
summary={summary}
|
|
||||||
content={content}
|
content={content}
|
||||||
templateId={templateId}
|
|
||||||
reloadKey={reloadKey}
|
reloadKey={reloadKey}
|
||||||
changeHighlight={changeHighlight}
|
|
||||||
minHeight={720}
|
minHeight={720}
|
||||||
maxHeight={12000}
|
maxHeight={12000}
|
||||||
className="mindspace-page-fullscreen-preview-frame"
|
className="mindspace-page-fullscreen-preview-frame"
|
||||||
|
showHint={false}
|
||||||
|
onContentChange={onContentChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import {
|
|||||||
regenerateMindSpacePageThumbnail,
|
regenerateMindSpacePageThumbnail,
|
||||||
uploadMindSpacePageThumbnail,
|
uploadMindSpacePageThumbnail,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import {
|
import { MindSpacePageEditablePreviewFrame } from './MindSpacePageEditablePreviewFrame';
|
||||||
buildEditablePreviewDocument,
|
|
||||||
MINDSPACE_PAGE_CONTENT_MESSAGE,
|
|
||||||
} from '../utils/mindspaceVisualEditor';
|
|
||||||
|
|
||||||
function readFileAsBase64(file: File): Promise<{ base64: string; mimeType: string }> {
|
function readFileAsBase64(file: File): Promise<{ base64: string; mimeType: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -40,15 +37,11 @@ export function MindSpacePagePreviewPanel({
|
|||||||
reloadKey: number;
|
reloadKey: number;
|
||||||
onContentChange: (html: string) => void;
|
onContentChange: (html: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const [previewFailed, setPreviewFailed] = useState(false);
|
|
||||||
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
||||||
const [thumbnailVersion, setThumbnailVersion] = useState(reloadKey);
|
const [thumbnailVersion, setThumbnailVersion] = useState(reloadKey);
|
||||||
const [thumbBusy, setThumbBusy] = useState<'upload' | 'ai' | null>(null);
|
const [thumbBusy, setThumbBusy] = useState<'upload' | 'ai' | null>(null);
|
||||||
const [thumbNotice, setThumbNotice] = useState<string | null>(null);
|
const [thumbNotice, setThumbNotice] = useState<string | null>(null);
|
||||||
const [thumbError, setThumbError] = useState<string | null>(null);
|
const [thumbError, setThumbError] = useState<string | null>(null);
|
||||||
const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content));
|
|
||||||
const iframeContentRef = useRef(content);
|
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
|
||||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const thumbnailUrl = `/api/mindspace/v1/pages/${pageId}/thumbnail?v=${thumbnailVersion}`;
|
const thumbnailUrl = `/api/mindspace/v1/pages/${pageId}/thumbnail?v=${thumbnailVersion}`;
|
||||||
@@ -61,33 +54,6 @@ export function MindSpacePagePreviewPanel({
|
|||||||
setThumbnailFailed(false);
|
setThumbnailFailed(false);
|
||||||
}, [thumbnailUrl]);
|
}, [thumbnailUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
iframeContentRef.current = content;
|
|
||||||
setSrcdoc(buildEditablePreviewDocument(content));
|
|
||||||
setPreviewFailed(false);
|
|
||||||
}, [reloadKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (content === iframeContentRef.current) return;
|
|
||||||
iframeContentRef.current = content;
|
|
||||||
setSrcdoc(buildEditablePreviewDocument(content));
|
|
||||||
setPreviewFailed(false);
|
|
||||||
}, [content]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onMessage = (event: MessageEvent) => {
|
|
||||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
|
||||||
if (event.data?.type !== MINDSPACE_PAGE_CONTENT_MESSAGE) return;
|
|
||||||
const html = String(event.data.html ?? '');
|
|
||||||
if (!html.trim()) return;
|
|
||||||
iframeContentRef.current = html;
|
|
||||||
onContentChange(html);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', onMessage);
|
|
||||||
return () => window.removeEventListener('message', onMessage);
|
|
||||||
}, [onContentChange]);
|
|
||||||
|
|
||||||
const bumpThumbnail = () => {
|
const bumpThumbnail = () => {
|
||||||
setThumbnailVersion(Date.now());
|
setThumbnailVersion(Date.now());
|
||||||
setThumbnailFailed(false);
|
setThumbnailFailed(false);
|
||||||
@@ -139,7 +105,6 @@ export function MindSpacePagePreviewPanel({
|
|||||||
instruction: '根据当前页面内容与标题,生成更精美、可在信息流中直接展示的中文封面。',
|
instruction: '根据当前页面内容与标题,生成更精美、可在信息流中直接展示的中文封面。',
|
||||||
});
|
});
|
||||||
if (result.content) {
|
if (result.content) {
|
||||||
iframeContentRef.current = result.content;
|
|
||||||
onContentChange(result.content);
|
onContentChange(result.content);
|
||||||
}
|
}
|
||||||
bumpThumbnail();
|
bumpThumbnail();
|
||||||
@@ -154,25 +119,18 @@ export function MindSpacePagePreviewPanel({
|
|||||||
return (
|
return (
|
||||||
<div className="page-save-preview-dual mindspace-page-preview-dual">
|
<div className="page-save-preview-dual mindspace-page-preview-dual">
|
||||||
<div className="page-save-preview-pane page-save-preview-pane-page">
|
<div className="page-save-preview-pane page-save-preview-pane-page">
|
||||||
<span className="page-save-preview-label">页面效果</span>
|
<span className="page-save-preview-label">页面预览编辑</span>
|
||||||
<p className="page-save-preview-hint">
|
<p className="page-save-preview-hint">
|
||||||
点击文字直接编辑 · 点击图片替换 · 点击空白背景区域替换背景
|
文字直接改 · 图片/音视频点击换 · 背景点空白区 · 链接双击改地址
|
||||||
</p>
|
</p>
|
||||||
<div className="page-save-mini-page mindspace-page-mini-page">
|
<MindSpacePageEditablePreviewFrame
|
||||||
{!previewFailed ? (
|
title={title}
|
||||||
<iframe
|
content={content}
|
||||||
ref={iframeRef}
|
reloadKey={reloadKey}
|
||||||
title="页面预览"
|
showHint={false}
|
||||||
srcDoc={srcdoc}
|
className="mindspace-page-preview-frame"
|
||||||
className="page-save-mini-page-frame"
|
onContentChange={onContentChange}
|
||||||
sandbox="allow-same-origin allow-scripts"
|
/>
|
||||||
scrolling="yes"
|
|
||||||
onError={() => setPreviewFailed(true)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="page-save-card-thumb-placeholder">页面预览加载失败</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="page-save-preview-pane page-save-preview-pane-thumb">
|
<div className="page-save-preview-pane page-save-preview-pane-thumb">
|
||||||
<span className="page-save-preview-label">卡片预览图</span>
|
<span className="page-save-preview-label">卡片预览图</span>
|
||||||
|
|||||||
@@ -35,12 +35,6 @@ import type {
|
|||||||
PortalUser,
|
PortalUser,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { buildMindSpaceChatContext } from '../utils/mindspaceChatContext';
|
import { buildMindSpaceChatContext } from '../utils/mindspaceChatContext';
|
||||||
import {
|
|
||||||
extractMindSpacePagePatch,
|
|
||||||
patchKey,
|
|
||||||
type MindSpacePagePatch,
|
|
||||||
} from '../utils/mindspacePagePatch';
|
|
||||||
import { getDisplayText } from '../utils/message';
|
|
||||||
import { resolveH5ApiBase } from '../utils/h5ApiBase';
|
import { resolveH5ApiBase } from '../utils/h5ApiBase';
|
||||||
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
|
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
|
||||||
import {
|
import {
|
||||||
@@ -204,14 +198,9 @@ export function MindSpaceView({
|
|||||||
content: string;
|
content: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
|
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
|
||||||
const [incomingPagePatch, setIncomingPagePatch] = useState<{
|
|
||||||
patch: MindSpacePagePatch;
|
|
||||||
sourceMessageId: string;
|
|
||||||
} | null>(null);
|
|
||||||
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
|
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
|
||||||
const [pageEditSubSessionId, setPageEditSubSessionId] = useState<string | null>(null);
|
const [pageEditSubSessionId, setPageEditSubSessionId] = useState<string | null>(null);
|
||||||
const [pageEditSubSessionActive, setPageEditSubSessionActive] = useState(false);
|
const [pageEditSubSessionActive, setPageEditSubSessionActive] = useState(false);
|
||||||
const appliedPatchKeysRef = useRef(new Set<string>());
|
|
||||||
const { chatState, messages, session } = useChat();
|
const { chatState, messages, session } = useChat();
|
||||||
const prevChatStateRef = useRef(chatState);
|
const prevChatStateRef = useRef(chatState);
|
||||||
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
|
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
|
||||||
@@ -228,12 +217,10 @@ export function MindSpaceView({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedPageId) {
|
if (!selectedPageId) {
|
||||||
setPageLiveContext(null);
|
setPageLiveContext(null);
|
||||||
setIncomingPagePatch(null);
|
|
||||||
setPageFullscreenPreviewOpen(false);
|
setPageFullscreenPreviewOpen(false);
|
||||||
setPageEditSubSessionId(null);
|
setPageEditSubSessionId(null);
|
||||||
setPageEditSubSessionActive(false);
|
setPageEditSubSessionActive(false);
|
||||||
}
|
}
|
||||||
appliedPatchKeysRef.current.clear();
|
|
||||||
}, [selectedPageId]);
|
}, [selectedPageId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -248,23 +235,6 @@ export function MindSpaceView({
|
|||||||
pageEditSubSessionActive,
|
pageEditSubSessionActive,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedPageId || previewMode || pageFullscreenPreviewOpen) return;
|
|
||||||
if (chatState !== 'idle') return;
|
|
||||||
|
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
||||||
const message = messages[i];
|
|
||||||
if (message.role !== 'assistant') continue;
|
|
||||||
const patch = extractMindSpacePagePatch(getDisplayText(message));
|
|
||||||
if (!patch) continue;
|
|
||||||
const key = `${message.id}:${patchKey(patch)}`;
|
|
||||||
if (appliedPatchKeysRef.current.has(key)) return;
|
|
||||||
appliedPatchKeysRef.current.add(key);
|
|
||||||
setIncomingPagePatch({ patch, sourceMessageId: message.id });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [chatState, messages, previewMode, selectedPageId, pageFullscreenPreviewOpen]);
|
|
||||||
|
|
||||||
const mindspaceChatContext = useMemo(() => {
|
const mindspaceChatContext = useMemo(() => {
|
||||||
if (!space) return null;
|
if (!space) return null;
|
||||||
return buildMindSpaceChatContext({
|
return buildMindSpaceChatContext({
|
||||||
@@ -1204,8 +1174,6 @@ export function MindSpaceView({
|
|||||||
onContextUpdate={setPageLiveContext}
|
onContextUpdate={setPageLiveContext}
|
||||||
autoOpenPlaza={selectedPageAutoPlaza}
|
autoOpenPlaza={selectedPageAutoPlaza}
|
||||||
refreshTrigger={pageRefreshTrigger}
|
refreshTrigger={pageRefreshTrigger}
|
||||||
incomingPagePatch={incomingPagePatch}
|
|
||||||
onPagePatchHandled={() => setIncomingPagePatch(null)}
|
|
||||||
onFullscreenPreviewChange={(open) => {
|
onFullscreenPreviewChange={(open) => {
|
||||||
setPageFullscreenPreviewOpen(open);
|
setPageFullscreenPreviewOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { bindIframeDocumentHeightSync } from '../utils/iframeContentHeight';
|
||||||
|
|
||||||
export function PagePreviewFrame({
|
export function PagePreviewFrame({
|
||||||
src,
|
src,
|
||||||
title,
|
title,
|
||||||
reloadKey = 0,
|
reloadKey = 0,
|
||||||
minHeight = 680,
|
minHeight = 680,
|
||||||
maxHeight = 4800,
|
maxHeight = 12000,
|
||||||
className = 'mindspace-page-preview-frame',
|
className = 'mindspace-page-preview-frame',
|
||||||
}: {
|
}: {
|
||||||
src: string;
|
src: string;
|
||||||
@@ -18,29 +19,6 @@ export function PagePreviewFrame({
|
|||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const [height, setHeight] = useState(minHeight);
|
const [height, setHeight] = useState(minHeight);
|
||||||
|
|
||||||
const syncHeight = useCallback(() => {
|
|
||||||
const iframe = iframeRef.current;
|
|
||||||
if (!iframe) return;
|
|
||||||
try {
|
|
||||||
const doc = iframe.contentDocument;
|
|
||||||
if (!doc) return;
|
|
||||||
const body = doc.body;
|
|
||||||
const root = doc.documentElement;
|
|
||||||
const next = Math.ceil(
|
|
||||||
Math.max(
|
|
||||||
body?.scrollHeight ?? 0,
|
|
||||||
body?.offsetHeight ?? 0,
|
|
||||||
root?.scrollHeight ?? 0,
|
|
||||||
root?.offsetHeight ?? 0,
|
|
||||||
minHeight,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
setHeight(Math.min(next, maxHeight));
|
|
||||||
} catch {
|
|
||||||
setHeight(minHeight);
|
|
||||||
}
|
|
||||||
}, [maxHeight, minHeight]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHeight(minHeight);
|
setHeight(minHeight);
|
||||||
}, [src, reloadKey, minHeight]);
|
}, [src, reloadKey, minHeight]);
|
||||||
@@ -48,9 +26,12 @@ export function PagePreviewFrame({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const iframe = iframeRef.current;
|
const iframe = iframeRef.current;
|
||||||
if (!iframe) return;
|
if (!iframe) return;
|
||||||
iframe.addEventListener('load', syncHeight);
|
return bindIframeDocumentHeightSync(iframe, {
|
||||||
return () => iframe.removeEventListener('load', syncHeight);
|
minHeight,
|
||||||
}, [src, reloadKey, syncHeight]);
|
maxHeight,
|
||||||
|
onHeight: setHeight,
|
||||||
|
});
|
||||||
|
}, [maxHeight, minHeight, src, reloadKey]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
|
|||||||
@@ -67,28 +67,28 @@ export function usePageDraftHistory(initial: PageDraftSnapshot) {
|
|||||||
[flushDebounced, snapshot],
|
[flushDebounced, snapshot],
|
||||||
);
|
);
|
||||||
|
|
||||||
const undo = useCallback(() => {
|
const undo = useCallback((): PageDraftSnapshot | null => {
|
||||||
flushDebounced();
|
flushDebounced();
|
||||||
const previous = pastRef.current[pastRef.current.length - 1];
|
const previous = pastRef.current[pastRef.current.length - 1];
|
||||||
if (!previous) return false;
|
if (!previous) return null;
|
||||||
pastRef.current = pastRef.current.slice(0, -1);
|
pastRef.current = pastRef.current.slice(0, -1);
|
||||||
futureRef.current = [snapshot, ...futureRef.current];
|
futureRef.current = [snapshot, ...futureRef.current];
|
||||||
stableRef.current = previous;
|
stableRef.current = previous;
|
||||||
setSnapshot(previous);
|
setSnapshot(previous);
|
||||||
bump();
|
bump();
|
||||||
return true;
|
return previous;
|
||||||
}, [flushDebounced, snapshot]);
|
}, [flushDebounced, snapshot]);
|
||||||
|
|
||||||
const redo = useCallback(() => {
|
const redo = useCallback((): PageDraftSnapshot | null => {
|
||||||
flushDebounced();
|
flushDebounced();
|
||||||
const next = futureRef.current[0];
|
const next = futureRef.current[0];
|
||||||
if (!next) return false;
|
if (!next) return null;
|
||||||
futureRef.current = futureRef.current.slice(1);
|
futureRef.current = futureRef.current.slice(1);
|
||||||
pastRef.current = [...pastRef.current, snapshot];
|
pastRef.current = [...pastRef.current, snapshot];
|
||||||
stableRef.current = next;
|
stableRef.current = next;
|
||||||
setSnapshot(next);
|
setSnapshot(next);
|
||||||
bump();
|
bump();
|
||||||
return true;
|
return next;
|
||||||
}, [flushDebounced, snapshot]);
|
}, [flushDebounced, snapshot]);
|
||||||
|
|
||||||
const reset = useCallback(
|
const reset = useCallback(
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
getToolConfirmation,
|
getToolConfirmation,
|
||||||
pushMessage,
|
pushMessage,
|
||||||
} from '../utils/message';
|
} from '../utils/message';
|
||||||
import { extractMindSpacePagePatch, type MindSpacePagePatch } from '../utils/mindspacePagePatch';
|
|
||||||
|
|
||||||
function buildPageEditSummary(messages: Message[], pageTitle: string): string {
|
function buildPageEditSummary(messages: Message[], pageTitle: string): string {
|
||||||
const lines = messages
|
const lines = messages
|
||||||
@@ -45,7 +44,6 @@ export function usePageEditSubChat({
|
|||||||
user,
|
user,
|
||||||
enabled,
|
enabled,
|
||||||
onSessionChange,
|
onSessionChange,
|
||||||
onPagePatch,
|
|
||||||
onForkUnavailable,
|
onForkUnavailable,
|
||||||
}: {
|
}: {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
@@ -55,7 +53,6 @@ export function usePageEditSubChat({
|
|||||||
user?: PortalUser | null;
|
user?: PortalUser | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
onSessionChange?: (sessionId: string | null) => void;
|
onSessionChange?: (sessionId: string | null) => void;
|
||||||
onPagePatch?: (patch: MindSpacePagePatch) => void;
|
|
||||||
onForkUnavailable?: () => void;
|
onForkUnavailable?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [session, setSession] = useState<Session | null>(null);
|
const [session, setSession] = useState<Session | null>(null);
|
||||||
@@ -69,7 +66,6 @@ export function usePageEditSubChat({
|
|||||||
const unsubscribeRef = useRef<(() => void) | null>(null);
|
const unsubscribeRef = useRef<(() => void) | null>(null);
|
||||||
const messagesRef = useRef<Message[]>([]);
|
const messagesRef = useRef<Message[]>([]);
|
||||||
const sessionRef = useRef<Session | null>(null);
|
const sessionRef = useRef<Session | null>(null);
|
||||||
const appliedPatchKeysRef = useRef(new Set<string>());
|
|
||||||
const closingRef = useRef(false);
|
const closingRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -230,7 +226,6 @@ export function usePageEditSubChat({
|
|||||||
setChatState('idle');
|
setChatState('idle');
|
||||||
setPendingTool(null);
|
setPendingTool(null);
|
||||||
activeRequestId.current = null;
|
activeRequestId.current = null;
|
||||||
appliedPatchKeysRef.current.clear();
|
|
||||||
closingRef.current = false;
|
closingRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -327,22 +322,6 @@ export function usePageEditSubChat({
|
|||||||
};
|
};
|
||||||
}, [close, enabled, pageId, parentSessionId, start]);
|
}, [close, enabled, pageId, parentSessionId, start]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!onPagePatch) return;
|
|
||||||
if (chatState !== 'idle') return;
|
|
||||||
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
||||||
const message = messages[i];
|
|
||||||
if (message.role !== 'assistant') continue;
|
|
||||||
const patch = extractMindSpacePagePatch(getDisplayText(message));
|
|
||||||
if (!patch) continue;
|
|
||||||
const key = `${message.id ?? i}:${JSON.stringify(patch)}`;
|
|
||||||
if (appliedPatchKeysRef.current.has(key)) return;
|
|
||||||
appliedPatchKeysRef.current.add(key);
|
|
||||||
onPagePatch(patch);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, [chatState, messages, onPagePatch]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
session,
|
session,
|
||||||
messages,
|
messages,
|
||||||
|
|||||||
+34
-5
@@ -5430,7 +5430,7 @@ body,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mindspace-page-preview {
|
.mindspace-page-preview {
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindspace-page-preview-dual {
|
.mindspace-page-preview-dual {
|
||||||
@@ -5442,10 +5442,6 @@ body,
|
|||||||
width: 180px;
|
width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mindspace-page-preview-dual .mindspace-page-mini-page {
|
|
||||||
height: min(520px, 58vh);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mindspace-page-preview-dual + .mindspace-html-source-editor {
|
.mindspace-page-preview-dual + .mindspace-html-source-editor {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
@@ -5518,6 +5514,31 @@ body,
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mindspace-page-preview-actions button.is-refresh-pending,
|
||||||
|
.mindspace-page-fullscreen-preview-actions button.is-refresh-pending {
|
||||||
|
color: #18211d;
|
||||||
|
background: #eeb04e;
|
||||||
|
box-shadow: 0 0 0 2px rgba(238, 176, 78, 0.35);
|
||||||
|
animation: mindspace-refresh-pulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mindspace-refresh-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 2px rgba(238, 176, 78, 0.35);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 6px rgba(238, 176, 78, 0.18);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mindspace-page-editable-hint {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 18px 0;
|
||||||
|
color: #6d7771;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.mindspace-page-preview-loading {
|
.mindspace-page-preview-loading {
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
color: #6d7771;
|
color: #6d7771;
|
||||||
@@ -6038,6 +6059,14 @@ body,
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mindspace-page-fullscreen-preview-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #6d7771;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
.mindspace-page-fullscreen-preview-actions {
|
.mindspace-page-fullscreen-preview-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
export function measureDocumentHeight(doc: Document, minHeight: number): number {
|
||||||
|
const body = doc.body;
|
||||||
|
const root = doc.documentElement;
|
||||||
|
let measured = Math.ceil(
|
||||||
|
Math.max(
|
||||||
|
body?.scrollHeight ?? 0,
|
||||||
|
body?.offsetHeight ?? 0,
|
||||||
|
root?.scrollHeight ?? 0,
|
||||||
|
root?.offsetHeight ?? 0,
|
||||||
|
minHeight,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (body && measured <= minHeight + 40) {
|
||||||
|
const blocks = body.querySelectorAll('.hero, .section, .quote-section, .footer, main, article');
|
||||||
|
for (const block of blocks) {
|
||||||
|
const el = block as HTMLElement;
|
||||||
|
measured = Math.max(measured, el.offsetTop + el.offsetHeight);
|
||||||
|
}
|
||||||
|
const last = body.lastElementChild as HTMLElement | null;
|
||||||
|
if (last) {
|
||||||
|
measured = Math.max(measured, last.offsetTop + last.offsetHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return measured;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampIframeHeight(height: number, minHeight: number, maxHeight: number): number {
|
||||||
|
return Math.min(Math.max(height, minHeight), maxHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindIframeDocumentHeightSync(
|
||||||
|
iframe: HTMLIFrameElement,
|
||||||
|
{
|
||||||
|
minHeight,
|
||||||
|
maxHeight,
|
||||||
|
onHeight,
|
||||||
|
}: {
|
||||||
|
minHeight: number;
|
||||||
|
maxHeight: number;
|
||||||
|
onHeight: (height: number) => void;
|
||||||
|
},
|
||||||
|
): () => void {
|
||||||
|
const disposers: Array<() => void> = [];
|
||||||
|
|
||||||
|
const apply = () => {
|
||||||
|
try {
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
onHeight(clampIframeHeight(measureDocumentHeight(doc, minHeight), minHeight, maxHeight));
|
||||||
|
} catch {
|
||||||
|
onHeight(minHeight);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const observeDocument = () => {
|
||||||
|
const doc = iframe.contentDocument;
|
||||||
|
if (!doc?.body) return;
|
||||||
|
|
||||||
|
apply();
|
||||||
|
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
const observer = new ResizeObserver(apply);
|
||||||
|
observer.observe(doc.body);
|
||||||
|
if (doc.documentElement && doc.documentElement !== doc.body) {
|
||||||
|
observer.observe(doc.documentElement);
|
||||||
|
}
|
||||||
|
disposers.push(() => observer.disconnect());
|
||||||
|
}
|
||||||
|
|
||||||
|
doc.querySelectorAll('img').forEach((img) => {
|
||||||
|
if (img.complete) return;
|
||||||
|
const handle = () => apply();
|
||||||
|
img.addEventListener('load', handle);
|
||||||
|
img.addEventListener('error', handle);
|
||||||
|
disposers.push(() => {
|
||||||
|
img.removeEventListener('load', handle);
|
||||||
|
img.removeEventListener('error', handle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const delay of [100, 400, 1200]) {
|
||||||
|
const timer = window.setTimeout(apply, delay);
|
||||||
|
disposers.push(() => window.clearTimeout(timer));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onLoad = () => observeDocument();
|
||||||
|
iframe.addEventListener('load', onLoad);
|
||||||
|
disposers.push(() => iframe.removeEventListener('load', onLoad));
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (iframe.contentDocument?.readyState === 'complete') {
|
||||||
|
observeDocument();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore cross-origin access errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposers.forEach((dispose) => dispose());
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,15 +10,27 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
|
|||||||
outline: 2px solid rgba(61, 139, 253, 0.95);
|
outline: 2px solid rgba(61, 139, 253, 0.95);
|
||||||
background: rgba(61, 139, 253, 0.06);
|
background: rgba(61, 139, 253, 0.06);
|
||||||
}
|
}
|
||||||
[data-mindspace-editing="true"] img {
|
[data-mindspace-editing="true"] img,
|
||||||
|
[data-mindspace-editing="true"] video,
|
||||||
|
[data-mindspace-editing="true"] audio,
|
||||||
|
[data-mindspace-editing="true"] iframe[data-mindspace-media-target="true"] {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
outline: 1px dashed transparent;
|
outline: 1px dashed transparent;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
transition: outline-color 0.15s ease;
|
transition: outline-color 0.15s ease;
|
||||||
}
|
}
|
||||||
[data-mindspace-editing="true"] img:hover {
|
[data-mindspace-editing="true"] img:hover,
|
||||||
|
[data-mindspace-editing="true"] video:hover,
|
||||||
|
[data-mindspace-editing="true"] audio:hover,
|
||||||
|
[data-mindspace-editing="true"] iframe[data-mindspace-media-target="true"]:hover {
|
||||||
outline-color: rgba(238, 176, 78, 0.95);
|
outline-color: rgba(238, 176, 78, 0.95);
|
||||||
}
|
}
|
||||||
|
[data-mindspace-editing="true"] a[data-mindspace-link-target="true"] {
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
[data-mindspace-editing="true"] a[data-mindspace-link-target="true"]:hover {
|
||||||
|
text-decoration: underline dotted rgba(238, 176, 78, 0.85);
|
||||||
|
}
|
||||||
[data-mindspace-editing="true"] [data-mindspace-bg-target="true"] {
|
[data-mindspace-editing="true"] [data-mindspace-bg-target="true"] {
|
||||||
cursor: copy;
|
cursor: copy;
|
||||||
}
|
}
|
||||||
@@ -28,7 +40,7 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
|
|||||||
.mindspace-editor-popover {
|
.mindspace-editor-popover {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 2147483646;
|
z-index: 2147483646;
|
||||||
width: min(280px, calc(100vw - 24px));
|
width: min(300px, calc(100vw - 24px));
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid rgba(24, 33, 29, 0.14);
|
border: 1px solid rgba(24, 33, 29, 0.14);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
@@ -42,11 +54,22 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
.mindspace-editor-popover-hint {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #6d7771;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
.mindspace-editor-popover label {
|
.mindspace-editor-popover label {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
.mindspace-editor-popover label[data-inline="true"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
.mindspace-editor-popover input[type='url'],
|
.mindspace-editor-popover input[type='url'],
|
||||||
.mindspace-editor-popover input[type='text'],
|
.mindspace-editor-popover input[type='text'],
|
||||||
.mindspace-editor-popover input[type='color'],
|
.mindspace-editor-popover input[type='color'],
|
||||||
@@ -62,6 +85,7 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
|
|||||||
.mindspace-editor-popover-actions {
|
.mindspace-editor-popover-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.mindspace-editor-popover-actions button {
|
.mindspace-editor-popover-actions button {
|
||||||
@@ -75,16 +99,34 @@ const EDITOR_STYLE = `<style id="mindspace-visual-editor-style">
|
|||||||
color: #fffaf0;
|
color: #fffaf0;
|
||||||
background: #18211d;
|
background: #18211d;
|
||||||
}
|
}
|
||||||
.mindspace-editor-popover-actions button[data-action='cancel'] {
|
.mindspace-editor-popover-actions button[data-action='cancel'],
|
||||||
|
.mindspace-editor-popover-actions button[data-action='clear'] {
|
||||||
color: #52605a;
|
color: #52605a;
|
||||||
background: rgba(24, 33, 29, 0.08);
|
background: rgba(24, 33, 29, 0.08);
|
||||||
}
|
}
|
||||||
|
.mindspace-editor-guide {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2147483645;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 14px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: min(720px, calc(100vw - 24px));
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #52605a;
|
||||||
|
font: 11px/1.4 -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: rgba(255, 252, 244, 0.94);
|
||||||
|
box-shadow: 0 8px 28px rgba(24, 33, 29, 0.12);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>`;
|
</style>`;
|
||||||
|
|
||||||
const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
||||||
(function () {
|
(function () {
|
||||||
var MESSAGE = ${JSON.stringify(MINDSPACE_PAGE_CONTENT_MESSAGE)};
|
var MESSAGE = ${JSON.stringify(MINDSPACE_PAGE_CONTENT_MESSAGE)};
|
||||||
var TEXT_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,figcaption,blockquote,span,a,label,small,strong,em,.sub,.tagline';
|
var TEXT_SELECTOR = 'h1,h2,h3,h4,h5,h6,p,li,td,th,figcaption,blockquote,span,a,label,small,strong,em,b,i,u,sub,sup,cite,time,address,dt,dd,button,.sub,.tagline,[class*="title"],[class*="subtitle"],[class*="desc"],[class*="text"]';
|
||||||
|
var BLOCK_CHILD_SELECTOR = 'div,section,article,header,footer,main,p,h1,h2,h3,h4,h5,h6,ul,ol,table,form,video,audio,iframe,svg,canvas';
|
||||||
|
var MEDIA_BLOCK_SELECTOR = 'img,video,iframe,svg,canvas,table,ul,ol,form';
|
||||||
var popover = null;
|
var popover = null;
|
||||||
var emitTimer = null;
|
var emitTimer = null;
|
||||||
|
|
||||||
@@ -93,15 +135,22 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
|||||||
emitTimer = window.setTimeout(emitChange, 350);
|
emitTimer = window.setTimeout(emitChange, 350);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeAttr(value) {
|
||||||
|
return String(value || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
||||||
|
}
|
||||||
|
|
||||||
function stripEditorArtifacts(root) {
|
function stripEditorArtifacts(root) {
|
||||||
root.querySelectorAll('#mindspace-visual-editor-style,#mindspace-visual-editor-script,.mindspace-editor-popover').forEach(function (node) {
|
root.querySelectorAll('#mindspace-visual-editor-style,#mindspace-visual-editor-script,.mindspace-editor-popover,.mindspace-editor-guide').forEach(function (node) {
|
||||||
node.remove();
|
node.remove();
|
||||||
});
|
});
|
||||||
root.querySelectorAll('[contenteditable]').forEach(function (node) {
|
root.querySelectorAll('[contenteditable]').forEach(function (node) {
|
||||||
node.removeAttribute('contenteditable');
|
node.removeAttribute('contenteditable');
|
||||||
|
node.removeAttribute('spellcheck');
|
||||||
});
|
});
|
||||||
root.querySelectorAll('[data-mindspace-bg-target]').forEach(function (node) {
|
root.querySelectorAll('[data-mindspace-bg-target],[data-mindspace-link-target],[data-mindspace-media-target]').forEach(function (node) {
|
||||||
node.removeAttribute('data-mindspace-bg-target');
|
node.removeAttribute('data-mindspace-bg-target');
|
||||||
|
node.removeAttribute('data-mindspace-link-target');
|
||||||
|
node.removeAttribute('data-mindspace-media-target');
|
||||||
});
|
});
|
||||||
if (root.body) root.body.removeAttribute('data-mindspace-editing');
|
if (root.body) root.body.removeAttribute('data-mindspace-editing');
|
||||||
}
|
}
|
||||||
@@ -125,12 +174,13 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
|||||||
popover = null;
|
popover = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPopover(title, fieldsHtml, onApply, anchor) {
|
function openPopover(title, fieldsHtml, onApply, anchor, hint) {
|
||||||
closePopover();
|
closePopover();
|
||||||
popover = document.createElement('div');
|
popover = document.createElement('div');
|
||||||
popover.className = 'mindspace-editor-popover';
|
popover.className = 'mindspace-editor-popover';
|
||||||
popover.innerHTML =
|
popover.innerHTML =
|
||||||
'<p>' + title + '</p>' +
|
'<p>' + title + '</p>' +
|
||||||
|
(hint ? '<p class="mindspace-editor-popover-hint">' + hint + '</p>' : '') +
|
||||||
fieldsHtml +
|
fieldsHtml +
|
||||||
'<div class="mindspace-editor-popover-actions">' +
|
'<div class="mindspace-editor-popover-actions">' +
|
||||||
'<button type="button" data-action="cancel">取消</button>' +
|
'<button type="button" data-action="cancel">取消</button>' +
|
||||||
@@ -148,6 +198,14 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
|||||||
closePopover();
|
closePopover();
|
||||||
if (pending !== 'async') emitChange();
|
if (pending !== 'async') emitChange();
|
||||||
});
|
});
|
||||||
|
var clearBtn = popover.querySelector('[data-action="clear"]');
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
var pending = onApply(popover, 'clear');
|
||||||
|
closePopover();
|
||||||
|
if (pending !== 'async') emitChange();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readFileAsDataUrl(file, callback) {
|
function readFileAsDataUrl(file, callback) {
|
||||||
@@ -159,11 +217,14 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
|||||||
function openImageEditor(img) {
|
function openImageEditor(img) {
|
||||||
openPopover(
|
openPopover(
|
||||||
'替换图片',
|
'替换图片',
|
||||||
'<label>图片链接<input type="url" data-role="url" value="' + (img.src.indexOf('data:') === 0 ? '' : img.src.replace(/"/g, '"')) + '" placeholder="https://..." /></label>' +
|
'<label>图片链接<input type="url" data-role="url" value="' + (img.src.indexOf('data:') === 0 ? '' : escapeAttr(img.src)) + '" placeholder="https://..." /></label>' +
|
||||||
|
'<label>替代文字(Alt)<input type="text" data-role="alt" value="' + escapeAttr(img.alt || '') + '" placeholder="图片描述" /></label>' +
|
||||||
'<label>本地图片<input type="file" accept="image/*" data-role="file" /></label>',
|
'<label>本地图片<input type="file" accept="image/*" data-role="file" /></label>',
|
||||||
function (panel) {
|
function (panel) {
|
||||||
var fileInput = panel.querySelector('[data-role="file"]');
|
var fileInput = panel.querySelector('[data-role="file"]');
|
||||||
var urlInput = panel.querySelector('[data-role="url"]');
|
var urlInput = panel.querySelector('[data-role="url"]');
|
||||||
|
var altInput = panel.querySelector('[data-role="alt"]');
|
||||||
|
img.alt = String(altInput.value || '');
|
||||||
if (fileInput.files && fileInput.files[0]) {
|
if (fileInput.files && fileInput.files[0]) {
|
||||||
readFileAsDataUrl(fileInput.files[0], function (dataUrl) {
|
readFileAsDataUrl(fileInput.files[0], function (dataUrl) {
|
||||||
img.src = dataUrl;
|
img.src = dataUrl;
|
||||||
@@ -178,6 +239,76 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openMediaEditor(el, label) {
|
||||||
|
var src = el.getAttribute('src') || '';
|
||||||
|
var poster = el.getAttribute('poster') || '';
|
||||||
|
openPopover(
|
||||||
|
label,
|
||||||
|
'<label>资源链接<input type="url" data-role="url" value="' + escapeAttr(src) + '" placeholder="https://..." /></label>' +
|
||||||
|
(el.tagName === 'VIDEO'
|
||||||
|
? '<label>封面图(Poster)<input type="url" data-role="poster" value="' + escapeAttr(poster) + '" placeholder="可选" /></label>'
|
||||||
|
: '') +
|
||||||
|
'<label>本地文件<input type="file" data-role="file" accept="' + (el.tagName === 'AUDIO' ? 'audio/*' : 'video/*,image/*') + '" /></label>',
|
||||||
|
function (panel) {
|
||||||
|
var fileInput = panel.querySelector('[data-role="file"]');
|
||||||
|
var urlInput = panel.querySelector('[data-role="url"]');
|
||||||
|
var posterInput = panel.querySelector('[data-role="poster"]');
|
||||||
|
if (fileInput.files && fileInput.files[0]) {
|
||||||
|
readFileAsDataUrl(fileInput.files[0], function (dataUrl) {
|
||||||
|
el.setAttribute('src', dataUrl);
|
||||||
|
if (el.tagName === 'VIDEO' && fileInput.files[0].type.startsWith('image/')) {
|
||||||
|
el.setAttribute('poster', dataUrl);
|
||||||
|
}
|
||||||
|
emitChange();
|
||||||
|
});
|
||||||
|
return 'async';
|
||||||
|
}
|
||||||
|
var next = String(urlInput.value || '').trim();
|
||||||
|
if (next) el.setAttribute('src', next);
|
||||||
|
if (posterInput) {
|
||||||
|
var posterNext = String(posterInput.value || '').trim();
|
||||||
|
if (posterNext) el.setAttribute('poster', posterNext);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
el,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openIframeEditor(iframe) {
|
||||||
|
var src = iframe.getAttribute('src') || '';
|
||||||
|
openPopover(
|
||||||
|
'替换嵌入内容',
|
||||||
|
'<label>嵌入链接<input type="url" data-role="url" value="' + escapeAttr(src) + '" placeholder="https://..." /></label>',
|
||||||
|
function (panel) {
|
||||||
|
var next = String(panel.querySelector('[data-role="url"]').value || '').trim();
|
||||||
|
if (next) iframe.setAttribute('src', next);
|
||||||
|
},
|
||||||
|
iframe,
|
||||||
|
'适用于地图、视频嵌入等 iframe 内容',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLinkEditor(anchor) {
|
||||||
|
openPopover(
|
||||||
|
'编辑链接',
|
||||||
|
'<label>显示文字<input type="text" data-role="text" value="' + escapeAttr(anchor.textContent || '') + '" /></label>' +
|
||||||
|
'<label>链接地址<input type="url" data-role="href" value="' + escapeAttr(anchor.getAttribute('href') || '') + '" placeholder="https://..." /></label>' +
|
||||||
|
'<label data-inline="true"><input type="checkbox" data-role="blank" ' + (anchor.target === '_blank' ? 'checked' : '') + ' /> 新窗口打开</label>',
|
||||||
|
function (panel) {
|
||||||
|
var text = String(panel.querySelector('[data-role="text"]').value || '');
|
||||||
|
var href = String(panel.querySelector('[data-role="href"]').value || '').trim();
|
||||||
|
var blank = panel.querySelector('[data-role="blank"]').checked;
|
||||||
|
anchor.textContent = text;
|
||||||
|
if (href) anchor.setAttribute('href', href);
|
||||||
|
else anchor.removeAttribute('href');
|
||||||
|
if (blank) anchor.setAttribute('target', '_blank');
|
||||||
|
else anchor.removeAttribute('target');
|
||||||
|
},
|
||||||
|
anchor,
|
||||||
|
'单击链接可改文字,双击打开此面板改地址',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function rgbToHex(color) {
|
function rgbToHex(color) {
|
||||||
var match = String(color || '').match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/i);
|
var match = String(color || '').match(/rgba?\\((\\d+),\\s*(\\d+),\\s*(\\d+)/i);
|
||||||
if (!match) return '#ffffff';
|
if (!match) return '#ffffff';
|
||||||
@@ -185,27 +316,41 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
|||||||
return '#' + hex(match[1]) + hex(match[2]) + hex(match[3]);
|
return '#' + hex(match[1]) + hex(match[2]) + hex(match[3]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractBackgroundUrl(style) {
|
||||||
|
var bg = style.backgroundImage;
|
||||||
|
if (!bg || bg === 'none') return '';
|
||||||
|
var match = bg.match(/url\\(["']?([^"')]+)["']?\\)/);
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
function openBackgroundEditor(el) {
|
function openBackgroundEditor(el) {
|
||||||
var style = window.getComputedStyle(el);
|
var style = window.getComputedStyle(el);
|
||||||
|
var currentUrl = extractBackgroundUrl(style);
|
||||||
openPopover(
|
openPopover(
|
||||||
'替换背景',
|
'替换背景',
|
||||||
'<label>背景颜色<input type="color" data-role="color" value="' + rgbToHex(style.backgroundColor) + '" /></label>' +
|
'<label>背景颜色<input type="color" data-role="color" value="' + rgbToHex(style.backgroundColor) + '" /></label>' +
|
||||||
'<label>背景图片链接<input type="url" data-role="bg-url" placeholder="留空则仅使用颜色" /></label>' +
|
'<label>背景图片链接<input type="url" data-role="bg-url" value="' + escapeAttr(currentUrl) + '" placeholder="留空则仅使用颜色" /></label>' +
|
||||||
'<label>本地背景图<input type="file" accept="image/*" data-role="bg-file" /></label>',
|
'<label>本地背景图<input type="file" accept="image/*" data-role="bg-file" /></label>' +
|
||||||
function (panel) {
|
'<button type="button" data-action="clear">清除背景图</button>',
|
||||||
|
function (panel, mode) {
|
||||||
var color = panel.querySelector('[data-role="color"]').value;
|
var color = panel.querySelector('[data-role="color"]').value;
|
||||||
var bgUrl = String(panel.querySelector('[data-role="bg-url"]').value || '').trim();
|
var bgUrl = String(panel.querySelector('[data-role="bg-url"]').value || '').trim();
|
||||||
var bgFile = panel.querySelector('[data-role="bg-file"]');
|
var bgFile = panel.querySelector('[data-role="bg-file"]');
|
||||||
function applyBackground(url) {
|
function applyBackground(url) {
|
||||||
if (url) {
|
if (mode === 'clear' || !url) {
|
||||||
|
el.style.backgroundImage = 'none';
|
||||||
|
} else {
|
||||||
el.style.backgroundImage = 'url("' + url.replace(/"/g, '\\"') + '")';
|
el.style.backgroundImage = 'url("' + url.replace(/"/g, '\\"') + '")';
|
||||||
el.style.backgroundSize = el.style.backgroundSize || 'cover';
|
el.style.backgroundSize = el.style.backgroundSize || 'cover';
|
||||||
el.style.backgroundPosition = el.style.backgroundPosition || 'center';
|
el.style.backgroundPosition = el.style.backgroundPosition || 'center';
|
||||||
} else {
|
el.style.backgroundRepeat = el.style.backgroundRepeat || 'no-repeat';
|
||||||
el.style.backgroundImage = 'none';
|
|
||||||
}
|
}
|
||||||
if (color) el.style.backgroundColor = color;
|
if (color) el.style.backgroundColor = color;
|
||||||
}
|
}
|
||||||
|
if (mode === 'clear') {
|
||||||
|
applyBackground('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (bgFile.files && bgFile.files[0]) {
|
if (bgFile.files && bgFile.files[0]) {
|
||||||
readFileAsDataUrl(bgFile.files[0], function (url) {
|
readFileAsDataUrl(bgFile.files[0], function (url) {
|
||||||
applyBackground(url);
|
applyBackground(url);
|
||||||
@@ -216,16 +361,17 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
|||||||
applyBackground(bgUrl);
|
applyBackground(bgUrl);
|
||||||
},
|
},
|
||||||
el,
|
el,
|
||||||
|
'点击大面积空白或有色区块即可替换背景',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function markBackgroundTargets() {
|
function markBackgroundTargets() {
|
||||||
var nodes = document.querySelectorAll('body, main, section, article, header, footer, div');
|
var nodes = document.querySelectorAll('body, main, section, article, header, footer, div');
|
||||||
nodes.forEach(function (el) {
|
nodes.forEach(function (el) {
|
||||||
if (el.closest('.mindspace-editor-popover')) return;
|
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
|
||||||
var style = window.getComputedStyle(el);
|
var style = window.getComputedStyle(el);
|
||||||
var cls = String(el.className || '');
|
var cls = String(el.className || '');
|
||||||
var structural = /\\b(page|hero|cover|banner|bg|background|section|card|panel|wrap|container)\\b/i.test(cls);
|
var structural = /\\b(page|hero|cover|banner|bg|background|section|card|panel|wrap|container|shell|stage|canvas|frame|viewport)\\b/i.test(cls);
|
||||||
var painted = style.backgroundImage !== 'none' || (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent');
|
var painted = style.backgroundImage !== 'none' || (style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent');
|
||||||
if (el.tagName === 'BODY' || structural || (painted && el.offsetWidth > 64 && el.offsetHeight > 64)) {
|
if (el.tagName === 'BODY' || structural || (painted && el.offsetWidth > 64 && el.offsetHeight > 64)) {
|
||||||
el.setAttribute('data-mindspace-bg-target', 'true');
|
el.setAttribute('data-mindspace-bg-target', 'true');
|
||||||
@@ -233,24 +379,66 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function markMediaTargets() {
|
||||||
|
document.querySelectorAll('iframe').forEach(function (iframe) {
|
||||||
|
if (iframe.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
|
||||||
|
iframe.setAttribute('data-mindspace-media-target', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markLinkTargets() {
|
||||||
|
document.querySelectorAll('a[href]').forEach(function (anchor) {
|
||||||
|
if (anchor.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
|
||||||
|
anchor.setAttribute('data-mindspace-link-target', 'true');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function enableTextEditing() {
|
function enableTextEditing() {
|
||||||
document.querySelectorAll(TEXT_SELECTOR).forEach(function (el) {
|
document.querySelectorAll(TEXT_SELECTOR).forEach(function (el) {
|
||||||
if (el.closest('.mindspace-editor-popover')) return;
|
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
|
||||||
if (el.querySelector && el.querySelector('img,video,iframe,svg,canvas')) return;
|
if (el.querySelector && el.querySelector(MEDIA_BLOCK_SELECTOR)) return;
|
||||||
|
el.setAttribute('contenteditable', 'true');
|
||||||
|
el.setAttribute('spellcheck', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('div').forEach(function (el) {
|
||||||
|
if (el.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
|
||||||
|
if (el.hasAttribute('contenteditable')) return;
|
||||||
|
if (el.querySelector(BLOCK_CHILD_SELECTOR)) return;
|
||||||
|
if (el.querySelector(MEDIA_BLOCK_SELECTOR)) return;
|
||||||
|
if (!String(el.textContent || '').trim()) return;
|
||||||
el.setAttribute('contenteditable', 'true');
|
el.setAttribute('contenteditable', 'true');
|
||||||
el.setAttribute('spellcheck', 'true');
|
el.setAttribute('spellcheck', 'true');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addEditGuide() {
|
||||||
|
var bar = document.createElement('div');
|
||||||
|
bar.className = 'mindspace-editor-guide';
|
||||||
|
bar.textContent = '文字:直接点击 · 图片/音视频:点击替换 · 背景:点击空白区域 · 链接:双击改地址';
|
||||||
|
document.body.appendChild(bar);
|
||||||
|
}
|
||||||
|
|
||||||
function onClick(event) {
|
function onClick(event) {
|
||||||
var target = event.target;
|
var target = event.target;
|
||||||
if (!(target instanceof Element)) return;
|
if (!(target instanceof Element)) return;
|
||||||
if (target.closest('.mindspace-editor-popover')) return;
|
if (target.closest('.mindspace-editor-popover,.mindspace-editor-guide')) return;
|
||||||
|
|
||||||
if (target.tagName === 'IMG') {
|
if (target.tagName === 'IMG') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
openImageEditor(target);
|
openImageEditor(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (target.tagName === 'VIDEO' || target.tagName === 'AUDIO') {
|
||||||
|
event.preventDefault();
|
||||||
|
openMediaEditor(target, target.tagName === 'VIDEO' ? '替换视频' : '替换音频');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.tagName === 'IFRAME' && target.getAttribute('data-mindspace-media-target') === 'true') {
|
||||||
|
event.preventDefault();
|
||||||
|
openIframeEditor(target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (target.isContentEditable) return;
|
if (target.isContentEditable) return;
|
||||||
if (target.matches('[data-mindspace-bg-target="true"]') || target.tagName === 'BODY') {
|
if (target.matches('[data-mindspace-bg-target="true"]') || target.tagName === 'BODY') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -258,13 +446,30 @@ const EDITOR_SCRIPT = `<script id="mindspace-visual-editor-script">
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onDblClick(event) {
|
||||||
|
var target = event.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
var anchor = target.closest('a[data-mindspace-link-target="true"]');
|
||||||
|
if (!anchor) return;
|
||||||
|
event.preventDefault();
|
||||||
|
openLinkEditor(anchor);
|
||||||
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
document.body.setAttribute('data-mindspace-editing', 'true');
|
document.body.setAttribute('data-mindspace-editing', 'true');
|
||||||
enableTextEditing();
|
enableTextEditing();
|
||||||
markBackgroundTargets();
|
markBackgroundTargets();
|
||||||
|
markMediaTargets();
|
||||||
|
markLinkTargets();
|
||||||
|
addEditGuide();
|
||||||
document.addEventListener('input', debounceEmit);
|
document.addEventListener('input', debounceEmit);
|
||||||
document.addEventListener('blur', debounceEmit, true);
|
document.addEventListener('blur', debounceEmit, true);
|
||||||
document.addEventListener('click', onClick, true);
|
document.addEventListener('click', onClick, true);
|
||||||
|
document.addEventListener('dblclick', onDblClick, true);
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
var target = event.target;
|
||||||
|
if (target instanceof HTMLAnchorElement) event.preventDefault();
|
||||||
|
}, true);
|
||||||
document.addEventListener('keydown', function (event) {
|
document.addEventListener('keydown', function (event) {
|
||||||
if (event.key === 'Escape') closePopover();
|
if (event.key === 'Escape') closePopover();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user