From 984a67fcccb3077b868fa5be65a9086830c43f1a Mon Sep 17 00:00:00 2001 From: yi-ge Date: Wed, 26 Aug 2026 11:42:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(editor):=20=E6=96=B0=E5=A2=9E=20Ribbon=20?= =?UTF-8?q?=E6=A0=87=E9=A2=98=E8=A1=8C=E5=8A=A8=E4=BD=9C=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cherry-markdown/README.md | 55 ++- packages/cherry-markdown/build/verify-dist.js | 11 +- packages/cherry-markdown/build/verify-pack.js | 25 +- packages/cherry-markdown/package.json | 2 +- packages/cherry-markdown/src/Cherry.config.js | 30 +- packages/cherry-markdown/src/Cherry.js | 16 + packages/cherry-markdown/src/capabilities.js | 1 + packages/cherry-markdown/src/sass/cherry.scss | 451 ++++++++++++++---- .../cherry-markdown/src/toolbars/Toolbar.js | 139 +++++- .../test/editor/RibbonHeaderActions.spec.ts | 99 ++++ .../test/release/ReleaseScript.spec.ts | 6 +- packages/cherry-markdown/types/cherry.d.ts | 56 +++ 12 files changed, 791 insertions(+), 100 deletions(-) create mode 100644 packages/cherry-markdown/test/editor/RibbonHeaderActions.spec.ts diff --git a/packages/cherry-markdown/README.md b/packages/cherry-markdown/README.md index 841d8ea1e..da1fa3866 100644 --- a/packages/cherry-markdown/README.md +++ b/packages/cherry-markdown/README.md @@ -59,7 +59,7 @@ SuperDoc is a JavaScript Markdown editor for browser and browser-like Webview ru - [WYSIWYG Mode](https://github.com/desirecore/super-doc/wiki/WYSIWYG-%E6%89%80%E8%A7%81%E5%8D%B3%E6%89%80%E5%BE%97%E7%BC%96%E8%BE%91%E6%A8%A1%E5%BC%8F) - [Stream Rendering](https://github.com/desirecore/super-doc/wiki/Stream-%E6%B5%81%E5%BC%8F%E6%B8%B2%E6%9F%93%E6%A8%A1%E5%BC%8F) ------ +--- ### **Out-of-the-box** @@ -72,6 +72,7 @@ When the syntax that SuperDoc supports can not meet your needs, secondary develo ### Incremental / Progressive / Streaming rendering After enabling streaming rendering, SuperDoc will automatically complete the following syntax elements to avoid exposing Markdown source code, ensuring stable output during the streaming process: + - Headings - Bold and italic text - Hyperlinks @@ -115,7 +116,7 @@ After enabling streaming rendering, SuperDoc will automatically complete the fol 13. Theme switching 14. Input suggestion (autocomplete) 15. AI Chat scenario: stream-mode output supported -16. **WYSIWYG mode: one-click switch between Markdown and rich-text editing** *(SuperDoc)* +16. **WYSIWYG mode: one-click switch between Markdown and rich-text editing** _(SuperDoc)_ ### Performance Feature @@ -177,6 +178,50 @@ const cherryInstance = new Cherry({ The published editor and engine entries require browser DOM APIs. Raw Node.js/SSR (including CommonJS `require`) is not a supported runtime. +### Ribbon header actions + +When `toolbarTabs` enables the Ribbon layout, applications can place document-level actions on the same row as the Ribbon tabs. SuperDoc owns the rendered DOM; the host provides only serializable labels/state plus callbacks, so no selector lookup, portal, or external DOM mount is required. + +```javascript +const cherryInstance = new Cherry({ + id: 'markdown-container', + toolbars: { + toolbarTabs: [ + { name: 'start', buttons: ['bold', 'italic'] }, + { name: 'insert', buttons: ['image', 'link'] }, + ], + ribbonHeaderActions: { + ariaLabel: 'Document actions', + actions: [ + { + type: 'segmented', + id: 'review-mode', + ariaLabel: 'Review mode', + value: 'inline', + options: [ + { value: 'inline', label: 'Inline' }, + { value: 'compare', label: 'Compare' }, + ], + onChange: (value) => console.log(value), + }, + { id: 'save', label: 'Save', onClick: () => saveDocument() }, + { + type: 'menu', + id: 'more', + label: 'More', + items: [{ id: 'export', label: 'Export', onClick: () => exportDocument() }], + }, + ], + }, + }, +}); + +// Update active/disabled/menu state without rebuilding the editor. +cherryInstance.setRibbonHeaderActions(nextRibbonHeaderActions); +``` + +The capability can be detected with `Cherry.capabilities.ribbonHeaderActions === true`. + ## Lite Version Because the size of the mermaid library is very large, the build product contains a core build package without built-in Mermaid. The core build can be imported in the following ways. @@ -242,9 +287,9 @@ cherryInstance.setMarkdown('# welcome to SuperDoc!'); | Build | File | Mermaid | CodeMirror | Use Case | | ------ | --------------------- | ------- | ---------- | ----------------- | -| Full | `super-doc.js` | ✅ | ✅ | General purpose | -| Core | `super-doc.core.js` | ❌ | ✅ | Without Mermaid | -| Stream | `super-doc.stream.js` | ❌ | ❌ | AI Chat streaming | +| Full | `super-doc.js` | ✅ | ✅ | General purpose | +| Core | `super-doc.core.js` | ❌ | ✅ | Without Mermaid | +| Stream | `super-doc.stream.js` | ❌ | ❌ | AI Chat streaming | > Note: MathJax/KaTeX are external dependencies loaded dynamically via CDN and are not included in any build package. diff --git a/packages/cherry-markdown/build/verify-dist.js b/packages/cherry-markdown/build/verify-dist.js index 1b566b357..611a8d3c1 100644 --- a/packages/cherry-markdown/build/verify-dist.js +++ b/packages/cherry-markdown/build/verify-dist.js @@ -67,11 +67,19 @@ if (missingFiles.length === 0) { 'modeCommitted', 'awaitableRefreshPreviewer', 'compactLayout', + 'ribbonHeaderActions', + 'setRibbonHeaderActions', ]); }); requireMarkers('dist/types/capabilities.d.ts', ['SUPER_DOC_CAPABILITIES', 'SuperDocCapabilities']); requireMarkers('dist/types/Cherry.d.ts', ['static capabilities', 'SuperDocCapabilities']); - requireMarkers('types/cherry.d.ts', ['ModeCommittedPayload', 'modeCommitted', 'SuperDocCapabilities']); + requireMarkers('types/cherry.d.ts', [ + 'ModeCommittedPayload', + 'modeCommitted', + 'SuperDocCapabilities', + 'CherryRibbonHeaderActions', + 'setRibbonHeaderActions', + ]); const compactCss = readFileSync(join(packageDir, 'dist/super-doc.min.css'), 'utf8').replace(/\s/g, ''); [ @@ -79,6 +87,7 @@ if (missingFiles.length === 0) { '--editor-content-line-height:24px', '--editor-split-padding-inline:var(--spacing-lg)', '--wysiwyg-content-padding-inline:clamp(var(--spacing-xl),4vw,var(--spacing-3xl))', + '.cherry-ribbon-header-actions{', ].forEach((marker) => { if (!compactCss.includes(marker)) { contentFailures.push(`dist/super-doc.min.css: missing ${marker}`); diff --git a/packages/cherry-markdown/build/verify-pack.js b/packages/cherry-markdown/build/verify-pack.js index 1181ce439..91dcd48eb 100644 --- a/packages/cherry-markdown/build/verify-pack.js +++ b/packages/cherry-markdown/build/verify-pack.js @@ -73,7 +73,14 @@ Object.entries(expectedEntrypoints).forEach(([field, value]) => { }); const coreBundle = readPackedText('dist/super-doc.core.js'); -['SUPER_DOC_CAPABILITIES', 'modeCommitted', 'awaitableRefreshPreviewer', 'compactLayout'].forEach((marker) => { +[ + 'SUPER_DOC_CAPABILITIES', + 'modeCommitted', + 'awaitableRefreshPreviewer', + 'compactLayout', + 'ribbonHeaderActions', + 'setRibbonHeaderActions', +].forEach((marker) => { if (!coreBundle.includes(marker)) fail(`core bundle is missing ${marker}`); }); @@ -83,6 +90,7 @@ const compactCss = readPackedText('dist/super-doc.min.css').replace(/\s/g, ''); '--editor-content-line-height:24px', '--editor-split-padding-inline:var(--spacing-lg)', '--wysiwyg-content-padding-inline:clamp(var(--spacing-xl),4vw,var(--spacing-3xl))', + '.cherry-ribbon-header-actions{', ].forEach((marker) => { if (!compactCss.includes(marker)) fail(`packed CSS is missing ${marker}`); }); @@ -92,7 +100,13 @@ if (!capabilityTypes.includes('SUPER_DOC_CAPABILITIES') || !capabilityTypes.incl fail('packed capability types are incomplete'); } const publicTypes = readPackedText('types/cherry.d.ts'); -['ModeCommittedPayload', 'modeCommitted', 'SuperDocCapabilities'].forEach((marker) => { +[ + 'ModeCommittedPayload', + 'modeCommitted', + 'SuperDocCapabilities', + 'CherryRibbonHeaderActions', + 'setRibbonHeaderActions', +].forEach((marker) => { if (!publicTypes.includes(marker)) fail(`packed public types are missing ${marker}`); }); @@ -134,6 +148,7 @@ try { asyncModeSwitch: true, awaitableRefreshPreviewer: true, compactLayout: true, + ribbonHeaderActions: true, }; if (JSON.stringify(publishedModule.SUPER_DOC_CAPABILITIES) !== JSON.stringify(expectedCapabilities)) { fail('published ESM named SUPER_DOC_CAPABILITIES export is invalid'); @@ -145,6 +160,9 @@ try { } if (typeof Cherry.prototype.switchModel !== 'function') fail('published Cherry.switchModel is not public'); if (typeof Cherry.prototype.refreshPreviewer !== 'function') fail('published Cherry.refreshPreviewer is not public'); + if (typeof Cherry.prototype.setRibbonHeaderActions !== 'function') { + fail('published Cherry.setRibbonHeaderActions is not public'); + } const mermaidRenderer = Cherry.config?.defaults?.engine?.syntax?.codeBlock?.customRenderer?.mermaid; if ( typeof mermaidRenderer?.mermaidAPIRefs?.initialize !== 'function' || @@ -172,6 +190,9 @@ try { if (typeof CommonJsCherry.prototype.refreshPreviewer !== 'function') { fail('published UMD CommonJS Cherry.refreshPreviewer is not public'); } + if (typeof CommonJsCherry.prototype.setRibbonHeaderActions !== 'function') { + fail('published UMD CommonJS Cherry.setRibbonHeaderActions is not public'); + } const modeHarness = Object.assign(Object.create(Cherry.prototype), { committedModel: 'edit&preview', diff --git a/packages/cherry-markdown/package.json b/packages/cherry-markdown/package.json index 58c26511c..bf03bf161 100644 --- a/packages/cherry-markdown/package.json +++ b/packages/cherry-markdown/package.json @@ -1,7 +1,7 @@ { "name": "@desirecore/super-doc", "license": "Apache-2.0", - "version": "0.2.15", + "version": "0.2.16", "type": "module", "description": "A powerful Markdown editor with WYSIWYG support, based on cherry-markdown", "repository": { diff --git a/packages/cherry-markdown/src/Cherry.config.js b/packages/cherry-markdown/src/Cherry.config.js index cfebf37db..32e49b7d1 100644 --- a/packages/cherry-markdown/src/Cherry.config.js +++ b/packages/cherry-markdown/src/Cherry.config.js @@ -702,9 +702,13 @@ const defaultConfig = { { buttons: ['paste', 'cut', 'copySelection'], large: ['paste'] }, { buttons: [ - 'header', 'bold', 'italic', 'underline', + 'header', + 'bold', + 'italic', + 'underline', { strikethrough: ['strikethrough', 'sub', 'sup', 'ruby'] }, - 'color', 'size', + 'color', + 'size', ], large: ['header'], }, @@ -715,7 +719,25 @@ const defaultConfig = { }, { name: 'tabInsert', - buttons: ['image', 'audio', 'video', '|', 'link', 'hr', 'br', '|', 'code', 'formula', '|', 'toc', 'table', '|', 'footnote', 'pdf', 'word'], + buttons: [ + 'image', + 'audio', + 'video', + '|', + 'link', + 'hr', + 'br', + '|', + 'code', + 'formula', + '|', + 'toc', + 'table', + '|', + 'footnote', + 'pdf', + 'word', + ], }, { name: 'tabDraw', @@ -730,6 +752,8 @@ const defaultConfig = { buttons: ['export'], }, ], + // Ribbon 标签行右侧的宿主动作。由编辑器本身渲染,嵌入方只提供数据与回调。 + ribbonHeaderActions: false, toolbarRight: [], sidebar: false, bubble: ['bold', 'italic', 'underline', 'strikethrough', 'sub', 'sup', 'quote', '|', 'size', 'color'], // array or false diff --git a/packages/cherry-markdown/src/Cherry.js b/packages/cherry-markdown/src/Cherry.js index 72f7c9c0f..78ca4c836 100644 --- a/packages/cherry-markdown/src/Cherry.js +++ b/packages/cherry-markdown/src/Cherry.js @@ -956,6 +956,22 @@ export default class Cherry extends CherryStatic { return true; } + /** + * 更新 Ribbon 标签行右侧动作。动作 DOM 始终由 SuperDoc 创建和维护,嵌入方无需 + * 查询编辑器内部节点或挂载外部组件。 + * @public + * @param {import('~types/cherry').CherryRibbonHeaderActions} actions + * @returns {Boolean} + */ + setRibbonHeaderActions(actions) { + if (actions !== false && (!actions || typeof actions !== 'object' || !Array.isArray(actions.actions))) { + return false; + } + this.options.toolbars.ribbonHeaderActions = actions; + this.toolbar?.setRibbonHeaderActions?.(actions); + return true; + } + /** * @private * @returns {Toolbar} diff --git a/packages/cherry-markdown/src/capabilities.js b/packages/cherry-markdown/src/capabilities.js index 4953e1e80..b4953cbfc 100644 --- a/packages/cherry-markdown/src/capabilities.js +++ b/packages/cherry-markdown/src/capabilities.js @@ -4,4 +4,5 @@ export const SUPER_DOC_CAPABILITIES = Object.freeze({ asyncModeSwitch: true, awaitableRefreshPreviewer: true, compactLayout: true, + ribbonHeaderActions: true, }); diff --git a/packages/cherry-markdown/src/sass/cherry.scss b/packages/cherry-markdown/src/sass/cherry.scss index e3866471a..675f79e86 100644 --- a/packages/cherry-markdown/src/sass/cherry.scss +++ b/packages/cherry-markdown/src/sass/cherry.scss @@ -148,7 +148,10 @@ background: var(--dropdown-bg); border-radius: var(--dropdown-item-radius); margin: 2px 4px; - transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease; + transition: + color 150ms ease, + background-color 150ms ease, + border-color 150ms ease; &:hover { background: var(--dropdown-item-hover-bg); @@ -173,12 +176,24 @@ } // 标题下拉菜单:字号递减区分级别 - &-item .ch-icon-h1 { font-size: 20px; } - &-item .ch-icon-h2 { font-size: 17px; } - &-item .ch-icon-h3 { font-size: 15px; } - &-item .ch-icon-h4 { font-size: 13px; } - &-item .ch-icon-h5 { font-size: 12px; } - &-item .ch-icon-h6 { font-size: 11px; } + &-item .ch-icon-h1 { + font-size: 20px; + } + &-item .ch-icon-h2 { + font-size: 17px; + } + &-item .ch-icon-h3 { + font-size: 15px; + } + &-item .ch-icon-h4 { + font-size: 13px; + } + &-item .ch-icon-h5 { + font-size: 12px; + } + &-item .ch-icon-h6 { + font-size: 11px; + } } .cherry-toolbar { @@ -215,13 +230,13 @@ border-color: var(--toolbar-btn-color) transparent var(--toolbar-btn-color) transparent; animation: loading 1.2s linear infinite; } - + &.success { display: inline-block; } &.success:after { - content: "\ea46"; + content: '\ea46'; display: inline-block; font-family: 'ch-icon' !important; font-style: normal; @@ -289,11 +304,10 @@ box-shadow: var(--shadow-sm); } - .cherry-ribbon-tabs { + .cherry-ribbon-header { display: flex; - gap: 2px; - padding: 0 var(--spacing-sm); min-height: 32px; + line-height: 1; align-items: stretch; background: var(--toolbar-bg); // 用渐变模拟底部分隔线,从边框色渐变到透明,避免主题色过于突兀 @@ -306,6 +320,127 @@ user-select: none; } + .cherry-ribbon-tabs { + display: flex; + flex: 1 1 auto; + min-width: 0; + gap: 2px; + padding: 0 var(--spacing-sm); + align-items: stretch; + // Ribbon 标签与右侧宿主动作共用一行。auto 会在部分 Chromium 配置中即使 + // 未实际溢出也预留水平滚动条高度,把 32px 标签行撑到 43px。 + overflow: hidden; + } + + .cherry-ribbon-header-actions { + display: flex; + flex: 0 0 auto; + align-items: center; + gap: 4px; + padding: 2px var(--spacing-sm) 2px 4px; + } + + .cherry-ribbon-header-action { + appearance: none; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--toolbar-btn-color); + min-height: 26px; + padding: 0 8px; + font: inherit; + font-size: var(--font-size-xs); + line-height: 1; + white-space: nowrap; + cursor: pointer; + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--toolbar-btn-color) 7%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } + + &--active { + color: var(--primary-color); + background: color-mix(in srgb, var(--primary-color) 10%, transparent); + } + } + + .cherry-ribbon-header-segmented { + display: flex; + align-items: center; + border: 1px solid color-mix(in srgb, var(--toolbar-btn-color) 15%, transparent); + border-radius: 5px; + overflow: hidden; + + .cherry-ribbon-header-action { + border-radius: 0; + min-height: 24px; + + + .cherry-ribbon-header-action { + border-left: 1px solid color-mix(in srgb, var(--toolbar-btn-color) 15%, transparent); + } + } + } + + .cherry-ribbon-header-menu { + position: relative; + } + + .cherry-ribbon-header-menu__items { + position: absolute; + z-index: 20; + top: calc(100% + 4px); + right: 0; + min-width: 132px; + padding: 4px; + border: 1px solid color-mix(in srgb, var(--toolbar-btn-color) 15%, transparent); + border-radius: 6px; + background: var(--toolbar-bg); + box-shadow: var(--shadow-md); + + &[hidden] { + display: none; + } + } + + .cherry-ribbon-header-menu__item { + display: block; + width: 100%; + min-height: 28px; + padding: 0 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--toolbar-btn-color); + font: inherit; + font-size: var(--font-size-xs); + text-align: left; + white-space: nowrap; + cursor: pointer; + + &:hover:not(:disabled) { + background: color-mix(in srgb, var(--toolbar-btn-color) 7%, transparent); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } + + &--active { + color: var(--primary-color); + } + } + .cherry-ribbon-tab { display: flex; align-items: center; @@ -318,7 +453,10 @@ border-bottom: none; border-radius: 4px 4px 0 0; background: transparent; - transition: color 150ms ease, background 150ms ease, border-color 150ms ease; + transition: + color 150ms ease, + background 150ms ease, + border-color 150ms ease; white-space: nowrap; opacity: 0.65; margin-bottom: -1px; @@ -370,7 +508,10 @@ padding: 6px 8px 4px; font-size: 0; - .ch-icon, i, svg, img { + .ch-icon, + i, + svg, + img { font-size: 18px; width: 18px; height: 18px; @@ -391,17 +532,21 @@ opacity: 0.75; } - &:hover::after { opacity: 1; } + &:hover::after { + opacity: 1; + } &.cherry-toolbar-button--selected { background: color-mix(in srgb, var(--toolbar-btn-color) 10%, transparent); border-radius: var(--toolbar-btn-radius); - &::after { opacity: 1; } + &::after { + opacity: 1; + } } } // 插入标签页:更紧凑的按钮 - &[data-tab-name="tabInsert"] .cherry-toolbar-button:not(.cherry-toolbar-split) { + &[data-tab-name='tabInsert'] .cherry-toolbar-button:not(.cherry-toolbar-split) { min-width: 36px; padding: 5px 6px 3px; @@ -412,11 +557,14 @@ } // 导出标签页:更宽的按钮,完整显示文字 - &[data-tab-name="tabExport"] .cherry-toolbar-button:not(.cherry-toolbar-split) { + &[data-tab-name='tabExport'] .cherry-toolbar-button:not(.cherry-toolbar-split) { min-width: 72px; padding: 8px 14px 6px; - .ch-icon, i, svg, img { + .ch-icon, + i, + svg, + img { font-size: 22px; width: 22px; height: 22px; @@ -446,7 +594,10 @@ min-width: 28px; padding: 4px 6px; - .ch-icon, i, svg, img { + .ch-icon, + i, + svg, + img { font-size: 16px; width: 16px; height: 16px; @@ -470,7 +621,9 @@ opacity: 0.85; overflow: visible; } - &:hover::after { opacity: 1; } + &:hover::after { + opacity: 1; + } } // 大按钮(图标在上 + 文字在下,占满组高度) @@ -481,7 +634,10 @@ min-width: 40px; padding: 4px 8px 3px; - .ch-icon, i, svg, img { + .ch-icon, + i, + svg, + img { font-size: 20px; width: 20px; height: 20px; @@ -498,14 +654,15 @@ opacity: 0.8; } - &:hover::after { opacity: 1; } + &:hover::after { + opacity: 1; + } // 大按钮有下拉菜单时,文字后追加箭头 &.cherry-toolbar-dropdown::after { content: attr(title) ' \25BE'; } } - } } @@ -557,8 +714,14 @@ color: var(--toolbar-btn-color); background: var(--toolbar-btn-bg); border-radius: var(--toolbar-btn-radius); - -webkit-transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease; - transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease; + -webkit-transition: + color 150ms ease, + background-color 150ms ease, + border-color 150ms ease; + transition: + color 150ms ease, + background-color 150ms ease, + border-color 150ms ease; cursor: pointer; font-style: normal; @@ -611,7 +774,7 @@ } } } - } + } } // 搜索框样式 @@ -834,7 +997,10 @@ color: var(--toolbar-btn-color); background: var(--toolbar-btn-bg); border-radius: var(--toolbar-btn-radius); - transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease; + transition: + color 150ms ease, + background-color 150ms ease, + border-color 150ms ease; &:hover { background: var(--toolbar-btn-hover-bg); @@ -904,7 +1070,10 @@ border: 0; cursor: pointer; font-style: normal; - transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease; + transition: + color 150ms ease, + background-color 150ms ease, + border-color 150ms ease; &.cherry-toolbar-split { border-left: 1px solid var(--toolbar-split-color); @@ -1066,7 +1235,7 @@ .url-truncated { max-width: 200px; } - .cm-invalidchar[title="\\\\u200b"] { + .cm-invalidchar[title='\\\\u200b'] { display: none; } } @@ -1169,12 +1338,36 @@ line-height: var(--editor-content-line-height); } - h1 { font-size: 32px; line-height: 40px; margin-top: var(--editor-content-block-gap); } - h2 { font-size: 26px; line-height: 34px; margin-top: var(--editor-content-block-gap); } - h3 { font-size: 22px; line-height: 30px; margin-top: var(--editor-content-block-gap); } - h4 { font-size: 19px; line-height: 27px; margin-top: var(--editor-content-block-gap); } - h5 { font-size: 17px; line-height: 25px; margin-top: var(--editor-content-block-gap); } - h6 { font-size: 15px; line-height: 24px; margin-top: var(--editor-content-block-gap); } + h1 { + font-size: 32px; + line-height: 40px; + margin-top: var(--editor-content-block-gap); + } + h2 { + font-size: 26px; + line-height: 34px; + margin-top: var(--editor-content-block-gap); + } + h3 { + font-size: 22px; + line-height: 30px; + margin-top: var(--editor-content-block-gap); + } + h4 { + font-size: 19px; + line-height: 27px; + margin-top: var(--editor-content-block-gap); + } + h5 { + font-size: 17px; + line-height: 25px; + margin-top: var(--editor-content-block-gap); + } + h6 { + font-size: 15px; + line-height: 24px; + margin-top: var(--editor-content-block-gap); + } } &.cherry-wysiwyg--hidden { @@ -1226,11 +1419,21 @@ } /* 层级缩进 */ - .toc-li-2 { padding-left: calc(var(--md-toc-indicator-gap, 16px) + 1.2em); } - .toc-li-3 { padding-left: calc(var(--md-toc-indicator-gap, 16px) + 2.4em); } - .toc-li-4 { padding-left: calc(var(--md-toc-indicator-gap, 16px) + 3.6em); } - .toc-li-5 { padding-left: calc(var(--md-toc-indicator-gap, 16px) + 4.8em); } - .toc-li-6 { padding-left: calc(var(--md-toc-indicator-gap, 16px) + 6.0em); } + .toc-li-2 { + padding-left: calc(var(--md-toc-indicator-gap, 16px) + 1.2em); + } + .toc-li-3 { + padding-left: calc(var(--md-toc-indicator-gap, 16px) + 2.4em); + } + .toc-li-4 { + padding-left: calc(var(--md-toc-indicator-gap, 16px) + 3.6em); + } + .toc-li-5 { + padding-left: calc(var(--md-toc-indicator-gap, 16px) + 4.8em); + } + .toc-li-6 { + padding-left: calc(var(--md-toc-indicator-gap, 16px) + 6em); + } /* 拖拽手柄 */ .cherry-toc-handle { @@ -1262,13 +1465,17 @@ left: var(--drop-indent, 0px); right: 0; height: 2px; - background: var(--primary-color, #30D158); + background: var(--primary-color, #30d158); pointer-events: none; } } - .cherry-toc-drop-above::after { top: 0; } - .cherry-toc-drop-below::after { bottom: 0; } + .cherry-toc-drop-above::after { + top: 0; + } + .cherry-toc-drop-below::after { + bottom: 0; + } /* 条目内容 — 富文本渲染,点击编辑 */ .cherry-toc-entry { @@ -1280,7 +1487,9 @@ flex-grow: 1; border-radius: var(--md-toc-link-radius, var(--radius-md, 4px)); padding: var(--spacing-xs, 2px) var(--spacing-sm, 4px); - transition: color 50ms ease-out, background-color 50ms ease-out; + transition: + color 50ms ease-out, + background-color 50ms ease-out; min-height: 1.4em; overflow: hidden; text-overflow: ellipsis; @@ -1292,18 +1501,22 @@ &:hover { color: var(--md-toc-link-hover-color, var(--md-link-hover-color)); - background-color: var(--md-toc-link-hover-bg, var(--oc-gray-1, rgba(0,0,0,0.04))); + background-color: var(--md-toc-link-hover-bg, var(--oc-gray-1, rgba(0, 0, 0, 0.04))); } - &[contenteditable="true"] { + &[contenteditable='true'] { cursor: text; caret-color: auto; white-space: normal; } /* ruby 在 TOC 中保持可见 */ - ruby { font-size: inherit; } - rt { font-size: 0.6em; } + ruby { + font-size: inherit; + } + rt { + font-size: 0.6em; + } } /* 操作按钮 — hover 时显示 */ @@ -1359,7 +1572,6 @@ align-items: center; gap: 8px; } - } } @@ -1375,7 +1587,9 @@ border-radius: 6px; white-space: nowrap; opacity: 0; - transition: opacity 0.2s, transform 0.2s; + transition: + opacity 0.2s, + transform 0.2s; pointer-events: none; z-index: 9999; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); @@ -1399,7 +1613,7 @@ } /* 脚注引用:用 data-index 显示编号 [1] [2] */ - sup[data-type="footnote_reference"] { + sup[data-type='footnote_reference'] { font-size: 0; line-height: 0; vertical-align: super; @@ -1420,7 +1634,7 @@ } /* 脚注定义:用 data-index 显示编号 + 虚线分隔 */ - dl[data-type="footnote_definition"] { + dl[data-type='footnote_definition'] { color: var(--md-paragraph-color, #555); margin-bottom: var(--spacing-lg, 16px); padding-bottom: 12px; @@ -1432,8 +1646,12 @@ padding: 5px; color: var(--md-link-color, var(--primary-color)); - &::before { content: '['; } - &::after { content: '] '; } + &::before { + content: '['; + } + &::after { + content: '] '; + } } /* 有 data-index 时:用 dl::before 显示编号(attr() 只能读取自身属性),隐藏 dt */ @@ -1444,14 +1662,19 @@ color: var(--md-link-color, var(--primary-color)); font-weight: normal; } - dt { display: none; } + dt { + display: none; + } } dd { display: inline; margin-left: 0; - p { display: inline; margin: 0; } + p { + display: inline; + margin: 0; + } } &.cherry-fn-highlight { @@ -1494,7 +1717,7 @@ } /* 展开状态 */ - &[data-open="true"] .cherry-detail-summary { + &[data-open='true'] .cherry-detail-summary { border-radius: var(--accordion-radius) var(--accordion-radius) 0 0; &::before { @@ -1503,7 +1726,7 @@ } /* 收起状态 — 隐藏内容体 */ - &[data-open="false"] { + &[data-open='false'] { .cherry-detail-summary { border-radius: var(--accordion-radius); } @@ -1662,7 +1885,9 @@ border-radius: 4px; cursor: pointer; user-select: none; - transition: background 0.2s, color 0.2s; + transition: + background 0.2s, + color 0.2s; &:hover { background: #e0e0e0; @@ -1704,7 +1929,13 @@ .cherry-mask-code-block { width: 100%; padding-top: 50px; - background-image: -webkit-gradient(linear, left top, left bottom, from(rgba(255, 255, 255, 0)), to(var(--toolbar-bg))); + background-image: -webkit-gradient( + linear, + left top, + left bottom, + from(rgba(255, 255, 255, 0)), + to(var(--toolbar-bg)) + ); background-image: linear-gradient(-180deg, rgba(255, 255, 255, 0) 0%, var(--toolbar-bg) 100%); text-align: center; position: absolute; @@ -1714,7 +1945,9 @@ z-index: 10; opacity: 0; visibility: hidden; - transition: opacity 0.7s ease-in-out, height 0.7s ease-in-out; + transition: + opacity 0.7s ease-in-out, + height 0.7s ease-in-out; //伪类方便添加动画,使用visibility和opacity来显示和隐藏 .expand-btn { @@ -1812,7 +2045,11 @@ .cherry-unExpand-code-block, .cherry-edit-code-block { cursor: pointer; - transition: background-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease, color 0.2s ease; + transition: + background-color 0.2s ease, + transform 0.2s ease, + box-shadow 0.2s ease, + color 0.2s ease; transform: translateY(0); outline: none; } @@ -1845,7 +2082,9 @@ .cherry-expand-code-block:focus-visible, .cherry-unExpand-code-block:focus-visible, .cherry-edit-code-block:focus-visible { - box-shadow: 0 0 0 2px var(--base-previewer-bg), 0 0 0 4px var(--primary-color); + box-shadow: + 0 0 0 2px var(--base-previewer-bg), + 0 0 0 4px var(--primary-color); } .cherry-code-preview-lang-select { @@ -1859,7 +2098,9 @@ border-radius: 6px; padding: 5px 36px 5px 12px; cursor: pointer; - transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + transition: + border-color 0.15s ease-in-out, + box-shadow 0.15s ease-in-out; background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23495057' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); background-repeat: no-repeat; background-position: right 12px center; @@ -1869,7 +2110,9 @@ .cherry-code-preview-lang-select:focus { border-color: var(--primary-color); outline: 0; - box-shadow: 0 0 0 2px var(--base-previewer-bg), 0 0 0 4px var(--primary-color); + box-shadow: + 0 0 0 2px var(--base-previewer-bg), + 0 0 0 4px var(--primary-color); background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23495057' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 11 6-6 6 6'/%3e%3c/svg%3e"); } @@ -1881,8 +2124,8 @@ } .cherry-code-preview-lang-select option:hover { - background-color: var(--toolbar-btn-hover-bg); - color: var(--toolbar-btn-hover-color); + background-color: var(--toolbar-btn-hover-bg); + color: var(--toolbar-btn-hover-color); } .cherry-code-preview-lang-select:disabled { background-color: var(--base-border-color); @@ -1917,7 +2160,6 @@ min-height: 300px; &.float-previewer-dragging { - .float-previewer-header { cursor: grabbing; background: #ace4ff; @@ -1983,12 +2225,30 @@ } // 仅收紧编辑器预览面板,不改变独立消费 .cherry-markdown 的聊天消息样式。 - h1 { font-size: 32px; line-height: 40px; } - h2 { font-size: 26px; line-height: 34px; } - h3 { font-size: 22px; line-height: 30px; } - h4 { font-size: 19px; line-height: 27px; } - h5 { font-size: 17px; line-height: 25px; } - h6 { font-size: 15px; line-height: 24px; } + h1 { + font-size: 32px; + line-height: 40px; + } + h2 { + font-size: 26px; + line-height: 34px; + } + h3 { + font-size: 22px; + line-height: 30px; + } + h4 { + font-size: 19px; + line-height: 27px; + } + h5 { + font-size: 17px; + line-height: 25px; + } + h6 { + font-size: 15px; + line-height: 24px; + } h1, h2, @@ -2302,7 +2562,10 @@ border-radius: var(--dropdown-item-radius); font-size: var(--font-size-sm); margin: 2px 4px; - transition: color 150ms ease, background-color 150ms ease, border-color 150ms ease; + transition: + color 150ms ease, + background-color 150ms ease, + border-color 150ms ease; display: flex; align-items: center; @@ -2601,14 +2864,30 @@ box-shadow: 0px 2px 2px 0px rgba(20, 81, 154, 0.5); pointer-events: all; } - .cherry-previewer-img-size-handler__points-leftTop { cursor: nw-resize; } - .cherry-previewer-img-size-handler__points-rightTop { cursor: sw-resize; } - .cherry-previewer-img-size-handler__points-leftBottom { cursor: sw-resize; } - .cherry-previewer-img-size-handler__points-rightBottom { cursor: nw-resize; } - .cherry-previewer-img-size-handler__points-middleTop { cursor: n-resize; } - .cherry-previewer-img-size-handler__points-middleBottom { cursor: n-resize; } - .cherry-previewer-img-size-handler__points-leftMiddle { cursor: e-resize; } - .cherry-previewer-img-size-handler__points-rightMiddle { cursor: e-resize; } + .cherry-previewer-img-size-handler__points-leftTop { + cursor: nw-resize; + } + .cherry-previewer-img-size-handler__points-rightTop { + cursor: sw-resize; + } + .cherry-previewer-img-size-handler__points-leftBottom { + cursor: sw-resize; + } + .cherry-previewer-img-size-handler__points-rightBottom { + cursor: nw-resize; + } + .cherry-previewer-img-size-handler__points-middleTop { + cursor: n-resize; + } + .cherry-previewer-img-size-handler__points-middleBottom { + cursor: n-resize; + } + .cherry-previewer-img-size-handler__points-leftMiddle { + cursor: e-resize; + } + .cherry-previewer-img-size-handler__points-rightMiddle { + cursor: e-resize; + } } .cherry-previewer-img-tool-handler { @@ -2642,7 +2921,9 @@ cursor: pointer; user-select: none; font-size: 14px; - transition: color 150ms, background-color 150ms; + transition: + color 150ms, + background-color 150ms; box-sizing: border-box; &.active, diff --git a/packages/cherry-markdown/src/toolbars/Toolbar.js b/packages/cherry-markdown/src/toolbars/Toolbar.js index ec48335fb..9c69c5700 100644 --- a/packages/cherry-markdown/src/toolbars/Toolbar.js +++ b/packages/cherry-markdown/src/toolbars/Toolbar.js @@ -119,6 +119,9 @@ export default class Toolbar { ) { this.hideAllSubMenu(); } + if (!target?.closest('.cherry-ribbon-header-menu')) { + this.$closeRibbonHeaderMenus(); + } }; this.$cherry.$event.on('cleanAllSubMenus', this.$handleCleanAllSubMenus); this.$cherry.$event.on('modeCommitted', this.$handleModeCommitted); @@ -143,6 +146,7 @@ export default class Toolbar { this.$handleCleanAllSubMenus = null; this.$handleModeCommitted = null; this.$handleDocumentClick = null; + this.ribbonHeaderActionsDom = null; } /** @@ -248,6 +252,7 @@ export default class Toolbar { this.options.dom.classList.add('cherry-toolbar--ribbon'); const ribbon = createElement('div', 'cherry-ribbon'); + const ribbonHeader = createElement('div', 'cherry-ribbon-header'); const tabHeaders = createElement('div', 'cherry-ribbon-tabs'); const tabPanelsContainer = createElement('div', 'cherry-ribbon-panels'); @@ -296,12 +301,144 @@ export default class Toolbar { tabPanelsContainer.appendChild(panel); }); - ribbon.appendChild(tabHeaders); + ribbonHeader.appendChild(tabHeaders); + ribbon.appendChild(ribbonHeader); ribbon.appendChild(tabPanelsContainer); const toolbarLeft = createElement('div', 'toolbar-left'); toolbarLeft.appendChild(ribbon); this.options.dom.appendChild(toolbarLeft); + this.ribbonHeader = ribbonHeader; + this.setRibbonHeaderActions(this.$cherry.options.toolbars.ribbonHeaderActions); + } + + /** + * 更新 Ribbon 标签行右侧的宿主动作。 + * @param {import('~types/cherry').CherryRibbonHeaderActions} configuration + */ + setRibbonHeaderActions(configuration) { + if (!this.ribbonHeader) return; + this.ribbonHeaderActionsDom?.remove(); + this.ribbonHeaderActionsDom = null; + if (!configuration || !Array.isArray(configuration.actions) || configuration.actions.length === 0) return; + + const container = createElement('div', 'cherry-ribbon-header-actions', { + role: 'toolbar', + 'aria-label': configuration.ariaLabel || 'Document actions', + }); + configuration.actions.forEach((action) => this.$renderRibbonHeaderAction(container, action)); + if (container.childElementCount === 0) return; + this.ribbonHeader.appendChild(container); + this.ribbonHeaderActionsDom = container; + } + + /** + * @param {HTMLElement} container + * @param {import('~types/cherry').CherryRibbonHeaderAction} action + */ + $renderRibbonHeaderAction(container, action) { + if (!action || typeof action !== 'object' || !action.id) return; + + if (action.type === 'segmented') { + const group = createElement('div', 'cherry-ribbon-header-segmented', { + role: 'group', + 'aria-label': action.ariaLabel || action.label || action.id, + }); + (action.options || []).forEach((option) => { + const selected = option.value === action.value; + const button = createElement('button', 'cherry-ribbon-header-action', { + type: 'button', + title: option.title || option.label, + 'aria-pressed': String(selected), + }); + button.textContent = option.label; + button.disabled = Boolean(option.disabled); + button.classList.toggle('cherry-ribbon-header-action--active', selected); + button.addEventListener('click', (event) => { + if (option.disabled || option.value === action.value) return; + this.$invokeRibbonHeaderCallback(action.onChange, option.value, event); + }); + group.appendChild(button); + }); + if (group.childElementCount > 0) container.appendChild(group); + return; + } + + if (action.type === 'menu') { + const menu = createElement('div', 'cherry-ribbon-header-menu'); + const button = this.$createRibbonHeaderButton(action); + button.setAttribute('aria-haspopup', 'menu'); + button.setAttribute('aria-expanded', 'false'); + const items = createElement('div', 'cherry-ribbon-header-menu__items', { role: 'menu' }); + items.hidden = true; + (action.items || []).forEach((item) => { + if (!item || !item.id) return; + const menuItem = createElement('button', 'cherry-ribbon-header-menu__item', { + type: 'button', + role: 'menuitem', + title: item.title || item.label, + }); + menuItem.textContent = item.label; + menuItem.disabled = Boolean(item.disabled); + menuItem.classList.toggle('cherry-ribbon-header-menu__item--active', Boolean(item.active)); + menuItem.addEventListener('click', (event) => { + event.stopPropagation(); + this.$closeRibbonHeaderMenus(); + this.$invokeRibbonHeaderCallback(item.onClick, event); + }); + items.appendChild(menuItem); + }); + button.addEventListener('click', (event) => { + event.stopPropagation(); + const willOpen = items.hidden; + this.$closeRibbonHeaderMenus(); + items.hidden = !willOpen; + button.setAttribute('aria-expanded', String(willOpen)); + }); + menu.appendChild(button); + menu.appendChild(items); + container.appendChild(menu); + return; + } + + const button = this.$createRibbonHeaderButton(action); + button.addEventListener('click', (event) => this.$invokeRibbonHeaderCallback(action.onClick, event)); + container.appendChild(button); + } + + /** @param {import('~types/cherry').CherryRibbonHeaderButtonAction | import('~types/cherry').CherryRibbonHeaderMenuAction} action */ + $createRibbonHeaderButton(action) { + const button = createElement('button', 'cherry-ribbon-header-action', { + type: 'button', + title: action.title || action.label, + 'aria-label': action.label, + }); + button.textContent = action.label; + button.disabled = Boolean(action.disabled); + button.classList.toggle('cherry-ribbon-header-action--active', Boolean(action.active)); + if (typeof action.active === 'boolean' && action.type !== 'menu') { + button.setAttribute('aria-pressed', String(action.active)); + } + return button; + } + + $closeRibbonHeaderMenus() { + this.ribbonHeaderActionsDom?.querySelectorAll('.cherry-ribbon-header-menu').forEach((menu) => { + const button = menu.querySelector('[aria-haspopup="menu"]'); + const items = menu.querySelector('.cherry-ribbon-header-menu__items'); + if (items instanceof HTMLElement) items.hidden = true; + button?.setAttribute('aria-expanded', 'false'); + }); + } + + $invokeRibbonHeaderCallback(callback, ...args) { + if (typeof callback !== 'function') return; + try { + const result = callback(...args, this.$cherry); + result?.catch?.((error) => Logger.error('[Toolbar] Async Ribbon header action failed:', error)); + } catch (error) { + Logger.error('[Toolbar] Ribbon header action failed:', error); + } } /** diff --git a/packages/cherry-markdown/test/editor/RibbonHeaderActions.spec.ts b/packages/cherry-markdown/test/editor/RibbonHeaderActions.spec.ts new file mode 100644 index 000000000..b5f0e381b --- /dev/null +++ b/packages/cherry-markdown/test/editor/RibbonHeaderActions.spec.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest'; +import Cherry from '../../src/Cherry'; +import Toolbar from '../../src/toolbars/Toolbar'; + +const createHarness = () => { + const ribbonHeader = document.createElement('div'); + ribbonHeader.className = 'cherry-ribbon-header'; + const cherry = Object.create(Cherry.prototype) as Cherry & Record; + const toolbar = Object.create(Toolbar.prototype) as Toolbar & Record; + Object.assign(cherry, { + options: { toolbars: { ribbonHeaderActions: false } }, + }); + Object.assign(toolbar, { + $cherry: cherry, + ribbonHeader, + ribbonHeaderActionsDom: null, + }); + cherry.toolbar = toolbar; + return { cherry, toolbar, ribbonHeader }; +}; + +describe('Ribbon header actions API', () => { + it('renders segmented, button and menu actions without an external mount point', () => { + const { cherry, ribbonHeader } = createHarness(); + const onModeChange = vi.fn(); + const onSave = vi.fn(); + const onExport = vi.fn(); + + expect( + cherry.setRibbonHeaderActions({ + ariaLabel: 'Markdown commands', + actions: [ + { + type: 'segmented', + id: 'diff-mode', + ariaLabel: 'Diff mode', + value: 'inline', + options: [ + { value: 'inline', label: 'Inline' }, + { value: 'side_by_side', label: 'Compare' }, + ], + onChange: onModeChange, + }, + { id: 'save', label: 'Save', onClick: onSave }, + { + type: 'menu', + id: 'more', + label: 'More', + items: [{ id: 'export', label: 'Export', onClick: onExport }], + }, + ], + }), + ).toBe(true); + + const actions = ribbonHeader.querySelector('[role="toolbar"]'); + expect(actions?.getAttribute('aria-label')).toBe('Markdown commands'); + + const inline = ribbonHeader.querySelector('[title="Inline"]')!; + const compare = ribbonHeader.querySelector('[title="Compare"]')!; + expect(inline.getAttribute('aria-pressed')).toBe('true'); + compare.click(); + expect(onModeChange).toHaveBeenCalledWith('side_by_side', expect.any(MouseEvent), cherry); + + ribbonHeader.querySelector('[aria-label="Save"]')!.click(); + expect(onSave).toHaveBeenCalledWith(expect.any(MouseEvent), cherry); + + const more = ribbonHeader.querySelector('[aria-label="More"]')!; + const menu = ribbonHeader.querySelector('[role="menu"]')!; + expect(menu.hidden).toBe(true); + more.click(); + expect(more.getAttribute('aria-expanded')).toBe('true'); + expect(menu.hidden).toBe(false); + ribbonHeader.querySelector('[role="menuitem"]')!.click(); + expect(onExport).toHaveBeenCalledWith(expect.any(MouseEvent), cherry); + expect(menu.hidden).toBe(true); + }); + + it('updates action state in place and rejects invalid configuration', () => { + const { cherry, ribbonHeader } = createHarness(); + expect( + cherry.setRibbonHeaderActions({ + actions: [{ id: 'save', label: 'Save', disabled: true }], + }), + ).toBe(true); + expect(ribbonHeader.querySelector('[aria-label="Save"]')?.disabled).toBe(true); + + expect( + cherry.setRibbonHeaderActions({ + actions: [{ id: 'save', label: 'Save', disabled: false }], + }), + ).toBe(true); + expect(ribbonHeader.querySelectorAll('[aria-label="Save"]')).toHaveLength(1); + expect(ribbonHeader.querySelector('[aria-label="Save"]')?.disabled).toBe(false); + + expect(cherry.setRibbonHeaderActions(null as any)).toBe(false); + expect(cherry.setRibbonHeaderActions(false)).toBe(true); + expect(ribbonHeader.querySelector('[role="toolbar"]')).toBeNull(); + }); +}); diff --git a/packages/cherry-markdown/test/release/ReleaseScript.spec.ts b/packages/cherry-markdown/test/release/ReleaseScript.spec.ts index 2efd047d7..cccd0f743 100644 --- a/packages/cherry-markdown/test/release/ReleaseScript.spec.ts +++ b/packages/cherry-markdown/test/release/ReleaseScript.spec.ts @@ -47,9 +47,11 @@ describe('protected-branch release contract', () => { expect(verifier).toContain('import(pathToFileURL'); expect(verifier).toContain('createRequire'); expect(verifier).toContain('Cherry.capabilities'); + expect(verifier).toContain('ribbonHeaderActions'); + expect(verifier).toContain('Cherry.prototype.setRibbonHeaderActions'); expect(verifier).toContain('mermaidAPIRefs'); - expect(verifier).toContain("Cherry.prototype.switchModel"); - expect(verifier).toContain("Cherry.prototype.refreshPreviewer"); + expect(verifier).toContain('Cherry.prototype.switchModel'); + expect(verifier).toContain('Cherry.prototype.refreshPreviewer'); expect(verifier).toContain('instanceof Promise'); }); diff --git a/packages/cherry-markdown/types/cherry.d.ts b/packages/cherry-markdown/types/cherry.d.ts index 5029c04f0..aff9dd556 100644 --- a/packages/cherry-markdown/types/cherry.d.ts +++ b/packages/cherry-markdown/types/cherry.d.ts @@ -63,6 +63,8 @@ export interface Cherry { switchModel(model?: EditorMode, showToolbar?: boolean): Promise; /** 用当前 Markdown 刷新预览 DOM,并等待 mounted hooks 及异步渲染完成;超时返回 false。 */ refreshPreviewer(): Promise; + /** 更新 Ribbon 标签行右侧动作。 */ + setRibbonHeaderActions(actions: CherryRibbonHeaderActions): boolean; on(eventName: 'modeCommitted', callback: (payload: ModeCommittedPayload) => void): void; off(eventName: 'modeCommitted', callback: (payload: ModeCommittedPayload) => void): void; } @@ -77,6 +79,7 @@ export interface SuperDocCapabilities { readonly asyncModeSwitch: true; readonly awaitableRefreshPreviewer: true; readonly compactLayout: true; + readonly ribbonHeaderActions: true; } export type CherryOptions = Partial<_CherryOptions>; @@ -813,6 +816,57 @@ export interface CherryToolbarGroup { showLabel?: boolean | string[]; styleCard?: boolean; } +export interface CherryRibbonHeaderOption { + value: string; + label: string; + title?: string; + disabled?: boolean; +} +export interface CherryRibbonHeaderMenuItem { + id: string; + label: string; + title?: string; + active?: boolean; + disabled?: boolean; + onClick?: (event: MouseEvent, cherry: Cherry) => void; +} +export interface CherryRibbonHeaderButtonAction { + type?: 'button'; + id: string; + label: string; + title?: string; + active?: boolean; + disabled?: boolean; + onClick?: (event: MouseEvent, cherry: Cherry) => void; +} +export interface CherryRibbonHeaderSegmentedAction { + type: 'segmented'; + id: string; + label?: string; + ariaLabel?: string; + value: string; + options: CherryRibbonHeaderOption[]; + onChange?: (value: string, event: MouseEvent, cherry: Cherry) => void; +} +export interface CherryRibbonHeaderMenuAction { + type: 'menu'; + id: string; + label: string; + title?: string; + active?: boolean; + disabled?: boolean; + items: CherryRibbonHeaderMenuItem[]; +} +export type CherryRibbonHeaderAction = + | CherryRibbonHeaderButtonAction + | CherryRibbonHeaderSegmentedAction + | CherryRibbonHeaderMenuAction; +export type CherryRibbonHeaderActions = + | { + ariaLabel?: string; + actions: CherryRibbonHeaderAction[]; + } + | false; export interface CherryToolbarsOptions { /** * @deprecated 不再支持theme的配置,统一在`themeSettings.toolbarTheme`中配置 @@ -840,6 +894,8 @@ export interface CherryToolbarsOptions