mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
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:
@@ -51,6 +51,10 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
|||||||
return modelPreview;
|
return modelPreview;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
collapsed$: Signal<boolean> = computed(
|
||||||
|
() => !!this.model.props.collapsed$.value
|
||||||
|
);
|
||||||
|
|
||||||
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
|
highlightTokens$: Signal<ThemedToken[][]> = signal([]);
|
||||||
|
|
||||||
languageName$: Signal<string> = computed(() => {
|
languageName$: Signal<string> = computed(() => {
|
||||||
@@ -417,6 +421,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
|||||||
CodeBlockPreviewIdentifier(this.model.props.language ?? '')
|
CodeBlockPreviewIdentifier(this.model.props.language ?? '')
|
||||||
);
|
);
|
||||||
const shouldRenderPreview = preview && previewContext;
|
const shouldRenderPreview = preview && previewContext;
|
||||||
|
const collapsed = this.collapsed$.value;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
@@ -426,6 +431,7 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
|||||||
mobile: IS_MOBILE,
|
mobile: IS_MOBILE,
|
||||||
wrap: this.model.props.wrap,
|
wrap: this.model.props.wrap,
|
||||||
'disable-line-numbers': !showLineNumbers,
|
'disable-line-numbers': !showLineNumbers,
|
||||||
|
collapsed,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<rich-text
|
<rich-text
|
||||||
@@ -453,9 +459,12 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
</rich-text>
|
</rich-text>
|
||||||
|
${collapsed
|
||||||
|
? html`<div class="code-collapsed-fade" aria-hidden="true"></div>`
|
||||||
|
: nothing}
|
||||||
<div
|
<div
|
||||||
style=${styleMap({
|
style=${styleMap({
|
||||||
display: shouldRenderPreview ? undefined : 'none',
|
display: shouldRenderPreview && !collapsed ? undefined : 'none',
|
||||||
})}
|
})}
|
||||||
contenteditable="false"
|
contenteditable="false"
|
||||||
class="affine-code-block-preview"
|
class="affine-code-block-preview"
|
||||||
@@ -471,6 +480,10 @@ export class CodeBlockComponent extends CaptionedBlockComponent<CodeBlockModel>
|
|||||||
this.store.updateBlock(this.model, { wrap });
|
this.store.updateBlock(this.model, { wrap });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCollapsed(collapsed: boolean) {
|
||||||
|
this.store.updateBlock(this.model, { collapsed });
|
||||||
|
}
|
||||||
|
|
||||||
@query('rich-text')
|
@query('rich-text')
|
||||||
private accessor _richTextElement: RichText | null = null;
|
private accessor _richTextElement: RichText | null = null;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { WithDisposable } from '@blocksuite/global/lit';
|
|||||||
import { noop } from '@blocksuite/global/utils';
|
import { noop } from '@blocksuite/global/utils';
|
||||||
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
|
import { MoreVerticalIcon } from '@blocksuite/icons/lit';
|
||||||
import { flip, offset } from '@floating-ui/dom';
|
import { flip, offset } from '@floating-ui/dom';
|
||||||
|
import { effect } from '@preact/signals-core';
|
||||||
import { css, html, LitElement } from 'lit';
|
import { css, html, LitElement } from 'lit';
|
||||||
import { property, query, state } from 'lit/decorators.js';
|
import { property, query, state } from 'lit/decorators.js';
|
||||||
|
|
||||||
@@ -108,6 +109,17 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
|
|||||||
this.closeCurrentMenu();
|
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() {
|
override render() {
|
||||||
return html`
|
return html`
|
||||||
<editor-toolbar class="code-toolbar-container" data-without-bg>
|
<editor-toolbar class="code-toolbar-container" data-without-bg>
|
||||||
@@ -136,6 +148,9 @@ export class AffineCodeToolbar extends WithDisposable(LitElement) {
|
|||||||
@state()
|
@state()
|
||||||
private accessor _moreMenuOpen = false;
|
private accessor _moreMenuOpen = false;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
private accessor _collapsed = false;
|
||||||
|
|
||||||
@property({ attribute: false })
|
@property({ attribute: false })
|
||||||
accessor context!: CodeBlockToolbarContext;
|
accessor context!: CodeBlockToolbarContext;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
CancelWrapIcon,
|
CancelWrapIcon,
|
||||||
CaptionIcon,
|
CaptionIcon,
|
||||||
|
CollapseCodeIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
DeleteIcon,
|
DeleteIcon,
|
||||||
DuplicateIcon,
|
DuplicateIcon,
|
||||||
|
ExpandCodeIcon,
|
||||||
WrapIcon,
|
WrapIcon,
|
||||||
} from '@blocksuite/affine-components/icons';
|
} from '@blocksuite/affine-components/icons';
|
||||||
import type { MenuItemGroup } from '@blocksuite/affine-components/toolbar';
|
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',
|
type: 'caption',
|
||||||
label: 'Caption',
|
label: 'Caption',
|
||||||
|
|||||||
@@ -80,4 +80,35 @@ export const codeBlockStyles = css`
|
|||||||
affine-code .affine-code-block-preview {
|
affine-code .affine-code-block-preview {
|
||||||
padding: 12px;
|
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;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -265,6 +265,16 @@ export const CancelWrapIcon = icons.CancelWrapIcon({
|
|||||||
height: '20',
|
height: '20',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const CollapseCodeIcon = icons.CollapseIcon({
|
||||||
|
width: '20',
|
||||||
|
height: '20',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ExpandCodeIcon = icons.ToggleRightIcon({
|
||||||
|
width: '20',
|
||||||
|
height: '20',
|
||||||
|
});
|
||||||
|
|
||||||
// Attachment
|
// Attachment
|
||||||
|
|
||||||
export const ViewIcon = icons.ViewIcon({
|
export const ViewIcon = icons.ViewIcon({
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type CodeBlockProps = {
|
|||||||
caption: string;
|
caption: string;
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
lineNumber?: boolean;
|
lineNumber?: boolean;
|
||||||
|
collapsed?: boolean;
|
||||||
comments?: Record<string, boolean>;
|
comments?: Record<string, boolean>;
|
||||||
} & BlockMeta;
|
} & BlockMeta;
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export const CodeBlockSchema = defineBlockSchema({
|
|||||||
caption: '',
|
caption: '',
|
||||||
preview: undefined,
|
preview: undefined,
|
||||||
lineNumber: undefined,
|
lineNumber: undefined,
|
||||||
|
collapsed: undefined,
|
||||||
comments: undefined,
|
comments: undefined,
|
||||||
'meta:createdAt': undefined,
|
'meta:createdAt': undefined,
|
||||||
'meta:createdBy': undefined,
|
'meta:createdBy': undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user