Skip to content

Commit 7be2a72

Browse files
kvapsclaude
andcommitted
fix: move heading-into-heading bumps levels through the whole block
moveBlock now accepts headingBump. trySurgicalEdit for moveNodeIn/ Before/After detects when both source and target are headings and computes the bump so the moved block lands at the correct level: - moveNodeIn into H2 makes the source heading H3 and every nested heading shifts +1 too (capped at H6) - moveNodeBefore/After against a heading keeps the source at the target's level and bumps nested headings by the same delta. For list-item targets the previous list-marker conversion is preserved, so we don't break those cases. Also add a transient toast (store.errorMessage) so when a structural mindmap op has no surgical handler the user sees that markdown wasn't updated. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <kvapss@gmail.com>
1 parent f464e9f commit 7be2a72

4 files changed

Lines changed: 92 additions & 6 deletions

File tree

src/App.vue

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ function onDragEnd(e: PointerEvent) {
8080

8181
<template>
8282
<div ref="appRef" :class="rootClass">
83+
<div v-if="store.errorMessage" class="app__toast" @click="store.setError(null)">
84+
<span>{{ store.errorMessage }}</span>
85+
<button type="button" class="app__toast-close" aria-label="Dismiss">×</button>
86+
</div>
8387
<div class="app__editor" :style="editorStyle"><Editor /></div>
8488
<div
8589
v-if="!dividerHidden"
@@ -151,6 +155,33 @@ html, body, #app {
151155
height: 100%;
152156
overflow: hidden;
153157
}
158+
.app__toast {
159+
position: fixed;
160+
bottom: 20px;
161+
left: 50%;
162+
transform: translateX(-50%);
163+
background: #c62828;
164+
color: #fff;
165+
padding: 10px 16px 10px 18px;
166+
border-radius: 6px;
167+
font-size: 13px;
168+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
169+
display: flex;
170+
align-items: center;
171+
gap: 12px;
172+
z-index: 1000;
173+
cursor: pointer;
174+
max-width: 80vw;
175+
}
176+
.app__toast-close {
177+
background: transparent;
178+
border: none;
179+
color: #fff;
180+
font-size: 18px;
181+
line-height: 1;
182+
cursor: pointer;
183+
padding: 0 2px;
184+
}
154185
.app__divider {
155186
flex: 0 0 6px;
156187
background: var(--divider-bg);

src/components/MindMap.vue

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -315,20 +315,33 @@ function trySurgicalEdit(op: unknown): boolean {
315315
let newIndent: number
316316
let newMarker: string | null = null
317317
let targetLine: number
318+
let headingBump = 0
318319
if (o.name === 'moveNodeIn') {
319320
newIndent = (toMeta.indent ?? 0) + (toMeta.kind === 'listItem' ? 2 : 0)
320321
targetLine = blockEnd(to) + 1
321-
newMarker = '- '
322+
if (meta.kind === 'heading' && toMeta.kind === 'heading') {
323+
headingBump = (toMeta.level ?? 1) + 1 - (meta.level ?? 1)
324+
} else {
325+
newMarker = '- '
326+
}
322327
} else if (o.name === 'moveNodeBefore') {
323328
newIndent = toMeta.indent ?? 0
324329
targetLine = toMeta.startLine
325-
newMarker = toMeta.kind === 'heading' ? (toMeta.marker ?? null) : '- '
330+
if (meta.kind === 'heading' && toMeta.kind === 'heading') {
331+
headingBump = (toMeta.level ?? 1) - (meta.level ?? 1)
332+
} else {
333+
newMarker = toMeta.kind === 'heading' ? (toMeta.marker ?? null) : '- '
334+
}
326335
} else {
327336
newIndent = toMeta.indent ?? 0
328337
targetLine = blockEnd(to) + 1
329-
newMarker = toMeta.kind === 'heading' ? (toMeta.marker ?? null) : '- '
338+
if (meta.kind === 'heading' && toMeta.kind === 'heading') {
339+
headingBump = (toMeta.level ?? 1) - (meta.level ?? 1)
340+
} else {
341+
newMarker = toMeta.kind === 'heading' ? (toMeta.marker ?? null) : '- '
342+
}
330343
}
331-
md = moveBlock(md, effMeta, newIndent, newMarker, targetLine)
344+
md = moveBlock(md, effMeta, newIndent, newMarker, targetLine, headingBump)
332345
}
333346
return commitMarkdown(md)
334347
}
@@ -835,7 +848,25 @@ onMounted(() => {
835848
if (el) el.style.visibility = 'hidden'
836849
return
837850
}
838-
if (trySurgicalEdit(op)) return
851+
const handled = trySurgicalEdit(op)
852+
if (handled) return
853+
const mutationOps = new Set([
854+
'addChild',
855+
'insertSibling',
856+
'insertParent',
857+
'removeNodes',
858+
'moveUpNode',
859+
'moveDownNode',
860+
'moveNodeIn',
861+
'moveNodeBefore',
862+
'moveNodeAfter',
863+
'copyNode',
864+
'copyNodes',
865+
'reshapeNode',
866+
])
867+
if (op?.name && mutationOps.has(op.name as string)) {
868+
store.setError(`Markdown not updated for "${op.name}" — mindmap may be out of sync`)
869+
}
839870
// No surgical handler — skip markdown update rather than reformatting everything.
840871
// Next surgical op will re-sync from the (possibly stale) markdown side.
841872
})

