feat(creator-studio): portal dropdowns, hidden content, two-step block delete
Toolbar - New ToolMenu renders menus through a portal on body, so they are no longer clipped by the horizontally scrolling toolbar and no longer fall back to a native <select>. Triggers reuse .jio-creator__tool-btn geometry, fixing the heading button's mismatched height/size/position. - Heading / list / align / quote / hidden-content are all dropdowns. - Lists: unordered + ordered via InsertOrderedList/InsertUnorderedList. The TOC button is removed (it also used the wrong icon). - Adds left / center / right alignment. - Adds hidden content: comment / login / VIP / paid, wired to the existing [reply], [jio_hide mode="login"], [vip] and [payshow] shortcodes. Editor content - Quote blocks now match the live article: full .quote_q structure, four colour variables, and an SVG-mask quote mark that does not depend on the Font Awesome font being present inside the iframe. - Block math is centred on a transparent background instead of a full-width grey bar. - Code blocks are plain <pre><code>, identical to the article page; language and theme selection is gone and EnlighterJS handles highlighting globally. The classic submit editor's code modal follows the same shape. - Hidden content reuses the .tinymce-hide structure and shows a readable label while keeping the shortcode text in the DOM for saving. Block deletion - Two-step delete, WPS style: the first click only selects and highlights the block, the second click removes it. Clicking elsewhere or Esc cancels. Contracts updated and extended; 884 widget-system tests pass.
This commit is contained in:
1 parent
3e48fdf61e
commit
c5bdbc0457
16 files changed
+1090
-545
No files matched your search
@@ -1 +1 @@
|
||||
<?php return array('dependencies' => array('react', 'wp-element', 'wp-i18n'), 'version' => 'f64e8f776f1d82965258');
|
||||
<?php return array('dependencies' => array('react', 'wp-element', 'wp-i18n'), 'version' => 'c9b50dfd71946ad961a9');
|
||||
@@ -1 +1 @@
|
||||
.jio-page-submit{background:#f6f6f6}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:#2b2b2b;--jc-ink-soft:#7d8683;--jc-paper:#f6f6f6;--jc-line:#e6e9e8;--jc-accent:#267665;--jc-accent-strong:#1d5f52;--jc-warn:#b4573f;--jc-top:112px;--jc-status:52px;-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-bottom:var(--jc-status);padding-top:var(--jc-top)}.jio-creator *,.jio-creator :after,.jio-creator :before{box-sizing:border-box}.jio-creator button{color:inherit;font:inherit}.jio-creator__top{background:rgba(250,251,250,.98);box-shadow:0 2px 8px rgba(27,46,39,.035);left:0;position:fixed;right:0;top:0;z-index:300}.jio-creator__masthead{align-items:center;display:flex;gap:16px;height:56px;justify-content:space-between;padding:0 clamp(14px,2.4vw,30px)}.jio-creator__masthead-left{align-items:center;display:flex;gap:10px;min-width:0}.jio-creator__back{align-items:center;border-radius:7px;color:var(--jc-ink-soft);display:inline-flex;flex:0 0 auto;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__brand{align-items:center;color:inherit;display:inline-flex;min-width:0;text-decoration:none}.jio-creator__brand:focus-visible{outline:2px solid rgba(38,118,101,.35);outline-offset:4px}.jio-creator__brand-logo{display:block;height:30px;max-width:168px;-o-object-fit:contain;object-fit:contain;width:auto}.jio-creator__brand-text{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:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.jio-creator__me{align-items:center;border-radius:50%;display:inline-flex;flex:0 0 auto;justify-content:center}.jio-creator__me:focus-visible{outline:2px solid rgba(38,118,101,.5);outline-offset:2px}.jio-creator__me-avatar{background:#eef1f0;border:1px solid rgba(24,39,34,.08);border-radius:50%;display:block;height:32px;-o-object-fit:cover;object-fit:cover;overflow:hidden;width:32px}.jio-creator__me-avatar.is-placeholder{align-items:center;color:#9aa2a0;display:inline-flex;font-size:13px;justify-content:center}.jio-creator__bar{align-items:center;border-bottom:1px solid var(--jc-line);border-top:1px solid var(--jc-line);display:flex;height:56px;justify-content:center;justify-content:safe center;padding:0 clamp(14px,2.4vw,30px)}.jio-creator__toolbar-tools{align-items:center;display:flex;gap:7px;max-width:100%;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:6px;color:#5f6664;cursor:pointer;display:inline-flex;flex:0 0 auto;flex-direction:column;font-size:11px;gap:2px;height:46px;justify-content:center;line-height:1.2;min-width:40px;padding:5px 6px;width:auto}.jio-creator__tool-btn i{font-size:14px;line-height:1}.jio-creator__tool-btn:focus-visible:not(:disabled),.jio-creator__tool-btn:hover:not(:disabled){background:#f0f2f1;color:var(--jc-accent-strong);outline:0}.jio-creator__tool-btn:disabled{color:#c7d0cc;cursor:default}.jio-creator__tool-label{max-width:52px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.jio-creator__format-field{align-items:center;background:transparent;border:0;border-radius:6px;color:#5f6664;cursor:pointer;display:inline-flex;flex:0 0 auto;flex-direction:column;font-size:11px;gap:2px;height:46px;justify-content:center;line-height:1.2;min-width:40px;padding:5px 6px;position:relative;vertical-align:middle;width:auto}.jio-creator__format-field i{font-size:14px;line-height:1}.jio-creator__format-field:focus-within,.jio-creator__format-field:hover{background:#f0f2f1;color:var(--jc-accent-strong)}.jio-creator__format-field:focus-within{outline:2px solid rgba(38,118,101,.35);outline-offset:-2px}.jio-creator__format-select{background:transparent;border:0;color:inherit;cursor:pointer;font:inherit;height:100%;inset:0;opacity:0;padding:0;position:absolute;width:100%}.jio-creator__format-select:disabled{cursor:default}.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:Line truncated
|
||||
.jio-page-submit{background:#f6f6f6}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:#2b2b2b;--jc-ink-soft:#7d8683;--jc-paper:#f6f6f6;--jc-line:#e6e9e8;--jc-accent:#267665;--jc-accent-strong:#1d5f52;--jc-warn:#b4573f;--jc-top:112px;--jc-status:52px;-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-bottom:var(--jc-status);padding-top:var(--jc-top)}.jio-creator *,.jio-creator :after,.jio-creator :before{box-sizing:border-box}.jio-creator button{color:inherit;font:inherit}.jio-creator__top{background:rgba(250,251,250,.98);box-shadow:0 2px 8px rgba(27,46,39,.035);left:0;position:fixed;right:0;top:0;z-index:300}.jio-creator__masthead{align-items:center;display:flex;gap:16px;height:56px;justify-content:space-between;padding:0 clamp(14px,2.4vw,30px)}.jio-creator__masthead-left{align-items:center;display:flex;gap:10px;min-width:0}.jio-creator__back{align-items:center;border-radius:7px;color:var(--jc-ink-soft);display:inline-flex;flex:0 0 auto;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__brand{align-items:center;color:inherit;display:inline-flex;min-width:0;text-decoration:none}.jio-creator__brand:focus-visible{outline:2px solid rgba(38,118,101,.35);outline-offset:4px}.jio-creator__brand-logo{display:block;height:30px;max-width:168px;-o-object-fit:contain;object-fit:contain;width:auto}.jio-creator__brand-text{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:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.jio-creator__me{align-items:center;border-radius:50%;display:inline-flex;flex:0 0 auto;justify-content:center}.jio-creator__me:focus-visible{outline:2px solid rgba(38,118,101,.5);outline-offset:2px}.jio-creator__me-avatar{background:#eef1f0;border:1px solid rgba(24,39,34,.08);border-radius:50%;display:block;height:32px;-o-object-fit:cover;object-fit:cover;overflow:hidden;width:32px}.jio-creator__me-avatar.is-placeholder{align-items:center;color:#9aa2a0;display:inline-flex;font-size:13px;justify-content:center}.jio-creator__bar{align-items:center;border-bottom:1px solid var(--jc-line);border-top:1px solid var(--jc-line);display:flex;height:56px;justify-content:center;justify-content:safe center;padding:0 clamp(14px,2.4vw,30px)}.jio-creator__toolbar-tools{align-items:center;display:flex;gap:7px;max-width:100%;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:6px;color:#5f6664;cursor:pointer;display:inline-flex;flex:0 0 auto;flex-direction:column;font-size:11px;gap:2px;height:46px;justify-content:center;line-height:1.2;min-width:40px;padding:5px 6px;width:auto}.jio-creator__tool-btn i{font-size:14px;line-height:1}.jio-creator__tool-btn:focus-visible:not(:disabled),.jio-creator__tool-btn:hover:not(:disabled){background:#f0f2f1;color:var(--jc-accent-strong);outline:0}.jio-creator__tool-btn.is-active:not(:disabled),.jio-creator__tool-btn.is-open:not(:disabled){background:#e9f1ee;color:var(--jc-accent-strong)}.jio-creator__tool-btn:disabled{color:#c7d0cc;cursor:default}.jio-creator__tool-label{max-width:52px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.jio-creator__tool-caret{bottom:4px;color:#b0b9b6;font-size:8px;line-height:1;position:absolute;right:3px}.jio-creator__tool-menu{display:inline-flex;flex:0 0 auto;position:relative}.jio-creator__tool-menu .jio-creator__tool-btn{position:relative}.jio-creator__menu{animation:jio-menu-in .12s ease-out;background:#fff;border:1px solid rgba(24,39,34,.06);border-radius:12px;box-shadow:0 14px 40px rgba(20,32,28,.16),0 2px 6px rgba(20,32,28,.06);display:flex;flex-direction:column;gap:2px;padding:6px;position:fixed;z-index:620}.jio-creator__menu.is-top{animation-name:jio-menu-in-up}@keyframes jio-menu-in{0%{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}@keyframes jio-menu-in-up{0%{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}}.jio-creator__menu-item{align-items:center;background:transparent;border:0;border-radius:7px;color:#4Line truncated
|
||||
@@ -1,8 +1,8 @@
|
||||
(()=>{"use strict";const e=window.React,t=window.wp.element,a=window.wp.i18n;function n(e,t){const a=e&&(e.message||e.error)||(401===t?"登录状态已失效,请刷新页面重新登录。":"请求失败,请稍后重试。"),n=new Error(String(a));return n.status=t,n.code=e&&e.code?String(e.code):"",n.data=e,n}async function o(e){try{return await e.json()}catch(e){return null}}async function r(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)}),i=await o(r);if(!r.ok)throw n(i,r.status);return i}async function i(e,t,a,r={}){const i=new FormData;i.append("file",t),Object.entries(r).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 o(l);if(!l.ok)throw n(c,l.status);return c}const l=window.jioCreatorStudio||{},c=l.nonce||"";function s(n){const{draft:o,setDraft:r,capabilities:s,limits:d,taxonomy:u,isPublishedEdit:m,onClose:p,onSaveDraft:_,onSubmit:f,privateStorage:b,privateUploads:g,saving:h,captcha:j}=n,[y,E]=(0,t.useState)(""),[v,k]=(0,t.useState)(""),[N,w]=(0,t.useState)(!1),C=(0,t.useRef)(null),S=(0,t.useRef)(null);(0,t.useEffect)(()=>{const e=S.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 x=(0,t.useMemo)(()=>(o.title||"").trim().length,[o.title])>=Number(d.titleMin||1),M=()=>{const e=y.trim();if(!e)return;const t=e.split(/[,,\n]+/u).map(e=>e.trim()).filter(Boolean);r({tags:Array.from(new Set([...o.tags,...t]))}),E("")};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"},m?(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"},m&&(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")),(0,e.createElement)("div",{className:"jio-creator-chips"},0===u.categories.length&&(0,e.createElement)("span",{className:"jio-creator-muted"},(0,a.__)("暂无可选分类","jio")),u.categories.map(t=>(0,e.createElement)("label",{key:t.id,className:"jio-creator-chip"+(o.categories.includes(t.id)?" is-active":"")},(0,e.createElement)("input",{type:"checkbox",checked:o.categories.includes(t.id),onChange:()=>(e=>{const t=o.categories.includes(e);r({categories:t?o.categories.filter(t=>t!==e):[...o.categories,e]})})(t.id)}),(0,e.createElement)("span",null,t.name))))),u.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"},o.tags.map(t=>(0,e.createElement)("span",{key:t,className:"jio-creator-chip is-active"},(0,e.createElement)("span",null,t),(0,e.createElement)("button",{type:"button",onClick:()=>r({tags:o.tags.filter(e=>e!==t)}),"aria-label":(0,a.sprintf)((0,a.__)("移除标签 %s","jio"),t)},(0,e.createElement)("i",{className:"fa fas fa-times","aria-hidden":"true"}))))),(0,e.createElement)("div",{className:"jio-creator-inline"},(0,e.createElement)("input",{type:"text",value:y,placeholder:(0,a.__)("输入标签,逗号分隔","jio"),onChange:e=>E(e.target.value),onKeyDown:e=>{"Enter"===e.key&&(e.preventDefault(),M())}}),(0,e.createElement)("button",{type:"button",onClick:M},(0,a.__)("添加","jio")))),u.customDefs.map(t=>(0,e.createElement)("section",{className:"jio-creator-field",key:t.key},(0,e.createElement)("h3",null,t.name),(0,e.createElement)("div",{className:"jio-creator-chips"},t.values.map(a=>{const n=o.custom[t.key]||[],i=n.includes(a.key);return(0,e.createElement)("label",{key:a.key,className:"jio-creator-chip"+(i?" is-active":"")},(0,e.createElement)("input",{type:"checkbox",checked:i,onChange:()=>r({custom:{...o.custom,[t.key]:i?n.filter(e=>e!==a.key):[...n,a.key]}})}),(0,e.createElement)("span",null,a.name))})))),s.canPay&&(0,e.createElement)("section",{className:"jio-creator-field"},(0,e.createElement)("div",{className:"jio-creator-switch"},(0,e.createElement)("div",null,(0,e.createElement)("h3",null,(0,a.__)("付费内容","jio")),(0,e.Line truncated
|
||||
(0,a.__)("按站点时区(%s)发布,至少需要晚于当前时间 1 分钟。","jio"),String(m.timezone||"UTC")))))}function g({centerUrl:t,site:n,viewer:o}){const r=String(n.name||""),i=String(o.avatar||""),l=t||"/";return(0,e.createElement)("header",{className:"jio-creator__masthead"},(0,e.createElement)("div",{className:"jio-creator__masthead-left"},(0,e.createElement)("a",{className:"jio-creator__back",href:t||"/","aria-label":(0,a.__)("返回作者中心","jio")},(0,e.createElement)("i",{className:"fa fas fa-angle-left","aria-hidden":"true"})),(0,e.createElement)("a",{className:"jio-creator__brand",href:String(n.home||"/"),"aria-label":r||(0,a.__)("返回首页","jio")},n.logo?(0,e.createElement)("img",{className:"jio-creator__brand-logo",src:String(n.logo),alt:r}):(0,e.createElement)("span",{className:"jio-creator__brand-text"},r))),(0,e.createElement)("a",{className:"jio-creator__me",href:l,"aria-label":(0,a.__)("返回作者中心","jio")},i?(0,e.createElement)("img",{className:"jio-creator__me-avatar",src:i,alt:"",width:32,height:32}):(0,e.createElement)("span",{className:"jio-creator__me-avatar is-placeholder","aria-hidden":"true"},(0,e.createElement)("i",{className:"fa fas fa-user"}))))}function h(e){const t=(e.downloads||[]).map(e=>{const t="string"==typeof e.assetKey?e.assetKey:"";if(t)return{type:"protected",asset_key:t,label:"string"==typeof e.label?e.label:""};const a="string"==typeof e.url?e.url.trim():"";return a?{type:"direct",url:a,label:"string"==typeof e.label?e.label:"",extract_code:"string"==typeof e.extractCode?e.extractCode:""}:null}).filter(Boolean);return{enabled:!!e.enabled,fields:{pay_type:e.enabled?t.length>0?"download":"reading":"no",pay_modo:"points"===e.modo?"points":"0",price:"points"===e.modo?"0.00":String(e.price||"0.00"),points_price:"points"===e.modo?Math.max(0,Number(e.points)||0):0,vip1_price:"points"===e.modo?"0.00":String(e.vip1Price||"0.00"),vip2_price:"points"===e.modo?"0.00":String(e.vip2Price||"0.00"),pay_limit:Number(e.payLimit)||0,downloads:t}}}const j=e=>e.replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<").replace(/>/g,">");function y(e){if(!e||-1===e.indexOf("data-jio-")&&-1===e.indexOf("jio-code-lang"))return e;const t=document.createElement("div");return t.innerHTML=e,t.querySelectorAll("[data-jio-math-source]").forEach(e=>{const t=e.getAttribute("data-jio-math-source")||"",a="0"!==e.getAttribute("data-jio-math-display"),n=`[jio_math display="${a?"1":"0"}"]${t}[/jio_math]`,o=e.closest("p");a&&o&&o.textContent?.trim()===e.textContent?.trim()?o.textContent=n:e.replaceWith(document.createTextNode(n))}),t.querySelectorAll("[data-jio-footnote]").forEach(e=>{const t=e.getAttribute("data-jio-footnote")||"";e.replaceWith(document.createTextNode(`[jio_footnote]${t}[/jio_footnote]`))}),t.querySelectorAll(".jio-code-lang").forEach(e=>e.remove()),t.innerHTML}function E(e){return e&&-1!==e.indexOf("[jio_")?e.replace(/\[jio_math display="([01])"\]([\s\S]*?)\[\/jio_math\]/g,(e,t,a)=>"0"===t?`<span class="jio-math" data-jio-math-source="${j(a)}" data-jio-math-display="0" contenteditable="false">${v(a)}</span>`:`<p><span class="jio-math jio-math--block" data-jio-math-source="${j(a)}" data-jio-math-display="1" contenteditable="false">${v(a)}</span></p>`).replace(/\[jio_footnote\]([\s\S]*?)\[\/jio_footnote\]/g,(e,t)=>`<sup class="jio-footnote-ref" data-jio-footnote="${j((e=>{const t=document.createElement("textarea");return t.innerHTML=e,t.value})(t))}" contenteditable="false">${t}</sup>`):e}const v=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/\^\{([^{}]*)\}|\^(\S)/gu,"<sup>$1$2</sup>").replace(/_\{([^{}]*)\}|_(\S)/gu,"<sub>$1$2</sub>"),k={ready:(0,a.__)("尚未保存","jio"),saving:(0,a.__)("保存中…","jio"),saved:(0,a.__)("已保存","jio"),error:(0,a.__)("保存失败","jio"),conflict:(0,a.__)("内容已在别处更新","jio"),submitting:(0,a.__)("提交中…","jio"),submitted:(0,a.__)("已提交","jio")};function N({value:t}){return(0,e.createElement)("span",{className:`jio-creator__save is-${t.state}`,role:"status","aria-live":"polite"},(0,e.createElement)("i",{className:"fa fas fa-circle","aria-hidden":"true"}),(0,e.createElement)("span",null,k[t.state]||k.ready),"saved"===t.state&&t.at?(0,e.createElement)("time",null,t.at):null)}const w=36e5,C=24*w,S=30*C,x=365*C,M={ready:(0,a.__)("草稿","jio"),saving:(0,a.__)("保存中","jio"),saved:(0,a.__)("草稿","jio"),error:(0,a.__)("保存失败","jio"),conflict:(0,a.__)("冲突","jio"),submitting:(0,a.__)("提交中","jio"),submitted:(0,a.__)("已提交","jio")};function $({charCount:t,saveState:n,isPublishedEdit:o,saving:r,onSaveDraft:i,onPreview:l,onPublish:c}){const s=n.at?function(e,t=Date.now()){const n=Date.parse(e);if(!Number.isFinite(n))return"";const o=Math.max(0,t-n);return o<6e4?(0,a.__)("刚刚","jio"):o<w?(0,a.sprintf)(/* translators: %d: minutes */ /* translators: %d: minutes */
|
||||
(0,a.__)("%d分钟前","jio"),Math.floor(o/6e4)):o<C?(0,a.sprintf)(/* translators: %d: hours */ /* translators: %d: hours */
|
||||
(0,a.__)("%d小时前","jio"),Math.floor(o/w)):o<S?(0,a.sprintf)(/* translators: %d: days */ /* translators: %d: days */
|
||||
(0,a.__)("%d天前","jio"),Math.floor(o/C)):o<x?(0,a.sprintf)(/* translators: %d: months */ /* translators: %d: months */
|
||||
(0,a.__)("%d个月前","jio"),Math.floor(o/S)):(0,a.sprintf)(/* translators: %d: years */ /* translators: %d: years */
|
||||
(0,a.__)("%d年前","jio"),Math.floor(o/x))}(n.at):"",d=M[n.state]||M.ready;return(0,e.createElement)("footer",{className:"jio-creator__status",role:"contentinfo"},(0,e.createElement)("div",{className:"jio-creator__status-left"},(0,e.createElement)("span",{className:"jio-creator__status-count"},(0,a.__)("字数","jio"),":",t),(0,e.createElement)("span",{className:"jio-creator__status-mode"},(0,a.__)("Markdown 语法输入中","jio"))),(0,e.createElement)("div",{className:"jio-creator__status-meta"},(0,e.createElement)(N,{value:n}),(0,e.createElement)("span",{className:"jio-creator__status-dot","aria-hidden":"true"},"·"),(0,e.createElement)("span",{className:"jio-creator__status-time"},s||d)),(0,e.createElement)("div",{className:"jio-creator__status-actions"},(0,e.createElement)("button",{type:"button",className:"jio-creator__status-ghost",onClick:i,disabled:r},(0,a.__)("保存草稿","jio")),(0,e.createElement)("button",{type:"button",className:"jio-creator__status-ghost",onClick:l,disabled:r},(0,a.__)("预览","jio")),(0,e.createElement)("button",{type:"button",className:"jio-creator__status-primary",onClick:c,disabled:r},o?(0,a.__)("提交改稿","jio"):(0,a.__)("发布","jio"))))}const q=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),L=[{value:"generic",label:(0,a.__)("通用","jio")},{value:"javascript",label:"JavaScript"},{value:"typescript",label:"TypeScript"},{value:"php",label:"PHP"},{value:"python",label:"Python"},{value:"java",label:"Java"},{value:"go",label:"Go"},{value:"rust",label:"Rust"},{value:"csharp",label:"C#"},{value:"cpp",label:"C++"},{value:"sql",label:"SQL"},{value:"json",label:"JSON"},{value:"bash",label:"Shell"},{value:"css",label:"CSS"},{value:"html",label:"HTML"},{value:"markdown",label:"Markdown"}];const I=(e,t,a)=>Math.min(a,Math.max(t,Number.isFinite(e)?Math.round(e):t)),D={table:(0,a.__)("插入表格","jio"),math:(0,a.__)("插入公式","jio"),footnote:(0,a.__)("插入注释","jio")};function R({kind:n,onSubmit:o,onClose:r}){const[i,l]=(0,t.useState)(3),[c,s]=(0,t.useState)(3),[d,u]=(0,t.useState)(!0),[m,p]=(0,t.useState)(!1),[_,f]=(0,t.useState)("a^2 + b^2 = c^2"),[b,g]=(0,t.useState)(!0),[h,j]=(0,t.useState)(""),y=(0,t.useRef)(null),E=(0,t.useRef)(null),v=(0,t.useRef)(null),k=(0,t.useRef)(null);(0,t.useEffect)(()=>{(E.current||v.current||k.current)?.focus();const e=e=>{"Escape"===e.key&&(e.preventDefault(),r())};return document.addEventListener("keydown",e),()=>document.removeEventListener("keydown",e)},[r]);const N=(0,t.useMemo)(()=>"footnote"===n?""!==h.trim():"math"!==n||""!==_.trim(),[n,h,_]);return(0,e.createElement)("div",{className:"jio-creator-dialog",role:"dialog","aria-modal":"true","aria-labelledby":"jio-creator-dialog-title"},(0,e.createElement)("button",{type:"button",className:"jio-creator-dialog__backdrop","aria-label":(0,a.__)("关闭","jio"),onClick:r}),(0,e.createElement)("div",{className:"jio-creator-dialog__panel",ref:y},(0,e.createElement)("header",{className:"jio-creator-dialog__head"},(0,e.createElement)("h2",{id:"jio-creator-dialog-title"},D[n]),(0,e.createElement)("button",{type:"button",className:"jio-creator-dialog__close",onClick:r,"aria-label":(0,a.__)("关闭","jio")},(0,e.createElement)("i",{className:"fa fas fa-times","aria-hidden":"true"}))),(0,e.createElement)("div",{className:"jio-creator-dialog__body"},"table"===n&&(0,e.createElement)(e.Fragment,null,(0,e.createElement)("div",{className:"jio-creator-dialog__row"},(0,e.createElement)("label",null,(0,e.createElement)("span",null,(0,a.__)("行数","jio")),(0,e.createElement)("input",{ref:E,type:"number",min:1,max:20,value:i,onChange:e=>l(Number(e.target.value))})),(0,e.createElement)("label",null,(0,e.createElement)("span",null,(0,a.__)("列数","jio")),(0,e.createElement)("input",{type:"number",min:1,max:10,value:c,onChange:e=>s(Number(e.target.value))}))),(0,e.createElement)("label",{className:"jio-creator-dialog__check"},(0,e.createElement)("input",{type:"checkbox",checked:d,onChange:e=>u(e.target.checked)}),(0,e.createElement)("span",null,(0,a.__)("首行为表头","jio"))),(0,e.createElement)("label",{className:"jio-creator-dialog__check"},(0,e.createElement)("input",{type:"checkbox",checked:m,onChange:e=>p(e.target.checked)}),(0,e.createElement)("span",null,(0,a.__)("隔行变色","jio")))),"math"===n&&(0,e.createElement)(e.Fragment,null,(0,e.createElement)("label",{className:"jio-creator-dialog__field"},(0,e.createElement)("span",null,(0,a.__)("公式","jio")),(0,e.createElement)("textarea",{ref:v,rows:3,value:_,onChange:e=>f(e.target.value),placeholder:"a^2 + b^2 = c^2"})),(0,e.createElement)("p",{className:"jio-creator-muted"},(0,a.__)("支持上标 ^ 与下标 _,例如 x^2 与 a_n;暂不支持完整 LaTeX。","jio")),(0,e.createElement)("label",{className:"jio-creator-dialog__check"},(0,e.createElement)("input",{type:"checkbox",checked:b,onChange:e=>g(e.target.checked)}),(0,e.createElement)("span",null,(0,a.__)("独占一行显示Line truncated
|
||||
(0,a.__)("请输入标题(最多 %d 个字)","jio"),Number(Y.titleMax)||100),spellCheck:!1,maxLength:Number(Y.titleMax)||100})),(0,e.createElement)("div",{className:"jio-creator__editor","data-editor-ready":_?"true":"false"},(0,e.createElement)("textarea",{id:F,defaultValue:n.content,onChange:e=>te(e.target.value),"aria-label":(0,a.__)("文章正文","jio")}))),(0,e.createElement)(b,{featured:n.featured,canEditCover:!!X.canUploadImage,canSchedule:!!X.canSchedule,scheduleEnabled:!!n.schedule.enabled,scheduleAt:n.schedule.at,onFeaturedChange:e=>ee({featured:e}),onScheduleChange:(e,t)=>ee({schedule:{enabled:e,at:t}})}),M&&(0,e.createElement)(R,{kind:M,onSubmit:je,onClose:()=>L(null)}),(0,e.createElement)($,{charCount:Z,saveState:w,isPublishedEdit:G,saving:!_||"saving"===w.state||"submitting"===w.state,onSaveDraft:()=>{re(!1)},onPreview:()=>{le()},onPublish:()=>x(!0)}),S&&(0,e.createElement)(s,{draft:n,setDraft:ee,capabilities:X,limits:Y,taxonomy:{categories:U.categories&&U.categories.items||[],tags:!!U.tags?.enabled,customDefs:U.customDefs||[]},isPublishedEdit:G,onClose:()=>x(!1),onSaveDraft:()=>{re(!1)},onSubmit:e=>{ie(e)},privateStorage:!!U.privateStorage,privateUploads:U.privateUploads||[],captcha:!!U.captcha?.enabled,saving:"saving"===w.state||"submitting"===w.state}))}function V(){const a=document.getElementById("jio-creator-studio");a&&(a.classList.add("is-ready"),(0,t.createRoot)(a).render((0,e.createElement)(J,null)))}"loading"===document.readyState?document.addEventListener("DOMContentLoaded",V):V()})();
|
||||
(()=>{"use strict";const e=window.React,t=window.wp.element,a=window.wp.i18n;function n(e,t){const a=e&&(e.message||e.error)||(401===t?"登录状态已失效,请刷新页面重新登录。":"请求失败,请稍后重试。"),n=new Error(String(a));return n.status=t,n.code=e&&e.code?String(e.code):"",n.data=e,n}async function o(e){try{return await e.json()}catch(e){return null}}async function r(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)}),i=await o(r);if(!r.ok)throw n(i,r.status);return i}async function i(e,t,a,r={}){const i=new FormData;i.append("file",t),Object.entries(r).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 o(l);if(!l.ok)throw n(c,l.status);return c}const l=window.jioCreatorStudio||{},c=l.nonce||"";function s(n){const{draft:o,setDraft:r,capabilities:s,limits:d,taxonomy:u,isPublishedEdit:m,onClose:p,onSaveDraft:f,onSubmit:_,privateStorage:b,privateUploads:h,saving:g,captcha:y}=n,[j,v]=(0,t.useState)(""),[E,k]=(0,t.useState)(""),[w,N]=(0,t.useState)(!1),C=(0,t.useRef)(null),S=(0,t.useRef)(null);(0,t.useEffect)(()=>{const e=S.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 x=(0,t.useMemo)(()=>(o.title||"").trim().length,[o.title])>=Number(d.titleMin||1),M=()=>{const e=j.trim();if(!e)return;const t=e.split(/[,,\n]+/u).map(e=>e.trim()).filter(Boolean);r({tags:Array.from(new Set([...o.tags,...t]))}),v("")};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"},m?(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"},m&&(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")),(0,e.createElement)("div",{className:"jio-creator-chips"},0===u.categories.length&&(0,e.createElement)("span",{className:"jio-creator-muted"},(0,a.__)("暂无可选分类","jio")),u.categories.map(t=>(0,e.createElement)("label",{key:t.id,className:"jio-creator-chip"+(o.categories.includes(t.id)?" is-active":"")},(0,e.createElement)("input",{type:"checkbox",checked:o.categories.includes(t.id),onChange:()=>(e=>{const t=o.categories.includes(e);r({categories:t?o.categories.filter(t=>t!==e):[...o.categories,e]})})(t.id)}),(0,e.createElement)("span",null,t.name))))),u.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"},o.tags.map(t=>(0,e.createElement)("span",{key:t,className:"jio-creator-chip is-active"},(0,e.createElement)("span",null,t),(0,e.createElement)("button",{type:"button",onClick:()=>r({tags:o.tags.filter(e=>e!==t)}),"aria-label":(0,a.sprintf)((0,a.__)("移除标签 %s","jio"),t)},(0,e.createElement)("i",{className:"fa fas fa-times","aria-hidden":"true"}))))),(0,e.createElement)("div",{className:"jio-creator-inline"},(0,e.createElement)("input",{type:"text",value:j,placeholder:(0,a.__)("输入标签,逗号分隔","jio"),onChange:e=>v(e.target.value),onKeyDown:e=>{"Enter"===e.key&&(e.preventDefault(),M())}}),(0,e.createElement)("button",{type:"button",onClick:M},(0,a.__)("添加","jio")))),u.customDefs.map(t=>(0,e.createElement)("section",{className:"jio-creator-field",key:t.key},(0,e.createElement)("h3",null,t.name),(0,e.createElement)("div",{className:"jio-creator-chips"},t.values.map(a=>{const n=o.custom[t.key]||[],i=n.includes(a.key);return(0,e.createElement)("label",{key:a.key,className:"jio-creator-chip"+(i?" is-active":"")},(0,e.createElement)("input",{type:"checkbox",checked:i,onChange:()=>r({custom:{...o.custom,[t.key]:i?n.filter(e=>e!==a.key):[...n,a.key]}})}),(0,e.createElement)("span",null,a.name))})))),s.canPay&&(0,e.createElement)("section",{className:"jio-creator-field"},(0,e.createElement)("div",{className:"jio-creator-switch"},(0,e.createElement)("div",null,(0,e.createElement)("h3",null,(0,a.__)("付费内容","jio")),(0,e.Line truncated
|
||||
(0,a.__)("按站点时区(%s)发布,至少需要晚于当前时间 1 分钟。","jio"),String(_.timezone||"UTC")))))}function j({centerUrl:t,site:n,viewer:o}){const r=String(n.name||""),i=String(o.avatar||""),l=t||"/";return(0,e.createElement)("header",{className:"jio-creator__masthead"},(0,e.createElement)("div",{className:"jio-creator__masthead-left"},(0,e.createElement)("a",{className:"jio-creator__back",href:t||"/","aria-label":(0,a.__)("返回作者中心","jio")},(0,e.createElement)("i",{className:"fa fas fa-angle-left","aria-hidden":"true"})),(0,e.createElement)("a",{className:"jio-creator__brand",href:String(n.home||"/"),"aria-label":r||(0,a.__)("返回首页","jio")},n.logo?(0,e.createElement)("img",{className:"jio-creator__brand-logo",src:String(n.logo),alt:r}):(0,e.createElement)("span",{className:"jio-creator__brand-text"},r))),(0,e.createElement)("a",{className:"jio-creator__me",href:l,"aria-label":(0,a.__)("返回作者中心","jio")},i?(0,e.createElement)("img",{className:"jio-creator__me-avatar",src:i,alt:"",width:32,height:32}):(0,e.createElement)("span",{className:"jio-creator__me-avatar is-placeholder","aria-hidden":"true"},(0,e.createElement)("i",{className:"fa fas fa-user"}))))}function v(e){const t=(e.downloads||[]).map(e=>{const t="string"==typeof e.assetKey?e.assetKey:"";if(t)return{type:"protected",asset_key:t,label:"string"==typeof e.label?e.label:""};const a="string"==typeof e.url?e.url.trim():"";return a?{type:"direct",url:a,label:"string"==typeof e.label?e.label:"",extract_code:"string"==typeof e.extractCode?e.extractCode:""}:null}).filter(Boolean);return{enabled:!!e.enabled,fields:{pay_type:e.enabled?t.length>0?"download":"reading":"no",pay_modo:"points"===e.modo?"points":"0",price:"points"===e.modo?"0.00":String(e.price||"0.00"),points_price:"points"===e.modo?Math.max(0,Number(e.points)||0):0,vip1_price:"points"===e.modo?"0.00":String(e.vip1Price||"0.00"),vip2_price:"points"===e.modo?"0.00":String(e.vip2Price||"0.00"),pay_limit:Number(e.payLimit)||0,downloads:t}}}const E=e=>e.replace(/&/g,"&").replace(/"/g,""").replace(/</g,"<").replace(/>/g,">");function k(e){if(!e||-1===e.indexOf("data-jio-"))return e;const t=document.createElement("div");return t.innerHTML=e,t.querySelectorAll("[data-jio-math-source]").forEach(e=>{const t=e.getAttribute("data-jio-math-source")||"",a="0"!==e.getAttribute("data-jio-math-display"),n=`[jio_math display="${a?"1":"0"}"]${t}[/jio_math]`,o=e.closest("p");a&&o&&o.textContent?.trim()===e.textContent?.trim()?o.textContent=n:e.replaceWith(document.createTextNode(n))}),t.querySelectorAll("[data-jio-footnote]").forEach(e=>{const t=e.getAttribute("data-jio-footnote")||"";e.replaceWith(document.createTextNode(`[jio_footnote]${t}[/jio_footnote]`))}),t.innerHTML}function w(e){return e&&-1!==e.indexOf("[jio_")?e.replace(/\[jio_math display="([01])"\]([\s\S]*?)\[\/jio_math\]/g,(e,t,a)=>"0"===t?`<span class="jio-math" data-jio-math-source="${E(a)}" data-jio-math-display="0" contenteditable="false">${N(a)}</span>`:`<p><span class="jio-math jio-math--block" data-jio-math-source="${E(a)}" data-jio-math-display="1" contenteditable="false">${N(a)}</span></p>`).replace(/\[jio_footnote\]([\s\S]*?)\[\/jio_footnote\]/g,(e,t)=>`<sup class="jio-footnote-ref" data-jio-footnote="${E((e=>{const t=document.createElement("textarea");return t.innerHTML=e,t.value})(t))}" contenteditable="false">${t}</sup>`):e}const N=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/\^\{([^{}]*)\}|\^(\S)/gu,"<sup>$1$2</sup>").replace(/_\{([^{}]*)\}|_(\S)/gu,"<sub>$1$2</sub>"),C={ready:(0,a.__)("尚未保存","jio"),saving:(0,a.__)("保存中…","jio"),saved:(0,a.__)("已保存","jio"),error:(0,a.__)("保存失败","jio"),conflict:(0,a.__)("内容已在别处更新","jio"),submitting:(0,a.__)("提交中…","jio"),submitted:(0,a.__)("已提交","jio")};function S({value:t}){return(0,e.createElement)("span",{className:`jio-creator__save is-${t.state}`,role:"status","aria-live":"polite"},(0,e.createElement)("i",{className:"fa fas fa-circle","aria-hidden":"true"}),(0,e.createElement)("span",null,C[t.state]||C.ready),"saved"===t.state&&t.at?(0,e.createElement)("time",null,t.at):null)}const x=36e5,M=24*x,$=30*M,L=365*M,q={ready:(0,a.__)("草稿","jio"),saving:(0,a.__)("保存中","jio"),saved:(0,a.__)("草稿","jio"),error:(0,a.__)("保存失败","jio"),conflict:(0,a.__)("冲突","jio"),submitting:(0,a.__)("提交中","jio"),submitted:(0,a.__)("已提交","jio")};function R({charCount:t,saveState:n,isPublishedEdit:o,saving:r,onSaveDraft:i,onPreview:l,onPublish:c}){const s=n.at?function(e,t=Date.now()){const n=Date.parse(e);if(!Number.isFinite(n))return"";const o=Math.max(0,t-n);return o<6e4?(0,a.__)("刚刚","jio"):o<x?(0,a.sprintf)(/* translators: %d: minutes */ /* translators: %d: minutes */
|
||||
(0,a.__)("%d分钟前","jio"),Math.floor(o/6e4)):o<M?(0,a.sprintf)(/* translators: %d: hours */ /* translators: %d: hours */
|
||||
(0,a.__)("%d小时前","jio"),Math.floor(o/x)):o<$?(0,a.sprintf)(/* translators: %d: days */ /* translators: %d: days */
|
||||
(0,a.__)("%d天前","jio"),Math.floor(o/M)):o<L?(0,a.sprintf)(/* translators: %d: months */ /* translators: %d: months */
|
||||
(0,a.__)("%d个月前","jio"),Math.floor(o/$)):(0,a.sprintf)(/* translators: %d: years */ /* translators: %d: years */
|
||||
(0,a.__)("%d年前","jio"),Math.floor(o/L))}(n.at):"",d=q[n.state]||q.ready;return(0,e.createElement)("footer",{className:"jio-creator__status",role:"contentinfo"},(0,e.createElement)("div",{className:"jio-creator__status-left"},(0,e.createElement)("span",{className:"jio-creator__status-count"},(0,a.__)("字数","jio"),":",t),(0,e.createElement)("span",{className:"jio-creator__status-mode"},(0,a.__)("Markdown 语法输入中","jio"))),(0,e.createElement)("div",{className:"jio-creator__status-meta"},(0,e.createElement)(S,{value:n}),(0,e.createElement)("span",{className:"jio-creator__status-dot","aria-hidden":"true"},"·"),(0,e.createElement)("span",{className:"jio-creator__status-time"},s||d)),(0,e.createElement)("div",{className:"jio-creator__status-actions"},(0,e.createElement)("button",{type:"button",className:"jio-creator__status-ghost",onClick:i,disabled:r},(0,a.__)("保存草稿","jio")),(0,e.createElement)("button",{type:"button",className:"jio-creator__status-ghost",onClick:l,disabled:r},(0,a.__)("预览","jio")),(0,e.createElement)("button",{type:"button",className:"jio-creator__status-primary",onClick:c,disabled:r},o?(0,a.__)("提交改稿","jio"):(0,a.__)("发布","jio"))))}const I=e=>e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,"""),D=/<(?:p|div|h[1-6]|ul|ol|li|blockquote|pre|table|figure|section)\b/i,P=e=>{const t=e.trim();return""===t?"<p></p>":D.test(t)?t:`<p>${t}</p>`};const z={reply:{open:"[reply]",close:"[/reply]",label:(0,a.__)("评论后可查看","jio")},login:{open:'[jio_hide mode="login"]',close:"[/jio_hide]",label:(0,a.__)("登录后可查看","jio")},vip:{open:"[vip]",close:"[/vip]",label:(0,a.__)("会员可查看","jio")},payshow:{open:"[payshow]",close:"[/payshow]",label:(0,a.__)("付费后可查看","jio")}},U=(e,t,a)=>Math.min(a,Math.max(t,Number.isFinite(e)?Math.round(e):t)),A={table:(0,a.__)("插入表格","jio"),math:(0,a.__)("插入公式","jio"),footnote:(0,a.__)("插入注释","jio")};function B({kind:n,onSubmit:o,onClose:r}){const[i,l]=(0,t.useState)(3),[c,s]=(0,t.useState)(3),[d,u]=(0,t.useState)(!0),[m,p]=(0,t.useState)(!1),[f,_]=(0,t.useState)("a^2 + b^2 = c^2"),[b,h]=(0,t.useState)(!0),[g,y]=(0,t.useState)(""),j=(0,t.useRef)(null),v=(0,t.useRef)(null),E=(0,t.useRef)(null),k=(0,t.useRef)(null);(0,t.useEffect)(()=>{(v.current||E.current||k.current)?.focus();const e=e=>{"Escape"===e.key&&(e.preventDefault(),r())};return document.addEventListener("keydown",e),()=>document.removeEventListener("keydown",e)},[r]);const w=(0,t.useMemo)(()=>"footnote"===n?""!==g.trim():"math"!==n||""!==f.trim(),[n,g,f]);return(0,e.createElement)("div",{className:"jio-creator-dialog",role:"dialog","aria-modal":"true","aria-labelledby":"jio-creator-dialog-title"},(0,e.createElement)("button",{type:"button",className:"jio-creator-dialog__backdrop","aria-label":(0,a.__)("关闭","jio"),onClick:r}),(0,e.createElement)("div",{className:"jio-creator-dialog__panel",ref:j},(0,e.createElement)("header",{className:"jio-creator-dialog__head"},(0,e.createElement)("h2",{id:"jio-creator-dialog-title"},A[n]),(0,e.createElement)("button",{type:"button",className:"jio-creator-dialog__close",onClick:r,"aria-label":(0,a.__)("关闭","jio")},(0,e.createElement)("i",{className:"fa fas fa-times","aria-hidden":"true"}))),(0,e.createElement)("div",{className:"jio-creator-dialog__body"},"table"===n&&(0,e.createElement)(e.Fragment,null,(0,e.createElement)("div",{className:"jio-creator-dialog__row"},(0,e.createElement)("label",null,(0,e.createElement)("span",null,(0,a.__)("行数","jio")),(0,e.createElement)("input",{ref:v,type:"number",min:1,max:20,value:i,onChange:e=>l(Number(e.target.value))})),(0,e.createElement)("label",null,(0,e.createElement)("span",null,(0,a.__)("列数","jio")),(0,e.createElement)("input",{type:"number",min:1,max:10,value:c,onChange:e=>s(Number(e.target.value))}))),(0,e.createElement)("label",{className:"jio-creator-dialog__check"},(0,e.createElement)("input",{type:"checkbox",checked:d,onChange:e=>u(e.target.checked)}),(0,e.createElement)("span",null,(0,a.__)("首行为表头","jio"))),(0,e.createElement)("label",{className:"jio-creator-dialog__check"},(0,e.createElement)("input",{type:"checkbox",checked:m,onChange:e=>p(e.target.checked)}),(0,e.createElement)("span",null,(0,a.__)("隔行变色","jio")))),"math"===n&&(0,e.createElement)(e.Fragment,null,(0,e.createElement)("label",{className:"jio-creator-dialog__field"},(0,e.createElement)("span",null,(0,a.__)("公式","jio")),(0,e.createElement)("textarea",{ref:E,rows:3,value:f,onChange:e=>_(e.target.value),placeholder:"a^2 + b^2 = c^2"})),(0,e.createElement)("p",{className:"jio-creator-muted"},(0,a.__)("支持上标 ^ 与下标 _,例如 x^2 与 a_n;暂不支持完整 LaTeX。","jio")),(0,e.createElement)("label",{className:"jio-creator-dialog__check"},(0,e.createElement)("input",{type:"checkbox",checked:b,onChange:e=>h(e.target.checked)}),(0,e.createElement)("span",null,(0,a.__)("独占一行显示","Line truncated
|
||||
(0,a.__)("请输入标题(最多 %d 个字)","jio"),Number(ne.titleMax)||100),spellCheck:!1,maxLength:Number(ne.titleMax)||100})),(0,e.createElement)("div",{className:"jio-creator__editor","data-editor-ready":p?"true":"false"},(0,e.createElement)("textarea",{id:J,defaultValue:n.content,onChange:e=>le(e.target.value),"aria-label":(0,a.__)("文章正文","jio")}))),(0,e.createElement)(y,{featured:n.featured,canEditCover:!!ae.canUploadImage,canSchedule:!!ae.canSchedule,scheduleEnabled:!!n.schedule.enabled,scheduleAt:n.schedule.at,onFeaturedChange:e=>ie({featured:e}),onScheduleChange:(e,t)=>ie({schedule:{enabled:e,at:t}})}),D&&(0,e.createElement)(B,{kind:D,onSubmit:xe,onClose:()=>U(null)}),(0,e.createElement)(R,{charCount:re,saveState:M,isPublishedEdit:oe,saving:!p||"saving"===M.state||"submitting"===M.state,onSaveDraft:()=>{me(!1)},onPreview:()=>{fe()},onPublish:()=>q(!0)}),L&&(0,e.createElement)(s,{draft:n,setDraft:ie,capabilities:ae,limits:ne,taxonomy:{categories:H.categories&&H.categories.items||[],tags:!!H.tags?.enabled,customDefs:H.customDefs||[]},isPublishedEdit:oe,onClose:()=>q(!1),onSaveDraft:()=>{me(!1)},onSubmit:e=>{pe(e)},privateStorage:!!H.privateStorage,privateUploads:H.privateUploads||[],captcha:!!H.captcha?.enabled,saving:"saving"===M.state||"submitting"===M.state}))}function G(){const a=document.getElementById("jio-creator-studio");a&&(a.classList.add("is-ready"),(0,t.createRoot)(a).render((0,e.createElement)(Q,null)))}"loading"===document.readyState?document.addEventListener("DOMContentLoaded",G):G()})();
|
||||
@@ -4,12 +4,16 @@
|
||||
* 每个按钮都是「上图标 + 下文字」,与公众号/知乎的单行工具栏一致;
|
||||
* 只渲染当前账号真正有权限使用的能力,全部动作由父组件转给 TinyMCE。
|
||||
*
|
||||
* 需要多选项的工具(标题级别、列表、引用配色、隐藏内容)统一走 ToolMenu,
|
||||
* 避免原生 <select> 的风格断裂和滚动容器裁剪。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from '@wordpress/element';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
|
||||
import { ToolMenu, type ToolMenuItem } from './tool-menu';
|
||||
|
||||
type Capabilities = Record<string, boolean>;
|
||||
|
||||
type Props = {
|
||||
@@ -18,6 +22,8 @@ type Props = {
|
||||
canRedo: boolean;
|
||||
formatValue: string;
|
||||
onFormatChange: (value: string) => void;
|
||||
alignValue: string;
|
||||
blockValue: string;
|
||||
capabilities: Capabilities;
|
||||
onCommand: (name: string, value?: string) => void;
|
||||
onRememberSelection: () => void;
|
||||
@@ -25,6 +31,7 @@ type Props = {
|
||||
onOpenMedia: (type: 'image' | 'video' | 'file') => void;
|
||||
onInsertBlock: (kind: string) => void;
|
||||
onInsertQuote: (color: '' | 'qe_wzk_c-red' | 'qe_wzk_lan' | 'qe_wzk_lv') => void;
|
||||
onInsertHidden: (kind: 'reply' | 'login' | 'vip' | 'payshow') => void;
|
||||
};
|
||||
|
||||
type ToolButtonProps = {
|
||||
@@ -33,17 +40,19 @@ type ToolButtonProps = {
|
||||
ready: boolean;
|
||||
onClick: () => void;
|
||||
title?: string;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
function ToolButton({ label, icon, ready, onClick, title }: ToolButtonProps) {
|
||||
function ToolButton({ label, icon, ready, onClick, title, active = false }: ToolButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="jio-creator__tool-btn"
|
||||
className={`jio-creator__tool-btn${active ? ' is-active' : ''}`}
|
||||
disabled={!ready}
|
||||
onClick={onClick}
|
||||
title={title || label}
|
||||
aria-label={title || label}
|
||||
aria-pressed={active || undefined}
|
||||
>
|
||||
<i className={icon} aria-hidden="true" />
|
||||
<span className="jio-creator__tool-label">{label}</span>
|
||||
@@ -51,12 +60,30 @@ function ToolButton({ label, icon, ready, onClick, title }: ToolButtonProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 引用配色:菜单项自带色块预览,和正文里的效果对得上。 */
|
||||
const QUOTE_OPTIONS = [
|
||||
{ key: '' as const, label: __('灰色引用', 'jio'), swatch: '#555555' },
|
||||
{ key: 'qe_wzk_c-red' as const, label: __('红色引用', 'jio'), swatch: '#dc3f3f' },
|
||||
{ key: 'qe_wzk_lan' as const, label: __('蓝色引用', 'jio'), swatch: '#0092ff' },
|
||||
{ key: 'qe_wzk_lv' as const, label: __('绿色引用', 'jio'), swatch: '#23790e' },
|
||||
];
|
||||
|
||||
/** 隐藏内容的四档可见性,对应服务端的 [reply] / [jio_hide] / [vip] / [payshow]。 */
|
||||
const HIDE_OPTIONS = [
|
||||
{ key: 'reply' as const, label: __('评论后可查看', 'jio'), icon: 'fa fas fa-comment-dots' },
|
||||
{ key: 'login' as const, label: __('登录后可查看', 'jio'), icon: 'fa fas fa-sign-in-alt' },
|
||||
{ key: 'vip' as const, label: __('会员可查看', 'jio'), icon: 'fa fas fa-crown' },
|
||||
{ key: 'payshow' as const, label: __('付费后可查看', 'jio'), icon: 'fa fas fa-coins' },
|
||||
];
|
||||
|
||||
export function EditorToolbar({
|
||||
ready,
|
||||
canUndo,
|
||||
canRedo,
|
||||
formatValue,
|
||||
onFormatChange,
|
||||
alignValue,
|
||||
blockValue,
|
||||
capabilities,
|
||||
onCommand,
|
||||
onRememberSelection,
|
||||
@@ -64,32 +91,64 @@ export function EditorToolbar({
|
||||
onOpenMedia,
|
||||
onInsertBlock,
|
||||
onInsertQuote,
|
||||
onInsertHidden,
|
||||
}: Props) {
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
const quoteRef = useRef<HTMLDivElement | null>(null);
|
||||
const headingItems: ToolMenuItem[] = [
|
||||
{ key: 'p', label: __('正文', 'jio'), icon: 'fa fas fa-paragraph' },
|
||||
{ key: 'h2', label: __('标题 2', 'jio'), icon: 'fa fas fa-heading' },
|
||||
{ key: 'h3', label: __('标题 3', 'jio'), icon: 'fa fas fa-heading' },
|
||||
{ key: 'h4', label: __('标题 4', 'jio'), icon: 'fa fas fa-heading' },
|
||||
].map((item) => ({
|
||||
...item,
|
||||
active: formatValue === item.key,
|
||||
onSelect: () => {
|
||||
onFormatChange(item.key);
|
||||
onCommand('format', item.key);
|
||||
},
|
||||
}));
|
||||
|
||||
// 点击别处或按 Esc 收起引用样式菜单。
|
||||
useEffect(() => {
|
||||
if (!quoteOpen) {
|
||||
return undefined;
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
if (!quoteRef.current?.contains(event.target as Node)) {
|
||||
setQuoteOpen(false);
|
||||
}
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setQuoteOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [quoteOpen]);
|
||||
const listItems: ToolMenuItem[] = [
|
||||
{ key: 'ul', label: __('无序列表', 'jio'), icon: 'fa fas fa-list-ul' },
|
||||
{ key: 'ol', label: __('有序列表', 'jio'), icon: 'fa fas fa-list-ol' },
|
||||
].map((item) => ({
|
||||
...item,
|
||||
active: blockValue === item.key,
|
||||
onSelect: () => onInsertBlock(item.key),
|
||||
}));
|
||||
|
||||
const alignItems: ToolMenuItem[] = [
|
||||
{ key: 'left', label: __('居左对齐', 'jio'), icon: 'fa fas fa-align-left' },
|
||||
{ key: 'center', label: __('居中对齐', 'jio'), icon: 'fa fas fa-align-center' },
|
||||
{ key: 'right', label: __('居右对齐', 'jio'), icon: 'fa fas fa-align-right' },
|
||||
].map((item) => ({
|
||||
...item,
|
||||
active: alignValue === item.key,
|
||||
onSelect: () => onCommand('align', item.key),
|
||||
}));
|
||||
|
||||
const quoteItems: ToolMenuItem[] = QUOTE_OPTIONS.map((option) => ({
|
||||
key: option.key || 'default',
|
||||
label: option.label,
|
||||
swatch: option.swatch,
|
||||
active: blockValue === (option.key || 'quote'),
|
||||
onSelect: () => onInsertQuote(option.key),
|
||||
}));
|
||||
|
||||
const hideItems: ToolMenuItem[] = HIDE_OPTIONS.map((option) => ({
|
||||
key: option.key,
|
||||
label: option.label,
|
||||
icon: option.icon,
|
||||
onSelect: () => onInsertHidden(option.key),
|
||||
}));
|
||||
|
||||
// 触发器图标跟随当前状态(对齐方式 / 列表类型),一眼能看出光标所在段落。
|
||||
const alignIcon =
|
||||
alignValue === 'center'
|
||||
? 'fa fas fa-align-center'
|
||||
: alignValue === 'right'
|
||||
? 'fa fas fa-align-right'
|
||||
: 'fa fas fa-align-left';
|
||||
const listIcon = blockValue === 'ol' ? 'fa fas fa-list-ol' : 'fa fas fa-list-ul';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -108,26 +167,14 @@ export function EditorToolbar({
|
||||
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
|
||||
|
||||
<div className="jio-creator__toolbar-group" role="group" aria-label={__('段落样式', 'jio')}>
|
||||
<label className="jio-creator__format-field">
|
||||
<select
|
||||
className="jio-creator__format-select"
|
||||
aria-label={__('标题级别', 'jio')}
|
||||
value={formatValue === 'blockquote' ? 'p' : 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>
|
||||
</select>
|
||||
<i className="fa fas fa-heading" aria-hidden="true" />
|
||||
<span className="jio-creator__tool-label">{__('标题', 'jio')}</span>
|
||||
</label>
|
||||
<ToolMenu
|
||||
label={__('标题', 'jio')}
|
||||
icon="fa fas fa-heading"
|
||||
items={headingItems}
|
||||
ready={ready}
|
||||
width={168}
|
||||
onRememberSelection={onRememberSelection}
|
||||
/>
|
||||
</div>
|
||||
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
|
||||
|
||||
@@ -139,35 +186,45 @@ export function EditorToolbar({
|
||||
<span className="jio-creator__toolbar-divider" aria-hidden="true" />
|
||||
|
||||
<div className="jio-creator__toolbar-group" role="group" aria-label={__('段落块', 'jio')}>
|
||||
<ToolButton label={__('列表', 'jio')} icon="fa fas fa-list-ul" ready={ready} onClick={() => onInsertBlock('list')} />
|
||||
<ToolButton label={__('目录', 'jio')} icon="fa fas fa-list-ol" ready={ready} onClick={() => onInsertBlock('toc')} />
|
||||
<div className="jio-creator__quote" ref={quoteRef}>
|
||||
<ToolButton label={__('引用', 'jio')} icon="fa fas fa-quote-left" ready={ready} onClick={() => setQuoteOpen((open) => !open)} />
|
||||
{quoteOpen && (
|
||||
<div className="jio-creator__quote-menu" role="menu" aria-label={__('引用样式', 'jio')}>
|
||||
{[
|
||||
{ key: '' as const, label: __('灰色引用', 'jio'), className: '' },
|
||||
{ key: 'qe_wzk_c-red' as const, label: __('红色引用', 'jio'), className: 'is-red' },
|
||||
{ key: 'qe_wzk_lan' as const, label: __('蓝色引用', 'jio'), className: 'is-blue' },
|
||||
{ key: 'qe_wzk_lv' as const, label: __('绿色引用', 'jio'), className: 'is-green' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.key || 'default'}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={`jio-creator__quote-option ${option.className}`.trim()}
|
||||
onClick={() => {
|
||||
setQuoteOpen(false);
|
||||
onInsertQuote(option.key);
|
||||
}}
|
||||
>
|
||||
<span className="jio-creator__quote-swatch" aria-hidden="true" />
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ToolMenu
|
||||
label={__('列表', 'jio')}
|
||||
icon={listIcon}
|
||||
items={listItems}
|
||||
ready={ready}
|
||||
width={168}
|
||||
onRememberSelection={onRememberSelection}
|
||||
active={blockValue === 'ul' || blockValue === 'ol'}
|
||||
/>
|
||||
<ToolMenu
|
||||
label={__('对齐', 'jio')}
|
||||
icon={alignIcon}
|
||||
items={alignItems}
|
||||
ready={ready}
|
||||
width={168}
|
||||
onRememberSelection={onRememberSelection}
|
||||
active={alignValue !== 'left'}
|
||||
title={__('对齐方式', 'jio')}
|
||||
/>
|
||||
<ToolMenu
|
||||
label={__('引用', 'jio')}
|
||||
icon="fa fas fa-quote-left"
|
||||
items={quoteItems}
|
||||
ready={ready}
|
||||
width={168}
|
||||
onRememberSelection={onRememberSelection}
|
||||
active={blockValue === 'quote' || QUOTE_OPTIONS.some((option) => option.key === blockValue)}
|
||||
/>
|
||||
{capabilities.canHide && (
|
||||
<ToolMenu
|
||||
label={__('隐藏', 'jio')}
|
||||
icon="fa fas fa-eye-slash"
|
||||
items={hideItems}
|
||||
ready={ready}
|
||||
width={190}
|
||||
onRememberSelection={onRememberSelection}
|
||||
title={__('隐藏内容', 'jio')}
|
||||
/>
|
||||
)}
|
||||
<ToolButton label={__('分割线', 'jio')} icon="fa fas fa-minus" ready={ready} onClick={() => onInsertBlock('divider')} />
|
||||
<ToolButton label={__('代码块', 'jio')} icon="fa fas fa-code" ready={ready} onClick={() => onInsertBlock('code')} />
|
||||
<ToolButton label={__('注释', 'jio')} icon="fa fas fa-comment-dots" ready={ready} onClick={() => onInsertBlock('footnote')} />
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 工具栏下拉控件。
|
||||
*
|
||||
* 工具条本身横向滚动(`overflow-x: auto`),如果菜单直接绝对定位在按钮下方,
|
||||
* 会被滚动容器裁掉、也只能复用生硬的原生 <select>。这里统一:
|
||||
* - 触发器与 ToolButton 共用同一套几何(同一行高、同一文字层级);
|
||||
* - 菜单通过 portal 挂到 document.body,按触发器位置 fixed 定位,永不被裁剪;
|
||||
* - 打开 / 关闭有过渡,点击外部、Esc、滚动都会收起。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { createPortal, useEffect, useLayoutEffect, useRef, useState } from '@wordpress/element';
|
||||
|
||||
export type ToolMenuItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
/** 当前是否已应用(菜单里显示勾选态)。 */
|
||||
active?: boolean;
|
||||
/** 副标题:用于图标 + 主标题 + 说明的三段式菜单。 */
|
||||
hint?: string;
|
||||
/** 色块:引用 / 隐藏内容之类需要预览颜色的菜单项。 */
|
||||
swatch?: string;
|
||||
onSelect: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
label: string;
|
||||
icon: string;
|
||||
items: ToolMenuItem[];
|
||||
ready: boolean;
|
||||
title?: string;
|
||||
/** 触发器是否显示激活态(例如光标已在引用块内)。 */
|
||||
active?: boolean;
|
||||
/** 菜单宽度(px)。 */
|
||||
width?: number;
|
||||
onRememberSelection?: () => void;
|
||||
};
|
||||
|
||||
type Position = { left: number; top: number; placement: 'bottom' | 'top' };
|
||||
|
||||
const MENU_GAP = 8;
|
||||
|
||||
export function ToolMenu({
|
||||
label,
|
||||
icon,
|
||||
items,
|
||||
ready,
|
||||
title,
|
||||
active = false,
|
||||
width = 216,
|
||||
onRememberSelection,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [position, setPosition] = useState<Position>({ left: 0, top: 0, placement: 'bottom' });
|
||||
|
||||
// 打开时按触发器矩形定位;优先向下展开,下方放不下时翻到上方。
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) {
|
||||
return;
|
||||
}
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const menuHeight = menuRef.current?.offsetHeight || 240;
|
||||
const placement: Position['placement'] =
|
||||
rect.bottom + MENU_GAP + menuHeight > window.innerHeight && rect.top > menuHeight
|
||||
? 'top'
|
||||
: 'bottom';
|
||||
const left = Math.min(
|
||||
Math.max(MENU_GAP, rect.left + rect.width / 2 - width / 2),
|
||||
Math.max(MENU_GAP, window.innerWidth - width - MENU_GAP)
|
||||
);
|
||||
const top = placement === 'bottom' ? rect.bottom + MENU_GAP : rect.top - menuHeight - MENU_GAP;
|
||||
setPosition({ left, top, placement });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [open, width]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const onPointerDown = (event: PointerEvent) => {
|
||||
const target = event.target as Node;
|
||||
if (triggerRef.current?.contains(target) || menuRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
const onScroll = () => setOpen(false);
|
||||
document.addEventListener('pointerdown', onPointerDown);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const menu = open
|
||||
? createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`jio-creator__menu is-${position.placement}`}
|
||||
style={{ left: `${position.left}px`, top: `${position.top}px`, width: `${width}px` }}
|
||||
role="menu"
|
||||
aria-label={title || label}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={item.active ? 'true' : 'false'}
|
||||
className={`jio-creator__menu-item${item.active ? ' is-active' : ''}`}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
item.onSelect();
|
||||
}}
|
||||
>
|
||||
{item.swatch ? (
|
||||
<span
|
||||
className="jio-creator__menu-swatch"
|
||||
style={{ background: item.swatch }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<i className={`${item.icon || 'fa fas fa-check'} jio-creator__menu-icon`} aria-hidden="true" />
|
||||
)}
|
||||
<span className="jio-creator__menu-text">
|
||||
<span className="jio-creator__menu-label">{item.label}</span>
|
||||
{item.hint && <span className="jio-creator__menu-hint">{item.hint}</span>}
|
||||
</span>
|
||||
<i className="fa fas fa-check jio-creator__menu-check" aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="jio-creator__tool-menu">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`jio-creator__tool-btn${active ? ' is-active' : ''}${open ? ' is-open' : ''}`}
|
||||
disabled={!ready}
|
||||
onClick={() => {
|
||||
onRememberSelection?.();
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
title={title || label}
|
||||
aria-label={title || label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open ? 'true' : 'false'}
|
||||
>
|
||||
<i className={icon} aria-hidden="true" />
|
||||
<span className="jio-creator__tool-label">{label}</span>
|
||||
<i className="fa fas fa-caret-down jio-creator__tool-caret" aria-hidden="true" />
|
||||
</button>
|
||||
{menu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
* TinyMCE iframe 内的正文样式。
|
||||
*
|
||||
* 编辑器正文跑在 iframe 里,页面级 SCSS 影响不到它,所以这里显式给出:
|
||||
* 正文排版、主题引用 .quote_q、表格、公式、目录占位、块级删除控件,以及
|
||||
* 代码块右上角的语言标签。
|
||||
* 正文排版、主题引用 .quote_q、隐藏内容 .tinymce-hide、表格、公式、
|
||||
* 块级删除控件与块选中态。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
@@ -25,16 +25,14 @@ img { height: auto; }
|
||||
|
||||
/*
|
||||
* 块级删除控件。它由 editor-runtime 插进 iframe 的 body,样式必须写在这里:
|
||||
* 父页面样式表管不到 iframe 内部,缺这条规则时按钮会退化成正文顶部的常显 ✕。
|
||||
* 父页面样式表管不到 iframe 内部,缺这条规则时按钮会退化成正文顶部的常显小方块。
|
||||
* 正文是流式布局且 iframe 高度随内容增长,所以按整篇正文定位,而不是相对某个块。
|
||||
*/
|
||||
#jio-block-actions {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
left: 6px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
@@ -45,59 +43,72 @@ img { height: auto; }
|
||||
}
|
||||
#jio-block-actions .jio-creator__block-action {
|
||||
display: inline-flex;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
gap: 5px;
|
||||
padding: 0 9px;
|
||||
border: 1px solid #e3e7e5;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #7d8683;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 2px 8px rgba(24, 39, 34, 0.08);
|
||||
cursor: pointer;
|
||||
}
|
||||
#jio-block-actions .jio-creator__block-action-icon {
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
}
|
||||
#jio-block-actions .jio-creator__block-action:hover {
|
||||
border-color: #e6b8ad;
|
||||
border-color: #cdd6d2;
|
||||
color: #4f5957;
|
||||
}
|
||||
#jio-block-actions.is-selected .jio-creator__block-action {
|
||||
border-color: #e0a99b;
|
||||
background: #fdf2ee;
|
||||
color: #b4573f;
|
||||
}
|
||||
#jio-block-actions.is-selected .jio-creator__block-action:hover {
|
||||
border-color: #cf8574;
|
||||
background: #fbe7e1;
|
||||
}
|
||||
|
||||
/* 代码块:右上角显示当前语言,点击即可更换(交互在父组件里接管)。 */
|
||||
.enlighter-pre {
|
||||
position: relative;
|
||||
/* 第一次点击选中的块:虚线高亮,明确「还没删」。 */
|
||||
.jio-creator__block-selected {
|
||||
outline: 2px dashed rgba(180, 87, 63, 0.55);
|
||||
outline-offset: 3px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
* 代码块:与前台文章页保持一致,只用 pre > code。
|
||||
* 前台由 EnlighterJS 高亮,编辑器里给出同一份底色与等宽排版。
|
||||
*/
|
||||
pre {
|
||||
margin: 0 0 1.1em;
|
||||
padding: 40px 14px 14px;
|
||||
overflow: auto;
|
||||
padding: 14px 18px;
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
background: #f2f4f5;
|
||||
cursor: pointer;
|
||||
color: #2b2b2b;
|
||||
font-family: Consolas, Monaco, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.enlighter-pre code.gl {
|
||||
display: block;
|
||||
pre code {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #2b2b2b;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.enlighter-pre .jio-code-lang {
|
||||
position: absolute;
|
||||
top: 9px;
|
||||
right: 9px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(135deg, #3dcabd 10%, #254bdd 100%);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.enlighter-pre .jio-code-lang:hover {
|
||||
filter: brightness(1.08);
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
padding: 0;
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
/* 引用:与前台主题的 .quote_q 视觉一致。 */
|
||||
@@ -106,24 +117,49 @@ img { height: auto; }
|
||||
margin: 10px 0 20px;
|
||||
padding: 1.2em;
|
||||
border-radius: 6px;
|
||||
color: var(--quote-color, #555);
|
||||
background: rgba(85, 85, 85, 0.04);
|
||||
--quote-color: #555;
|
||||
color: var(--quote-color);
|
||||
}
|
||||
.quote_q.qe_wzk_c-red { --quote-color: #dc3f3f; background: rgba(220, 63, 63, 0.05); }
|
||||
.quote_q.qe_wzk_lan { --quote-color: #0092ff; background: rgba(0, 146, 255, 0.05); }
|
||||
.quote_q.qe_wzk_lv { --quote-color: #23790e; background: rgba(35, 121, 14, 0.05); }
|
||||
.quote_q::after {
|
||||
.quote_q::before {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 18px;
|
||||
color: var(--quote-color, #555);
|
||||
content: '\\201C';
|
||||
font-size: 1.4em;
|
||||
inset: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--quote-color);
|
||||
content: '';
|
||||
opacity: 0.04;
|
||||
}
|
||||
.quote_q.quote-mce::after {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
color: var(--quote-color);
|
||||
content: '';
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
/* 引号用图标字体绘制时依赖 FA;这里用内联 SVG 数据,编辑器与前台都不缺图标。 */
|
||||
background: var(--quote-color);
|
||||
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M464 256h-80v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8c-88.4 0-160 71.6-160 160v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48zm-288 0H96v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8C71.6 32 0 103.6 0 192v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48z'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E%3Cpath d='M464 256h-80v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8c-88.4 0-160 71.6-160 160v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48zm-288 0H96v-64c0-35.3 28.7-64 64-64h8c13.3 0 24-10.7 24-24V56c0-13.3-10.7-24-24-24h-8C71.6 32 0 103.6 0 192v240c0 26.5 21.5 48 48 48h128c26.5 0 48-21.5 48-48V304c0-26.5-21.5-48-48-48z'/%3E%3C/svg%3E") center / contain no-repeat;
|
||||
}
|
||||
.quote_q > div { padding-left: 2em; opacity: 0.85; }
|
||||
.quote_q > div > p:last-child { margin-bottom: 0; }
|
||||
.quote_q > div {
|
||||
position: relative;
|
||||
min-height: 24px;
|
||||
padding-left: 2em;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.quote_q > div > :first-child { margin-top: 0; }
|
||||
.quote_q > div > :last-child { margin-bottom: 0; }
|
||||
.quote_q [contenteditable="true"] {
|
||||
outline: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
.quote_q.qe_wzk_c-red { --quote-color: #dc3f3f; }
|
||||
.quote_q.qe_wzk_lan { --quote-color: #0092ff; }
|
||||
.quote_q.qe_wzk_lv { --quote-color: #23790e; }
|
||||
|
||||
/* 表格。 */
|
||||
.jio-content-table {
|
||||
@@ -145,7 +181,7 @@ img { height: auto; }
|
||||
}
|
||||
.jio-content-table.is-striped tbody tr:nth-child(odd) td { background: #f7f9f8; }
|
||||
|
||||
/* 公式。 */
|
||||
/* 公式:独占一行时居中显示,不铺满整行底色。 */
|
||||
.jio-math {
|
||||
font-family: "Cambria Math", "Latin Modern Math", "Times New Roman", serif;
|
||||
font-size: 1.05em;
|
||||
@@ -153,25 +189,57 @@ img { height: auto; }
|
||||
}
|
||||
.jio-math--block {
|
||||
display: block;
|
||||
margin: 12px 0;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: #f8faf9;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2px 0;
|
||||
overflow-x: auto;
|
||||
text-align: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
.jio-math sup, .jio-math sub { font-size: 0.72em; }
|
||||
.jio-toc-placeholder {
|
||||
height: 40px;
|
||||
margin: 0 0 1.1em;
|
||||
border: 1px dashed #d3ded8;
|
||||
border-radius: 8px;
|
||||
background: #f7faf9;
|
||||
color: #8c9a94;
|
||||
font-size: 12px;
|
||||
line-height: 40px;
|
||||
text-align: center;
|
||||
|
||||
/* 隐藏内容:短代码放进 .hide-before / .hide-after,编辑器只显示可读标签。 */
|
||||
.tinymce-hide {
|
||||
position: relative;
|
||||
margin: 18px 0;
|
||||
padding: 38px 14px 14px;
|
||||
border: 1px dashed rgba(240, 68, 148, 0.35);
|
||||
border-radius: 6px;
|
||||
background: rgba(240, 68, 148, 0.04);
|
||||
}
|
||||
.jio-toc-placeholder::after { content: '目录将在发布后自动生成'; }
|
||||
.tinymce-hide > .hide-before,
|
||||
.tinymce-hide > .hide-after {
|
||||
margin: 0;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 0;
|
||||
line-height: 1.5;
|
||||
color: #f04494;
|
||||
}
|
||||
.tinymce-hide > .hide-before {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 14px;
|
||||
}
|
||||
.tinymce-hide > .hide-before::before {
|
||||
content: attr(data-jio-hide-label);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.tinymce-hide > .hide-after {
|
||||
display: none;
|
||||
}
|
||||
.tinymce-hide > [contenteditable="true"] {
|
||||
min-height: 38px;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
outline: 0;
|
||||
}
|
||||
.tinymce-hide > [contenteditable="true"] > :first-child { margin-top: 0; }
|
||||
.tinymce-hide > [contenteditable="true"] > :last-child { margin-bottom: 0; }
|
||||
|
||||
/* 注释:编辑器里显示上标标记,发布后由服务端渲染编号与注释列表。 */
|
||||
.jio-footnote-ref {
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
/**
|
||||
* TinyMCE 编辑器增强:块级删除、代码块语言角标、自适应高度。
|
||||
* TinyMCE 编辑器增强:块级两步删除、自适应高度。
|
||||
*
|
||||
* 这些行为都作用在编辑器 iframe 内部,与 React 状态无关,因此独立成模块。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
|
||||
import { CODE_LANGUAGES } from './utils/block-markup';
|
||||
|
||||
/** 可整体删除的块级元素选择器。 */
|
||||
const BLOCK_SELECTOR = 'table,pre.enlighter-pre,hr,.quote_q,p';
|
||||
const BLOCK_SELECTOR = 'table,pre,hr,.quote_q,.tinymce-hide,p';
|
||||
|
||||
/** 可整体删除的块级元素(排除普通段落)。 */
|
||||
const STRUCTURAL_SELECTOR = 'table,pre,hr,.quote_q,.jio-math--block,.jio-toc-placeholder';
|
||||
/** 结构块(不能从内部被拆开的块)。 */
|
||||
const STRUCTURAL_SELECTOR = 'table,pre,hr,.quote_q,.tinymce-hide,.jio-math--block';
|
||||
|
||||
/**
|
||||
* Backspace / Delete 删除相邻的块级元素。
|
||||
@@ -108,41 +106,14 @@ export function syncEditorHeight(editor: any): void {
|
||||
iframe.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
/**
|
||||
* 在编辑器里为每个代码块渲染语言角标。
|
||||
*
|
||||
* 角标是编辑期提示(纯 UI),保存前由 serializeBlocks 移除。
|
||||
*/
|
||||
export function syncCodeLabels(editor: any): void {
|
||||
const root: HTMLElement | null = editor?.getBody?.() ?? null;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
root.querySelectorAll<HTMLElement>('pre.enlighter-pre[data-jio-code]').forEach((pre) => {
|
||||
const existing = pre.querySelector(':scope > .jio-code-lang');
|
||||
const language = pre.getAttribute('data-enlighter-language') || 'generic';
|
||||
const label = CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
|
||||
if (existing) {
|
||||
existing.setAttribute('data-jio-code-lang', language);
|
||||
if ((existing.textContent || '') !== label) {
|
||||
existing.textContent = label;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'jio-code-lang';
|
||||
badge.contentEditable = 'false';
|
||||
badge.setAttribute('data-jio-code-lang', language);
|
||||
badge.textContent = label;
|
||||
pre.insertBefore(badge, pre.firstChild);
|
||||
});
|
||||
}
|
||||
/** 块选中状态写在块自身的 class 上,跨 iframe 重排也能保留。 */
|
||||
const SELECTED_CLASS = 'jio-creator__block-selected';
|
||||
|
||||
/**
|
||||
* 给表格 / 代码块 / 引用等块渲染一个浮层删除按钮。
|
||||
*
|
||||
* 与 WPS 的块操作一致:鼠标悬停到块上时在右上角出现删除入口,
|
||||
* 避免只能靠反复按退格键。
|
||||
* 与 WPS 一致的两步删除:第一次点击只是选中该块并高亮,第二次点击才真正删除。
|
||||
* 选中态下按钮改为醒目的红色「再次点击删除」,且鼠标移开不会消失,避免误删。
|
||||
*
|
||||
* 浮层直接插在 iframe 的 body 里,因此用 data-mce-bogus 让 TinyMCE 在
|
||||
* 序列化、字数和选区计算时完全忽略它;它的样式写在 editor-content-style.ts
|
||||
@@ -159,62 +130,158 @@ export function installBlockActions(editor: any): void {
|
||||
// "all" 让 TinyMCE 连同子节点一起跳过。
|
||||
bar.setAttribute('data-mce-bogus', 'all');
|
||||
bar.innerHTML =
|
||||
'<button type="button" class="jio-creator__block-action" data-jio-block-delete title="' +
|
||||
'删除此块' +
|
||||
'" aria-label="删除此块"><span aria-hidden="true">✕</span></button>';
|
||||
'<button type="button" class="jio-creator__block-action" data-jio-block-delete>' +
|
||||
'<span class="jio-creator__block-action-icon" aria-hidden="true">✕</span>' +
|
||||
'<span class="jio-creator__block-action-text"></span>' +
|
||||
'</button>';
|
||||
doc.body.appendChild(bar);
|
||||
|
||||
let current: HTMLElement | null = null;
|
||||
const button: HTMLButtonElement | null = bar.querySelector('[data-jio-block-delete]');
|
||||
const text: HTMLElement | null = bar.querySelector('.jio-creator__block-action-text');
|
||||
let hovered: HTMLElement | null = null;
|
||||
let selected: HTMLElement | null = null;
|
||||
|
||||
const hide = () => {
|
||||
current = null;
|
||||
bar.classList.remove('is-visible');
|
||||
/** 底部状态栏以外的提示文字。 */
|
||||
const labelFor = (state: 'idle' | 'selected'): string =>
|
||||
state === 'selected' ? '再次点击删除此块' : '选中此块';
|
||||
|
||||
const paint = (state: 'idle' | 'selected'): void => {
|
||||
bar.classList.toggle('is-selected', state === 'selected');
|
||||
if (text) {
|
||||
text.textContent = labelFor(state);
|
||||
}
|
||||
const label = labelFor(state);
|
||||
button?.setAttribute('title', label);
|
||||
button?.setAttribute('aria-label', label);
|
||||
};
|
||||
|
||||
const show = (block: HTMLElement) => {
|
||||
const place = (block: HTMLElement): void => {
|
||||
const rect = block.getBoundingClientRect();
|
||||
// 右上角贴住块,但不越出正文左边界(窄块时按钮会溢出)。
|
||||
const left = Math.max(rect.left + 8, rect.right - 96);
|
||||
bar.style.top = `${rect.top + doc.documentElement.scrollTop + 6}px`;
|
||||
bar.style.left = `${left + doc.documentElement.scrollLeft}px`;
|
||||
};
|
||||
|
||||
const show = (block: HTMLElement | null): void => {
|
||||
if (!block || block === doc.body) {
|
||||
hide();
|
||||
bar.classList.remove('is-visible');
|
||||
return;
|
||||
}
|
||||
// 空段落没有删除价值,交给退格键处理。
|
||||
if (block.tagName === 'P' && (block.textContent || '').trim() === '') {
|
||||
hide();
|
||||
if (block.tagName === 'P' && (block.textContent || '').trim() === '' && !block.querySelector('img,iframe,table,pre')) {
|
||||
bar.classList.remove('is-visible');
|
||||
return;
|
||||
}
|
||||
current = block;
|
||||
const rect = block.getBoundingClientRect();
|
||||
bar.style.top = `${rect.top + doc.documentElement.scrollTop + 6}px`;
|
||||
bar.style.left = `${rect.right - 40}px`;
|
||||
hovered = block;
|
||||
place(block);
|
||||
bar.classList.add('is-visible');
|
||||
};
|
||||
|
||||
const deselect = (): void => {
|
||||
if (selected) {
|
||||
selected.classList.remove(SELECTED_CLASS);
|
||||
}
|
||||
selected = null;
|
||||
paint('idle');
|
||||
if (hovered) {
|
||||
show(hovered);
|
||||
} else {
|
||||
bar.classList.remove('is-visible');
|
||||
}
|
||||
};
|
||||
|
||||
doc.addEventListener('mouseover', (event: MouseEvent) => {
|
||||
const block = (event.target as HTMLElement)?.closest?.(BLOCK_SELECTOR) as HTMLElement | null;
|
||||
if (block && block !== doc.body && !block.classList.contains('jio-code-lang')) {
|
||||
show(block);
|
||||
if (!block || block === doc.body) {
|
||||
return;
|
||||
}
|
||||
// 选中块时保持浮层贴在选中块上,不受悬停目标影响。
|
||||
if (selected) {
|
||||
return;
|
||||
}
|
||||
show(block);
|
||||
});
|
||||
|
||||
doc.addEventListener('mouseleave', hide);
|
||||
doc.addEventListener('mouseleave', () => {
|
||||
hovered = null;
|
||||
if (selected) {
|
||||
place(selected);
|
||||
return;
|
||||
}
|
||||
bar.classList.remove('is-visible');
|
||||
});
|
||||
|
||||
bar.addEventListener('mousedown', (event: MouseEvent) => event.preventDefault());
|
||||
bar.addEventListener('click', (event: MouseEvent) => {
|
||||
const button = (event.target as HTMLElement)?.closest('[data-jio-block-delete]');
|
||||
if (!button || !current) {
|
||||
const onClick = (event: MouseEvent): void => {
|
||||
const target = (event.target as HTMLElement)?.closest('[data-jio-block-delete]');
|
||||
const block = selected || hovered;
|
||||
if (!target || !block) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const target = current;
|
||||
hide();
|
||||
|
||||
if (selected !== block) {
|
||||
// 第一次点击:只选中,不删除。
|
||||
if (selected) {
|
||||
selected.classList.remove(SELECTED_CLASS);
|
||||
}
|
||||
selected = block;
|
||||
block.classList.add(SELECTED_CLASS);
|
||||
paint('selected');
|
||||
place(block);
|
||||
bar.classList.add('is-visible');
|
||||
return;
|
||||
}
|
||||
|
||||
// 第二次点击:真正删除。
|
||||
const victim = block;
|
||||
const parent = victim.parentElement;
|
||||
victim.classList.remove(SELECTED_CLASS);
|
||||
selected = null;
|
||||
hovered = null;
|
||||
bar.classList.remove('is-visible', 'is-selected');
|
||||
paint('idle');
|
||||
editor.undoManager.transact(() => {
|
||||
editor.dom.remove(target);
|
||||
editor.dom.remove(victim);
|
||||
});
|
||||
editor.nodeChanged();
|
||||
editor.undoManager.add();
|
||||
if (editor.getBody().children.length === 0) {
|
||||
editor.setContent('<p></p>');
|
||||
editor.selection.setCursorLocation(editor.getBody().firstChild, 0);
|
||||
}
|
||||
if (parent && parent.children.length === 0) {
|
||||
editor.setContent('<p></p>');
|
||||
}
|
||||
const first = editor.getBody().firstChild;
|
||||
if (first) {
|
||||
editor.selection.setCursorLocation(first, 0);
|
||||
}
|
||||
editor.nodeChanged();
|
||||
editor.undoManager.add();
|
||||
syncEditorHeight(editor);
|
||||
};
|
||||
bar.addEventListener('click', onClick);
|
||||
|
||||
// 点击正文其它位置或按 Esc 取消选中态。
|
||||
doc.addEventListener('mousedown', (event: MouseEvent) => {
|
||||
if (bar.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
if (selected) {
|
||||
deselect();
|
||||
}
|
||||
});
|
||||
doc.addEventListener('keydown', (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && selected) {
|
||||
deselect();
|
||||
}
|
||||
});
|
||||
|
||||
// 内容被撤销 / 重排后,被选中的块可能已不在文档里。
|
||||
editor.on('Undo Redo SetContent', () => {
|
||||
if (selected && !doc.body.contains(selected)) {
|
||||
selected = null;
|
||||
paint('idle');
|
||||
bar.classList.remove('is-visible', 'is-selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -26,11 +26,12 @@ import {
|
||||
codeBlockMarkup,
|
||||
dividerMarkup,
|
||||
footnoteMarkup,
|
||||
listMarkup,
|
||||
hiddenContentMarkup,
|
||||
mathMarkup,
|
||||
quoteMarkup,
|
||||
retargetHiddenContent,
|
||||
tableMarkup,
|
||||
tocMarkup,
|
||||
type HideKind,
|
||||
type QuoteColor,
|
||||
} from './utils/block-markup';
|
||||
import { BlockDialog, type BlockDialogKind, type BlockDialogResult } from './components/block-dialog';
|
||||
@@ -38,7 +39,6 @@ import { type Draft, type Limits, initialDraft } from './draft';
|
||||
import {
|
||||
installBlockActions,
|
||||
removeAdjacentBlock,
|
||||
syncCodeLabels,
|
||||
syncEditorHeight,
|
||||
} from './editor-runtime';
|
||||
import './styles/studio.scss';
|
||||
@@ -83,6 +83,8 @@ function Studio(): JSX.Element {
|
||||
const [editorReady, setEditorReady] = useState(false);
|
||||
const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false });
|
||||
const [formatValue, setFormatValue] = useState('p');
|
||||
const [alignValue, setAlignValue] = useState('left');
|
||||
const [blockValue, setBlockValue] = useState('p');
|
||||
const [saveState, setSaveState] = useState<SaveStateValue>(() => ({
|
||||
state: bootstrap.sourceId || bootstrap.versionId || bootstrap.content ? 'saved' : 'ready',
|
||||
at: '',
|
||||
@@ -137,9 +139,39 @@ function Studio(): JSX.Element {
|
||||
|
||||
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 block = node ? editor.dom.getParent(node, 'p,h1,h2,h3,h4,h5,h6') || node : null;
|
||||
const name = block?.nodeName?.toLowerCase() || 'p';
|
||||
setFormatValue(['h1', 'h2', 'h3', 'blockquote'].includes(name) ? name : 'p');
|
||||
setFormatValue(['h1', 'h2', 'h3', 'h4'].includes(name) ? name : 'p');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 同步工具栏的激活态:对齐方式、列表类型、以及光标是否在引用 / 隐藏内容里。
|
||||
* 只读编辑器状态,不写 DOM,所以可以安全地在 NodeChange 上高频调用。
|
||||
*/
|
||||
const updateBlockValue = useCallback((editor: any) => {
|
||||
const node = editor?.selection?.getNode();
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
const body = editor.getBody();
|
||||
const alignment = String(editor.formatter?.match?.(['alignleft', 'aligncenter', 'alignright']) || '');
|
||||
setAlignValue(alignment === 'aligncenter' ? 'center' : alignment === 'alignright' ? 'right' : 'left');
|
||||
|
||||
const quote = editor.dom.getParent(node, '.quote_q', body);
|
||||
if (quote) {
|
||||
const color = Array.from(quote.classList as unknown as string[]).find((name) => name.indexOf('qe_wzk_') === 0);
|
||||
setBlockValue(color || 'quote');
|
||||
return;
|
||||
}
|
||||
if (editor.dom.getParent(node, 'ul', body)) {
|
||||
setBlockValue('ul');
|
||||
return;
|
||||
}
|
||||
if (editor.dom.getParent(node, 'ol', body)) {
|
||||
setBlockValue('ol');
|
||||
return;
|
||||
}
|
||||
setBlockValue('p');
|
||||
}, []);
|
||||
|
||||
const buildPayload = useCallback((extra: Record<string, any> = {}) => {
|
||||
@@ -359,18 +391,19 @@ function Studio(): JSX.Element {
|
||||
} else {
|
||||
editor.setContent(hydrateBlocks(editor.getContent()));
|
||||
}
|
||||
syncCodeLabels(editor);
|
||||
syncEditorHeight(editor);
|
||||
installBlockActions(editor);
|
||||
updateHistoryAvailability(editor);
|
||||
updateFormatValue(editor);
|
||||
updateBlockValue(editor);
|
||||
});
|
||||
editor.on('NodeChange', () => {
|
||||
updateFormatValue(editor);
|
||||
updateBlockValue(editor);
|
||||
});
|
||||
editor.on('NodeChange', () => updateFormatValue(editor));
|
||||
// 代码块右上角的语言标签:点击整块即可换语言,等价于公众号的「高亮」入口。
|
||||
editor.on('input change Undo Redo AddUndo', () => {
|
||||
updateContent(serializeBlocks(editor.getContent()));
|
||||
updateHistoryAvailability(editor);
|
||||
syncCodeLabels(editor);
|
||||
syncEditorHeight(editor);
|
||||
});
|
||||
editor.on('keydown', (event: any) => {
|
||||
@@ -391,7 +424,7 @@ function Studio(): JSX.Element {
|
||||
editorReadyRef.current = false;
|
||||
editorRef.current = null;
|
||||
};
|
||||
}, [updateContent]);
|
||||
}, [updateContent, updateBlockValue, updateFormatValue]);
|
||||
|
||||
const restore = useCallback(() => {
|
||||
try {
|
||||
@@ -450,17 +483,19 @@ function Studio(): JSX.Element {
|
||||
} 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');
|
||||
}
|
||||
const block = value || 'p';
|
||||
editor.execCommand('FormatBlock', false, block);
|
||||
updateFormatValue(editor);
|
||||
} else if (name === 'align') {
|
||||
editor.execCommand(
|
||||
value === 'center' ? 'JustifyCenter' : value === 'right' ? 'JustifyRight' : 'JustifyLeft'
|
||||
);
|
||||
updateBlockValue(editor);
|
||||
} else {
|
||||
editor.execCommand(name);
|
||||
}
|
||||
editor.nodeChanged();
|
||||
}, [focusEditor]);
|
||||
}, [focusEditor, updateBlockValue, updateFormatValue]);
|
||||
|
||||
const openLink = useCallback(() => {
|
||||
const editor = focusEditor();
|
||||
@@ -485,52 +520,109 @@ function Studio(): JSX.Element {
|
||||
return;
|
||||
}
|
||||
editor.insertContent(html);
|
||||
// 让刚插入的块成为一次可撤销的操作(insertContent 本身不进 undo 栈)。
|
||||
editor.undoManager.add();
|
||||
editor.nodeChanged();
|
||||
updateContent(serializeBlocks(editor.getContent()));
|
||||
updateHistoryAvailability(editor);
|
||||
syncCodeLabels(editor);
|
||||
syncEditorHeight(editor);
|
||||
}, [focusEditor]);
|
||||
|
||||
// 直接插入、无需参数的小块;需要参数的块打开通用弹窗。
|
||||
const insertBlock = useCallback((kind: string) => {
|
||||
rememberSelection();
|
||||
// code 走「语言选择」弹窗,其余三类走通用插入弹窗。
|
||||
if (kind === 'table' || kind === 'math' || kind === 'footnote') {
|
||||
setBlockDialog(kind as BlockDialogKind);
|
||||
return;
|
||||
}
|
||||
if (kind === 'code') {
|
||||
// 直接插入;语言可稍后点代码块右上角的角标更换。
|
||||
insertHtml(codeBlockMarkup('generic'));
|
||||
insertHtml(codeBlockMarkup());
|
||||
return;
|
||||
}
|
||||
if (kind === 'list') {
|
||||
insertHtml(listMarkup(false));
|
||||
return;
|
||||
}
|
||||
if (kind === 'toc') {
|
||||
insertHtml(tocMarkup());
|
||||
if (kind === 'ul' || kind === 'ol') {
|
||||
const editor = focusEditor();
|
||||
if (editor) {
|
||||
editor.execCommand(kind === 'ol' ? 'InsertOrderedList' : 'InsertUnorderedList');
|
||||
editor.undoManager.add();
|
||||
editor.nodeChanged();
|
||||
updateContent(serializeBlocks(editor.getContent()));
|
||||
updateBlockValue(editor);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind === 'divider') {
|
||||
insertHtml(dividerMarkup());
|
||||
}
|
||||
}, [insertHtml, rememberSelection]);
|
||||
}, [focusEditor, insertHtml, rememberSelection, updateBlockValue]);
|
||||
|
||||
/**
|
||||
* 把当前选区(或光标所在的顶层段落)替换成一个整块。
|
||||
*
|
||||
* - 有选区:包住选中的内容;
|
||||
* - 光标停在顶层段落里:整段替换,避免正文被复制成两份;
|
||||
* - 其它位置:插入一个空块。
|
||||
*/
|
||||
const wrapSelection = useCallback((build: (innerHtml: string) => string) => {
|
||||
const editor = focusEditor();
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const selected = String(editor.selection.getContent({ format: 'html' }) || '').trim();
|
||||
if (selected === '') {
|
||||
const node = editor.selection.getNode();
|
||||
if (node && node.nodeName === 'P' && node.parentNode === editor.getBody()) {
|
||||
// 选中整段后再插入,让 TinyMCE 用新块替换掉原段落。
|
||||
editor.selection.select(node);
|
||||
}
|
||||
}
|
||||
const inner = String(editor.selection.getContent({ format: 'html' }) || '').trim();
|
||||
// insertContent 不进 undo 栈,这里包一层 transact 让整次插入可一次撤销。
|
||||
editor.undoManager.transact(() => {
|
||||
editor.insertContent(build(inner));
|
||||
});
|
||||
editor.nodeChanged();
|
||||
updateContent(serializeBlocks(editor.getContent()));
|
||||
updateHistoryAvailability(editor);
|
||||
syncEditorHeight(editor);
|
||||
updateBlockValue(editor);
|
||||
}, [focusEditor, updateBlockValue]);
|
||||
|
||||
const insertQuote = useCallback((colorClass: QuoteColor) => {
|
||||
const editor = focusEditor();
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
const selected = String(editor.selection.getContent() || '').trim();
|
||||
// 已有引用块:只换配色,正文保持不变。
|
||||
const existing = editor.dom.getParent(editor.selection.getNode(), '.quote_q');
|
||||
if (existing) {
|
||||
existing.className = `quote_q quote-mce${colorClass ? ` ${colorClass}` : ''}`;
|
||||
const classes = ['quote_q', 'quote-mce'];
|
||||
if (colorClass) {
|
||||
classes.push(colorClass);
|
||||
}
|
||||
existing.className = classes.join(' ');
|
||||
editor.nodeChanged();
|
||||
updateBlockValue(editor);
|
||||
updateContent(serializeBlocks(editor.getContent()));
|
||||
return;
|
||||
}
|
||||
insertHtml(quoteMarkup(selected, colorClass));
|
||||
}, [focusEditor, insertHtml]);
|
||||
wrapSelection((inner) => quoteMarkup(inner, colorClass));
|
||||
}, [focusEditor, updateBlockValue, wrapSelection]);
|
||||
|
||||
const insertHidden = useCallback((kind: HideKind) => {
|
||||
const editor = focusEditor();
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
// 已选中隐藏块:换可见性档位,正文保持不变。
|
||||
const existing = editor.dom.getParent(editor.selection.getNode(), '.tinymce-hide') as HTMLElement | null;
|
||||
if (existing) {
|
||||
retargetHiddenContent(existing, kind);
|
||||
editor.nodeChanged();
|
||||
updateContent(serializeBlocks(editor.getContent()));
|
||||
return;
|
||||
}
|
||||
wrapSelection((inner) => hiddenContentMarkup(kind, inner));
|
||||
}, [focusEditor, wrapSelection]);
|
||||
|
||||
const applyBlockDialog = useCallback((result: BlockDialogResult) => {
|
||||
setBlockDialog(null);
|
||||
@@ -608,6 +700,8 @@ function Studio(): JSX.Element {
|
||||
ready={editorReady}
|
||||
formatValue={formatValue}
|
||||
onFormatChange={setFormatValue}
|
||||
alignValue={alignValue}
|
||||
blockValue={blockValue}
|
||||
canUndo={historyAvailability.canUndo}
|
||||
canRedo={historyAvailability.canRedo}
|
||||
capabilities={capabilities}
|
||||
@@ -617,6 +711,7 @@ function Studio(): JSX.Element {
|
||||
onOpenMedia={openMedia}
|
||||
onInsertBlock={insertBlock}
|
||||
onInsertQuote={insertQuote}
|
||||
onInsertHidden={insertHidden}
|
||||
/>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* 通用插入弹窗 + 引用样式菜单 + 正文块(表格 / 代码 / 公式)的编辑器内样式。
|
||||
* 通用插入弹窗 + 正文块(表格 / 代码 / 公式)的编辑器内样式。
|
||||
* 工具栏下拉菜单的样式在 studio.scss 里(菜单 portal 到 body,不受本文件作用域影响)。
|
||||
*/
|
||||
|
||||
/* ---------- 通用插入弹窗 ---------- */
|
||||
@@ -197,69 +198,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 引用样式菜单 ---------- */
|
||||
|
||||
.jio-creator__quote {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.jio-creator__quote-menu {
|
||||
position: absolute;
|
||||
z-index: 400;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: 148px;
|
||||
flex-direction: column;
|
||||
padding: 6px;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
box-shadow: 0 12px 34px rgba(20, 32, 28, 0.18);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.jio-creator__quote-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #4f5957;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: #f2f5f3;
|
||||
outline: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__quote-swatch {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: #6f7875;
|
||||
}
|
||||
|
||||
.is-red .jio-creator__quote-swatch {
|
||||
background: #dc3f3f;
|
||||
}
|
||||
|
||||
.is-blue .jio-creator__quote-swatch {
|
||||
background: #0092ff;
|
||||
}
|
||||
|
||||
.is-green .jio-creator__quote-swatch {
|
||||
background: #23790e;
|
||||
}
|
||||
|
||||
/* ---------- 底部固定状态栏 ---------- */
|
||||
|
||||
.jio-creator__status {
|
||||
|
||||
@@ -259,6 +259,17 @@ body.jio-page-submit.has-static-nav {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
/* 已应用的格式(光标在引用 / 居中段落里)用浅底常显。 */
|
||||
&.is-active:not(:disabled) {
|
||||
background: #e9f1ee;
|
||||
color: var(--jc-accent-strong);
|
||||
}
|
||||
|
||||
&.is-open:not(:disabled) {
|
||||
background: #e9f1ee;
|
||||
color: var(--jc-accent-strong);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #c7d0cc;
|
||||
cursor: default;
|
||||
@@ -272,61 +283,156 @@ body.jio-page-submit.has-static-nav {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 标题按钮沿用「图标 + 文字」外观,内部叠一个原生的级别下拉。
|
||||
几何与 .jio-creator__tool-btn 完全一致,否则会在同一行里错位。 */
|
||||
.jio-creator__format-field {
|
||||
/* 下拉触发器右下角的小三角:告诉作者这里还有更多选项。 */
|
||||
.jio-creator__tool-caret {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
bottom: 4px;
|
||||
color: #b0b9b6;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.jio-creator__tool-menu {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: auto;
|
||||
min-width: 40px;
|
||||
height: 46px;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 5px 6px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #5f6664;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
line-height: 1.2;
|
||||
vertical-align: middle;
|
||||
|
||||
i {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
background: #f0f2f1;
|
||||
color: var(--jc-accent-strong);
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
outline: 2px solid rgba(38, 118, 101, 0.35);
|
||||
outline-offset: -2px;
|
||||
.jio-creator__tool-btn {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__format-select {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
/* ---------- 工具栏下拉菜单(portal 到 body,避免被滚动容器裁剪) ---------- */
|
||||
|
||||
&:disabled {
|
||||
cursor: default;
|
||||
.jio-creator__menu {
|
||||
position: fixed;
|
||||
z-index: 620;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
border: 1px solid rgba(24, 39, 34, 0.06);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 40px rgba(20, 32, 28, 0.16), 0 2px 6px rgba(20, 32, 28, 0.06);
|
||||
animation: jio-menu-in 0.12s ease-out;
|
||||
}
|
||||
|
||||
.jio-creator__menu.is-top {
|
||||
animation-name: jio-menu-in-up;
|
||||
}
|
||||
|
||||
@keyframes jio-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes jio-menu-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__menu-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #4f5957;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
text-align: left;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: #f2f5f3;
|
||||
color: var(--jc-accent-strong);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: #eef4f1;
|
||||
color: var(--jc-accent-strong);
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__menu-icon {
|
||||
width: 16px;
|
||||
flex: 0 0 16px;
|
||||
color: #8b9491;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.jio-creator__menu-item.is-active .jio-creator__menu-icon,
|
||||
.jio-creator__menu-item:hover .jio-creator__menu-icon {
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.jio-creator__menu-swatch {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: 0 0 12px;
|
||||
margin: 0 2px;
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.jio-creator__menu-text {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.jio-creator__menu-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.jio-creator__menu-hint {
|
||||
color: #9aa2a0;
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.jio-creator__menu-check {
|
||||
flex: 0 0 auto;
|
||||
color: var(--jc-accent);
|
||||
font-size: 10px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.jio-creator__menu-item.is-active .jio-creator__menu-check {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.jio-creator__menu {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -728,16 +834,6 @@ body.jio-page-submit.has-static-nav {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.jio-creator__format-field {
|
||||
min-width: 36px;
|
||||
height: 46px;
|
||||
padding: 4px 4px;
|
||||
|
||||
i {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.jio-creator__canvas {
|
||||
width: calc(100% - 20px);
|
||||
margin: 14px auto 0;
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* 正文块的 HTML 片段。
|
||||
*
|
||||
* 这些块都由服务端再次清洗(FrontSubmitSave 的 wp_kses_post),因此这里只负责
|
||||
* 生成符合前台渲染习惯的结构:代码块沿用 enlighter 的既有高亮约定,引用沿用
|
||||
* 主题的 .quote_q 结构,目录使用文章目录树识别标题的占位结构。
|
||||
* 生成符合前台渲染习惯的结构:代码块沿用前台 enlighter 的 pre/code 约定,引用沿用
|
||||
* 主题的 .quote_q 结构,隐藏内容沿用 .tinymce-hide 的短代码包裹结构。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
@@ -13,64 +13,31 @@ import { __ } from '@wordpress/i18n';
|
||||
const escapeHtml = (value: string): string =>
|
||||
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
/** 有序 / 无序列表:直接插入一个空列表块,光标落在第一项。 */
|
||||
export function listMarkup(ordered: boolean): string {
|
||||
const tag = ordered ? 'ol' : 'ul';
|
||||
return `<${tag}><li></li></${tag}><p></p>`;
|
||||
}
|
||||
/** 已经是块级结构的内容原样保留,纯行内内容补一个段落包裹。 */
|
||||
const BLOCK_TAG = /<(?:p|div|h[1-6]|ul|ol|li|blockquote|pre|table|figure|section)\b/i;
|
||||
const normalizeInner = (html: string): string => {
|
||||
const trimmed = html.trim();
|
||||
if (trimmed === '') {
|
||||
return '<p></p>';
|
||||
}
|
||||
return BLOCK_TAG.test(trimmed) ? trimmed : `<p>${trimmed}</p>`;
|
||||
};
|
||||
|
||||
/** 分割线。 */
|
||||
export function dividerMarkup(): string {
|
||||
return '<hr><p></p>';
|
||||
}
|
||||
|
||||
export type CodeLanguage = { value: string; label: string };
|
||||
|
||||
/** 常用语言;与前台 enlighter 的语言标识保持一致。 */
|
||||
export const CODE_LANGUAGES: CodeLanguage[] = [
|
||||
{ value: 'generic', label: __('通用', 'jio') },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'php', label: 'PHP' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'java', label: 'Java' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'rust', label: 'Rust' },
|
||||
{ value: 'csharp', label: 'C#' },
|
||||
{ value: 'cpp', label: 'C++' },
|
||||
{ value: 'sql', label: 'SQL' },
|
||||
{ value: 'json', label: 'JSON' },
|
||||
{ value: 'bash', label: 'Shell' },
|
||||
{ value: 'css', label: 'CSS' },
|
||||
{ value: 'html', label: 'HTML' },
|
||||
{ value: 'markdown', label: 'Markdown' },
|
||||
];
|
||||
|
||||
/** 语言值 → 展示名;未知值原样返回。 */
|
||||
export function codeLabel(language: string): string {
|
||||
return CODE_LANGUAGES.find((item) => item.value === language)?.label || language;
|
||||
}
|
||||
|
||||
/** 代码块右上角角标:编辑器内由 React 渲染,这里给出标识供 CSS 定位。 */
|
||||
export const CODE_LABEL_CLASS = 'jio-code-lang';
|
||||
|
||||
/**
|
||||
* 代码块:语言写在 data 属性里,前台 enlighter 与编辑器角标共用。
|
||||
* 代码块:与前台文章页一致,只落 pre > code。
|
||||
*
|
||||
* `data-jio-code` 标记这是编辑器创建的块,编辑器据此保证回车不跳出代码区、
|
||||
* 并渲染可点击的语言角标。
|
||||
* 前台由 EnlighterJS 按「全局功能 → 代码高亮」统一高亮,因此正文里不再写
|
||||
* 语言 / 主题属性,也不需要编辑器内的语言角标。
|
||||
*/
|
||||
export function codeBlockMarkup(language = 'generic', code = ''): string {
|
||||
const body = code !== '' ? escapeHtml(code) : escapeHtml(__('在此输入代码', 'jio'));
|
||||
return (
|
||||
`<pre class="enlighter-pre" data-jio-code="1" data-enlighter-language="${escapeHtml(language)}">` +
|
||||
`<code class="gl" data-enlighter-language="${escapeHtml(language)}">` +
|
||||
body +
|
||||
'</code></pre><p></p>'
|
||||
);
|
||||
export function codeBlockMarkup(): string {
|
||||
return '<pre><code></code></pre><p></p>';
|
||||
}
|
||||
|
||||
/** 把代码块的语言写成编辑器里的内联标签(纯文本,保存前由编辑器移除)。 */
|
||||
export type TableOptions = {
|
||||
rows: number;
|
||||
cols: number;
|
||||
@@ -101,14 +68,6 @@ export function tableMarkup({ rows, cols, header, striped }: TableOptions): stri
|
||||
return `<table class="${classes.join(' ')}">${body}</table><p></p>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 目录:文章页的目录树由前台 section-navs 依据 [data-nav] 生成,
|
||||
* 短代码在服务端替换成目录卡片,编辑器里保留可识别的占位。
|
||||
*/
|
||||
export function tocMarkup(): string {
|
||||
return '<p>[jio_toc]</p><p></p>';
|
||||
}
|
||||
|
||||
export type MathOptions = { formula: string; display: boolean };
|
||||
|
||||
/** 公式的上标 / 下标排版(与 ContentBlocks::typeset_math 保持一致)。 */
|
||||
@@ -143,12 +102,62 @@ export type QuoteColor = '' | 'qe_wzk_c-red' | 'qe_wzk_lan' | 'qe_wzk_lv';
|
||||
/**
|
||||
* 引用:沿用主题的 .quote_q 结构(.quote-mce 提供左侧引号标记),
|
||||
* 颜色通过主题已有的 qe_wzk_* 类切换。
|
||||
*
|
||||
* 外层的 contenteditable=false 让整块保持为一个整体,内层 contenteditable=true
|
||||
* 仍可直接编辑 —— 与前台投稿编辑器的引用块一致。
|
||||
*/
|
||||
export function quoteMarkup(content: string, color: QuoteColor): string {
|
||||
export function quoteMarkup(innerHtml: string, color: QuoteColor): string {
|
||||
const classes = ['quote_q', 'quote-mce'];
|
||||
if (color) {
|
||||
classes.push(color);
|
||||
}
|
||||
const inner = content.trim() !== '' ? `<p>${content}</p>` : '<p></p>';
|
||||
return `<div class="${classes.join(' ')}"><div>${inner}</div></div><p></p>`;
|
||||
return `<div class="${classes.join(' ')}" contenteditable="false"><div contenteditable="true">${normalizeInner(innerHtml)}</div></div><p></p>`;
|
||||
}
|
||||
|
||||
export type HideKind = 'reply' | 'login' | 'vip' | 'payshow';
|
||||
|
||||
/**
|
||||
* 隐藏内容的四档可见性。
|
||||
*
|
||||
* open / close 是真正的短代码文本,必须先写进 DOM 再被 TinyMCE 序列化,
|
||||
* 服务端 do_shortcode 才能识别;label 只用于编辑器里的可读角标。
|
||||
*/
|
||||
export const HIDE_BLOCKS: Record<HideKind, { open: string; close: string; label: string }> = {
|
||||
reply: { open: '[reply]', close: '[/reply]', label: __('评论后可查看', 'jio') },
|
||||
login: { open: '[jio_hide mode="login"]', close: '[/jio_hide]', label: __('登录后可查看', 'jio') },
|
||||
vip: { open: '[vip]', close: '[/vip]', label: __('会员可查看', 'jio') },
|
||||
payshow: { open: '[payshow]', close: '[/payshow]', label: __('付费后可查看', 'jio') },
|
||||
};
|
||||
|
||||
/**
|
||||
* 隐藏内容块:短代码作为文本节点包住正文,服务端据此控制可见性。
|
||||
*
|
||||
* .hide-before 里的短代码文本通过 CSS 的 font-size:0 隐藏,只显示 data-jio-hide-label;
|
||||
* .hide-after 与前台投稿编辑器一致地保持 display:none。两者都留在 DOM 里,保存时
|
||||
* 不需要额外的序列化转换。
|
||||
*/
|
||||
export function hiddenContentMarkup(kind: HideKind, innerHtml: string): string {
|
||||
const def = HIDE_BLOCKS[kind];
|
||||
return (
|
||||
`<div class="tinymce-hide" data-jio-hide-type="${kind}">` +
|
||||
`<p class="hide-before" data-jio-hide-label="${escapeHtml(def.label)}">${escapeHtml(def.open)}</p>` +
|
||||
`<div contenteditable="true">${normalizeInner(innerHtml)}</div>` +
|
||||
`<p class="hide-after">${escapeHtml(def.close)}</p>` +
|
||||
'</div><p></p>'
|
||||
);
|
||||
}
|
||||
|
||||
/** 把已有隐藏内容块换成另一种可见性(保留正文)。 */
|
||||
export function retargetHiddenContent(node: HTMLElement, kind: HideKind): void {
|
||||
const def = HIDE_BLOCKS[kind];
|
||||
node.setAttribute('data-jio-hide-type', kind);
|
||||
const before = node.querySelector(':scope > .hide-before');
|
||||
const after = node.querySelector(':scope > .hide-after');
|
||||
if (before) {
|
||||
before.textContent = def.open;
|
||||
before.setAttribute('data-jio-hide-label', def.label);
|
||||
}
|
||||
if (after) {
|
||||
after.textContent = def.close;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* 编辑器内容 ↔ 保存格式的转换。
|
||||
*
|
||||
* 编辑器里把公式、注释、代码语言标签渲染成可读的样子(方便写作时看到最终效果),
|
||||
* 但落库必须回到短代码/纯标记,服务端才能稳定渲染。这里在两个方向上都做一次转换。
|
||||
* 编辑器里把公式、注释渲染成可读的样子(方便写作时看到最终效果),
|
||||
* 但落库必须回到短代码,服务端才能稳定渲染。隐藏内容与代码块本身就是
|
||||
* 短代码 / 前台结构,不需要转换。
|
||||
*
|
||||
* @package Jio
|
||||
*/
|
||||
@@ -20,7 +21,7 @@ const decodeEntities = (value: string): string => {
|
||||
* 保存前:把编辑器里的可视化块换回短代码 / 纯标记。
|
||||
*/
|
||||
export function serializeBlocks(html: string): string {
|
||||
if (!html || (html.indexOf('data-jio-') === -1 && html.indexOf('jio-code-lang') === -1)) {
|
||||
if (!html || html.indexOf('data-jio-') === -1) {
|
||||
return html;
|
||||
}
|
||||
|
||||
@@ -46,9 +47,6 @@ export function serializeBlocks(html: string): string {
|
||||
node.replaceWith(document.createTextNode(`[jio_footnote]${note}[/jio_footnote]`));
|
||||
});
|
||||
|
||||
// 代码块语言角标只是编辑期提示
|
||||
container.querySelectorAll('.jio-code-lang').forEach((node) => node.remove());
|
||||
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
/* Content shown inside the front-submit TinyMCE iframe. */
|
||||
|
||||
/* 隐藏内容:短代码留在 .hide-before / .hide-after,编辑器只显示可读标签。 */
|
||||
.tinymce-hide {
|
||||
position: relative;
|
||||
margin: 18px 0;
|
||||
@@ -13,7 +15,7 @@
|
||||
margin: 0;
|
||||
color: #f04494;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
font-size: 12px;
|
||||
font-size: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -23,8 +25,16 @@
|
||||
left: 14px;
|
||||
}
|
||||
|
||||
.tinymce-hide > .hide-before::before {
|
||||
content: attr(data-jio-hide-label);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.tinymce-hide > .hide-after {
|
||||
margin-top: 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tinymce-hide > [contenteditable="true"] {
|
||||
@@ -105,23 +115,30 @@
|
||||
--quote-color: #23790e;
|
||||
}
|
||||
|
||||
.enlighter-pre {
|
||||
position: relative;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
padding: 32px 12px 12px;
|
||||
border-radius: 6px;
|
||||
/* 代码块:与前台文章页一致,只用 pre > code,由 EnlighterJS 在发布页高亮。 */
|
||||
pre {
|
||||
margin: 0 0 1.1em;
|
||||
padding: 14px 18px;
|
||||
overflow-x: auto;
|
||||
border-radius: 8px;
|
||||
background: #f2f4f5;
|
||||
color: #2b2b2b;
|
||||
font-family: Consolas, Monaco, "Courier New", monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.enlighter-pre code.gl {
|
||||
display: block;
|
||||
pre code {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: Consolas, Monaco, monospace;
|
||||
line-height: 1.45;
|
||||
white-space: break-spaces;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
padding: 0;
|
||||
white-space: inherit;
|
||||
}
|
||||
|
||||
.file-download-box {
|
||||
|
||||
@@ -504,61 +504,6 @@
|
||||
editor.nodeChanged();
|
||||
}
|
||||
|
||||
var codeLanguages = [
|
||||
['yaml', 'yaml'],
|
||||
['xml/html', 'xml'],
|
||||
['visualbasic', 'visualbasic'],
|
||||
['vhdl', 'vhdl'],
|
||||
['typescript', 'typescript'],
|
||||
['swift', 'swift'],
|
||||
['sql', 'sql'],
|
||||
['shell', 'shell'],
|
||||
['scss/sass', 'scss'],
|
||||
['rust', 'rust'],
|
||||
['ruby', 'ruby'],
|
||||
['python', 'python'],
|
||||
['powershell', 'powershell'],
|
||||
['php', 'php'],
|
||||
['markdown', 'markdown'],
|
||||
['lua', 'lua'],
|
||||
['less', 'less'],
|
||||
['kotlin', 'kotlin'],
|
||||
['json', 'json'],
|
||||
['javascript', 'javascript'],
|
||||
['java', 'java'],
|
||||
['ini/conf', 'ini'],
|
||||
['go/golang', 'go'],
|
||||
['docker', 'dockerfile'],
|
||||
['diff', 'diff'],
|
||||
['css', 'css'],
|
||||
['csharp', 'csharp'],
|
||||
['Cpp/C++/C', 'cpp'],
|
||||
['assembly', 'assembly'],
|
||||
[i18n.codeGeneric || '通用高亮', 'generic'],
|
||||
];
|
||||
|
||||
var codeThemes = [
|
||||
['enlighter', 'enlighter'],
|
||||
['classic', 'classic'],
|
||||
['beyond', 'beyond'],
|
||||
['mowtwo', 'mowtwo'],
|
||||
['eclipse', 'eclipse'],
|
||||
['droide', 'droide'],
|
||||
['minimal', 'minimal'],
|
||||
['atomic', 'atomic'],
|
||||
['dracula', 'dracula'],
|
||||
['bootstrap4', 'bootstrap4'],
|
||||
['rowhammer', 'rowhammer'],
|
||||
['godzilla', 'godzilla'],
|
||||
[i18n.codeFollowGlobal || '跟随全局设置', 'qj'],
|
||||
];
|
||||
|
||||
function listboxValues(items) {
|
||||
return items.map(function (item) {
|
||||
return { text: item[0], value: item[1] };
|
||||
});
|
||||
}
|
||||
|
||||
function bindCharacterCount(editor) {
|
||||
var counter = null;
|
||||
|
||||
@@ -592,40 +537,27 @@
|
||||
editor.on('init SetContent input keyup change Undo Redo', updateCount);
|
||||
}
|
||||
|
||||
// 代码块:与前台文章页完全一致(pre > code),语言交给「全局功能 → 代码高亮」
|
||||
// 在发布页统一决定,编辑器里不再选语言 / 主题。
|
||||
function openCodeModal(editor) {
|
||||
var selected = editor.dom.getParent(editor.selection.getNode(), 'pre.enlighter-pre');
|
||||
var selected = editor.dom.getParent(editor.selection.getNode(), 'pre');
|
||||
var code = selected ? selected.querySelector('code') : null;
|
||||
var width = window.innerWidth > 800 ? Math.round(window.innerWidth * 0.5) :
|
||||
(window.innerWidth > 640 ? Math.round(window.innerWidth * 0.7) : window.innerWidth - 20);
|
||||
var height = Math.round(window.innerHeight * 0.6);
|
||||
|
||||
editor.windowManager.open({
|
||||
title: (selected ? (i18n.edit || '编辑') : (i18n.insert || '插入')) +
|
||||
(i18n.highlightCode || '高亮代码'),
|
||||
width: width,
|
||||
width: window.innerWidth > 800 ? Math.round(window.innerWidth * 0.5) :
|
||||
(window.innerWidth > 640 ? Math.round(window.innerWidth * 0.7) : window.innerWidth - 20),
|
||||
height: height,
|
||||
body: [
|
||||
{
|
||||
type: 'listbox',
|
||||
name: 'language',
|
||||
label: i18n.selectLanguage || '选择语言',
|
||||
value: code ? (code.getAttribute('data-enlighter-language') || 'generic') : 'generic',
|
||||
values: listboxValues(codeLanguages),
|
||||
},
|
||||
{
|
||||
type: 'listbox',
|
||||
name: 'theme',
|
||||
label: i18n.codeTheme || '主题',
|
||||
value: code ? (code.getAttribute('data-enlighter-theme') || 'qj') : 'qj',
|
||||
values: listboxValues(codeThemes),
|
||||
},
|
||||
{
|
||||
type: 'textbox',
|
||||
name: 'content',
|
||||
label: i18n.codeLabel || '代码:',
|
||||
value: code ? (code.textContent || '') : '',
|
||||
multiline: true,
|
||||
minHeight: Math.max(160, height - 115),
|
||||
minHeight: Math.max(160, height - 60),
|
||||
},
|
||||
],
|
||||
onsubmit: function (event) {
|
||||
@@ -634,12 +566,7 @@
|
||||
return;
|
||||
}
|
||||
var encoded = window.tinymce.html.Entities.encodeAllRaw(content);
|
||||
var language = event.data.language === 'generic' ? '' :
|
||||
' data-enlighter-language="' + escapeAttr(event.data.language) + '"';
|
||||
var theme = event.data.theme === 'qj' ? '' :
|
||||
' data-enlighter-theme="' + escapeAttr(event.data.theme) + '"';
|
||||
var markup = '<pre contenteditable="false" class="enlighter-pre"><code class="gl"' +
|
||||
language + theme + '>' + encoded + '</code></pre>';
|
||||
var markup = '<pre><code>' + encoded + '</code></pre>';
|
||||
if (selected) {
|
||||
editor.dom.setOuterHTML(selected, markup);
|
||||
} else {
|
||||
@@ -846,7 +773,7 @@
|
||||
text: '',
|
||||
icon: 'code',
|
||||
tooltip: i18n.highlightCode || '高亮代码',
|
||||
stateSelector: 'pre.enlighter-pre',
|
||||
stateSelector: 'pre',
|
||||
onclick: function () {
|
||||
openCodeModal(editor);
|
||||
},
|
||||
|
||||
@@ -162,8 +162,8 @@ test('fixed top toolbar has TinyMCE formatting, media, and extension actions', (
|
||||
assert.match(appSource, /className="jio-creator__bar"/);
|
||||
assert.match(appSource, /undoManager\.undo\(\)/);
|
||||
assert.match(appSource, /undoManager\.redo\(\)/);
|
||||
assert.match(appSource, /editor\.formatter\.toggle\(value \|\| 'h2'\)/);
|
||||
assert.match(editorToolbar, /value=\{formatValue === 'blockquote' \? 'p' : formatValue\}/);
|
||||
assert.match(appSource, /execCommand\('FormatBlock', false, block\)/);
|
||||
assert.match(appSource, /JustifyCenter|JustifyRight|JustifyLeft/);
|
||||
assert.match(editorToolbar, /canUndo/);
|
||||
assert.match(editorToolbar, /canRedo/);
|
||||
assert.match(editorToolbar, /onCommand\('Bold'\)/);
|
||||
@@ -171,18 +171,39 @@ test('fixed top toolbar has TinyMCE formatting, media, and extension actions', (
|
||||
assert.match(editorToolbar, /onOpenMedia\('image'\)/);
|
||||
assert.match(editorToolbar, /onOpenMedia\('video'\)/);
|
||||
assert.match(editorToolbar, /onClick=\{onOpenLink\}/);
|
||||
// 引用不再是标题下拉的一部分,而是独立按钮(带样式菜单)。
|
||||
// 引用不再是标题下拉的一部分,而是独立菜单(四种配色)。
|
||||
assert.match(editorToolbar, /onInsertQuote/);
|
||||
assert.doesNotMatch(editorToolbar, /<option value="blockquote"/);
|
||||
assert.doesNotMatch(editorToolbar, /onCommand\('format', 'blockquote'\)/);
|
||||
for (const kind of ['list', 'toc', 'divider', 'code', 'footnote', 'math', 'table']) {
|
||||
// 标题级别 / 列表 / 对齐 / 引用 / 隐藏内容都是下拉,不再是原生 select。
|
||||
assert.doesNotMatch(editorToolbar, /jio-creator__format-select/);
|
||||
assert.doesNotMatch(editorToolbar, /<option value=/);
|
||||
assert.match(editorToolbar, /<ToolMenu/);
|
||||
for (const kind of ['divider', 'code', 'footnote', 'math', 'table']) {
|
||||
assert.ok(editorToolbar.includes(`onInsertBlock('${kind}')`), `missing block button: ${kind}`);
|
||||
}
|
||||
// 列表改为下拉:有序 / 无序两种,走 TinyMCE 的列表命令。
|
||||
assert.ok(editorToolbar.includes("key: 'ul'"), 'missing unordered list option');
|
||||
assert.ok(editorToolbar.includes("key: 'ol'"), 'missing ordered list option');
|
||||
assert.match(appSource, /InsertOrderedList|InsertUnorderedList/);
|
||||
// 目录按钮已删除,工具栏不再输出目录入口。
|
||||
assert.ok(!editorToolbar.includes("onInsertBlock('toc')"), 'TOC button must be removed');
|
||||
assert.ok(!editorToolbar.includes('tocMarkup'), 'tocMarkup must not be used');
|
||||
// 三个对齐按钮:居左 / 居中 / 居右。
|
||||
for (const align of ['left', 'center', 'right']) {
|
||||
assert.ok(editorToolbar.includes(`key: '${align}'`), `missing align option: ${align}`);
|
||||
}
|
||||
// 隐藏内容四档可见性。
|
||||
for (const kind of ['reply', 'login', 'vip', 'payshow']) {
|
||||
assert.ok(editorToolbar.includes(`key: '${kind}'`), `missing hidden-content kind: ${kind}`);
|
||||
}
|
||||
assert.match(editorToolbar, /onInsertHidden/);
|
||||
assert.match(appSource, /wrapSelection\(\(inner\) => hiddenContentMarkup\(kind, inner\)\)/);
|
||||
// 表格 / 公式 / 注释 / 代码都走参数弹窗,不再是固定内容。
|
||||
assert.match(appSource, /<BlockDialog/);
|
||||
assert.match(appSource, /setBlockDialog\(kind as BlockDialogKind\)/);
|
||||
// 代码块直接插入,不弹窗。
|
||||
assert.match(appSource, /insertHtml\(codeBlockMarkup\('generic'\)\)/);
|
||||
// 代码块直接插入,不弹窗、不选语言。
|
||||
assert.match(appSource, /insertHtml\(codeBlockMarkup\(\)\)/);
|
||||
assert.doesNotMatch(appSource, /setCodeDialog/);
|
||||
assert.doesNotMatch(read('assets/creator-studio/src/components/block-dialog.tsx'), /kind === 'code'/);
|
||||
assert.match(read('assets/creator-studio/src/components/block-dialog.tsx'), /onSubmit\(\{ kind, rows: clamp\(rows, 1, 20\), cols: clamp\(cols, 1, 10\), header, striped \}\)/);
|
||||
@@ -199,10 +220,20 @@ test('fixed top toolbar has TinyMCE formatting, media, and extension actions', (
|
||||
assert.match(markup, /data-jio-math-source/);
|
||||
assert.match(read('assets/creator-studio/src/utils/serialize.ts'), /\[jio_math display=/);
|
||||
assert.match(read('assets/creator-studio/src/utils/serialize.ts'), /\[jio_footnote\]/);
|
||||
assert.match(markup, /\[jio_toc\]/);
|
||||
// 代码块角标不再输出固定「高亮代码」文案。
|
||||
assert.doesNotMatch(read('assets/css/frontend/pages/submit-editor.css'), /高亮代码/);
|
||||
assert.doesNotMatch(read('assets/css/tinymce.css'), /content:\s*'高亮代码'/);
|
||||
// 代码块与前台文章页一致:pre > code,不写语言 / 主题属性。
|
||||
assert.match(markup, /<pre><code><\/code><\/pre>/);
|
||||
assert.doesNotMatch(markup, /enlighter-pre/);
|
||||
assert.doesNotMatch(markup, /data-enlighter-language/);
|
||||
assert.doesNotMatch(read('assets/creator-studio/src/editor-runtime.ts'), /syncCodeLabels/);
|
||||
assert.doesNotMatch(read('assets/creator-studio/src/components/block-dialog.tsx'), /CODE_LANGUAGES/);
|
||||
// 外观:编辑器 iframe 与前台投稿编辑器保持同一套代码块样式。
|
||||
const editorCss = read('assets/css/frontend/pages/submit-editor.css');
|
||||
assert.doesNotMatch(editorCss, /\.enlighter-pre/);
|
||||
assert.match(editorCss, /pre code \{/);
|
||||
// 隐藏内容沿用 .tinymce-hide 结构,短代码作为文本落库。
|
||||
assert.match(markup, /tinymce-hide/);
|
||||
assert.match(markup, /\[jio_hide mode="login"\]/);
|
||||
assert.match(markup, /\[payshow\]/);
|
||||
|
||||
const blocks = read('src/Content/ContentBlocks.php');
|
||||
const shortcodes = read('src/Core/Shortcodes.php');
|
||||
@@ -282,6 +313,63 @@ test('block delete overlay lives inside the editor iframe and stays hidden', ()
|
||||
assert.doesNotMatch(pageStyles, /\.jio-creator__block-actions\s*\{/);
|
||||
// data-mce-bogus 让 TinyMCE 序列化、字数和选区都跳过这个浮层。
|
||||
assert.match(runtime, /setAttribute\('data-mce-bogus', 'all'\)/);
|
||||
|
||||
// 两步删除:第一次点击只选中,第二次才真删;不再有关闭 / 叉号语义。
|
||||
const firstClick = runtime.indexOf('if (selected !== block) {');
|
||||
const secondClick = runtime.indexOf('// 第二次点击:真正删除。');
|
||||
assert.ok(firstClick !== -1 && secondClick !== -1, 'two-step delete branches both exist');
|
||||
assert.ok(firstClick < secondClick, 'first click only selects');
|
||||
assert.ok(
|
||||
runtime.indexOf('block.classList.add(SELECTED_CLASS)', firstClick) < secondClick,
|
||||
'selection class is applied on the first click'
|
||||
);
|
||||
assert.match(contentStyle, /\.jio-creator__block-selected\s*\{/);
|
||||
assert.match(runtime, /再次点击删除此块/);
|
||||
});
|
||||
|
||||
// 工具栏下拉:菜单必须 portal 到 body,否则会被横向滚动的工具条裁剪。
|
||||
test('toolbar dropdowns render through a portal with shared trigger geometry', () => {
|
||||
const menu = read('assets/creator-studio/src/components/tool-menu.tsx');
|
||||
const styles = read('assets/creator-studio/src/styles/studio.scss');
|
||||
const toolbar = read('assets/creator-studio/src/components/editor-toolbar.tsx');
|
||||
|
||||
assert.match(menu, /createPortal/);
|
||||
assert.match(menu, /document\.body/);
|
||||
// 定位写成内联 style,只保留 left / top / width。
|
||||
assert.match(menu, /style=\{\{\s*left:/);
|
||||
assert.match(styles, /\.jio-creator__menu\s*\{[^}]*position:\s*fixed/);
|
||||
assert.match(styles, /\.jio-creator__menu\s*\{[^}]*box-shadow/);
|
||||
// 下拉触发器复用 .jio-creator__tool-btn,高度 / 字号 / 定位与其它按钮一致。
|
||||
assert.doesNotMatch(styles, /\.jio-creator__format-field\s*\{/);
|
||||
assert.doesNotMatch(styles, /\.jio-creator__format-select\s*\{/);
|
||||
assert.doesNotMatch(toolbar, /jio-creator__quote-menu/);
|
||||
});
|
||||
|
||||
// 引用块 / 隐藏内容 / 公式的编辑器内表现。
|
||||
test('quote, hidden content, and block math render like the live article', () => {
|
||||
const contentStyle = read('assets/creator-studio/src/editor-content-style.ts');
|
||||
const markup = read('assets/creator-studio/src/utils/block-markup.ts');
|
||||
const css = read('assets/css/frontend/pages/submit-editor.css');
|
||||
|
||||
// 引用:四种配色都有独立变量,引号标记不再依赖 FA 字体。
|
||||
for (const cls of ['qe_wzk_c-red', 'qe_wzk_lan', 'qe_wzk_lv']) {
|
||||
assert.match(contentStyle, new RegExp(`\\.quote_q\\.${cls}`));
|
||||
assert.match(css, new RegExp(`\\.${cls}\\s*\\{`));
|
||||
}
|
||||
assert.match(contentStyle, /\.quote_q \.quote-mce::after|\.quote_q\.quote-mce::after/);
|
||||
assert.match(contentStyle, /-webkit-mask/);
|
||||
// 引用保持整块可识别,内部仍可直接编辑。
|
||||
assert.match(markup, /class="\$\{classes\.join\(' '\)\}" contenteditable="false"><div contenteditable="true">/);
|
||||
|
||||
// 隐藏内容:短代码文本被 CSS 隐藏,只显示可读标签。
|
||||
assert.match(contentStyle, /\.tinymce-hide > \.hide-before::before/);
|
||||
assert.match(contentStyle, /content: attr\(data-jio-hide-label\)/);
|
||||
assert.match(css, /content: attr\(data-jio-hide-label\)/);
|
||||
|
||||
// 公式独占一行时居中,不再铺满一条灰底横条。
|
||||
assert.match(contentStyle, /\.jio-math--block\s*\{[^}]*text-align:\s*center/);
|
||||
assert.match(contentStyle, /\.jio-math--block\s*\{[^}]*background:\s*transparent/);
|
||||
assert.doesNotMatch(contentStyle, /\.jio-math--block\s*\{[^}]*background:\s*#/);
|
||||
});
|
||||
|
||||
test('creator studio build artifacts and contracts exist', () => {
|
||||
|
||||
@@ -224,16 +224,19 @@ test('front submit exposes the complete reference media and formatting toolset',
|
||||
assert.match(editor, /new-dplayer post-dplayer dplayer/);
|
||||
assert.match(editor, /file-download-box/);
|
||||
assert.match(editor, /wp-block-embed is-type-video/);
|
||||
assert.match(editor, /data-enlighter-language/);
|
||||
assert.match(editor, /data-enlighter-theme/);
|
||||
assert.match(editor, /enlighter-pre/);
|
||||
// 代码块与前台文章页一致(pre > code),语言由全局代码高亮设置决定,编辑器不再选语言 / 主题。
|
||||
assert.match(editor, /'<pre><code>' \+ encoded \+ '<\/code><\/pre>'/);
|
||||
assert.doesNotMatch(editor, /data-enlighter-language/);
|
||||
assert.doesNotMatch(editor, /data-enlighter-theme/);
|
||||
assert.doesNotMatch(editor, /enlighter-pre/);
|
||||
assert.match(editor, /qe_wzk_c-red/);
|
||||
assert.match(editor, /qe_wzk_lan/);
|
||||
assert.match(editor, /qe_wzk_lv/);
|
||||
assert.match(editorCss, /\.quote_q/);
|
||||
assert.match(editorCss, /\.quote_q \[contenteditable="true"\][\s\S]*?box-shadow:\s*none/);
|
||||
assert.match(editorCss, /\.file-download-box/);
|
||||
assert.match(editorCss, /\.enlighter-pre/);
|
||||
assert.match(editorCss, /pre code \{/);
|
||||
assert.doesNotMatch(editorCss, /\.enlighter-pre/);
|
||||
assert.doesNotMatch(editor, /shop|forum|bbs_/i);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user