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>
This commit is contained in:
Alexander Onnikov
2026-02-03 16:52:36 +07:00
committed by GitHub
parent 68db02a634
commit a59c5d0cb0
3 changed files with 163 additions and 12 deletions
+13 -2
View File
@@ -574,13 +574,24 @@ pre.proseCodeBlock>pre.proseCode {
.mermaidPreviewContainer {
padding: 0.5rem;
cursor: default;
overflow-x: auto;
overflow: auto;
}
&:not(.folded) .mermaidPreviewContainer {
border-top: 1px solid var(--border-color);
min-height: 6rem;
}
.mermaidPreview {
width: 100%;
svg {
width: 100%;
max-width: 100%;
height: auto;
display: block;
}
}
}
.proseInlineCommentHighlight {
@@ -602,4 +613,4 @@ pre.proseCodeBlock>pre.proseCode {
.theme-light {
@include meta.load-css('./github-light.scss');
}
}
@@ -0,0 +1,73 @@
<!--
//
// Copyright © 2026 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
-->
<script lang="ts">
import { Html, Modal, ButtonIcon, IconClose, IconMaximize, IconMinimize, Scroller } from '@hcengineering/ui'
import { createEventDispatcher } from 'svelte'
export let svg: string
export let fullSize = false
const dispatch = createEventDispatcher()
</script>
<Modal type={'type-component'} padding={'0.5rem'} bottomPadding={'0'} on:fullsize on:close>
<svelte:fragment slot="beforeTitle">
<ButtonIcon
icon={IconClose}
kind={'tertiary'}
size={'small'}
noPrint
on:click={() => {
dispatch('close')
}}
/>
<div class="hulyHeader-divider short no-line no-print" />
<ButtonIcon
icon={!fullSize ? IconMaximize : IconMinimize}
kind={'tertiary'}
size={'small'}
noPrint
on:click={() => {
fullSize = !fullSize
dispatch('fullsize', fullSize)
}}
/>
<div class="hulyHeader-divider short no-print" />
</svelte:fragment>
<Scroller horizontal stickedScrollBars thinScrollBars>
<div class="mermaid-container">
<Html value={svg} />
</div>
</Scroller>
</Modal>
<style>
.mermaid-container {
display: flex;
justify-content: center;
align-items: flex-start;
width: 100%;
}
.mermaid-container :global(svg) {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto;
}
</style>
@@ -15,16 +15,19 @@
import { codeBlockOptions } from '@hcengineering/text'
import { getCurrentTheme, isThemeDark, themeStore } from '@hcengineering/theme'
import { showPopup } from '@hcengineering/ui'
import { mergeAttributes } from '@tiptap/core'
import { CodeBlockLowlight, type CodeBlockLowlightOptions } from '@tiptap/extension-code-block-lowlight'
import { type Node as ProseMirrorNode } from '@tiptap/pm/model'
import { NodeSelection, Plugin, PluginKey, TextSelection, type Transaction } from '@tiptap/pm/state'
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view'
import { createLowlight } from 'lowlight'
import type { MermaidConfig } from 'mermaid'
import { createRelativePositionFromTypeIndex, type RelativePosition, type Doc as YDoc } from 'yjs'
import { isChangeEditable } from '../hooks/editable'
import { mergeAttributes } from '@tiptap/core'
import { createRelativePositionFromTypeIndex, type RelativePosition, type Doc as YDoc } from 'yjs'
import MermaidPopup from './MermaidPopup.svelte'
export interface MermaidOptions extends CodeBlockLowlightOptions {
ydoc?: YDoc
@@ -156,6 +159,9 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
diagramBuilder: () => null,
textContent: node.textContent
}
let pendingSelection = false
let pendingSelectionPos: number | null = null
let pendingSelectionTimer: number | null = null
const toggleFoldState = (newState: boolean, event?: MouseEvent): void => {
event?.preventDefault()
@@ -187,16 +193,56 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
toggleButtonNode.onmousedown = (e) => {
toggleFoldState(!nodeState.folded, e)
}
previewNode.ondblclick = (e) => {
toggleFoldState(!nodeState.folded, e)
}
previewNode.onclick = (e) => {
if (typeof getPos !== 'function') return
const pos = getPos()
const selection = NodeSelection.create(editor.view.state.doc, pos)
editor.view.dispatch(editor.view.state.tr.setSelection(selection))
if (node?.type.name !== MermaidExtension.name) return
e.preventDefault()
e.stopPropagation()
const pos = getPos()
const { selection } = editor.view.state
const mermaid = editor.view.state.doc.nodeAt(pos)
const isSelected =
containerNode.classList.contains('selected') ||
nodeState.selected ||
(pendingSelection && pendingSelectionPos === pos) ||
(selection instanceof NodeSelection &&
mermaid !== null &&
selection.from === pos &&
selection.to === pos + mermaid.nodeSize)
if (!isSelected) {
const nodePatch: NodePatchSpec = {
pos,
folded: nodeState.folded,
selected: true
}
const selection = NodeSelection.create(editor.view.state.doc, pos)
const tr = setTxMeta(editor.view.state.tr, { nodePatch })
tr.setSelection(selection)
editor.view.dispatch(tr)
pendingSelection = true
pendingSelectionPos = pos
if (pendingSelectionTimer !== null) {
clearTimeout(pendingSelectionTimer)
}
pendingSelectionTimer = window.setTimeout(() => {
pendingSelection = false
pendingSelectionPos = null
pendingSelectionTimer = null
}, 1000)
return
}
const diagram = nodeState.diagramBuilder?.(editor.view)
if (diagram?.svg != null) {
showPopup(MermaidPopup, { svg: diagram.svg, fullSize: true }, 'centered')
}
}
const syncState = (decorations: readonly Decoration[]): void => {
@@ -228,8 +274,10 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
if (nodeState.selected) {
containerNode.classList.add('selected')
previewNode.style.cursor = 'zoom-in'
} else {
containerNode.classList.remove('selected')
previewNode.style.cursor = 'default'
}
if (!isEmpty && error !== null) {
@@ -255,6 +303,15 @@ export const MermaidExtension = CodeBlockLowlight.extend<MermaidOptions>({
if (!allowFold && nodeState.folded) {
toggleFoldState(false)
}
if (nodeState.selected && pendingSelection) {
pendingSelection = false
pendingSelectionPos = null
if (pendingSelectionTimer !== null) {
clearTimeout(pendingSelectionTimer)
pendingSelectionTimer = null
}
}
}
const toggleSelection = (newState: boolean): void => {
@@ -416,14 +473,24 @@ function buildState (
// Ensure SVG maintains its natural size
const svg = container.querySelector('svg')
if (svg !== null) {
svg.style.width = '100%'
svg.style.maxWidth = '100%'
svg.style.height = 'auto'
svg.style.display = 'block'
// Remove any width/height attributes that might cause stretching
if (!svg.hasAttribute('viewBox')) {
const width = svg.getAttribute('width')
const height = svg.getAttribute('height')
if (width !== null && height !== null) {
svg.setAttribute('viewBox', `0 0 ${width} ${height}`)
const widthValue = width !== null ? Number.parseFloat(width) : NaN
const heightValue = height !== null ? Number.parseFloat(height) : NaN
if (Number.isFinite(widthValue) && Number.isFinite(heightValue)) {
svg.setAttribute('viewBox', `0 0 ${widthValue} ${heightValue}`)
svg.removeAttribute('width')
svg.removeAttribute('height')
}
} else {
svg.removeAttribute('width')
svg.removeAttribute('height')
}
}