feat(creator-studio): switch writing editor to TinyMCE

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kate522andClaude Opus 5 committed 2026-11-06 20:51:18 +08:00
1 parent 4868de2cda
commit 384d2b7aa0
25 files changed
+1255 -7076

No files matched your search

+3 -3
View File
@@ -1,6 +1,6 @@
# Jio Creator Studio(前台沉浸式创作工作台)
面向作者的前台写作体验:Gutenberg 作为内容内核,React + TypeScript 负责工作台与发布抽屉,Jio 服务端继续作为权限、审核、付费与附件交付的唯一权威。
面向作者的前台写作体验:WordPress TinyMCE 负责 HTML 正文编辑,React + TypeScript 负责单行固定工具栏、写作工作台与发布抽屉,Jio 服务端继续作为权限、审核、付费与附件交付的唯一权威。
## 启动
@@ -37,7 +37,7 @@ define('JIO_PRIVATE_STORAGE_VERIFIED', true);
| 编辑已发布文章 | 写入私有改稿版本(`jio_submission`),线上文章与永久链接保持不变 |
| 审核通过 | 后台「文章 → 改稿审核」一次性应用内容、摘要、分类、标签、封面与付费设置 |
| 自动保存 | 云端 4 秒防抖 + `rev` 版本号;冲突返回 409,不覆盖较新内容 |
| 本地恢复 | 仅保存标题与序列化正文,恢复前询问用户 |
| 本地恢复 | 仅保存标题与 TinyMCE HTML 正文,恢复前询问用户 |
| 付费附件 | 上传到私有存储,只保存随机 `asset_key`;下载先取短期令牌,服务端每次复核订单/VIP/配额 |
## 相关代码
@@ -45,7 +45,7 @@ define('JIO_PRIVATE_STORAGE_VERIFIED', true);
- 服务端:`src/Core/CreatorRest.php`、`CreatorStudio.php`、`SubmissionVersion.php`、`PreviewToken.php`
- 安全交付:`src/Content/PrivateAssets.php`、`src/Content/ProtectedDownload.php`
- 原生审核:`src/Admin/Native/SubmissionReview.php`
- 前端:`assets/creator-studio/src/`(`index.tsx` / `panels/` / `hooks/` / `services/` / `styles/`)
- 前端:`assets/creator-studio/src/`(`index.tsx` / `components/editor-toolbar.tsx` / `panels/` / `hooks/` / `services/` / `styles/`)
- 契约测试:`tests/widget-system/creator-studio-contract.test.cjs`
## 测试
+1 -1
View File
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-block-library', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-data', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-wordcount'), 'version' => 'a7a69219755aa071c854');
<?php return array('dependencies' => array('react', 'wp-element', 'wp-i18n'), 'version' => '7498c793a760249be02b');
+1 -1
View File
@@ -1 +1 @@
.jio-page-submit{background:#fafbfa}body.jio-page-submit.has-static-nav,body.jio-page-submit.nav-fixed{padding-top:0!important}.jio-page-submit .float-btn,.jio-page-submit .footer,.jio-page-submit .footer-tabbar,.jio-page-submit .header,.jio-page-submit .jio-float-buttons{display:none!important}.jio-page-submit #main-content.container{max-width:none;padding:0}#jio-creator-studio.is-ready{display:block}.jio-creator{--jc-ink:#243330;--jc-ink-soft:#5c6b66;--jc-paper:#fafbfa;--jc-line:#e2e9e5;--jc-accent:#267665;--jc-accent-strong:#1d5f52;--jc-warn:#b4573f;--jc-bar:60px;-webkit-font-smoothing:antialiased;background:var(--jc-paper);color:var(--jc-ink);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Noto Sans SC,PingFang SC,Microsoft YaHei,sans-serif;min-height:100vh;min-height:100dvh;padding-top:var(--jc-bar)}.jio-creator *,.jio-creator :after,.jio-creator :before{box-sizing:border-box}.jio-creator button{color:inherit;font:inherit}.jio-creator__bar{align-items:center;backdrop-filter:blur(14px);background:rgba(250,251,250,.97);border-bottom:1px solid var(--jc-line);box-shadow:0 2px 8px rgba(27,46,39,.035);display:flex;gap:20px;height:var(--jc-bar);justify-content:space-between;left:0;padding:0 clamp(16px,3vw,34px);position:fixed;right:0;top:0;z-index:300}.jio-creator__bar-actions,.jio-creator__bar-center,.jio-creator__bar-left{align-items:center;display:flex;gap:12px;min-width:0}.jio-creator__bar-left{flex:0 0 auto}.jio-creator__bar-center{flex:1 1 auto;gap:14px;justify-content:center}.jio-creator__bar-actions{flex:0 0 auto;gap:8px}.jio-creator__back{align-items:center;border-radius:7px;color:var(--jc-ink-soft);display:inline-flex;height:30px;justify-content:center;text-decoration:none;width:30px}.jio-creator__back:focus-visible,.jio-creator__back:hover{background:#edf3f0;color:var(--jc-accent);outline:0}.jio-creator__bar-brand{color:var(--jc-accent-strong);font-family:Songti SC,"Noto Serif SC","Source Han Serif SC",serif;font-size:17px;font-weight:700;letter-spacing:-.04em}.jio-creator__bar-divider{background:var(--jc-line);height:18px;width:1px}.jio-creator__bar-mode{color:var(--jc-ink-soft);font-size:13px}.jio-creator__doc-title{color:#65736d;font-size:13px;max-width:min(32vw,360px);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.jio-creator__icon-btn{align-items:center;background:transparent;border:0;border-radius:7px;color:var(--jc-ink-soft);cursor:pointer;display:inline-flex;height:32px;justify-content:center;width:32px}.jio-creator__icon-btn:focus-visible:not(:disabled),.jio-creator__icon-btn:hover:not(:disabled){background:#edf3f0;color:var(--jc-accent-strong);outline:0}.jio-creator__icon-btn:disabled{color:#c7d0cc;cursor:default}.jio-creator__history{align-items:center;border-right:1px solid var(--jc-line);display:inline-flex;gap:2px;padding-right:8px}.jio-creator__save-draft{background:#fff;border-color:var(--jc-line);color:var(--jc-ink-soft)}.jio-creator__save-draft:focus-visible,.jio-creator__save-draft:hover{background:#f7faf8;border-color:#b7cdc3;color:var(--jc-accent-strong)}.jio-creator__save{align-items:center;color:var(--jc-ink-soft);display:inline-flex;font-size:13px;gap:6px}.jio-creator__save>i{color:var(--jc-line);font-size:7px}.jio-creator__save time{font-variant-numeric:tabular-nums;opacity:.7}.jio-creator__save.is-saved>i{color:var(--jc-accent)}.jio-creator__save.is-conflict,.jio-creator__save.is-conflict>i,.jio-creator__save.is-error,.jio-creator__save.is-error>i{color:var(--jc-warn)}.jio-creator__btn,.jio-creator__ghost,.jio-creator__primary{align-items:center;border:1px solid transparent;border-radius:8px;cursor:pointer;display:inline-flex;font-size:14px;justify-content:center;min-height:36px;padding:0 16px;transition:background .18s ease,border-color .18s ease,color .18s ease}.jio-creator__ghost{background:transparent;border-color:var(--jc-line)}.jio-creator__ghost:focus-visible,.jio-creator__ghost:hover{border-color:var(--jc-accent);color:var(--jc-accent)}.jio-creator__primary{background:var(--jc-accent);color:#fff}.jio-creator__primary:focus-visible,.jio-creator__primary:hover{background:var(--jc-accent-strong)}.jio-creator__primary:disabled{cursor:not-allowed;opacity:.5}.jio-creator__notice{align-items:center;background:#f1f5f2;border-bottom:1px solid var(--jc-line);color:var(--jc-ink-soft);display:flex;font-size:13px;gap:12px;margin:0 auto;max-width:820px;padding:10px 16px}.jio-creator__notice>span{flex:1}.jio-creator__notice button{background:transparent;border:0;color:var(--jc-accent);cursor:pointer}.jio-creator__notice.is-error{background:#fdf2ee;color:var(--jc-warn)}.jio-creator__hint{color:var(--jc-ink-soft);font-size:13px;margin:18px auto 0;max-width:820px;padding:0 clamp(16px,4vw,40px)}.jio-creator__canvas{margin:0 auto;padding:clamp(20px,4vw,44px) clamp(20px,5vw,52px) 140px;width:min(720px,100%)}.jio-creator__title{display:block;margin-bottom:6px}.jio-creator__title textarea{background:transparent;border:0;color:var(--jc-ink);font-family:Songti SC,"Noto SLine truncated
.jio-page-submit{background:#fafbfa}body.jio-page-submit.has-static-nav,body.jio-page-submit.nav-fixed{padding-top:0!important}.jio-page-submit .float-btn,.jio-page-submit .footer,.jio-page-submit .footer-tabbar,.jio-page-submit .header,.jio-page-submit .jio-float-buttons{display:none!important}.jio-page-submit #main-content.container{max-width:none;padding:0}#jio-creator-studio.is-ready{display:block}.jio-creator{--jc-ink:#243330;--jc-ink-soft:#5c6b66;--jc-paper:#fafbfa;--jc-line:#e2e9e5;--jc-accent:#267665;--jc-accent-strong:#1d5f52;--jc-warn:#b4573f;--jc-bar:60px;-webkit-font-smoothing:antialiased;background:var(--jc-paper);color:var(--jc-ink);font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Noto Sans SC,PingFang SC,Microsoft YaHei,sans-serif;min-height:100vh;min-height:100dvh;padding-top:var(--jc-bar)}.jio-creator *,.jio-creator :after,.jio-creator :before{box-sizing:border-box}.jio-creator button{color:inherit;font:inherit}.jio-creator__bar{align-items:center;background:rgba(250,251,250,.98);border-bottom:1px solid var(--jc-line);box-shadow:0 2px 8px rgba(27,46,39,.035);display:flex;gap:clamp(10px,1.7vw,24px);height:var(--jc-bar);left:0;padding:0 clamp(14px,2.4vw,30px);position:fixed;right:0;top:0;z-index:300}.jio-creator__bar-actions,.jio-creator__bar-left{align-items:center;display:flex;flex:0 0 auto;gap:9px;min-width:0}.jio-creator__bar-left{border-right:1px solid var(--jc-line);padding-right:clamp(10px,1.5vw,20px)}.jio-creator__bar-actions{gap:7px;margin-left:auto}.jio-creator__bar-brand{color:var(--jc-accent-strong);font-family:Songti SC,"Noto Serif SC","Source Han Serif SC",serif;font-size:17px;font-weight:700;letter-spacing:-.04em}.jio-creator__bar-divider{background:var(--jc-line);height:18px;width:1px}.jio-creator__bar-mode{color:var(--jc-ink-soft);font-size:13px;white-space:nowrap}.jio-creator__back{align-items:center;border-radius:7px;color:var(--jc-ink-soft);display:inline-flex;height:30px;justify-content:center;text-decoration:none;width:30px}.jio-creator__back:focus-visible,.jio-creator__back:hover{background:#edf3f0;color:var(--jc-accent);outline:0}.jio-creator__toolbar-tools{align-items:center;display:flex;flex:1 1 auto;gap:7px;min-width:0;overflow-x:auto;scrollbar-width:none}.jio-creator__toolbar-tools::-webkit-scrollbar{display:none}.jio-creator__toolbar-group{align-items:center;display:inline-flex;flex:0 0 auto;gap:2px}.jio-creator__toolbar-divider{background:var(--jc-line);flex:0 0 auto;height:22px;width:1px}.jio-creator__tool-btn{align-items:center;background:transparent;border:0;border-radius:7px;color:var(--jc-ink-soft);cursor:pointer;display:inline-flex;flex:0 0 auto;font-size:13px;gap:7px;height:34px;justify-content:center;min-width:32px;padding:0 8px}.jio-creator__tool-btn:focus-visible:not(:disabled),.jio-creator__tool-btn:hover:not(:disabled){background:#edf3f0;color:var(--jc-accent-strong);outline:0}.jio-creator__tool-btn:disabled{color:#c7d0cc;cursor:default}.jio-creator__tool-labeled span{white-space:nowrap}.jio-creator__format-select{background:transparent;border:1px solid transparent;border-radius:7px;color:var(--jc-ink-soft);flex:0 0 auto;font:inherit;font-size:13px;height:34px;padding:0 8px;width:94px}.jio-creator__format-select:focus-visible,.jio-creator__format-select:hover{background:#fff;border-color:var(--jc-line);outline:0}.jio-creator__format-select:disabled{color:#aebbb5}.jio-creator__save-draft{align-items:center;background:#fff;border:1px solid var(--jc-line);border-radius:7px;color:var(--jc-ink-soft);cursor:pointer;display:inline-flex;flex:0 0 auto;font-size:13px;gap:7px;justify-content:center;min-height:34px;padding:0 10px;white-space:nowrap}.jio-creator__save-draft:focus-visible:not(:disabled),.jio-creator__save-draft:hover:not(:disabled){background:#f7faf8;border-color:#b7cdc3;color:var(--jc-accent-strong);outline:0}.jio-creator__save-draft:disabled{cursor:default;opacity:.55}.jio-creator__save{align-items:center;color:var(--jc-ink-soft);display:inline-flex;flex:0 0 auto;font-size:12px;gap:6px;white-space:nowrap}.jio-creator__save>i{color:var(--jc-line);font-size:7px}.jio-creator__save time{font-variant-numeric:tabular-nums;opacity:.7}.jio-creator__save.is-saved>i{color:var(--jc-accent)}.jio-creator__save.is-conflict,.jio-creator__save.is-conflict>i,.jio-creator__save.is-error,.jio-creator__save.is-error>i{color:var(--jc-warn)}.jio-creator__btn,.jio-creator__ghost,.jio-creator__primary{align-items:center;border:1px solid transparent;border-radius:7px;cursor:pointer;display:inline-flex;font-size:13px;justify-content:center;min-height:34px;padding:0 13px;transition:background .18s ease,border-color .18s ease,color .18s ease;white-space:nowrap}.jio-creator__ghost{background:transparent;border-color:var(--jc-line)}.jio-creator__ghost:focus-visible,.jio-creator__ghost:hover{border-color:var(--jc-accent);color:var(--jc-accent)}.jio-creator__primary{background:var(--jc-accent);color:#fff}.jio-creator__primary:focus-visible,.jio-creator__primary:hover{backLine truncated
+1 -1
View File
@@ -1 +1 @@
(()=>{"use strict";const e={n:t=>{const a=t&&t.__esModule?()=>t.default:()=>t;return e.d(a,{a}),a},d:(t,a)=>{for(var r in a)e.o(a,r)&&!e.o(t,r)&&Object.defineProperty(t,r,{enumerable:!0,get:a[r]})},o:(e,t)=>Object.hasOwn(e,t)},t=window.React,a=window.wp.element,r=window.wp.blocks,n=window.wp.blockLibrary,o=window.wp.data,i=window.wp.blockEditor,c=window.wp.wordcount,l=window.wp.i18n;function s(e,t){const a=e&&(e.message||e.error)||(401===t?"登录状态已失效,请刷新页面重新登录。":"请求失败,请稍后重试。"),r=new Error(String(a));return r.status=t,r.code=e&&e.code?String(e.code):"",r.data=e,r}async function u(e){try{return await e.json()}catch(e){return null}}async function m(e,t,a){const r=await fetch(e,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json","X-WP-Nonce":a},body:JSON.stringify(t)}),n=await u(r);if(!r.ok)throw s(n,r.status);return n}async function d(e,t,a,r={}){const n=new FormData;n.append("file",t),Object.entries(r).forEach(([e,t])=>n.append(e,t));const o=await fetch(e,{method:"POST",credentials:"same-origin",headers:{"X-WP-Nonce":a},body:n}),i=await u(o);if(!o.ok)throw s(i,o.status);return i}const p=window.wp.apiFetch;var _=e.n(p);const f=window.wp.hooks,b=window.wp.compose,j=window.wp.components,y=[{key:"half",label:(0,l.__)("半屏","jio"),className:"jio-img-w--half"},{key:"medium",label:(0,l.__)("中等","jio"),className:"jio-img-w--medium"},{key:"wide",label:(0,l.__)("通栏","jio"),className:"jio-img-w--wide"},{key:"full",label:(0,l.__)("全宽","jio"),className:"jio-img-w--full"}];function E(e){return a=>{if("core/image"!==a.name)return(0,t.createElement)(e,{...a});const r=String(a.attributes?.className||""),n=function(e){const t=y.find(t=>(e||"").split(/\s+/).includes(t.className));return t?t.key:"wide"}(r);return(0,t.createElement)(t.Fragment,null,(0,t.createElement)(e,{...a}),(0,t.createElement)(i.BlockControls,{group:"inline"},(0,t.createElement)(j.ToolbarGroup,{controls:y.map(e=>({title:e.label,children:e.label,isActive:n===e.key,onClick:()=>(e=>{const t=r.split(/\s+/).filter(e=>e&&!y.some(t=>t.className===e));a.setAttributes({className:[...t,e.className].join(" ")})})(e)}))})))}}const g=window.jioCreatorStudio||{},h=g.nonce||"";function w(e){const{draft:r,setDraft:n,capabilities:o,limits:i,taxonomy:c,isPublishedEdit:s,onClose:u,onSaveDraft:m,onSubmit:p,privateStorage:_,privateUploads:f,saving:b,captcha:j}=e,[y,E]=(0,a.useState)(""),[w,v]=(0,a.useState)(""),[N,k]=(0,a.useState)(!1),C=(0,a.useRef)(null),S=(0,a.useRef)(null),x=(0,a.useRef)(null);(0,a.useEffect)(()=>{const e=x.current,t=document.getElementById("jio-creator-captcha");if(!e||!t)return;const a=t.parentElement;return e.appendChild(t),t.removeAttribute("hidden"),()=>{a&&(a.appendChild(t),t.setAttribute("hidden",""))}},[]);const I=(0,a.useMemo)(()=>(r.title||"").trim().length,[r.title])>=Number(i.titleMin||1),L=()=>{const e=y.trim();if(!e)return;const t=e.split(/[,,\n]+/u).map(e=>e.trim()).filter(Boolean);n({tags:Array.from(new Set([...r.tags,...t]))}),E("")};return(0,t.createElement)("div",{className:"jio-creator-drawer",role:"dialog","aria-modal":"true","aria-labelledby":"jio-creator-publish-title"},(0,t.createElement)("button",{type:"button",className:"jio-creator-drawer__backdrop","aria-label":(0,l.__)("关闭","jio"),onClick:u}),(0,t.createElement)("div",{className:"jio-creator-drawer__panel"},(0,t.createElement)("header",{className:"jio-creator-drawer__head"},(0,t.createElement)("h2",{id:"jio-creator-publish-title"},s?(0,l.__)("提交改稿","jio"):(0,l.__)("发布文章","jio")),(0,t.createElement)("button",{type:"button",className:"jio-creator-drawer__close",onClick:u,"aria-label":(0,l.__)("关闭","jio")},(0,t.createElement)("i",{className:"fa fas fa-times","aria-hidden":"true"}))),(0,t.createElement)("div",{className:"jio-creator-drawer__body"},s&&(0,t.createElement)("p",{className:"jio-creator-drawer__note"},(0,l.__)("审核通过前,读者看到的仍然是当前线上版本。","jio")),(0,t.createElement)("section",{className:"jio-creator-field"},(0,t.createElement)("h3",null,(0,l.__)("封面","jio")),r.featured?.data?.url?(0,t.createElement)("div",{className:"jio-creator-cover"},(0,t.createElement)("img",{src:String(r.featured.data.url),alt:""}),(0,t.createElement)("button",{type:"button",onClick:()=>n({featured:{type:"",data:{}}}),"aria-label":(0,l.__)("移除封面","jio")},(0,t.createElement)("i",{className:"fa fas fa-times","aria-hidden":"true"}))):o.canUploadImage?(0,t.createElement)("label",{className:"jio-creator-cover-empty"},(0,t.createElement)("input",{ref:C,type:"file",accept:"image/*",onChange:()=>{(async()=>{const e=C.current?.files?.[0];if(e&&g.media?.uploadUrl?.image){v(""),k(!0);try{const t=await d(g.media.uploadUrl.image,e,h),a=Number(t.attachment_id||t.id||0),r=String(t.url||"");if(!a||!r)throw new Error((0,l.__)("上传后无法读取图片。","jio"));n({featured:{type:"image",mediaId:a,data:{id:a,url:r}}})}catch(e){v(String(e?.Line truncated
(()=>{"use strict";const e=window.React,t=window.wp.element,a=window.wp.i18n;function r(e,t){const a=e&&(e.message||e.error)||(401===t?"登录状态已失效,请刷新页面重新登录。":"请求失败,请稍后重试。"),r=new Error(String(a));return r.status=t,r.code=e&&e.code?String(e.code):"",r.data=e,r}async function n(e){try{return await e.json()}catch(e){return null}}async function o(e,t,a){const o=await fetch(e,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json","X-WP-Nonce":a},body:JSON.stringify(t)}),i=await n(o);if(!o.ok)throw r(i,o.status);return i}async function i(e,t,a,o={}){const i=new FormData;i.append("file",t),Object.entries(o).forEach(([e,t])=>i.append(e,t));const l=await fetch(e,{method:"POST",credentials:"same-origin",headers:{"X-WP-Nonce":a},body:i}),c=await n(l);if(!l.ok)throw r(c,l.status);return c}const l=window.jioCreatorStudio||{},c=l.nonce||"";function s(r){const{draft:n,setDraft:o,capabilities:s,limits:u,taxonomy:m,isPublishedEdit:d,onClose:p,onSaveDraft:_,onSubmit:f,privateStorage:b,privateUploads:y,saving:j,captcha:E}=r,[g,h]=(0,t.useState)(""),[v,N]=(0,t.useState)(""),[w,k]=(0,t.useState)(!1),C=(0,t.useRef)(null),S=(0,t.useRef)(null),x=(0,t.useRef)(null);(0,t.useEffect)(()=>{const e=x.current,t=document.getElementById("jio-creator-captcha");if(!e||!t)return;const a=t.parentElement;return e.appendChild(t),t.removeAttribute("hidden"),()=>{a&&(a.appendChild(t),t.setAttribute("hidden",""))}},[]);const I=(0,t.useMemo)(()=>(n.title||"").trim().length,[n.title])>=Number(u.titleMin||1),M=()=>{const e=g.trim();if(!e)return;const t=e.split(/[,,\n]+/u).map(e=>e.trim()).filter(Boolean);o({tags:Array.from(new Set([...n.tags,...t]))}),h("")};return(0,e.createElement)("div",{className:"jio-creator-drawer",role:"dialog","aria-modal":"true","aria-labelledby":"jio-creator-publish-title"},(0,e.createElement)("button",{type:"button",className:"jio-creator-drawer__backdrop","aria-label":(0,a.__)("关闭","jio"),onClick:p}),(0,e.createElement)("div",{className:"jio-creator-drawer__panel"},(0,e.createElement)("header",{className:"jio-creator-drawer__head"},(0,e.createElement)("h2",{id:"jio-creator-publish-title"},d?(0,a.__)("提交改稿","jio"):(0,a.__)("发布文章","jio")),(0,e.createElement)("button",{type:"button",className:"jio-creator-drawer__close",onClick:p,"aria-label":(0,a.__)("关闭","jio")},(0,e.createElement)("i",{className:"fa fas fa-times","aria-hidden":"true"}))),(0,e.createElement)("div",{className:"jio-creator-drawer__body"},d&&(0,e.createElement)("p",{className:"jio-creator-drawer__note"},(0,a.__)("审核通过前,读者看到的仍然是当前线上版本。","jio")),(0,e.createElement)("section",{className:"jio-creator-field"},(0,e.createElement)("h3",null,(0,a.__)("封面","jio")),n.featured?.data?.url?(0,e.createElement)("div",{className:"jio-creator-cover"},(0,e.createElement)("img",{src:String(n.featured.data.url),alt:""}),(0,e.createElement)("button",{type:"button",onClick:()=>o({featured:{type:"",data:{}}}),"aria-label":(0,a.__)("移除封面","jio")},(0,e.createElement)("i",{className:"fa fas fa-times","aria-hidden":"true"}))):s.canUploadImage?(0,e.createElement)("label",{className:"jio-creator-cover-empty"},(0,e.createElement)("input",{ref:C,type:"file",accept:"image/*",onChange:()=>{(async()=>{const e=C.current?.files?.[0];if(e&&l.media?.uploadUrl?.image){N(""),k(!0);try{const t=await i(l.media.uploadUrl.image,e,c),r=Number(t.attachment_id||t.id||0),n=String(t.url||"");if(!r||!n)throw new Error((0,a.__)("上传后无法读取图片。","jio"));o({featured:{type:"image",mediaId:r,data:{id:r,url:n}}})}catch(e){N(String(e?.message||(0,a.__)("上传失败","jio")))}finally{k(!1)}}})()},disabled:w}),(0,e.createElement)("i",{className:"fa fas fa-plus","aria-hidden":"true"}),(0,e.createElement)("span",null,w?(0,a.__)("上传中…","jio"):(0,a.__)("上传封面","jio"))):(0,e.createElement)("p",{className:"jio-creator-muted"},(0,a.__)("当前账号不能设置封面。","jio"))),(0,e.createElement)("section",{className:"jio-creator-field"},(0,e.createElement)("h3",null,(0,a.__)("分类","jio")),(0,e.createElement)("div",{className:"jio-creator-chips"},0===m.categories.length&&(0,e.createElement)("span",{className:"jio-creator-muted"},(0,a.__)("暂无可选分类","jio")),m.categories.map(t=>(0,e.createElement)("label",{key:t.id,className:"jio-creator-chip"+(n.categories.includes(t.id)?" is-active":"")},(0,e.createElement)("input",{type:"checkbox",checked:n.categories.includes(t.id),onChange:()=>(e=>{const t=n.categories.includes(e);o({categories:t?n.categories.filter(t=>t!==e):[...n.categories,e]})})(t.id)}),(0,e.createElement)("span",null,t.name))))),m.tags&&(0,e.createElement)("section",{className:"jio-creator-field"},(0,e.createElement)("h3",null,(0,a.__)("标签","jio")),(0,e.createElement)("div",{className:"jio-creator-chips"},n.tags.map(t=>(0,e.createElement)("span",{key:t,className:"jio-creator-chip is-active"},(0,e.createLine truncated
File diff suppressed because it is too large. Load diff
-9
View File
@@ -9,18 +9,9 @@
"devDependencies": {
"@types/react": "18.3.31",
"@types/react-dom": "18.3.7",
"@wordpress/api-fetch": "^6.28.0",
"@wordpress/block-editor": "11.8.0",
"@wordpress/block-library": "^8.8.0",
"@wordpress/blocks": "12.8.0",
"@wordpress/components": "^23.8.0",
"@wordpress/compose": "^6.8.0",
"@wordpress/data": "9.5.0",
"@wordpress/element": "5.8.0",
"@wordpress/hooks": "^3.31.0",
"@wordpress/i18n": "4.31.0",
"@wordpress/scripts": "26.4.0",
"@wordpress/wordcount": "3.31.0",
"sass": "1.77.8",
"typescript": "5.4.5"
}
+23 -67
View File
@@ -1,5 +1,5 @@
/**
* 交互冒烟:验证插入面板能打开、能插入区块,封面条存在,
* TinyMCE 交互冒烟:验证固定工具栏能够编辑正文格式,
* 并记录无法捕获的 promise 拒绝。
*/
const fs = require('fs');
@@ -22,82 +22,38 @@ const URL = process.argv[2] || 'https://localhost/jio-preview.html';
args: ['--disable-gpu', '--no-sandbox'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1440, height: 560 });
if (process.env.JIO_USE_WORKTREE_BUILD === '1') {
await page.setRequestInterception(true);
page.on('request', (request) => {
const pathname = new URL(request.url()).pathname;
const jsSuffix = '/assets/creator-studio/build/index.js';
const cssSuffix = '/assets/creator-studio/build/index.css';
if (pathname.endsWith(jsSuffix)) {
request.respond({
status: 200,
contentType: 'application/javascript',
body: fs.readFileSync(path.join(__dirname, '../build/index.js')),
});
} else if (pathname.endsWith(cssSuffix)) {
request.respond({
status: 200,
contentType: 'text/css',
body: fs.readFileSync(path.join(__dirname, '../build/index.css')),
});
} else {
request.continue();
}
});
}
await page.setViewport({ width: 1440, height: 900 });
const errors = [];
page.on('pageerror', (e) => errors.push('pageerror: ' + e.message));
page.on('pageerror', (error) => errors.push('pageerror: ' + error.message));
await page.goto(URL, { waitUntil: 'networkidle2', timeout: 60000 });
await new Promise((r) => setTimeout(r, 2500));
await page.waitForFunction(() => window.tinymce?.get('jio-creator-content'), { timeout: 20000 });
const before = await page.evaluate(() => ({
blocks: document.querySelectorAll('.block-editor-block-list__block').length,
hasInsertBtn: !!document.querySelector('.jio-creator__insert'),
hasCover: !!document.querySelector('.jio-creator__cover'),
toolbarTop: document.querySelector('.jio-creator__bar')?.getBoundingClientRect().top,
fixed: getComputedStyle(document.querySelector('.jio-creator__bar')).position === 'fixed',
toolbar: !!document.querySelector('[role="toolbar"]'),
editor: !!window.tinymce?.get('jio-creator-content'),
}));
await page.evaluate(() => window.scrollTo(0, 700));
await new Promise((r) => setTimeout(r, 300));
const toolbarAfterScroll = await page.$eval('.jio-creator__bar', (el) => el.getBoundingClientRect().top);
console.log('BEFORE ' + JSON.stringify({ ...before, toolbarAfterScroll }));
// 打开插入面板
await page.click('.jio-creator__insert');
await new Promise((r) => setTimeout(r, 400));
const panel = await page.evaluate(() => ({
open: !!document.querySelector('.jio-insert__panel'),
items: Array.from(document.querySelectorAll('.jio-insert-item__label')).map((n) => n.textContent),
}));
console.log('PANEL ' + JSON.stringify(panel));
// 插入「标题」区块
const clicked = await page.evaluate(() => {
const btn = Array.from(document.querySelectorAll('.jio-insert-item')).find(
(b) => (b.textContent || '').includes('标题')
);
if (!btn) return false;
btn.click();
return true;
await page.evaluate(() => {
const editor = window.tinymce.get('jio-creator-content');
editor.setContent('<p>Toolbar smoke test</p>');
editor.selection.select(editor.dom.select('p')[0], true);
});
await new Promise((r) => setTimeout(r, 900));
await page.click('.jio-creator__tool-btn[aria-label="加粗"]');
const bold = await page.evaluate(() => /<(strong|b)>Toolbar smoke test<\/(strong|b)>/.test(window.tinymce.get('jio-creator-content').getContent()));
const after = await page.evaluate(() => ({
panelClosed: !document.querySelector('.jio-insert__panel'),
blocks: document.querySelectorAll('.block-editor-block-list__block').length,
hasHeading: !!document.querySelector('[data-type="core/heading"]'),
}));
console.log('AFTER ' + JSON.stringify({ clicked, ...after }));
await page.screenshot({ path: 'C:/Users/Xing/Desktop/WP-Date/data/wordpress/shot-insert.png' });
if (errors.length) {
console.log('--- errors ---');
errors.slice(0, 10).forEach((e) => console.log(e));
}
await page.evaluate(() => {
const editor = window.tinymce.get('jio-creator-content');
editor.setContent('<p>Heading smoke test</p>');
editor.selection.select(editor.dom.select('p')[0], true);
});
await page.select('.jio-creator__format-select', 'h2');
const heading = await page.evaluate(() => /<h2[^>]*>Heading smoke test<\/h2>/.test(window.tinymce.get('jio-creator-content').getContent()));
console.log('STATE ' + JSON.stringify({ ...before, bold, heading }));
if (errors.length) errors.forEach((error) => console.log(error));
await page.screenshot({ path: 'C:/Users/Xing/Desktop/WP-Date/data/wordpress/shot-tinymce-toolbar.png' });
await browser.close();
})();
+1 -2
View File
@@ -21,7 +21,7 @@ const mockBootstrap = {
title: '',
excerpt: '',
content:
'<!-- wp:paragraph --><p>这是正文的第一段。微信公众号式的编辑体验强调沉浸感:大面积留白、克制的工具栏、只在需要时才出现的设置面板。</p><!-- /wp:paragraph -->',
'<p>这是正文的第一段。微信公众号式的编辑体验强调沉浸感:留白克制,固定工具栏始终可用,发布设置只在需要时打开。</p>',
permalink: '',
isPublished: false,
categories: {
@@ -81,7 +81,6 @@ const mockBootstrap = {
privateUrl: '#',
bootstrapUrl: '#',
},
blocks: { mediaUploadUrl: '#', mediaListUrl: '#' },
nonce: 'preview',
};
+2 -2
View File
@@ -59,8 +59,8 @@ const PREFIX = process.argv[3] || 'C:/Users/Xing/Desktop/WP-Date/data/wordpress/
title: pick('.jio-creator__title textarea'),
excerpt: pick('.jio-creator__excerpt textarea'),
canvas: pick('.jio-creator__canvas'),
para: pick('.block-editor-block-list__block'),
appender: pick('.block-editor-default-block-appender'),
toolbar: pick('.jio-creator__toolbar-tools'),
editor: pick('.jio-creator__editor .mce-edit-area'),
primaryBtn: pick('.jio-creator__primary'),
ghostBtn: pick('.jio-creator__ghost'),
pageBg: getComputedStyle(document.body).backgroundColor,
+6 -3
View File
@@ -75,9 +75,12 @@ if (!EDGE) {
const state = await page.evaluate(() => ({
hasBar: !!document.querySelector('.jio-creator__bar'),
hasCanvas: !!document.querySelector('.jio-creator__canvas'),
appender: !!document.querySelector('.block-editor-default-block-appender'),
blocks: document.querySelectorAll('.block-editor-block-list__block').length,
hasToolbar: !!document.querySelector('.jio-creator__toolbar-tools'),
hasTinyMce: !!window.tinymce?.get('jio-creator-content'),
hasEditorFrame: !!document.querySelector('.mce-tinymce iframe, .tox-tinymce iframe'),
hasTitle: !!document.querySelector('.jio-creator__title textarea'),
hasSaveDraft: !!document.querySelector('.jio-creator__save-draft'),
toolbarActions: Array.from(document.querySelectorAll('.jio-creator__toolbar-tools button')).map((button) => button.getAttribute('aria-label') || button.textContent.trim()),
saveText: (document.querySelector('.jio-creator__save') || {}).textContent,
rejections: window.__jioRejections || [],
}));
@@ -0,0 +1,96 @@
import { __ } from '@wordpress/i18n';
type Capabilities = Record<string, boolean>;
type Props = {
ready: boolean;
canUndo: boolean;
canRedo: boolean;
formatValue: string;
onFormatChange: (value: string) => void;
capabilities: Capabilities;
onCommand: (name: string, value?: string) => void;
onRememberSelection: () => void;
onOpenLink: () => void;
onOpenMedia: (type: 'image' | 'video') => void;
};
export function EditorToolbar({
ready,
canUndo,
canRedo,
formatValue,
onFormatChange,
capabilities,
onCommand,
onRememberSelection,
onOpenLink,
onOpenMedia,
}: Props) {
return (
<div
className="jio-creator__toolbar-tools"
role="toolbar"
aria-label={__('正文编辑工具', 'jio')}
onMouseDown={(event) => {
onRememberSelection();
if ((event.target as HTMLElement).closest('button')) event.preventDefault();
}}
>
<div className="jio-creator__toolbar-group" role="group" aria-label={__('撤销和重做', 'jio')}>
<button type="button" className="jio-creator__tool-btn" disabled={!canUndo} onClick={() => onCommand('undo')} title={__('撤销', 'jio')} aria-label={__('撤销', 'jio')}>
<i className="fa fas fa-undo" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__tool-btn" disabled={!canRedo} onClick={() => onCommand('redo')} title={__('重做', 'jio')} aria-label={__('重做', 'jio')}>
<i className="fa fas fa-redo" aria-hidden="true" />
</button>
</div>
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
<select
className="jio-creator__format-select"
aria-label={__('段落样式', 'jio')}
value={formatValue}
disabled={!ready}
onFocus={onRememberSelection}
onChange={(event) => {
onFormatChange(event.target.value);
onCommand('format', event.target.value);
}}
>
<option value="p">{__('正文', 'jio')}</option>
<option value="h1">{__('标题 1', 'jio')}</option>
<option value="h2">{__('标题 2', 'jio')}</option>
<option value="h3">{__('标题 3', 'jio')}</option>
<option value="blockquote">{__('引用', 'jio')}</option>
</select>
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
<div className="jio-creator__toolbar-group" role="group" aria-label={__('文字格式', 'jio')}>
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Bold')} title={__('加粗', 'jio')} aria-label={__('加粗', 'jio')}>
<i className="fa fas fa-bold" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Italic')} title={__('斜体', 'jio')} aria-label={__('斜体', 'jio')}>
<i className="fa fas fa-italic" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__tool-btn" disabled={!ready} onClick={() => onCommand('Underline')} title={__('下划线', 'jio')} aria-label={__('下划线', 'jio')}>
<i className="fa fas fa-underline" aria-hidden="true" />
</button>
</div>
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
<div className="jio-creator__toolbar-group" role="group" aria-label={__('媒体和链接', 'jio')}>
{capabilities.canUploadImage && (
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={() => onOpenMedia('image')} title={__('插入图片', 'jio')}>
<i className="fa far fa-image" aria-hidden="true" /><span>{__('图片', 'jio')}</span>
</button>
)}
{(capabilities.canUploadVideo || capabilities.canEmbed) && (
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={() => onOpenMedia('video')} title={__('插入视频', 'jio')}>
<i className="fa fas fa-video" aria-hidden="true" /><span>{__('视频', 'jio')}</span>
</button>
)}
<button type="button" className="jio-creator__tool-btn jio-creator__tool-labeled" disabled={!ready} onClick={onOpenLink} title={__('插入链接', 'jio')}>
<i className="fa fas fa-link" aria-hidden="true" /><span>{__('链接', 'jio')}</span>
</button>
</div>
</div>
);
}
@@ -1,73 +0,0 @@
/**
* 图片宽度预设(公众号式四档)。
*
* 通过 block filter 给 core/image 追加「半屏 / 中等 / 通栏 / 全宽」四档宽度,
* 选中图片时出现在区块工具条里。宽度存进图片区块的 className,序列化到
* post_content 后由前台样式生效,不需要自定义区块或额外元数据。
*
* @package Jio
*/
import { addFilter } from '@wordpress/hooks';
import { createHigherOrderComponent } from '@wordpress/compose';
import { BlockControls } from '@wordpress/block-editor';
import { ToolbarGroup } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const WIDTH_CLASSES = [
{ key: 'half', label: __('半屏', 'jio'), className: 'jio-img-w--half' },
{ key: 'medium', label: __('中等', 'jio'), className: 'jio-img-w--medium' },
{ key: 'wide', label: __('通栏', 'jio'), className: 'jio-img-w--wide' },
{ key: 'full', label: __('全宽', 'jio'), className: 'jio-img-w--full' },
];
function currentPreset(className: string): string {
const found = WIDTH_CLASSES.find((w) => (className || '').split(/\s+/).includes(w.className));
return found ? found.key : 'wide';
}
function withImageWidthControls(BlockEdit: any) {
return (props: any) => {
if (props.name !== 'core/image') {
return <BlockEdit {...props} />;
}
const className = String(props.attributes?.className || '');
const active = currentPreset(className);
const setPreset = (preset: (typeof WIDTH_CLASSES)[number]) => {
const rest = className
.split(/\s+/)
.filter((c) => c && !WIDTH_CLASSES.some((w) => w.className === c));
props.setAttributes({ className: [...rest, preset.className].join(' ') });
};
return (
<>
<BlockEdit {...props} />
<BlockControls group="inline">
<ToolbarGroup
controls={WIDTH_CLASSES.map((preset) => ({
title: preset.label,
// 用文字而不是图标,四档宽度靠标签更易分辨。
children: preset.label,
isActive: active === preset.key,
onClick: () => setPreset(preset),
}))}
/>
</BlockControls>
</>
);
};
}
export function installImageWidthPresets(): void {
if ((window as any).__jioImageWidthInstalled) {
return;
}
(window as any).__jioImageWidthInstalled = true;
addFilter(
'editor.BlockEdit',
'jio/creator-studio/image-width',
createHigherOrderComponent(withImageWidthControls, 'withJioImageWidth')
);
}
@@ -1,121 +0,0 @@
/**
* 工作台级撤销/重做历史。
*
* BlockEditorProvider 独立运行时不带 core/editor 的 post history store,
* 因此为标题、摘要、区块树保存快照。输入以 450ms 合并成一条历史记录,
* 避免每个字符都占用一步。
*
* @package Jio
*/
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
type Snapshot<T> = { value: T; signature: string };
type HistoryState<T> = { past: Snapshot<T>[]; future: Snapshot<T>[]; present: Snapshot<T>; pending: T | null };
type Options<T> = {
initial: T;
onRestore: (value: T) => void;
limit?: number;
mergeDelay?: number;
};
function copy<T>(value: T): T {
return JSON.parse(JSON.stringify(value)) as T;
}
function snapshot<T>(value: T): Snapshot<T> {
const cloned = copy(value);
return { value: cloned, signature: JSON.stringify(cloned) };
}
export function useEditorHistory<T>({ initial, onRestore, limit = 80, mergeDelay = 450 }: Options<T>) {
const state = useRef<HistoryState<T>>({
past: [],
future: [],
present: snapshot(initial),
pending: null,
});
const timer = useRef<number | null>(null);
const restoreRef = useRef(onRestore);
restoreRef.current = onRestore;
const [availability, setAvailability] = useState({ canUndo: false, canRedo: false });
const updateAvailability = useCallback(() => {
setAvailability({
canUndo: state.current.past.length > 0 || state.current.pending !== null,
canRedo: state.current.future.length > 0,
});
}, []);
const flush = useCallback(() => {
if (timer.current !== null) {
window.clearTimeout(timer.current);
timer.current = null;
}
const current = state.current;
if (current.pending === null) {
return;
}
const next = snapshot(current.pending);
current.pending = null;
if (next.signature === current.present.signature) {
updateAvailability();
return;
}
current.past = [...current.past, current.present].slice(-limit);
current.present = next;
current.future = [];
updateAvailability();
}, [limit, updateAvailability]);
const record = useCallback(
(value: T) => {
state.current.pending = copy(value);
if (timer.current !== null) {
window.clearTimeout(timer.current);
}
timer.current = window.setTimeout(flush, mergeDelay);
updateAvailability();
},
[flush, mergeDelay, updateAvailability]
);
const undo = useCallback(() => {
flush();
const current = state.current;
if (!current.past.length) {
return false;
}
const previous = current.past[current.past.length - 1];
current.past = current.past.slice(0, -1);
current.future = [current.present, ...current.future].slice(0, limit);
current.present = previous;
restoreRef.current(copy(previous.value));
updateAvailability();
return true;
}, [flush, limit, updateAvailability]);
const redo = useCallback(() => {
flush();
const current = state.current;
if (!current.future.length) {
return false;
}
const next = current.future[0];
current.future = current.future.slice(1);
current.past = [...current.past, current.present].slice(-limit);
current.present = next;
restoreRef.current(copy(next.value));
updateAvailability();
return true;
}, [flush, limit, updateAvailability]);
useEffect(() => () => {
if (timer.current !== null) {
window.clearTimeout(timer.current);
}
}, []);
return { ...availability, record, undo, redo, flush };
}
+297 -218
View File
@@ -1,47 +1,30 @@
/**
* Jio Creator Studio — 前台沉浸式创作界面。
* Jio Creator Studio — 前台 TinyMCE 写作工作台。
*
* Gutenberg 负责区块解析、编辑与序列化;本入口只搭建工作台外壳、发布配置抽屉
* 与保存/预览/提交编排。所有写入都走 Jio REST(CreatorRest),服务端负责权限、
* 字段清洗、审核状态与私有附件归属。
* TinyMCE 负责文章正文编辑,固定操作栏通过 TinyMCE API 执行排版与媒体命令;
* Jio REST 继续负责保存、审核、权限、付费配置与私有附件归属。
*
* @package Jio
*/
import { createRoot, useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element';
import { createBlock, parse, serialize } from '@wordpress/blocks';
import { registerCoreBlocks } from '@wordpress/block-library';
import { useDispatch, useSelect } from '@wordpress/data';
import { BlockEditorProvider, BlockList, BlockTools } from '@wordpress/block-editor';
import { count as countWords } from '@wordpress/wordcount';
import { __ } from '@wordpress/i18n';
import { apiPost } from './services/api';
import { installApiFetchNonce } from './services/api-fetch';
import { installMediaUploadBridge } from './services/media';
import { installImageWidthPresets } from './extensions/image-width';
import { PublishDrawer } from './panels/publish-drawer';
import { CoverStrip } from './components/cover-strip';
import { AuthorRow } from './components/author-row';
import { InsertPanel, type InsertItem } from './panels/insert-panel';
import { buildInsertGroups } from './utils/insert-items';
import { EditorToolbar } from './components/editor-toolbar';
import { SaveState, type SaveStateValue } from './components/save-state';
import { useAutosave } from './hooks/use-autosave';
import { useEditorHistory } from './hooks/use-editor-history';
import { useLocalRecovery } from './hooks/use-local-recovery';
import { normalizePayForRequest } from './utils/pay';
import './styles/studio.scss';
// 核心区块注册只做一次;重复调用会触发 "Block type already registered"。
const coreRegistered = (window as any).__jioCoreBlocksRegistered;
if (!coreRegistered && typeof registerCoreBlocks === 'function') {
registerCoreBlocks();
(window as any).__jioCoreBlocksRegistered = true;
}
const bootstrap: Record<string, any> = window['jioCreatorStudio'] || {};
const nonce: string = bootstrap.nonce || '';
const STORAGE_KEY = `jio-studio-${bootstrap.sourceId || 0}`;
const EDITOR_ID = 'jio-creator-content';
type Featured = { type: string; data: Record<string, any>; mediaId?: number };
type PayState = {
@@ -107,58 +90,50 @@ function initialDraft(): Draft {
};
}
function escapeAttribute(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function safeHttpUrl(value: unknown): string {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
try {
const url = new URL(raw, window.location.href);
return url.protocol === 'http:' || url.protocol === 'https:' ? url.href : '';
} catch (error) {
return '';
}
}
function plainText(content: string): string {
const container = document.createElement('div');
container.innerHTML = content;
return (container.textContent || '').replace(/\s+/g, '');
}
function Studio(): JSX.Element {
const [draft, setDraft] = useState<Draft>(initialDraft);
const [blocks, setBlocks] = useState(() => parse(draft.content || ''));
const blocksRef = useRef(blocks);
blocksRef.current = blocks;
const draftRef = useRef(draft);
draftRef.current = draft;
const editorRef = useRef<any>(null);
const editorBookmarkRef = useRef<any>(null);
const pendingRestoreContentRef = useRef<string | null>(null);
const editorReadyRef = useRef(false);
const saveActionRef = useRef<() => void>(() => undefined);
const [editorReady, setEditorReady] = useState(false);
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
const [formatValue, setFormatValue] = useState('p');
const [saveState, setSaveState] = useState<SaveStateValue>(() => ({
state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready',
at: '',
}));
const [drawerOpen, setDrawerOpen] = useState(false);
const [insertOpen, setInsertOpen] = useState(false);
const [notice, setNotice] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null);
const [recovery, setRecovery] = useState<null | { savedAt: string }>(null);
const dirtyRef = useRef(false);
const restoreBlockSignatureRef = useRef('');
const restoreHistory = useCallback((value: { blocks: any[]; title: string; excerpt: string }) => {
dirtyRef.current = true;
blocksRef.current = value.blocks;
restoreBlockSignatureRef.current = serialize(value.blocks);
setBlocks(value.blocks);
const next = { ...draftRef.current, title: value.title, excerpt: value.excerpt };
draftRef.current = next;
setDraft(next);
}, []);
const history = useEditorHistory({
initial: { blocks, title: draft.title, excerpt: draft.excerpt },
onRestore: restoreHistory,
});
const historyRef = useRef(history);
historyRef.current = history;
const undo = useCallback(() => historyRef.current.undo(), []);
const redo = useCallback(() => historyRef.current.redo(), []);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== 'z') {
return;
}
const target = event.target as HTMLElement | null;
if (target?.closest('input, textarea, [contenteditable="true"]')) {
return;
}
event.preventDefault();
event.shiftKey ? redo() : undo();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [redo, undo]);
const editRevisionRef = useRef(0);
const capabilities = bootstrap.capabilities || {};
const limits: Limits = {
@@ -174,97 +149,103 @@ function Studio(): JSX.Element {
const patchDraft = useCallback((patch: Partial<Draft>) => {
dirtyRef.current = true;
editRevisionRef.current += 1;
const next = { ...draftRef.current, ...patch };
draftRef.current = next;
if ('title' in patch || 'excerpt' in patch) {
historyRef.current.record({
blocks: blocksRef.current,
title: next.title,
excerpt: next.excerpt,
});
}
setDraft(next);
}, []);
const onChangeBlocks = useCallback((next: any[]) => {
dirtyRef.current = true;
blocksRef.current = next;
const signature = serialize(next);
if (restoreBlockSignatureRef.current !== '' && signature === restoreBlockSignatureRef.current) {
restoreBlockSignatureRef.current = '';
setBlocks(next);
const updateContent = useCallback((content: string) => {
if (content === draftRef.current.content) {
return;
}
historyRef.current.record({
blocks: next,
title: draftRef.current.title,
excerpt: draftRef.current.excerpt,
});
setBlocks(next);
dirtyRef.current = true;
editRevisionRef.current += 1;
const next = { ...draftRef.current, content };
draftRef.current = next;
setDraft(next);
}, []);
const serialized = useMemo(() => serialize(blocks), [blocks]);
const updateHistoryAvailability = useCallback((editor: any) => {
const manager = editor?.undoManager;
if (!manager) return;
setHistoryAvailability({
canUndo: typeof manager.hasUndo === 'function' && manager.hasUndo(),
canRedo: typeof manager.hasRedo === 'function' && manager.hasRedo(),
});
}, []);
// 中文没有空格分词,wordcount 的 'words' 会把整段算成 1。
// 'characters_excluding_spaces' 才是中文场景该用的口径(与前台 TinyMCE 字数一致)。
const charCount = useMemo(() => {
const text = serialized.replace(/<[^>]*>/g, ' ');
return countWords(text, 'characters_excluding_spaces', {});
}, [serialized]);
const updateFormatValue = useCallback((editor: any) => {
const node = editor?.selection?.getNode();
const block = node ? editor.dom.getParent(node, 'p,h1,h2,h3,blockquote') || node : null;
const name = block?.nodeName?.toLowerCase() || 'p';
setFormatValue(['h1', 'h2', 'h3', 'blockquote'].includes(name) ? name : 'p');
}, []);
const buildPayload = useCallback(
(extra: Record<string, any> = {}) => ({
post_id: draft.postId,
version_id: draft.versionId,
rev: draft.rev,
title: draft.title,
excerpt: draft.excerpt,
content: serialized,
categories: draft.categories,
tags: draft.tags,
custom: draft.custom,
featured: draft.featured,
pay: normalizePayForRequest(draft.pay),
const charCount = useMemo(() => Array.from(plainText(draft.content)).length, [draft.content]);
const buildPayload = useCallback((extra: Record<string, any> = {}) => {
const current = draftRef.current;
return {
post_id: current.postId,
version_id: current.versionId,
rev: current.rev,
title: current.title,
excerpt: current.excerpt,
content: current.content,
categories: current.categories,
tags: current.tags,
custom: current.custom,
featured: current.featured,
pay: normalizePayForRequest(current.pay),
...extra,
}),
[draft, serialized]
);
};
}, []);
const save = useCallback(
async (autosave = false): Promise<boolean> => {
if (!dirtyRef.current && (draft.postId > 0 || draft.versionId > 0)) {
const editor = editorRef.current;
if (editor && !editor.removed) {
updateContent(editor.getContent());
}
const current = draftRef.current;
if (!dirtyRef.current && (current.postId > 0 || current.versionId > 0)) {
return true;
}
// 空文档不触发自动保存:服务端会拒绝无标题/无内容的写入,
// 保留本地快照即可,避免把“保存失败”当成真的故障。
const hasDocument = draft.title.trim() !== '' || serialized.replace(/<[^>]*>/g, '').trim() !== '';
const requestRevision = editRevisionRef.current;
const hasDocument = current.title.trim() !== '' || plainText(current.content).trim() !== '';
if (autosave && !hasDocument) {
return true;
}
setSaveState({ state: 'saving', at: '' });
try {
const response = await apiPost(bootstrap.creator.saveUrl, buildPayload({ autosave }), nonce);
const savedId = Number(response.post_id || draft.postId || 0);
const versionId = Number(response.version_id || draft.versionId || 0);
const rev = Number(response.rev || response.versionRev || draft.rev || 0);
dirtyRef.current = false;
const updated = { ...draftRef.current, postId: savedId, versionId, rev };
const updated = {
...draftRef.current,
postId: Number(response.post_id || draftRef.current.postId || 0),
versionId: Number(response.version_id || draftRef.current.versionId || 0),
rev: Number(response.rev || response.versionRev || draftRef.current.rev || 0),
};
if ((window as any).jioSubmitData) {
(window as any).jioSubmitData.postId = updated.postId;
}
const settled = editRevisionRef.current === requestRevision;
dirtyRef.current = !settled;
draftRef.current = updated;
setDraft(updated);
setSaveState({ state: 'saved', at: new Date().toLocaleTimeString() });
return true;
setSaveState({ state: settled ? 'saved' : 'saving', at: settled ? new Date().toLocaleTimeString() : '' });
return settled;
} catch (error: any) {
setSaveState({ state: error?.status === 409 ? 'conflict' : 'error', at: '' });
return false;
}
},
[buildPayload, draft.postId, draft.versionId, draft.rev]
[buildPayload]
);
saveActionRef.current = () => void save(false);
const submit = useCallback(
async (publishNow: boolean): Promise<void> => {
// 提交前先落一次草稿:审核 / 立即发布都以服务端最终保存的内容为准,
// 避免自动保存尚未触发时提交到旧内容。
const persisted = await save(false);
if (!persisted) {
setNotice({ tone: 'error', text: __('内容保存失败,请重试后再提交', 'jio') });
@@ -272,8 +253,6 @@ function Studio(): JSX.Element {
}
setSaveState({ state: 'submitting', at: '' });
try {
// 站点启用「前台投稿」人机验证时,先完成挑战再提交;
// 服务端仍会独立校验证明,前端只是把凭证带过去。
let captchaProof = '';
const captcha = (window as any).JioCaptcha;
if (bootstrap.captcha?.enabled && captcha && typeof captcha.run === 'function') {
@@ -331,11 +310,11 @@ function Studio(): JSX.Element {
} catch (error: any) {
setNotice({ tone: 'error', text: String(error?.message || __('预览失败', 'jio')) });
}
}, [draft.postId, draft.versionId, save]);
}, [save]);
const { markLocalSaved, clear } = useLocalRecovery({
key: STORAGE_KEY,
snapshot: { title: draft.title, content: serialized, at: new Date().toISOString() },
snapshot: { title: draft.title, content: draft.content, at: new Date().toISOString() },
onFound: (savedAt: string) => setRecovery({ savedAt }),
});
@@ -357,7 +336,7 @@ function Studio(): JSX.Element {
if (dirtyRef.current) {
autosaveSchedule();
}
}, [draft, serialized, autosaveSchedule]);
}, [draft, autosaveSchedule]);
useEffect(() => {
const beforeUnload = (event: BeforeUnloadEvent) => {
@@ -382,56 +361,198 @@ function Studio(): JSX.Element {
return () => window.removeEventListener('keydown', onKey);
}, [save]);
useEffect(() => {
const wpEditor = (window as any).wp?.editor;
if (!wpEditor || typeof wpEditor.initialize !== 'function') {
setNotice({ tone: 'error', text: __('TinyMCE 加载失败,请刷新页面后重试。', 'jio') });
return undefined;
}
const editorSettings: Record<string, any> = {
quicktags: false,
tinymce: {
menubar: false,
statusbar: false,
resize: false,
toolbar1: '',
toolbar2: '',
toolbar3: '',
plugins: 'paste,link,lists,wordpress,wplink',
wpautop: true,
body_class: 'jio-creator__tinymce',
content_css: bootstrap.creator?.contentCss || '',
content_style: 'body{margin:0;color:#243330;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Noto Sans SC","PingFang SC","Microsoft YaHei",sans-serif;font-size:16px;line-height:1.85;letter-spacing:.01em}p{margin:0 0 1.1em}h1,h2,h3,h4,h5,h6{margin:1.5em 0 .65em;line-height:1.4}img,iframe{max-width:100%}img{height:auto}',
height: 650,
placeholder: String(bootstrap.limits?.contentPlaceholder || ''),
setup: (editor: any) => {
editorRef.current = editor;
editor.on('init', () => {
editorReadyRef.current = true;
setEditorReady(true);
if (pendingRestoreContentRef.current !== null) {
editor.setContent(pendingRestoreContentRef.current);
pendingRestoreContentRef.current = null;
}
updateHistoryAvailability(editor);
updateFormatValue(editor);
});
editor.on('NodeChange', () => updateFormatValue(editor));
editor.on('input change Undo Redo AddUndo', () => {
updateContent(editor.getContent());
updateHistoryAvailability(editor);
});
editor.on('keydown', (event: any) => {
if ((event.metaKey || event.ctrlKey) && event.keyCode === 83) {
event.preventDefault();
saveActionRef.current();
}
});
},
},
};
wpEditor.initialize(EDITOR_ID, editorSettings);
return () => {
wpEditor.remove?.(EDITOR_ID);
editorReadyRef.current = false;
editorRef.current = null;
};
}, [updateContent]);
const restore = useCallback(() => {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (typeof parsed.title === 'string') {
patchDraft({ title: parsed.title });
}
const patch: Partial<Draft> = {};
if (typeof parsed.title === 'string') patch.title = parsed.title;
if (typeof parsed.content === 'string') patch.content = parsed.content;
patchDraft(patch);
if (typeof parsed.content === 'string') {
onChangeBlocks(parse(parsed.content));
if (editorReadyRef.current) {
editorRef.current?.setContent(parsed.content);
} else {
pendingRestoreContentRef.current = parsed.content;
}
}
}
} catch (error) {
// 快照损坏时忽略,不影响服务端内容。
}
setRecovery(null);
}, [patchDraft]);
const rememberSelection = useCallback(() => {
const editor = editorRef.current;
if (editor && !editor.removed) {
editorBookmarkRef.current = editor.selection.getBookmark(2, true);
}
}, []);
const selectedBlockCount = useSelect(
(select: any) => select('core/block-editor').getBlockCount(),
[]
);
const insertGroups = useMemo(() => buildInsertGroups(capabilities), [capabilities]);
const insertBlock = useCallback(
(item: InsertItem) => {
setInsertOpen(false);
dirtyRef.current = true;
const current = [...blocksRef.current];
let next: any[];
// 空文档时直接替换初始的空白段落,避免留下一个空行。
if (current.length === 1 && current[0].name === 'core/paragraph' && !current[0].attributes?.content) {
next = [createBlock(item.name, item.attributes || {})];
} else {
next = [...current, createBlock(item.name, item.attributes || {})];
const focusEditor = useCallback(() => {
const editor = editorRef.current;
if (!editor || editor.removed) {
return null;
}
editor.focus();
if (editorBookmarkRef.current) {
try {
editor.selection.moveToBookmark(editorBookmarkRef.current);
} catch (error) {
// TinyMCE discards invalid bookmarks after content replacement.
}
blocksRef.current = next;
historyRef.current.record({
blocks: next,
title: draftRef.current.title,
excerpt: draftRef.current.excerpt,
});
setBlocks(next);
},
[]
);
editorBookmarkRef.current = null;
}
return editor;
}, []);
const command = useCallback((name: string, value?: string) => {
const editor = focusEditor();
if (!editor) {
return;
}
if (name === 'undo') {
editor.undoManager.undo();
} else if (name === 'redo') {
editor.undoManager.redo();
} else if (name === 'format') {
if (value === 'p') {
editor.execCommand('FormatBlock', false, 'p');
} else {
editor.formatter.toggle(value || 'h2');
}
updateFormatValue(editor);
} else {
editor.execCommand(name);
}
editor.nodeChanged();
}, [focusEditor]);
const openLink = useCallback(() => {
const editor = focusEditor();
if (!editor) {
return;
}
const wpLink = (window as any).wpLink;
if (wpLink && typeof wpLink.open === 'function') {
wpLink.open(editor.id);
return;
}
const url = window.prompt(__('输入链接地址', 'jio'), 'https://');
const safeUrl = url ? safeHttpUrl(url) : '';
if (safeUrl) {
editor.execCommand('mceInsertLink', false, { href: safeUrl });
}
}, [focusEditor]);
const openMedia = useCallback((type: 'image' | 'video') => {
const editor = editorRef.current;
const media = (window as any).JioSubmitMedia;
if (!editor || !media || typeof media.open !== 'function') {
setNotice({ tone: 'error', text: __('媒体工具尚未加载,请刷新页面后重试。', 'jio') });
return;
}
rememberSelection();
media.open({
type,
multiple: type === 'image' ? undefined : false,
onSelect: (selected: any) => {
const items = Array.isArray(selected) ? selected : [selected];
const target = focusEditor();
if (!target) return;
const markup = items.map((item) => {
const url = safeHttpUrl(item?.large_url || item?.url);
if (!url) return '';
const id = Number(item.attachment_id || item.id || 0);
const idAttribute = id > 0 ? ` data-edit-file-id="${id}"` : '';
if (type === 'image') {
const fullUrl = safeHttpUrl(item?.url || url);
const fullAttribute = fullUrl ? ` data-full-url="${escapeAttribute(fullUrl)}"` : '';
return `<p><img src="${escapeAttribute(url)}"${idAttribute} alt="${escapeAttribute(String(item.name || item.title || ''))}"${fullAttribute}></p>`;
}
return `<div contenteditable="false" data-video-url="${escapeAttribute(url)}" data-video-name="${escapeAttribute(String(item.filename || item.name || url))}"${idAttribute} class="new-dplayer post-dplayer dplayer"></div><p></p>`;
}).join('');
if (markup) {
target.insertContent(markup);
target.nodeChanged();
updateContent(target.getContent());
updateHistoryAvailability(target);
}
},
onEmbed: (html: string) => {
const target = focusEditor();
if (target) {
target.insertContent(`${html}<p></p>`);
target.nodeChanged();
updateContent(target.getContent());
updateHistoryAvailability(target);
}
},
});
}, [focusEditor, rememberSelection]);
return (
<div className="jio-creator" data-status={bootstrap.status || ''} data-blocks={selectedBlockCount}>
<div className="jio-creator" data-status={bootstrap.status || ''} data-editor-ready={editorReady ? 'true' : 'false'}>
<header className="jio-creator__bar">
<div className="jio-creator__bar-left">
<a className="jio-creator__back" href={bootstrap.urls?.center || '/'} aria-label={__('返回作者中心', 'jio')}>
@@ -444,24 +565,22 @@ function Studio(): JSX.Element {
</span>
</div>
<div className="jio-creator__bar-center">
<span className="jio-creator__doc-title">
{draft.title.trim() || __('无标题文章', 'jio')}
</span>
<EditorToolbar
ready={editorReady}
formatValue={formatValue}
onFormatChange={setFormatValue}
canUndo={historyAvailability.canUndo}
canRedo={historyAvailability.canRedo}
capabilities={capabilities}
onCommand={command}
onRememberSelection={rememberSelection}
onOpenLink={openLink}
onOpenMedia={openMedia}
/>
<div className="jio-creator__bar-actions jio-creator__toolbar-extension" data-toolbar-extension aria-label={__('工具扩展区', 'jio')}>
<SaveState value={saveState} />
</div>
<div className="jio-creator__bar-actions">
<div className="jio-creator__history" role="group" aria-label={__('编辑历史', 'jio')}>
<button type="button" className="jio-creator__icon-btn" onClick={() => history.undo()} disabled={!history.canUndo} aria-label={__('撤销', 'jio')} title={__('撤销', 'jio')}>
<i className="fa fas fa-undo" aria-hidden="true" />
</button>
<button type="button" className="jio-creator__icon-btn" onClick={() => history.redo()} disabled={!history.canRedo} aria-label={__('重做', 'jio')} title={__('重做', 'jio')}>
<i className="fa fas fa-redo" aria-hidden="true" />
</button>
</div>
<button type="button" className="jio-creator__ghost jio-creator__save-draft" onClick={() => void save(false)}>
{__('保存草稿', 'jio')}
<button type="button" className="jio-creator__save-draft" onClick={() => void save(false)} disabled={!editorReady}>
<i className="fa fas fa-save" aria-hidden="true" /><span>{__('保存草稿', 'jio')}</span>
</button>
<button type="button" className="jio-creator__ghost jio-creator__preview" onClick={() => void preview()}>
{__('预览', 'jio')}
@@ -490,9 +609,7 @@ function Studio(): JSX.Element {
)}
{isPublishedEdit && (
<p className="jio-creator__hint">
{__('这篇改稿不会立即覆盖线上文章;审核通过后才会替换。', 'jio')}
</p>
<p className="jio-creator__hint">{__('这篇改稿不会立即覆盖线上文章;审核通过后才会替换。', 'jio')}</p>
)}
<main className="jio-creator__canvas">
@@ -526,51 +643,16 @@ function Studio(): JSX.Element {
<AuthorRow author={bootstrap.author || {}} />
<div className="jio-creator__editor">
<BlockEditorProvider
value={blocks}
onInput={onChangeBlocks}
onChange={onChangeBlocks}
settings={{
hasFixedToolbar: false,
supportsLayout: false,
mediaUpload: (window as any).jioCreatorMediaUpload,
}}
>
<BlockTools>
<BlockList />
</BlockTools>
</BlockEditorProvider>
{/* 公众号式常驻插入按钮:始终停在正文末尾,点击打开内容面板。 */}
<button
type="button"
className="jio-creator__insert"
onClick={() => setInsertOpen(true)}
aria-haspopup="dialog"
aria-expanded={insertOpen}
>
<i className="fa fas fa-plus" aria-hidden="true" />
<span>{__('添加内容', 'jio')}</span>
</button>
<div className="jio-creator__editor" data-editor-ready={editorReady ? 'true' : 'false'}>
<textarea id={EDITOR_ID} defaultValue={draft.content} onChange={(event) => updateContent(event.target.value)} aria-label={__('文章正文', 'jio')} />
</div>
<footer className="jio-creator__footer">
<span>{__('字数', 'jio')}:{charCount}</span>
<span>
{__('约', 'jio')} {Math.max(1, Math.round(charCount / 400))} {__('分钟阅读', 'jio')}
</span>
<span>{__('约', 'jio')} {Math.max(1, Math.round(charCount / 400))} {__('分钟阅读', 'jio')}</span>
</footer>
</main>
{insertOpen && (
<InsertPanel
groups={insertGroups}
onInsert={insertBlock}
onClose={() => setInsertOpen(false)}
/>
)}
{drawerOpen && (
<PublishDrawer
draft={draft}
@@ -601,9 +683,6 @@ function mount() {
if (!target) {
return;
}
installApiFetchNonce(nonce);
installMediaUploadBridge(bootstrap, nonce);
installImageWidthPresets();
target.classList.add('is-ready');
createRoot(target).render(<Studio />);
}
@@ -1,122 +0,0 @@
/**
* 悬浮插入面板(公众号式)。
*
* 正文底部常驻一个「+」,点开是分组网格:常用文本、媒体、样式。
* 桌面端为居中浮层,移动端贴底弹出;键盘可操作,Esc 关闭。
*
* @package Jio
*/
import { useEffect, useMemo, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
export type InsertItem = {
name: string;
title: string;
icon: string;
attributes?: Record<string, any>;
};
export type InsertGroup = {
label: string;
items: InsertItem[];
};
type Props = {
groups: InsertGroup[];
onInsert: (item: InsertItem) => void;
onClose: () => void;
};
export function InsertPanel({ groups, onInsert, onClose }: Props) {
const panelRef = useRef<HTMLDivElement | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const items = useMemo(() => groups.flatMap((group) => group.items), [groups]);
useEffect(() => {
closeRef.current?.focus();
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (!['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp'].includes(event.key)) {
return;
}
// 在网格内用方向键移动焦点,避免必须用鼠标。
const buttons = Array.from(
panelRef.current?.querySelectorAll<HTMLButtonElement>('.jio-insert-item') || []
);
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (index === -1) {
return;
}
event.preventDefault();
const columns = window.innerWidth <= 767 ? 4 : 6;
const delta =
event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : event.key === 'ArrowDown' ? columns : -columns;
const next = Math.min(buttons.length - 1, Math.max(0, index + delta));
buttons[next]?.focus();
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [onClose]);
return (
<div className="jio-insert" role="dialog" aria-modal="true" aria-label={__('添加内容', 'jio')}>
<button
type="button"
className="jio-insert__backdrop"
aria-label={__('关闭', 'jio')}
onClick={onClose}
/>
<div className="jio-insert__panel" ref={panelRef}>
<header className="jio-insert__head">
<h2>{__('添加内容', 'jio')}</h2>
<button
type="button"
className="jio-insert__close"
onClick={onClose}
ref={closeRef}
aria-label={__('关闭', 'jio')}
>
<i className="fa fas fa-times" aria-hidden="true" />
</button>
</header>
<div className="jio-insert__body">
{groups.map((group) =>
group.items.length === 0 ? null : (
<section className="jio-insert__group" key={group.label}>
<h3>{group.label}</h3>
<div className="jio-insert__grid">
{group.items.map((item) => (
<button
key={item.name}
type="button"
className="jio-insert-item"
onClick={() => onInsert(item)}
>
<span className="jio-insert-item__icon" aria-hidden="true">
<i className={item.icon} />
</span>
<span className="jio-insert-item__label">{item.title}</span>
</button>
))}
</div>
</section>
)
)}
{items.length === 0 && (
<p className="jio-creator-muted">{__('当前账号没有可插入的内容类型。', 'jio')}</p>
)}
</div>
</div>
</div>
);
}
@@ -1,33 +0,0 @@
/**
* 给 @wordpress/api-fetch 注入 REST nonce。
*
* WordPress 仅在后台为 wp-api 注入 wpApiSettings(含 nonce),前台工作台里
* Gutenberg 的 core-data 请求(如 /wp/v2/users/me)因此全部 403。这里统一挂一个
* nonce 中间件,让区块编辑器的数据请求与 Jio REST 使用同一次页面加载的 nonce。
*
* @package Jio
*/
import apiFetch from '@wordpress/api-fetch';
export function installApiFetchNonce(nonce: string): void {
if (!nonce || typeof apiFetch.use !== 'function') {
return;
}
if ((window as any).__jioApiFetchNonceInstalled) {
return;
}
(window as any).__jioApiFetchNonceInstalled = true;
// createNonceMiddleware 在收到 rest_cookie_invalid_nonce 时会带着 nonce 重试,
// 比在每次请求上手工加 header 更稳妥。
const factory = (apiFetch as any).createNonceMiddleware;
if (typeof factory === 'function') {
apiFetch.use(factory(nonce));
return;
}
apiFetch.use((options: any, next: any) => {
options.headers = { ...(options.headers || {}), 'X-WP-Nonce': nonce };
return next(options);
});
}
@@ -1,68 +0,0 @@
/**
* 编辑器媒体上传桥。
*
* Gutenberg 核心图片区块通过 block-editor settings.mediaUpload 上传;这里把它接到
* Jio 前台上传接口,保留 Jio 的能力校验、文件校验与待认领生命周期,而不是让区块
* 直接走 /wp/v2/media。
*
* @package Jio
*/
export function installMediaUploadBridge(bootstrap: Record<string, any>, nonce: string) {
const url = bootstrap?.blocks?.mediaUploadUrl;
if (!url) {
return;
}
const maxBytes = Number(bootstrap?.media?.maxBytes || 0);
const allowed = bootstrap?.capabilities?.canUploadImage !== false;
window['jioCreatorMediaUpload'] = (payload: any) => {
const { file, onError, onSuccess } = payload || {};
if (!file || typeof file === 'object' && !(file instanceof File)) {
onError?.('无效的文件');
return;
}
if (!allowed) {
onError?.('当前账号不能上传图片');
return;
}
if (maxBytes > 0 && file.size > maxBytes) {
onError?.('图片大小超过限制');
return;
}
const body = new FormData();
body.append('file', file);
fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'X-WP-Nonce': nonce },
body,
})
.then((response) =>
response.json().then((data) => {
if (!response.ok) {
throw new Error((data && data.message) || '上传失败');
}
return data;
})
)
.then((data) => {
onSuccess?.({
id: Number(data.attachment_id || data.id || 0),
url: String(data.url || ''),
alt: '',
caption: '',
title: String(data.title || data.name || ''),
width: Number(data.width || 0),
height: Number(data.height || 0),
sizes: data.sizes || {},
mime: String(data.mime || ''),
});
})
.catch((error) => {
onError?.(error?.message || '上传失败');
});
};
}
@@ -0,0 +1,424 @@
@use './tokens' as *;
.jio-creator-drawer {
position: fixed;
z-index: 500;
inset: 0;
display: flex;
justify-content: flex-end;
}
.jio-creator-drawer__backdrop {
position: absolute;
inset: 0;
border: 0;
background: rgba(23, 34, 31, 0.32);
cursor: default;
}
.jio-creator-drawer__panel {
position: relative;
display: flex;
width: min(460px, 100%);
height: 100%;
flex-direction: column;
background: #fff;
box-shadow: -20px 0 60px rgba(20, 32, 28, 0.16);
}
.jio-creator-drawer__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid $line;
h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
}
}
.jio-creator-drawer__close {
border: 0;
background: transparent;
color: $ink-soft;
cursor: pointer;
}
.jio-creator-drawer__body {
flex: 1;
overflow-y: auto;
padding: 20px 24px 32px;
}
.jio-creator-drawer__note {
margin: 0 0 20px;
padding: 12px 14px;
border-left: 3px solid $accent;
background: #f2f7f4;
color: $ink-soft;
font-size: 13px;
line-height: 1.6;
}
.jio-creator-drawer__foot {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 24px calc(16px + env(safe-area-inset-bottom));
border-top: 1px solid $line;
background: #fff;
.jio-creator-primary {
flex: 1;
}
}
.jio-creator-drawer__actions {
display: flex;
align-items: center;
gap: 12px;
}
.jio-creator-drawer__captcha {
min-height: 40px;
}
.jio-creator-field {
margin-bottom: 26px;
> h3 {
margin: 0 0 10px;
font-size: 13px;
font-weight: 600;
color: $ink;
}
}
.jio-creator-muted {
margin: 0;
color: $ink-soft;
font-size: 12px;
line-height: 1.6;
}
.jio-creator-error {
margin: 8px 0 0;
color: $warn;
font-size: 12px;
}
.jio-creator-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.jio-creator-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 12px;
border: 1px solid $line;
border-radius: 999px;
color: $ink-soft;
font-size: 13px;
cursor: pointer;
input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
}
&.is-active {
border-color: $accent;
background: #eef5f2;
color: $accent-strong;
}
button {
border: 0;
background: transparent;
cursor: pointer;
color: inherit;
}
}
.jio-creator-inline {
display: flex;
gap: 8px;
margin-top: 10px;
input[type="text"],
input:not([type]) {
flex: 1;
min-width: 0;
}
input[type="text"],
input:not([type]) {
height: 36px;
padding: 0 12px;
border: 1px solid $line;
border-radius: 8px;
}
label {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
}
}
.jio-creator-switch {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
h3 {
margin: 0 0 4px;
font-size: 13px;
}
}
.jio-creator-toggle {
position: relative;
flex: 0 0 auto;
input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
span {
display: block;
width: 42px;
height: 24px;
border-radius: 999px;
background: #d6dfda;
transition: background 0.18s ease;
&::after {
display: block;
width: 18px;
height: 18px;
margin: 3px;
border-radius: 50%;
background: #fff;
content: "";
transition: transform 0.18s ease;
}
}
input:checked + span {
background: $accent;
}
input:checked + span::after {
transform: translateX(18px);
}
input:focus-visible + span {
outline: 2px solid $accent;
outline-offset: 2px;
}
}
.jio-creator-pay {
margin-top: 16px;
padding: 16px;
border: 1px solid $line;
border-radius: 10px;
background: #fcfdfc;
}
.jio-creator-input {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
font-size: 13px;
input {
width: 140px;
height: 36px;
padding: 0 12px;
border: 1px solid $line;
border-radius: 8px;
text-align: right;
}
}
.jio-creator-cover {
position: relative;
overflow: hidden;
border-radius: 10px;
img {
display: block;
width: 100%;
aspect-ratio: 2 / 1;
object-fit: cover;
}
button {
position: absolute;
top: 8px;
right: 8px;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
}
}
.jio-creator-cover-empty {
display: flex;
min-height: 120px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 8px;
border: 1px dashed $line;
border-radius: 10px;
background: #fafcfb;
color: $ink-soft;
font-size: 13px;
cursor: pointer;
input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
}
.jio-creator-attachments {
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid $line;
}
.jio-creator-attachments__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
h4 {
margin: 0;
font-size: 13px;
}
}
.jio-creator-upload {
display: inline-flex;
align-items: center;
min-height: 32px;
padding: 0 12px;
border: 1px solid $line;
border-radius: 8px;
color: $accent-strong;
font-size: 12px;
cursor: pointer;
input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
}
.jio-creator-files {
margin: 12px 0 0;
padding: 0;
list-style: none;
li {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #f0f4f1;
font-size: 13px;
}
button {
margin-left: auto;
border: 0;
background: transparent;
color: $ink-soft;
cursor: pointer;
}
}
.jio-creator-files__name {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
i {
color: $accent;
}
}
.jio-creator-reuse {
margin-top: 14px;
font-size: 13px;
summary {
cursor: pointer;
color: $ink-soft;
}
ul {
margin: 10px 0 0;
padding: 0;
list-style: none;
}
li {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
}
button {
margin-left: auto;
border: 1px solid $line;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
}
.jio-creator-link {
border: 0;
background: transparent;
color: var(--jc-ink-soft, #{$ink-soft});
font-size: 13px;
text-decoration: underline;
cursor: pointer;
}
@@ -0,0 +1,7 @@
$ink: #243330;
$ink-soft: #5c6b66;
$paper: #fafbfa;
$line: #e2e9e5;
$accent: #267665;
$accent-strong: #1d5f52;
$warn: #b4573f;
File diff suppressed because it is too large. Load diff
@@ -1,65 +0,0 @@
/**
* 插入面板的可插入条目。
*
* 只列出当前账号真正能用的类型:图片/视频依赖上传能力,付费片段依赖付费能力。
* 区块名用 WordPress 核心与 Jio 自有区块的真实注册名,避免插入时报
* "Block type is not registered"。
*
* @package Jio
*/
import { __ } from '@wordpress/i18n';
import type { InsertGroup } from '../panels/insert-panel';
type Capabilities = {
canUploadImage?: boolean;
canUploadVideo?: boolean;
canPay?: boolean;
canEmbed?: boolean;
};
export function buildInsertGroups(capabilities: Capabilities): InsertGroup[] {
const text: InsertGroup = {
label: __('文字', 'jio'),
items: [
{ name: 'core/paragraph', title: __('正文', 'jio'), icon: 'fa fas fa-align-left' },
{ name: 'core/heading', title: __('标题', 'jio'), icon: 'fa fas fa-heading', attributes: { level: 2 } },
{ name: 'core/list', title: __('列表', 'jio'), icon: 'fa fas fa-list-ul' },
{ name: 'core/quote', title: __('引用', 'jio'), icon: 'fa fas fa-quote-left' },
],
};
const media: InsertGroup = {
label: __('媒体', 'jio'),
items: [],
};
if (capabilities.canUploadImage) {
media.items.push({ name: 'core/image', title: __('图片', 'jio'), icon: 'fa far fa-image' });
}
if (capabilities.canUploadVideo) {
media.items.push({ name: 'core/video', title: __('视频', 'jio'), icon: 'fa fas fa-video' });
}
if (capabilities.canEmbed) {
media.items.push({ name: 'core/embed', title: __('嵌入', 'jio'), icon: 'fa fas fa-code' });
}
const blocks: InsertGroup = {
label: __('组件', 'jio'),
items: [
{ name: 'core/code', title: __('代码', 'jio'), icon: 'fa fas fa-terminal' },
{ name: 'core/separator', title: __('分隔线', 'jio'), icon: 'fa fas fa-minus' },
{ name: 'core/table', title: __('表格', 'jio'), icon: 'fa fas fa-table' },
],
};
if (capabilities.canPay) {
blocks.items.push({
name: 'jio/paid-fragment',
title: __('付费内容', 'jio'),
icon: 'fa fas fa-lock',
});
}
return [text, media, blocks];
}
+2 -9
View File
@@ -214,7 +214,7 @@ final class CreatorRest {
$data = [
'posts_id' => $post_id,
'post_title' => $payload['title'],
'post_content' => $this->block_content($payload['content']),
'post_content' => $payload['content'],
'excerpt' => $payload['excerpt'],
'category' => $payload['categories'],
'tags' => implode(',', array_map('strval', $payload['tags'])),
@@ -298,7 +298,7 @@ final class CreatorRest {
$data = [
'posts_id' => $post_id,
'post_title' => $payload['title'],
'post_content' => $this->block_content($payload['content']),
'post_content' => $payload['content'],
'excerpt' => $payload['excerpt'],
'category' => $payload['categories'],
'tags' => implode(',', array_map('strval', $payload['tags'])),
@@ -507,13 +507,6 @@ final class CreatorRest {
PrivateAssets::claim_many($keys, $post_id, $user_id);
}
/**
* 客户端提交的是 JSON 区块,落到 post_content 前统一交给 wp_kses。
*/
private function block_content(string $content): string {
return $content;
}
/**
* @param array<string, mixed> $settings
* @return array<string, mixed>
+126 -43
View File
@@ -2,8 +2,7 @@
/**
* Jio Creator Studio 前台创作工作台。
*
* 与默认 WordPress 后台编辑器隔离的独立入口:只承载前台写作体验所需的静态资源、
* 初始上下文与挂载点。写作界面是 Gutenberg 内核 + 自定义 React 工作台。
* 与 WordPress 后台编辑器隔离的前台写作工作台;正文由 TinyMCE 编辑,React 只负责操作栏、发布配置与保存编排。
*
* @package Jio
*/
@@ -53,25 +52,57 @@ final class CreatorStudio {
$uri = get_template_directory_uri() . '/' . self::BUILD_DIR;
$meta = include $dir . '/index.asset.php';
$deps = is_array($meta['dependencies'] ?? null) ? $meta['dependencies'] : [];
// core/image 等块的行内 RichTextToolbarButton 由 WordPress 的格式库注册;
// BlockTools 负责浮层插槽,但必须显式入队前台 wp-format-library。
$deps[] = 'wp-format-library';
// Creator Studio uses the classic WordPress TinyMCE runtime, not Gutenberg.
wp_enqueue_editor();
$deps[] = 'editor';
$deps = array_values(array_unique($deps));
$ver = (string) ($meta['version'] ?? AKSRT_VERSION);
// 区块样式是前台编辑体验的一部分;编辑器专属样式只在已注册时追加,
// 避免在没有注册该 handle 的前台请求上产生悬空依赖。
$style_deps = ['jio-public'];
foreach (['wp-block-library', 'wp-block-editor', 'wp-components', 'wp-format-library'] as $handle) {
if (wp_style_is($handle, 'registered')) {
$style_deps[] = $handle;
}
$plugin_path = get_template_directory() . '/assets/js/front-submit-editor.js';
$plugin_url = '';
if (file_exists($plugin_path)) {
$plugin_url = add_query_arg(
'ver',
(string) filemtime($plugin_path),
get_template_directory_uri() . '/assets/js/front-submit-editor.js'
);
}
$content_css_path = get_template_directory() . '/assets/css/frontend/pages/submit-editor.css';
$content_css_url = get_template_directory_uri() . '/assets/css/frontend/pages/submit-editor.css';
if (file_exists($content_css_path)) {
$content_css_url = add_query_arg('ver', (string) filemtime($content_css_path), $content_css_url);
}
$payload = $this->bootstrap();
$payload['creator']['contentCss'] = $content_css_url;
$tinymce_data = $this->tinymce_payload($payload);
if (file_exists($plugin_path)) {
wp_enqueue_script('jio-creator-tinymce-tools', $plugin_url, ['editor'], (string) filemtime($plugin_path), true);
wp_add_inline_script(
'jio-creator-tinymce-tools',
'window.jioSubmitData = ' . wp_json_encode($tinymce_data) . ';',
'before'
);
$deps[] = 'jio-creator-tinymce-tools';
}
$deps = array_values(array_unique($deps));
$media_css = get_template_directory() . '/assets/css/frontend/pages/submit.css';
if (file_exists($media_css)) {
wp_enqueue_style(
'jio-creator-media',
get_template_directory_uri() . '/assets/css/frontend/pages/submit.css',
['jio-public'],
(string) filemtime($media_css)
);
}
wp_enqueue_style(
'jio-creator-studio',
$uri . '/index.css',
$style_deps,
['jio-public', 'jio-creator-media'],
$ver
);
@@ -83,38 +114,93 @@ final class CreatorStudio {
true
);
// Jio 自有区块:编辑器里已注册的同一份定义,前台工作台复用,
// 避免维护第二套 Jio Blocks。前台没有注册 wp-media-utils,
// 该脚本只依赖 block-editor 的 MediaUpload,不需要它。
$blocks_js = get_template_directory() . '/assets/js/gutenberg-blocks.js';
if (file_exists($blocks_js) && !wp_script_is('jio-gutenberg-blocks', 'enqueued')) {
wp_enqueue_script(
'jio-gutenberg-blocks',
get_template_directory_uri() . '/assets/js/gutenberg-blocks.js',
['wp-blocks', 'wp-element', 'wp-components', 'wp-block-editor', 'wp-hooks', 'wp-data'],
(string) filemtime($blocks_js),
true
);
}
// 区块前台样式(jio/carousel 等自定义区块的输出样式)。
$blocks_css = get_template_directory() . '/assets/css/gutenberg-blocks.css';
if (file_exists($blocks_css)) {
wp_enqueue_style(
'jio-gutenberg-blocks-front',
get_template_directory_uri() . '/assets/css/gutenberg-blocks.css',
[],
(string) filemtime($blocks_css)
);
}
wp_add_inline_script(
'jio-creator-studio',
'window.jioCreatorStudio = ' . wp_json_encode($this->bootstrap()) . ';',
'window.jioCreatorStudio = ' . wp_json_encode($payload) . ';',
'before'
);
}
private function tinymce_payload(array $payload): array {
$capabilities = is_array($payload['capabilities'] ?? null) ? $payload['capabilities'] : [];
$base = rest_url(CreatorRest::NS);
$system_tools = class_exists('Jio\\Settings\\ExtensionRuntime')
? \Jio\Settings\ExtensionRuntime::get_system_tools_settings()
: [];
return [
'restUrl' => esc_url_raw($base),
'restNonce' => wp_create_nonce('wp_rest'),
'postId' => (int) ($payload['sourceId'] ?? 0),
'media' => [
'image' => ['url' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/media/image'))],
'video' => ['url' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/media/video'))],
'file' => ['url' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/media/file'))],
],
'uploads' => [
'image' => !empty($capabilities['canUploadImage']) ? [
'url' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/upload/image')),
'maxSize' => FrontSubmit::upload_limit_bytes('image'),
] : null,
'video' => !empty($capabilities['canUploadVideo']) ? [
'url' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/upload/video')),
'maxSize' => FrontSubmit::upload_limit_bytes('video'),
] : null,
'resource' => !empty($capabilities['canUploadFile']) ? [
'url' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/upload/resource')),
'maxSize' => FrontSubmit::upload_limit_bytes('resource'),
] : null,
],
'editor' => [
'canUploadImage' => !empty($capabilities['canUploadImage']),
'canUploadVideo' => !empty($capabilities['canUploadVideo']),
'canEmbedVideo' => !empty($capabilities['canEmbed']),
'canUploadFile' => !empty($capabilities['canUploadFile']),
'canHide' => !empty($capabilities['canHide']),
'canPay' => !empty($capabilities['canPay']),
'floatToolbar' => false,
],
'imageUploadMultiple' => class_exists('Jio\\Attachment\\UploadLimits')
? \Jio\Attachment\UploadLimits::image_batch_limit()
: 6,
'splitUpload' => [
'enabled' => !empty($system_tools['split_upload_s']),
'minSize' => (int) ($system_tools['split_minimum_size'] ?? 20),
],
'i18n' => [
'insertImage' => __('插入图片', 'jio'),
'insertVideo' => __('插入视频', 'jio'),
'uploadUnavailable' => __('当前账号不能上传此类文件', 'jio'),
'fileTooLarge' => __('文件大小超过限制', 'jio'),
'invalidFile' => __('请选择有效文件', 'jio'),
'error' => __('操作失败,请重试', 'jio'),
'close' => __('关闭', 'jio'),
'mineImage' => __('我的图片', 'jio'),
'mineVideo' => __('我的视频', 'jio'),
'uploadImage' => __('上传图片', 'jio'),
'uploadVideo' => __('上传视频', 'jio'),
'externalImage' => __('外链图片', 'jio'),
'externalVideo' => __('外链视频', 'jio'),
'selectImages' => __('选择或拖入图片', 'jio'),
'selectVideo' => __('选择或拖入视频', 'jio'),
'confirmUpload' => __('确认上传', 'jio'),
'confirmInsert' => __('确认插入', 'jio'),
'loading' => __('加载中…', 'jio'),
'emptyMedia' => __('暂无媒体文件', 'jio'),
'searchMedia' => __('搜索媒体(至少 2 个字符)', 'jio'),
'searchMin' => __('请输入至少 2 个字符进行搜索', 'jio'),
'uploading' => __('正在上传…', 'jio'),
'uploaded' => __('上传成功', 'jio'),
'invalidMediaUrl' => __('请填写有效地址', 'jio'),
'externalImageHint' => __('每行填写一个图片地址:', 'jio'),
'externalVideoHint' => __('请填写视频文件地址:', 'jio'),
'embedVideo' => __('嵌入视频', 'jio'),
'embedVideoHint' => __('请输入嵌入地址或者直接粘贴 iframe 嵌入代码:', 'jio'),
'confirmEmbed' => __('确认嵌入', 'jio'),
],
];
}
/**
* 渲染给前端的启动上下文。
*
@@ -139,10 +225,7 @@ final class CreatorStudio {
'privateUrl'=> esc_url_raw(rest_url(CreatorRest::NS . '/creator/private-upload')),
'bootstrapUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/creator/bootstrap')),
],
'blocks' => [
'mediaUploadUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/upload/image')),
'mediaListUrl' => esc_url_raw(rest_url(CreatorRest::NS . '/front-submit/media/image')),
],
]);
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ if (!empty($_GET['edit']) && empty($ctx['edit_post'])) {
exit;
}
// Jio Creator Studio:登录用户 + 构建产物存在时使用沉浸式 Gutenberg 工作台。
// Jio Creator Studio:登录用户 + 构建产物存在时使用沉浸式 TinyMCE 工作台。
// 否则保留原有 TinyMCE 表单作为降级路径(游客投稿、未构建、无权限)。
$studio = class_exists('\Jio\Core\CreatorStudio') ? \Jio\Core\CreatorStudio::get_instance() : null;
$use_studio = $studio
@@ -25,6 +25,7 @@ const pageSubmit = read('templates/pages/page-submit.php');
const saveTrait = read('src/Core/FrontSubmitSave.php');
const domainModel = read('src/Core/DomainModel.php');
const appSource = read('assets/creator-studio/src/index.tsx');
const editorToolbar = read('assets/creator-studio/src/components/editor-toolbar.tsx');
const drawer = read('assets/creator-studio/src/panels/publish-drawer.tsx');
const api = read('assets/creator-studio/src/services/api.ts');
@@ -117,13 +118,16 @@ test('studio is mounted only on the submit template with a fallback', () => {
assert.match(studio, /wp_add_inline_script/);
});
test('studio app keeps autosave, recovery, and the publish drawer wired', () => {
test('studio app uses TinyMCE while preserving autosave, recovery, and publishing', () => {
assert.match(appSource, /useAutosave/);
assert.match(appSource, /useLocalRecovery/);
assert.match(appSource, /PublishDrawer/);
assert.match(appSource, /BlockEditorProvider/);
assert.match(appSource, /registerCoreBlocks/);
assert.match(appSource, /installMediaUploadBridge/);
assert.match(appSource, /wpEditor\.initialize\(EDITOR_ID/);
assert.match(appSource, /editor\.getContent\(\)/);
assert.match(appSource, /editorRef\.current\?\.setContent/);
assert.doesNotMatch(appSource, /BlockEditorProvider|registerCoreBlocks|BlockList|registerCoreBlocks/);
assert.doesNotMatch(creatorRest, /block_content\(/);
assert.match(creatorRest, /'post_content'\s*=>\s*\$payload\['content'\]/);
assert.match(api, /'X-WP-Nonce'/);
assert.match(drawer, /canPay/);
assert.match(drawer, /canPayDownload/);
@@ -132,23 +136,32 @@ test('studio app keeps autosave, recovery, and the publish drawer wired', () =>
assert.match(drawer, /立即发布/);
});
test('frontend enqueues the WordPress format library for inline RichText tools', () => {
test('studio enqueues the WordPress editor and TinyMCE media tools', () => {
const enqueue = read('src/Core/CreatorStudio.php');
assert.match(enqueue, /\$deps\[\]\s*=\s*'wp-format-library'/);
assert.match(enqueue, /wp-format-library/);
assert.match(read('assets/creator-studio/src/index.tsx'), /<BlockTools>/);
assert.match(read('assets/creator-studio/src/styles/studio.scss'), /block-editor-rich-text__inline-format-toolbar/);
assert.match(enqueue, /wp_enqueue_editor\(\)/);
assert.match(enqueue, /\$deps\[\]\s*=\s*'editor'/);
assert.match(enqueue, /jio-creator-tinymce-tools/);
assert.match(enqueue, /tinymce_payload/);
assert.doesNotMatch(enqueue, /wp-format-library|gutenberg-blocks\.js/);
});
test('固定顶栏包含保存状态、撤销/重做、预览和发布动作', () => {
test('fixed top toolbar has TinyMCE formatting, media, and extension actions', () => {
assert.match(appSource, /className="jio-creator__bar"/);
assert.match(appSource, /history\.undo\(\)/);
assert.match(appSource, /history\.redo\(\)/);
assert.match(appSource, /undoManager\.undo\(\)/);
assert.match(appSource, /undoManager\.redo\(\)/);
assert.match(appSource, /editor\.formatter\.toggle\(value \|\| 'h2'\)/);
assert.match(editorToolbar, /value=\{formatValue\}/);
assert.match(editorToolbar, /canUndo/);
assert.match(editorToolbar, /canRedo/);
assert.match(editorToolbar, /onCommand\('Bold'\)/);
assert.match(editorToolbar, /onCommand\('Italic'\)/);
assert.match(editorToolbar, /onOpenMedia\('image'\)/);
assert.match(editorToolbar, /onOpenMedia\('video'\)/);
assert.match(editorToolbar, /onClick=\{onOpenLink\}/);
assert.match(appSource, /保存草稿/);
assert.match(appSource, /<SaveState value=\{saveState\}/);
assert.match(appSource, /draftRef\.current\.postId/);
assert.match(appSource, /useEditorHistory/);
assert.match(appSource, /event\.shiftKey \? redo\(\) : undo\(\)/);
assert.match(editorToolbar, /role="toolbar"/);
assert.match(read('assets/creator-studio/src/styles/studio.scss'), /\.jio-creator__bar\s*\{[^}]*position:\s*fixed/);
assert.match(read('assets/creator-studio/src/styles/studio.scss'), /\.jio-creator\s*\{[\s\S]*?padding-top:\s*var\(--jc-bar\)/);
});
@@ -176,26 +189,19 @@ test('沉浸式布局屏蔽主题头部/页脚与挂件区', () => {
assert.match(pageSubmit, /add_filter\('jio_render_fluid_widgets', '__return_false'\)/);
});
test('插入面板与封面条走真实区块名与服务端能力', () => {
const insertItems = read('assets/creator-studio/src/utils/insert-items.ts');
const insertPanel = read('assets/creator-studio/src/panels/insert-panel.tsx');
const cover = read('assets/creator-studio/src/components/cover-strip.tsx');
assert.match(appSource, /createBlock/);
assert.match(insertItems, /'core\/paragraph'/);
assert.match(insertItems, /'core\/heading'/);
assert.match(insertItems, /'core\/image'/);
assert.match(insertItems, /'jio\/paid-fragment'/);
// 没有能力的条目不能出现在面板里。
assert.match(insertItems, /if \(capabilities\.canUploadImage\)/);
assert.match(insertItems, /if \(capabilities\.canPay\)/);
assert.match(insertPanel, /role="dialog"/);
assert.match(insertPanel, /event\.key === 'Escape'/);
assert.match(cover, /添加封面/);
test('TinyMCE media buttons reuse Jio uploads and preserve bookmarks', () => {
assert.match(appSource, /JioSubmitMedia/);
assert.match(appSource, /editor\.selection\.getBookmark/);
assert.match(appSource, /moveToBookmark/);
assert.match(appSource, /data-edit-file-id/);
assert.match(appSource, /data-video-url/);
assert.match(read('src/Core/CreatorStudio.php'), /front-submit\/upload\/image/);
assert.match(read('assets/css/frontend/pages/submit.css'), /\.jio-editor-media\s*\{/);
assert.match(read('assets/creator-studio/src/components/cover-strip.tsx'), /添加封面/);
assert.match(appSource, /<CoverStrip/);
});
test('中文字数按字符统计而非按空格分词', () => {
assert.match(appSource, /characters_excluding_spaces/);
assert.doesNotMatch(appSource, /countWords\([^)]*'words'/);
test('Chinese character count strips markup and counts visible text', () => {
assert.match(appSource, /Array\.from\(plainText\(draft\.content\)\)\.length/);
assert.match(appSource, /container\.textContent/);
});