src/stores/document.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ export const useDocumentStore = defineStore('document', () => {
104104
const cursorSource = ref<'editor' | 'mindmap' | null>(null)
105105
const undoHandler = ref<(() => boolean) | null>(null)
106106
const redoHandler = ref<(() => boolean) | null>(null)
107+
const errorMessage = ref<string | null>(null)
108+
let errorTimer: number | null = null
107109

108110
watch(
109111
[markdown, viewMode, splitRatio, theme],
@@ -149,13 +151,25 @@ export const useDocumentStore = defineStore('document', () => {
149151
return redoHandler.value?.() ?? false
150152
}
151153

154+
function setError(msg: string | null, autoDismissMs = 5000) {
155+
errorMessage.value = msg
156+
if (errorTimer) window.clearTimeout(errorTimer)
157+
if (msg && autoDismissMs > 0) {
158+
errorTimer = window.setTimeout(() => {
159+
errorMessage.value = null
160+
errorTimer = null
161+
}, autoDismissMs)
162+
}
163+
}
164+
152165
return {
153166
markdown,
154167
viewMode,
155168
splitRatio,
156169
theme,
157170
cursorLine,
158171
cursorSource,
172+
errorMessage,
159173
setMarkdown,
160174
setViewMode,
161175
setSplitRatio,
@@ -164,5 +178,6 @@ export const useDocumentStore = defineStore('document', () => {
164178
registerHistory,
165179
undo,
166180
redo,
181+
setError,
167182
}
168183
})

src/utils/surgical.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,13 +178,22 @@ export function moveBlock(
178178
fromMeta: NodeMeta,
179179
newIndent: number,
180180
newMarker: string | null,
181-
insertBeforeLine: number
181+
insertBeforeLine: number,
182+
headingBump = 0
182183
): string {
183184
const lines = markdown.split('\n')
184185
const blockLen = fromMeta.endLine - fromMeta.startLine + 1
185186
const block = lines.slice(fromMeta.startLine, fromMeta.endLine + 1)
186187
const indentDelta = newIndent - (fromMeta.indent ?? 0)
187188
const adjusted = block.map((line, i) => {
189+
if (headingBump !== 0) {
190+
const h = line.match(/^(\s*)(#{1,6})\s/)
191+
if (h) {
192+
const oldLen = h[2].length
193+
const newLen = Math.max(1, Math.min(6, oldLen + headingBump))
194+
return h[1] + '#'.repeat(newLen) + line.slice(h[1].length + oldLen)
195+
}
196+
}
188197
if (i === 0 && newMarker) {
189198
const m = line.match(/^(\s*)(?:[-*+]|\d+[.)])\s+(.*)$/)
190199
if (m) return ' '.repeat(newIndent) + newMarker + m[2]

0 commit comments

Comments
 (0)