Skip to content

Commit a59c5d0

Browse files
authored
fix: allow to open mermaid diagram in fullscreen (#10477)
* fix: allow to open mermaid diagram in fullscreen * fix large diagram scroll Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com> --------- Signed-off-by: Alexander Onnikov <Alexander.Onnikov@xored.com>
1 parent 68db02a commit a59c5d0

File tree

3 files changed

+163
-12
lines changed

3 files changed

+163
-12
lines changed

packages/theme/styles/prose.scss

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -574,13 +574,24 @@ pre.proseCodeBlock>pre.proseCode {
574574
.mermaidPreviewContainer {
575575
padding: 0.5rem;
576576
cursor: default;
577-
overflow-x: auto;
577+
overflow: auto;
578578
}
579579

580580
&:not(.folded) .mermaidPreviewContainer {
581581
border-top: 1px solid var(--border-color);
582582
min-height: 6rem;
583583
}
584+
585+
.mermaidPreview {
586+
width: 100%;
587+
588+
svg {
589+
width: 100%;
590+
max-width: 100%;
591+
height: auto;
592+
display: block;
593+
}
594+
}
584595
}
585596

586597
.proseInlineCommentHighlight {
@@ -602,4 +613,4 @@ pre.proseCodeBlock>pre.proseCode {
602613

603614
.theme-light {
604615
@include meta.load-css('./github-light.scss');
605-
}
616+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<!--
2+
//
3+
// Copyright © 2026 Hardcore Engineering Inc.
4+
//
5+
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License. You may
7+
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
//
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//
16+
-->
17+
<script lang="ts">
18+
import { Html, Modal, ButtonIcon, IconClose, IconMaximize, IconMinimize, Scroller } from '@hcengineering/ui'
19+
import { createEventDispatcher } from 'svelte'
20+
21+
export let svg: string
22+
export let fullSize = false
23+
24+
const dispatch = createEventDispatcher()
25+
</script>
26+
27+
<Modal type={'type-component'} padding={'0.5rem'} bottomPadding={'0'} on:fullsize on:close>
28+
<svelte:fragment slot="beforeTitle">
29+
<ButtonIcon
30+
icon={IconClose}
31+
kind={'tertiary'}
32+
size={'small'}
33+
noPrint
34+
on:click={() => {
35+
dispatch('close')
36+
}}
37+
/>
38+
<div class="hulyHeader-divider short no-line no-print" />
39+
<ButtonIcon
40+
icon={!fullSize ? IconMaximize : IconMinimize}
41+
kind={'tertiary'}
42+
size={'small'}
43+
noPrint
44+
on:click={() => {
45+
fullSize = !fullSize
46+
dispatch('fullsize', fullSize)
47+
}}
48+
/>
49+
<div class="hulyHeader-divider short no-print" />
50+
</svelte:fragment>
51+
52+
<Scroller horizontal stickedScrollBars thinScrollBars>
53+
<div class="mermaid-container">
54+
<Html value={svg} />
55+
</div>
56+
</Scroller>
57+
</Modal>
58+
59+
<style>
60+
.mermaid-container {
61+
display: flex;
62+
justify-content: center;
63+
align-items: flex-start;
64+
width: 100%;
65+
}
66+
67+
.mermaid-container :global(svg) {
68+
max-width: 100%;
69+
height: auto;
70+
display: block;
71+
margin: 0 auto;
72+
}
73+
</style>

plugins/text-editor-resources/src/components/extension/codeSnippets/mermaid.ts

Lines changed: 77 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,19 @@
1515

1616
import { codeBlockOptions } from '@hcengineering/text'
1717
import { getCurrentTheme, isThemeDark, themeStore } from '@hcengineering/theme'
18+
import { showPopup } from '@hcengineering/ui'
19+
import { mergeAttributes } from '@tiptap/core'
1820
import { CodeBlockLowlight, type CodeBlockLowlightOptions } from '@tiptap/extension-code-block-lowlight'
1921
import { type Node as ProseMirrorNode } from '@tiptap/pm/model'
2022
import { NodeSelection, Plugin, PluginKey, TextSelection, type Transaction } from '@tiptap/pm/state'
2123
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'
2224
import { createLowlight } from 'lowlight'
2325
import type { MermaidConfig } from 'mermaid'
26+
import { createRelativePositionFromTypeIndex, type RelativePosition, type Doc as YDoc } from 'yjs'
27+
2428
import { isChangeEditable } from '../hooks/editable'
2529

26-
import { mergeAttributes } from '@tiptap/core'
27-
import { createRelativePositionFromTypeIndex, type RelativePosition, type Doc as YDoc } from 'yjs'
30+
import MermaidPopup from './MermaidPopup.svelte'
2831

2932
export interface MermaidOptions extends CodeBlockLowlightOptions {
3033
ydoc?: YDoc
@@ -156,6 +159,9 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
156159
diagramBuilder: () => null,
157160
textContent: node.textContent
158161
}
162+
let pendingSelection = false
163+
let pendingSelectionPos: number | null = null
164+
let pendingSelectionTimer: number | null = null
159165

160166
const toggleFoldState = (newState: boolean, event?: MouseEvent): void => {
161167
event?.preventDefault()
@@ -187,16 +193,56 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
187193
toggleButtonNode.onmousedown = (e) => {
188194
toggleFoldState(!nodeState.folded, e)
189195
}
190-
previewNode.ondblclick = (e) => {
191-
toggleFoldState(!nodeState.folded, e)
192-
}
193196

194197
previewNode.onclick = (e) => {
195198
if (typeof getPos !== 'function') return
196-
const pos = getPos()
197-
const selection = NodeSelection.create(editor.view.state.doc, pos)
198-
editor.view.dispatch(editor.view.state.tr.setSelection(selection))
199+
if (node?.type.name !== MermaidExtension.name) return
200+
199201
e.preventDefault()
202+
e.stopPropagation()
203+
204+
const pos = getPos()
205+
206+
const { selection } = editor.view.state
207+
const mermaid = editor.view.state.doc.nodeAt(pos)
208+
const isSelected =
209+
containerNode.classList.contains('selected') ||
210+
nodeState.selected ||
211+
(pendingSelection && pendingSelectionPos === pos) ||
212+
(selection instanceof NodeSelection &&
213+
mermaid !== null &&
214+
selection.from === pos &&
215+
selection.to === pos + mermaid.nodeSize)
216+
217+
if (!isSelected) {
218+
const nodePatch: NodePatchSpec = {
219+
pos,
220+
folded: nodeState.folded,
221+
selected: true
222+
}
223+
224+
const selection = NodeSelection.create(editor.view.state.doc, pos)
225+
const tr = setTxMeta(editor.view.state.tr, { nodePatch })
226+
tr.setSelection(selection)
227+
228+
editor.view.dispatch(tr)
229+
pendingSelection = true
230+
pendingSelectionPos = pos
231+
if (pendingSelectionTimer !== null) {
232+
clearTimeout(pendingSelectionTimer)
233+
}
234+
pendingSelectionTimer = window.setTimeout(() => {
235+
pendingSelection = false
236+
pendingSelectionPos = null
237+
pendingSelectionTimer = null
238+
}, 1000)
239+
return
240+
}
241+
242+
const diagram = nodeState.diagramBuilder?.(editor.view)
243+
if (diagram?.svg != null) {
244+
showPopup(MermaidPopup, { svg: diagram.svg, fullSize: true }, 'centered')
245+
}
200246
}
201247

202248
const syncState = (decorations: readonly Decoration[]): void => {
@@ -228,8 +274,10 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
228274

229275
if (nodeState.selected) {
230276
containerNode.classList.add('selected')
277+
previewNode.style.cursor = 'zoom-in'
231278
} else {
232279
containerNode.classList.remove('selected')
280+
previewNode.style.cursor = 'default'
233281
}
234282

235283
if (!isEmpty && error !== null) {
@@ -255,6 +303,15 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
255303
if (!allowFold && nodeState.folded) {
256304
toggleFoldState(false)
257305
}
306+
307+
if (nodeState.selected && pendingSelection) {
308+
pendingSelection = false
309+
pendingSelectionPos = null
310+
if (pendingSelectionTimer !== null) {
311+
clearTimeout(pendingSelectionTimer)
312+
pendingSelectionTimer = null
313+
}
314+
}
258315
}
259316

260317
const toggleSelection = (newState: boolean): void => {
@@ -416,14 +473,24 @@ function buildState (
416473
// Ensure SVG maintains its natural size
417474
const svg = container.querySelector('svg')
418475
if (svg !== null) {
476+
svg.style.width = '100%'
477+
svg.style.maxWidth = '100%'
419478
svg.style.height = 'auto'
479+
svg.style.display = 'block'
420480
// Remove any width/height attributes that might cause stretching
421481
if (!svg.hasAttribute('viewBox')) {
422482
const width = svg.getAttribute('width')
423483
const height = svg.getAttribute('height')
424-
if (width !== null && height !== null) {
425-
svg.setAttribute('viewBox', `0 0 ${width} ${height}`)
484+
const widthValue = width !== null ? Number.parseFloat(width) : NaN
485+
const heightValue = height !== null ? Number.parseFloat(height) : NaN
486+
if (Number.isFinite(widthValue) && Number.isFinite(heightValue)) {
487+
svg.setAttribute('viewBox', `0 0 ${widthValue} ${heightValue}`)
488+
svg.removeAttribute('width')
489+
svg.removeAttribute('height')
426490
}
491+
} else {
492+
svg.removeAttribute('width')
493+
svg.removeAttribute('height')
427494
}
428495
}
429496

0 commit comments

Comments
 (0)