This commit is contained in:
Tom Moor
2026-04-04 21:29:44 -04:00
parent d4dec42bc5
commit 2fffb2f83d
5 changed files with 373 additions and 18 deletions
+2
View File
@@ -125,6 +125,8 @@ export default function useDictionary() {
formattingControls: t("Formatting controls"),
distributeColumns: t("Distribute columns"),
wrapText: t("Wrap text"),
collapseCode: t("Collapse"),
expandCode: t("Expand"),
}),
[t]
);
+52
View File
@@ -1854,6 +1854,58 @@ mark {
}
}
.code-block.collapsed {
max-height: 350px;
overflow: hidden;
pointer-events: none;
border-bottom: 1px solid ${props.theme.codeBorder};
border-bottom-left-radius: ${EditorStyleHelper.blockRadius};
border-bottom-right-radius: ${EditorStyleHelper.blockRadius};
&::before {
content: "";
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 120px;
z-index: 1;
pointer-events: none;
background: linear-gradient(
to bottom,
${transparentize(1, props.theme.codeBackground)} 0%,
${transparentize(0.2, props.theme.codeBackground)} 70%,
${props.theme.codeBackground} 100%
);
}
}
.${EditorStyleHelper.codeBlockToggle} {
display: inline-flex;
position: relative;
z-index: 2;
left: 50%;
transform: translate3d(-50%, -50px, 0)
align-items: center;
gap: 4px;
border: none;
border-radius: 100px;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
color: white;
font-size: 13px;
font-weight: 500;
line-height: 1;
padding: 6px 12px;
cursor: var(--pointer);
user-select: none;
pointer-events: auto;
@media print {
display: none !important;
}
}
.mermaid-diagram-wrapper {
display: flex;
align-items: center;
+314 -16
View File
@@ -14,7 +14,7 @@ import {
PluginKey,
TextSelection,
} from "prosemirror-state";
import { Decoration, DecorationSet } from "prosemirror-view";
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
import { toast } from "sonner";
import type { Primitive } from "utility-types";
import type { Dictionary } from "~/hooks/useDictionary";
@@ -42,16 +42,111 @@ import {
setRecentlyUsedCodeLanguage,
} from "../lib/code";
import { isCode, isMermaid } from "../lib/isCode";
import { findBlockNodes } from "../queries/findChildren";
import type { MarkdownSerializerState } from "../lib/markdown/serializer";
import { findNextNewline, findPreviousNewline } from "../queries/findNewlines";
import { findParentNode } from "../queries/findParentNode";
import {
findParentNode,
findParentNodeClosestToPos,
} from "../queries/findParentNode";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import { getMarkRange } from "../queries/getMarkRange";
import { isInCode } from "../queries/isInCode";
import Node from "./Node";
const DEFAULT_LANGUAGE = "javascript";
const TALL_HEIGHT = 350;
interface CollapseState {
/** Positions of code blocks measured as taller than TALL_HEIGHT. */
tallBlocks: Set<number>;
/** Positions of code blocks currently collapsed by the user or auto-collapse. */
collapsedBlocks: Set<number>;
/** Node decorations that add the `collapsed` CSS class. */
decorations: DecorationSet;
}
/**
* Remap a set of document positions through a transaction mapping,
* dropping any that no longer point to a code block.
*
* @param positions set of positions to remap.
* @param mapping the mapping to remap through.
* @param doc the new document to validate against.
* @returns a new set with remapped positions.
*/
function remapCodePositions(
positions: Set<number>,
mapping: { map: (pos: number) => number },
doc: ProsemirrorNode
): Set<number> {
const result = new Set<number>();
for (const pos of positions) {
const newPos = mapping.map(pos);
if (newPos < doc.content.size) {
const node = doc.nodeAt(newPos);
if (node && isCode(node)) {
result.add(newPos);
}
}
}
return result;
}
/**
* Build a CollapseState with node decorations for the collapsed class and
* widget decorations for toggle buttons on all tall blocks.
*/
function buildCollapseState(
doc: ProsemirrorNode,
tallBlocks: Set<number>,
collapsedBlocks: Set<number>,
expandLabel: string,
collapseLabel: string
): CollapseState {
const decorations: Decoration[] = [];
for (const pos of tallBlocks) {
const node = doc.nodeAt(pos);
if (!node || !isCode(node)) {
continue;
}
const isCollapsed = collapsedBlocks.has(pos);
if (isCollapsed) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, { class: "collapsed" })
);
}
const label = isCollapsed ? expandLabel : collapseLabel;
decorations.push(
Decoration.widget(
pos + node.nodeSize,
() => {
const button = document.createElement("button");
button.className = EditorStyleHelper.codeBlockToggle;
button.contentEditable = "false";
button.type = "button";
button.textContent = label;
return button;
},
{ side: 1, key: `toggle-${pos}-${isCollapsed}` }
)
);
}
return {
tallBlocks,
collapsedBlocks,
decorations: DecorationSet.create(doc, decorations),
};
}
export default class CodeFence extends Node {
/** Plugin key for the collapse state, shared with the command. */
private static readonly collapseKey = new PluginKey<CollapseState>(
"collapse-code-block"
);
constructor(options: {
dictionary: Dictionary;
userPreferences?: UserPreferences | null;
@@ -109,20 +204,27 @@ export default class CodeFence extends Node {
},
},
],
toDOM: (node) => [
"div",
{
class: `code-block ${
node.attrs.wrap
? "with-line-wrap"
: this.showLineNumbers
? "with-line-numbers"
: ""
}`,
"data-language": node.attrs.language,
},
["pre", ["code", { spellCheck: "false" }, 0]],
],
toDOM: (node) => {
const classes = [
"code-block",
node.attrs.wrap
? "with-line-wrap"
: this.showLineNumbers
? "with-line-numbers"
: "",
]
.filter(Boolean)
.join(" ");
return [
"div",
{
class: classes,
"data-language": node.attrs.language,
},
["pre", ["code", { spellCheck: "false" }, 0]],
];
},
};
}
@@ -137,6 +239,21 @@ export default class CodeFence extends Node {
...attrs,
});
},
toggleCodeBlockCollapse: (): Command => (state, dispatch) => {
const codeBlock = findParentNode(isCode)(state.selection);
if (!codeBlock) {
return false;
}
if (dispatch) {
dispatch(
state.tr.setMeta(CodeFence.collapseKey, {
toggle: codeBlock.pos,
})
);
}
return true;
},
toggleCodeBlockWrap: (): Command => (state, dispatch) => {
const codeBlock = findParentNode(isCode)(state.selection);
if (!codeBlock) {
@@ -251,6 +368,185 @@ export default class CodeFence extends Node {
return output;
}
/** Plugins for collapsible code block behavior. */
private collapsePlugins(): Plugin[] {
const collapseKey = CodeFence.collapseKey;
const { expandCode, collapseCode } = this.options.dictionary;
const emptyState: CollapseState = {
tallBlocks: new Set(),
collapsedBlocks: new Set(),
decorations: DecorationSet.empty,
};
const build = (
doc: ProsemirrorNode,
tall: Set<number>,
collapsed: Set<number>
) => buildCollapseState(doc, tall, collapsed, expandCode, collapseCode);
return [
// Main collapse plugin: manages state and decorations
new Plugin<CollapseState>({
key: collapseKey,
state: {
init: () => emptyState,
apply: (tr, prev, _oldState, newState) => {
const meta = tr.getMeta(collapseKey);
// Initial measurement from rAF
if (meta?.measured) {
const tallBlocks = new Set<number>(meta.measured);
return build(newState.doc, tallBlocks, new Set(tallBlocks));
}
// Toggle collapsed state
if (meta?.toggle !== undefined) {
const next = new Set(prev.collapsedBlocks);
if (next.has(meta.toggle)) {
next.delete(meta.toggle);
} else {
next.add(meta.toggle);
}
return build(newState.doc, prev.tallBlocks, next);
}
// Expand a specific block (auto-expand on focus)
if (meta?.expand !== undefined) {
if (prev.collapsedBlocks.has(meta.expand)) {
const next = new Set(prev.collapsedBlocks);
next.delete(meta.expand);
return build(newState.doc, prev.tallBlocks, next);
}
return prev;
}
// Remap positions on doc changes
if (tr.docChanged) {
return build(
newState.doc,
remapCodePositions(prev.tallBlocks, tr.mapping, newState.doc),
remapCodePositions(
prev.collapsedBlocks,
tr.mapping,
newState.doc
)
);
}
return prev;
},
},
view: (view: EditorView) => {
// Measure code block heights after initial render
requestAnimationFrame(() => {
if (view.isDestroyed) {
return;
}
const blocks = findBlockNodes(view.state.doc, true).filter((item) =>
isCode(item.node)
);
const tallPositions: number[] = [];
for (const block of blocks) {
const dom = view.nodeDOM(block.pos);
if (!(dom instanceof HTMLElement)) {
continue;
}
const pre = dom.querySelector("pre");
if (pre && pre.scrollHeight > TALL_HEIGHT) {
tallPositions.push(block.pos);
}
}
if (tallPositions.length > 0) {
view.dispatch(
view.state.tr
.setMeta(collapseKey, { measured: tallPositions })
.setMeta("addToHistory", false)
);
}
});
return {};
},
props: {
decorations(state) {
return this.getState(state)?.decorations ?? DecorationSet.empty;
},
},
}),
// Click handler for toggle button + auto-expand on focus
new Plugin({
key: new PluginKey("collapse-toggle"),
appendTransaction: (transactions, _oldState, newState) => {
const hasCollapseMeta = transactions.some((tr) =>
tr.getMeta(collapseKey)
);
const hasSelectionSet = transactions.some((tr) => tr.selectionSet);
if (hasCollapseMeta || !hasSelectionSet) {
return null;
}
const codeBlock = findParentNode(isCode)(newState.selection);
const collapseState = collapseKey.getState(newState);
if (
!codeBlock ||
!collapseState?.collapsedBlocks.has(codeBlock.pos)
) {
return null;
}
return newState.tr
.setMeta(collapseKey, { expand: codeBlock.pos })
.setMeta("addToHistory", false);
},
props: {
handleDOMEvents: {
mousedown: (view: EditorView, event: MouseEvent) => {
const target = event.target as HTMLElement;
const button = target.closest(
`.${EditorStyleHelper.codeBlockToggle}`
);
if (!button) {
return false;
}
const codeBlockEl =
button.previousElementSibling?.classList.contains("code-block")
? button.previousElementSibling
: null;
if (!codeBlockEl) {
return false;
}
const codeEl = codeBlockEl.querySelector("code");
if (!codeEl) {
return false;
}
const pos = view.posAtDOM(codeEl, 0);
const $pos = view.state.doc.resolve(pos);
const parent = findParentNodeClosestToPos($pos, isCode);
if (!parent) {
return false;
}
view.dispatch(
view.state.tr
.setMeta(collapseKey, { toggle: parent.pos })
.setMeta("addToHistory", false)
);
event.preventDefault();
event.stopPropagation();
return true;
},
},
},
}),
];
}
get plugins() {
const createActiveCodeBlockDecoration = (state: EditorState) => {
const codeBlock = findParentNode(isCode)(state.selection);
@@ -363,6 +659,8 @@ export default class CodeFence extends Node {
},
},
}),
// Collapse plugins - only on code_fence (not CodeBlock subclass)
...(this.name === "code_fence" ? this.collapsePlugins() : []),
].filter(Boolean) as Plugin[];
}
+2 -2
View File
@@ -28,11 +28,11 @@ export const findParentNodeClosestToPos = (
$pos: ResolvedPos,
predicate: Predicate
): ContentNodeWithPos | undefined => {
for (let i = $pos.depth; i > 0; i--) {
for (let i = $pos.depth; i >= 0; i--) {
const node = $pos.node(i);
if (predicate(node)) {
return {
pos: $pos.before(i),
pos: i > 0 ? $pos.before(i) : 0,
start: $pos.start(i),
depth: i,
node,
@@ -26,6 +26,9 @@ export class EditorStyleHelper {
static readonly codeWord = "code-word";
/** Toggle button for collapsible code blocks */
static readonly codeBlockToggle = "code-block-toggle";
// Diffs
static readonly diffInsertion = "diff-insertion";