Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions packages/theme/styles/prose.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -602,4 +613,4 @@ pre.proseCodeBlock>pre.proseCode {

.theme-light {
@include meta.load-css('./github-light.scss');
}
}
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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 => {
Expand Down Expand Up @@ -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')
}
}

Expand Down
Loading