feat(editor): add collapse/expand functionality to code block component (#14884)

This PR fixes #14040 

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Code blocks can be collapsed and expanded via a toolbar toggle
(visible when the document is editable).
* Collapsed code blocks show a limited preview (~8 lines) with a bottom
fade overlay and reduced padding.
* Toolbar button updates icon and tooltip to reflect collapsed/expanded
state.
* Collapse state is preserved on the block so its current
collapsed/expanded setting is retained.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ahsan Khaleeq
2026-05-04 02:07:42 +05:00
committed by GitHub
parent 5d234ad6a8
commit 9e412f58ec
6 changed files with 106 additions and 1 deletions

View File

@@ -51,6 +51,10 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
return modelPreview;
});
collapsed$: Signal<boolean> = computed(
() => !!this.model.props.collapsed$.value
);
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
languageName$: Signal<string> = computed(() => {
@@ -417,6 +421,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
CodeBlockPreviewIdentifier(this.model.props.language ?? '')
);
const shouldRenderPreview = preview && previewContext;
const collapsed = this.collapsed$.value;
return html`
<div
@@ -426,6 +431,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
mobile: IS_MOBILE,
wrap: this.model.props.wrap,
'disable-line-numbers': !showLineNumbers,
collapsed,
})}
>
<rich-text
@@ -453,9 +459,12 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
}}
>
</rich-text>
${collapsed
? html`<div class="code-collapsed-fade" aria-hidden="true"></div>`
: nothing}
<div
style=${styleMap({
display: shouldRenderPreview ? undefined : 'none',
display: shouldRenderPreview && !collapsed ? undefined : 'none',
})}
contenteditable="false"
class="affine-code-block-preview"
@@ -471,6 +480,10 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
this.store.updateBlock(this.model, { wrap });
}
setCollapsed(collapsed: boolean) {
this.store.updateBlock(this.model, { collapsed });
}
@query('rich-text')
private accessor _richTextElement: RichText | null = null;

View File

@@ -9,6 +9,7 @@ import { WithDisposable } from '@blocksuite/global/lit';
import { noop } from '@blocksuite/global/utils';
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
import { flip, offset } from '@floating-ui/dom';
import { effect } from '@preact/signals-core';
import { css, html, LitElement } from 'lit';
import { property, query, state } from 'lit/decorators.js';
@@ -108,6 +109,17 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
this.closeCurrentMenu();
}
override connectedCallback() {
super.connectedCallback();
// Mirror the collapsed$ signal from the block component into local @state
// so this LitElement re-renders when it changes.
this.disposables.add(
effect(() => {
this._collapsed = this.context.blockComponent.collapsed$.value;
})
);
}
override render() {
return html`
<editor-toolbar class="code-toolbar-container" data-without-bg>
@@ -136,6 +148,9 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
@state()
private accessor _moreMenuOpen = false;
@state()
private accessor _collapsed = false;
@property({ attribute: false })
accessor context!: CodeBlockToolbarContext;

View File

@@ -1,9 +1,11 @@
import {
CancelWrapIcon,
CaptionIcon,
CollapseCodeIcon,
CopyIcon,
DeleteIcon,
DuplicateIcon,
ExpandCodeIcon,
WrapIcon,
} from '@blocksuite/affine-components/icons';
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
@@ -85,6 +87,38 @@ export const PRIMARY_GROUPS: MenuItemGroup<CodeBlockToolbarContext>[] = [
};
},
},
{
type: 'collapse',
when: ({ doc }) => !doc.readonly,
generate: ({ blockComponent }) => {
return {
action: () => {
blockComponent.setCollapsed(!blockComponent.collapsed$.value);
},
render: item => {
const collapsed = blockComponent.collapsed$.value;
const icon = collapsed ? ExpandCodeIcon : CollapseCodeIcon;
const label = collapsed ? 'Expand code' : 'Collapse code';
return html`
<editor-icon-button
class="code-toolbar-button collapse"
aria-label=${label}
.tooltip=${label}
.tooltipOffset=${4}
.iconSize=${'16px'}
.iconContainerPadding=${4}
@click=${(e: MouseEvent) => {
e.stopPropagation();
item.action();
}}
>
${icon}
</editor-icon-button>
`;
},
};
},
},
{
type: 'caption',
label: 'Caption',

View File

@@ -80,4 +80,35 @@ export const codeBlockStyles = css`
affine-code .affine-code-block-preview {
padding: 12px;
}
/* ── Collapsed state ──────────────────────────────────────────────── */
/* Clamp the rich-text to the first 8 lines */
.affine-code-block-container.collapsed rich-text {
display: block;
max-height: calc(8 * var(--affine-line-height));
overflow: hidden;
}
/* Reduce bottom padding so the fade sits flush with the border */
.affine-code-block-container.collapsed {
padding-bottom: 0;
}
/* Gradient overlay that fades to the block background */
.affine-code-block-container .code-collapsed-fade {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 80px;
background: linear-gradient(
to bottom,
transparent,
var(--affine-background-code-block)
);
border-radius: 0 0 10px 10px;
pointer-events: none;
z-index: 1;
}
`;

View File

@@ -265,6 +265,16 @@ export const CancelWrapIcon = icons.CancelWrapIcon({
height: '20',
});
export const CollapseCodeIcon = icons.CollapseIcon({
width: '20',
height: '20',
});
export const ExpandCodeIcon = icons.ToggleRightIcon({
width: '20',
height: '20',
});
// Attachment
export const ViewIcon = icons.ViewIcon({

View File

@@ -14,6 +14,7 @@ type CodeBlockProps = {
caption: string;
preview?: boolean;
lineNumber?: boolean;
collapsed?: boolean;
comments?: Record<string, boolean>;
} & BlockMeta;
@@ -27,6 +28,7 @@ export const CodeBlockSchema = defineBlockSchema({
caption: '',
preview: undefined,
lineNumber: undefined,
collapsed: undefined,
comments: undefined,
'meta:createdAt': undefined,
'meta:createdBy': undefined,