Compare commits

...

3 Commits

Author SHA1 Message Date
Salihu 2c0f375901 requested changes 2026-03-21 16:07:08 +01:00
Salihu 4894e92d6b add gradient 2026-03-21 16:07:08 +01:00
Salihu 5e6eebd0ec collapsible code blocks 2026-03-21 16:07:03 +01:00
6 changed files with 289 additions and 18 deletions
-3
View File
@@ -61,9 +61,6 @@ export default function codeMenuItems(
: undefined,
tooltip: dictionary.copy,
},
{
name: "separator",
},
{
name: "edit_mermaid",
icon: <EditIcon />,
+2
View File
@@ -32,6 +32,7 @@ export default function useDictionary() {
codeBlock: t("Code block"),
codeCopied: t("Copied to clipboard"),
codeInline: t("Code"),
collapseCode: t("Collapse"),
comment: t("Comment"),
copy: t("Copy"),
createLink: t("Create link"),
@@ -54,6 +55,7 @@ export default function useDictionary() {
replaceImage: t("Replace image"),
em: t("Italic"),
embedInvalidLink: t("Sorry, that link wont work for this embed type"),
expandCode: t("Expand"),
file: t("File attachment"),
pdf: t("Embed PDF"),
enterLink: `${t("Enter a link")}`,
+90
View File
@@ -1739,6 +1739,49 @@ mark {
font-size: 90%;
}
.code-block.collapsed {
max-height: 350px;
clip-path: inset(0 -1px 0 0);
pointer-events: none;
&::before {
z-index: 1;
content: "";
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 120px;
width: 100%;
pointer-events: none;
${
props.theme.isDark
? `
background: linear-gradient(
to bottom,
rgba(15, 17, 21, 0) 0%,
rgba(15, 17, 21, 0.2) 40%,
rgba(15, 17, 21, 0.4) 60%,
rgba(15, 17, 21, 0.5) 85%,
rgba(15, 17, 21, 0.6) 100%
);
`
: `
background: linear-gradient(
to bottom,
rgba(255, 255, 255, 0) 0%,
rgba(255, 255, 255, 0.2) 40%,
rgba(255, 255, 255, 0.4) 60%,
rgba(255, 255, 255, 0.5) 85%,
rgba(255, 255, 255, 0.6) 100%
);
`
}
}
}
.code-block[data-language=none],
.code-block[data-language=markdown] {
pre code {
@@ -1808,6 +1851,10 @@ mark {
.mermaid-diagram-wrapper {
display: none;
}
.code-block-toggle {
display: none;
}
}
.code-block.with-line-wrap {
@@ -1841,6 +1888,49 @@ mark {
}
}
.code-block-toggle {
display: none;
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 100px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(0, 0, 0, 0.5);
color: rgba(255, 255, 255, 0.9);
font-family: ${props.theme.fontFamily};
font-size: 13px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
cursor: var(--pointer);
z-index: 2;
pointer-events: auto;
transition: background 150ms ease-out;
backdrop-filter: blur(4px);
user-select: none;
&:hover {
background: rgba(0, 0, 0, 0.7);
}
&:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}
}
.code-block.collapsed .code-block-toggle {
display: inline-flex;
}
.code-block.tall:not(.collapsed):hover .code-block-toggle {
display: inline-flex;
}
.mermaid-diagram-wrapper {
display: flex;
align-items: center;
@@ -206,6 +206,7 @@ export function CodeHighlighting({
state: {
init: (_, { doc }) => DecorationSet.create(doc, []),
apply: (transaction: Transaction, decorationSet, oldState, state) => {
const collapsedChanged = transaction.getMeta("collapsed")?.changed;
const nodeName = state.selection.$head.parent.type.name;
const previousNodeName = oldState.selection.$head.parent.type.name;
const codeBlockChanged =
@@ -216,6 +217,7 @@ export function CodeHighlighting({
const langLoaded = transaction.getMeta("codeHighlighting")?.langLoaded;
if (
collapsedChanged ||
!highlighted ||
codeBlockChanged ||
isPaste ||
+193 -15
View File
@@ -7,13 +7,14 @@ import type {
Schema,
Node as ProsemirrorNode,
} from "prosemirror-model";
import type { Command, EditorState } from "prosemirror-state";
import type { Command, EditorState, Transaction } from "prosemirror-state";
import {
NodeSelection,
Plugin,
PluginKey,
TextSelection,
} from "prosemirror-state";
import type { EditorView } from "prosemirror-view";
import { Decoration, DecorationSet } from "prosemirror-view";
import { toast } from "sonner";
import type { Primitive } from "utility-types";
@@ -44,12 +45,23 @@ import {
import { isCode, isMermaid } from "../lib/isCode";
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 { getMarkRange } from "../queries/getMarkRange";
import { isInCode } from "../queries/isInCode";
import { EditorStyleHelper } from "../styles/EditorStyleHelper";
import Node from "./Node";
const DEFAULT_LANGUAGE = "javascript";
const TALL_HEIGHT = 350;
const CHEVRON_DOWN =
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M4 7L8 11L12 7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const CHEVRON_UP =
'<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M4 11L8 7L12 11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>';
export default class CodeFence extends Node {
constructor(options: {
@@ -78,6 +90,11 @@ export default class CodeFence extends Node {
default: false,
validate: "boolean",
},
collapsed: {
default: null,
validate: (value: any) =>
value === null || typeof value === "boolean",
},
},
content: "text*",
marks: "comment",
@@ -109,20 +126,48 @@ export default class CodeFence extends Node {
},
},
],
toDOM: (node) => [
"div",
{
class: `code-block ${
node.attrs.wrap
? "with-line-wrap"
: this.showLineNumbers
? "with-line-numbers"
: ""
toDOM: (node) => {
const buttonIcon = document.createElement("i");
buttonIcon.innerHTML = node.attrs.collapsed ? CHEVRON_DOWN : CHEVRON_UP;
return [
"div",
{
class: `code-block ${
node.attrs.wrap
? "with-line-wrap"
: this.showLineNumbers
? "with-line-numbers"
: ""
} ${node.attrs.collapsed === null ? "" : node.attrs.collapsed ? "tall collapsed" : "tall"} `,
"data-language": node.attrs.language,
},
["pre", ["code", { spellCheck: "false" }, 0]],
[
"button",
{
class: EditorStyleHelper.codeBlockToggle,
contenteditable: "false",
type: "button",
"data-expand-label": this.options.dictionary.expandCode,
"data-collapse-label": this.options.dictionary.collapseCode,
},
[
"span",
{
class: "icon",
},
buttonIcon,
],
`
${
node.attrs.collapsed
? this.options.dictionary.expandCode
: this.options.dictionary.collapseCode
}`,
"data-language": node.attrs.language,
},
["pre", ["code", { spellCheck: "false" }, 0]],
],
],
];
},
};
}
@@ -218,6 +263,18 @@ export default class CodeFence extends Node {
return false;
},
toggleCodeBlockCollapse: (): Command => (state, dispatch) => {
const codeBlock = findParentNode(isCode)(state.selection);
if (codeBlock && dispatch) {
dispatch(
state.tr.setNodeMarkup(codeBlock.pos, undefined, {
...codeBlock.node.attrs,
collapsed: !codeBlock.node.attrs.collapsed,
})
);
}
return true;
},
};
}
@@ -363,6 +420,127 @@ export default class CodeFence extends Node {
},
},
}),
new Plugin({
key: new PluginKey("collapse-toggle"),
props: {
handleDOMEvents: {
mousedown: (view, event) => {
const target = event.target as HTMLElement;
const button = target.closest(
`.${EditorStyleHelper.codeBlockToggle}`
) as HTMLElement | null;
if (!button) {
return false;
}
event.preventDefault();
event.stopPropagation();
const codeBlockEl = button.closest<HTMLElement>(".code-block");
const codeEl = codeBlockEl?.querySelector("code");
if (!codeEl) {
return false;
}
const innerPos = view.posAtDOM(codeEl, 0);
const $pos = view.state.doc.resolve(innerPos);
const codeBlock = findParentNodeClosestToPos($pos, isCode);
if (!codeBlock) {
return false;
}
view.dispatch(
view.state.tr
.setNodeMarkup(codeBlock.pos, undefined, {
...codeBlock.node.attrs,
collapsed: !codeBlock.node.attrs.collapsed,
})
.setMeta("collapsed", { changed: true })
);
return true;
},
},
},
}),
new Plugin({
key: new PluginKey("auto-collapse-code-block"),
view: () => {
let initialized = false;
let processing = false;
const processNode = (
view: EditorView,
tr: Transaction,
node: ProsemirrorNode,
pos: number,
initialized: boolean
) => {
if (!isCode(node)) {return false;}
const dom = view.nodeDOM(pos) as HTMLElement | null;
if (!dom) {return false;}
let modified = false;
if (node.attrs.collapsed === null) {
const collapsedValue =
dom.scrollHeight > TALL_HEIGHT ? false : null;
if (collapsedValue !== node.attrs.collapsed) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
collapsed: collapsedValue,
});
modified = true;
}
} else if (dom.scrollHeight < TALL_HEIGHT) {
if (node.attrs.collapsed !== null) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
collapsed: null,
});
modified = true;
}
} else if (!initialized && node.attrs.collapsed !== true) {
tr.setNodeMarkup(pos, undefined, {
...node.attrs,
collapsed: true,
});
modified = true;
}
return modified;
};
return {
update: (view, prevState) => {
if (processing) {return;}
processing = true;
const { state } = view;
const docChanged = !prevState || state.doc !== prevState.doc;
if (docChanged || !initialized) {
const tr = state.tr;
let modified = false;
state.doc.descendants((node, pos) => {
modified =
processNode(view, tr, node, pos, initialized) || modified;
});
if (modified) {
view.dispatch(tr);
}
}
initialized = true;
processing = false;
},
};
},
}),
].filter(Boolean) as Plugin[];
}
@@ -26,6 +26,8 @@ export class EditorStyleHelper {
static readonly codeWord = "code-word";
static readonly codeBlockToggle = "code-block-toggle";
// Diffs
static readonly diffInsertion = "diff-insertion";