diff --git a/expand-edit-mode-fix.md b/expand-edit-mode-fix.md new file mode 100644 index 0000000..b5bb5ee --- /dev/null +++ b/expand-edit-mode-fix.md @@ -0,0 +1,60 @@ +# expand edit-mode fix (issue #161) + +## Behavior (preview43+) + +With **Readable line length** ON, live preview: + +| Mode | Block width | Text | Narrow pane | +|---|---|---|---| +| Wrapped | Same as a normal column code block (left-aligned) | `pre-wrap` | Soft-wraps in column | +| Unwrapped | Hugs longest line (per-block via `--cbc-min-scroll-width`) | `pre` | `.cm-scroller` horizontal scroll; buttons clamped into view | + +Reading mode expand is unchanged (separate `setupExpandObserver`). Callouts/admonitions are skipped (same as reading mode). Layout clears when RLL is off (expand CSS is RLL-scoped; no dead scroll zone). + +## Architecture + +1. **State on `.markdown-source-view`** - `cbc-expand-ready`, `cbc-expand-has-nowrap`, `--cbc-expand-margin-left`. Never put layout state on `.cm-line` (CM rebuilds wipe it). +2. **Selector** - `.markdown-source-view.is-readable-line-width` (class is on the source view itself). +3. **Obsidian cooperation** - override `--content-margin` / `--line-width` / `--max-width` instead of fighting `margin-inline: var(--content-margin) !important`. +4. **Per-block unwrapped width** - from Wrapping.ts `--cbc-min-scroll-width: Nch` on nowrap lines; rebuilt on `docChanged` so typing updates hug width. +5. **Headers** - both `header-container` and `header-container-specific`; clamp prefers header buttons (begin-line buttons are `display:none` with specific headers). +6. **Button clamp** - absolute `right` inset only (never `left` - shrink-to-fit crushed "Plain text"). +7. **Clear on fail** - source mode, RLL off, narrow pane, measure failure, and no main-column expands all call `clearExpandLayout`. +8. **Column anchor** - prefers a normal codeblock outside callouts/admonitions. + +## Files + +- `src/EditorView/Expand.ts` - measure margin, ready class, header sync, button clamp +- `src/css/code-block.scss` - edit-mode expand rules +- `src/EditorView/Wrapping.ts` - skip `ScrollbarWidget` for expand; rebuild nowrap width on edit + +## Dead ends (do not resurrect) + +| Approach | Failure | +|---|---| +| ResizeObserver + inline width on every line | Flashing (180+ style writes) | +| Widen entire `.cm-content` | Full-pane stretch | +| `--cbc-expand-width` px + large margin | Margin+width > container → auto-center | +| Per-line `.cbc-expand-positioned` | CM wipes class | +| Direct `margin-left !important` vs Obsidian | Loses to `--content-margin !important` | +| Descendant `.is-readable-line-width .markdown-source-view` | Never matches (class is on source view) | +| Clamp via absolute `left` | Shrink-to-fit caps width; "Plain text" wraps vertically | + +## Known limits + +- Unwrapped expand uses pane-level scroll (no sticky per-line gutters like normal nowrap) +- RTL not specially tested +- `ch`-based width under-counts tabs vs display width +- Button clamp uses absolute `right` while scrolling horizontally + +## Test + +1. Readable line length ON, live preview, quit/reopen Obsidian after deploy +2. Normal block vs wrapped expand - same width and left edge +3. Unwrap - block hugs long line; wrap - returns to column +4. Narrow the pane while unwrapped - text scrolls via editor; Plain text / wrap stay visible +5. Note with only expand blocks; note with two expand blocks (one wrap, one unwrap) +6. Toggle live preview ↔ source; layout should clear in source when plugin source-mode is off +7. Titled expand (`title:...`) - header width matches body; header buttons clamp when narrow +8. Callout with a normal fence above main-column expand - expand stays on readable column +9. Unwrap then type a longer line - block width grows without wrap toggle diff --git a/src/EditorView/EditorExtensions.ts b/src/EditorView/EditorExtensions.ts index 5d380ab..360159d 100644 --- a/src/EditorView/EditorExtensions.ts +++ b/src/EditorView/EditorExtensions.ts @@ -18,6 +18,7 @@ import { annotationsExtension } from "./Annotations"; import { executeCodeExtension } from "./ExecuteCodePlugin"; import { admonitionExtension } from "./Admonitions"; import { wrapExtension } from "./Wrapping"; +import { expandExtension } from "./Expand"; import { prismHighlightExtension } from "./PrismHighlight"; @@ -25,6 +26,7 @@ export function extensions(plugin: CodeBlockCustomizerPlugin, settings: Codebloc const codeBlockPositionsField = createCodeBlockPositionsField(plugin, settings); const { hiddenLinesUnhiddenField, hiddenLinesField, getHiddenRanges, getHiddenLines } = hideLinesExtension(settings, codeBlockPositionsField); const { wrappingField, unwrappedCodeBlocksField, scrollSyncPlugin } = wrapExtension(codeBlockPositionsField, settings); + const expandExtensions = expandExtension(codeBlockPositionsField, settings); const { collapseField, foldCommandField, rememberedFoldField, defaultFoldUnfoldedField, toggleCodeBlockFold, getFoldingState, foldAll, unfoldAll, restoreDefaultFold } = foldingExtension(plugin, settings, codeBlockPositionsField, hiddenLinesUnhiddenField, getHiddenLines, () => groupedCodeBlocksField); @@ -78,8 +80,8 @@ export function extensions(plugin: CodeBlockCustomizerPlugin, settings: Codebloc headerField, hiddenLinesUnhiddenField, hiddenLinesField, - wrappingField, unwrappedCodeBlocksField, + wrappingField, viewPlugin, linkViewPlugin, inlineCodeViewPlugin, @@ -89,6 +91,7 @@ export function extensions(plugin: CodeBlockCustomizerPlugin, settings: Codebloc admonitionViewPlugin, prismHighlightPlugin, scrollSyncPlugin, + ...expandExtensions, liveUpdateExtension(), forceRefreshListener, EditorView.domEventHandlers({ diff --git a/src/EditorView/Expand.ts b/src/EditorView/Expand.ts new file mode 100644 index 0000000..a5e8012 --- /dev/null +++ b/src/EditorView/Expand.ts @@ -0,0 +1,506 @@ +import { EditorView, ViewPlugin } from "@codemirror/view"; +import { Extension, StateField } from "@codemirror/state"; +import { CodeblockCustomizerSettings } from "../Settings"; +import { isSourceMode } from "../Utils"; +import { wrapEffect } from "./EditorEffects"; +import { CodeBlockPositions } from "./CodeBlockPositions"; + +const MIN_EXPAND_WIDTH = 50; +const MIN_READABLE_MARGIN = 8; +const READY_CLASS = "cbc-expand-ready"; +const SCROLL_CLAMP_MS = 50; +const HEADER_SELECTOR = + ".codeblock-customizer-header-container, .codeblock-customizer-header-container-specific"; + +/** + * Edit-mode expand layout (issue #161). + * + * State lives on `.markdown-source-view` (`cbc-expand-ready` + CSS vars). + * Do not put layout state on `.cm-line` - CodeMirror rebuilds those and wipes them. + * + * Cooperates with Obsidian readable-line-width via `--content-margin` / + * `--line-width` / `--max-width` overrides. + * + * Wrapped: same column width as a normal code block (left-aligned via margin). + * Unwrapped: hug content using `--cbc-min-scroll-width` from Wrapping.ts + * (per-block); `.cm-scroller` scrolls when content exceeds the pane. + */ + +function computeMarginFromFileLineWidth(content: HTMLElement, contentWidth: number): number { + const raw = getComputedStyle(content).getPropertyValue("--file-line-width").trim(); + const fileLineWidth = parseFloat(raw); + + if (!Number.isFinite(fileLineWidth) || fileLineWidth < MIN_EXPAND_WIDTH) { + return 0; + } + + return Math.max(0, (contentWidth - fileLineWidth) / 2); +} + +function isInSkippedContainer(el: HTMLElement): boolean { + return !!el.closest(".callout, .admonition"); +} + +function findColumnAnchor(content: HTMLElement): HTMLElement | null { + const begins = Array.from( + content.querySelectorAll(".HyperMD-codeblock-begin:not(.codeblock-customizer-expand)") + ) as HTMLElement[]; + + for (const el of begins) { + if (!isInSkippedContainer(el) && el.getBoundingClientRect().width >= 1) { + return el; + } + } + + const lines = Array.from( + content.querySelectorAll( + ".cm-line:not(.codeblock-customizer-expand)" + + ":not(.codeblock-customizer-header-container)" + + ":not(.codeblock-customizer-header-container-specific)" + ) + ) as HTMLElement[]; + + for (const el of lines) { + if (!isInSkippedContainer(el) && el.getBoundingClientRect().width >= 1) { + return el; + } + } + + return null; +} + +function measureMarginLeft(sourceView: HTMLElement): number | null { + const content = sourceView.querySelector(".cm-content") as HTMLElement | null; + const contentRect = content?.getBoundingClientRect(); + + if (!content || !contentRect || contentRect.width < 1) { + return null; + } + + const anchor = findColumnAnchor(content); + let marginLeft: number; + + if (anchor) { + marginLeft = anchor.getBoundingClientRect().left - contentRect.left; + } else { + marginLeft = computeMarginFromFileLineWidth(content, contentRect.width); + } + + const readable = sourceView.classList.contains("is-readable-line-width"); + + if (readable && marginLeft < MIN_READABLE_MARGIN) { + const fallback = computeMarginFromFileLineWidth(content, contentRect.width); + if (fallback >= MIN_READABLE_MARGIN) { + marginLeft = fallback; + } else if (marginLeft < 0) { + return null; + } + } + + if (contentRect.width - marginLeft < MIN_EXPAND_WIDTH) { + return null; + } + + return marginLeft; +} + +function findExpandBeginLines(sourceView: HTMLElement): HTMLElement[] { + return Array.from( + sourceView.querySelectorAll(".HyperMD-codeblock-begin.codeblock-customizer-expand") + ).filter(el => !isInSkippedContainer(el as HTMLElement)) as HTMLElement[]; +} + +function getHeaderForBegin(beginLine: HTMLElement): HTMLElement | null { + const prev = beginLine.previousElementSibling as HTMLElement | null; + if ( + prev?.classList.contains("codeblock-customizer-header-container") + || prev?.classList.contains("codeblock-customizer-header-container-specific") + ) { + return prev; + } + return null; +} + +function isVisibleButton(el: HTMLElement | null): el is HTMLElement { + if (!el) { + return false; + } + + const style = getComputedStyle(el); + if (style.display === "none" || style.visibility === "hidden") { + return false; + } + + // Non-specific headers can leave an empty header button container at 0×0 while + // the real controls live on the begin line. + const rect = el.getBoundingClientRect(); + return rect.width > 1 && rect.height > 1; +} + +function getExpandButton(begin: HTMLElement): HTMLElement | null { + const header = getHeaderForBegin(begin); + const headerBtn = header?.querySelector( + ".codeblock-customizer-header-button-container" + ) as HTMLElement | null; + const beginBtn = begin.querySelector( + ".codeblock-customizer-button-container" + ) as HTMLElement | null; + + // Prefer whichever control cluster is actually visible. With -specific headers the + // begin-line container is display:none; with normal headers the header button box + // can be 0×0 while begin-line buttons are shown. + if (isVisibleButton(headerBtn)) { + return headerBtn; + } + + if (isVisibleButton(beginBtn)) { + return beginBtn; + } + + return beginBtn ?? headerBtn; +} + +function syncExpandHeaders(sourceView: HTMLElement, marginLeft: number): void { + const marginPx = `${marginLeft}px`; + + for (const begin of findExpandBeginLines(sourceView)) { + const header = getHeaderForBegin(begin); + if (!header) { + continue; + } + + header.style.setProperty("--content-margin", `${marginPx} 0`); + + const unwrapped = begin.classList.contains("codeblock-customizer-nowrap"); + if (unwrapped) { + const width = Math.max(MIN_EXPAND_WIDTH, Math.round(begin.getBoundingClientRect().width)); + header.style.setProperty("--line-width", `${width}px`); + header.style.setProperty("--max-width", "none"); + } else { + header.style.removeProperty("--line-width"); + header.style.removeProperty("--max-width"); + } + } +} + +function clearExpandHeaders(sourceView: HTMLElement): void { + for (const header of Array.from( + sourceView.querySelectorAll(HEADER_SELECTOR) + ) as HTMLElement[]) { + if (!header.classList.contains("codeblock-customizer-expand")) { + continue; + } + + header.style.removeProperty("--content-margin"); + header.style.removeProperty("--line-width"); + header.style.removeProperty("--max-width"); + } +} + +function clampExpandButtons(sourceView: HTMLElement): void { + const scroller = sourceView.querySelector(".cm-scroller") as HTMLElement | null; + if (!scroller) { + return; + } + + const scrollerRect = scroller.getBoundingClientRect(); + // Use right inset only - setting left triggers absolute shrink-to-fit and crushes + // "Plain text" / wrap controls into a few dozen pixels. + const begins = findExpandBeginLines(sourceView); + + for (const begin of begins) { + const btn = getExpandButton(begin); + if (!btn) { + continue; + } + + // Host must be the positioned ancestor that actually owns the button width. + // Prefer begin line when clamping begin buttons so header 0-width widgets + // do not become the containing block for inset math. + const headerHost = btn.closest(HEADER_SELECTOR) as HTMLElement | null; + const host = (headerHost && headerHost.getBoundingClientRect().width > 1) + ? headerHost + : begin; + const hostRect = host.getBoundingClientRect(); + + btn.style.removeProperty("left"); + btn.style.position = "absolute"; + btn.style.top = btn.classList.contains("codeblock-customizer-header-button-container") ? "0" : "6px"; + btn.style.zIndex = "50"; + + // Visible viewport of the scroller (ignore host overflow past the pane) + const visibleRight = scrollerRect.right - 6; + const visibleLeft = scrollerRect.left + 6; + const btnWidth = Math.max(btn.scrollWidth, btn.offsetWidth, 100); + + // Default: pin to host's right edge (same as CSS right: 6px) + let rightInset = 6; + + if (hostRect.right - 6 > visibleRight + 1) { + // Host extends past the pane - pull controls into the visible scroller + const desiredLeft = Math.min( + Math.max(visibleLeft, visibleRight - btnWidth), + hostRect.right - btnWidth - 6 + ); + rightInset = Math.max(6, hostRect.right - (desiredLeft + btnWidth)); + } + + btn.style.right = `${rightInset}px`; + } +} + +function clearExpandButtonClamp(sourceView: HTMLElement): void { + const buttons = sourceView.querySelectorAll( + ".HyperMD-codeblock-begin.codeblock-customizer-expand .codeblock-customizer-button-container, " + + `${HEADER_SELECTOR}.codeblock-customizer-expand .codeblock-customizer-header-button-container` + ); + + for (const btn of Array.from(buttons) as HTMLElement[]) { + btn.style.removeProperty("left"); + btn.style.removeProperty("right"); + btn.style.removeProperty("top"); + btn.style.removeProperty("position"); + btn.style.removeProperty("z-index"); + } +} + +function clearExpandLayout(sourceView: HTMLElement): void { + sourceView.classList.remove(READY_CLASS); + sourceView.classList.remove("cbc-expand-has-nowrap"); + sourceView.style.removeProperty("--cbc-expand-margin-left"); + clearExpandButtonClamp(sourceView); + clearExpandHeaders(sourceView); +} + +function applyExpandLayout(sourceView: HTMLElement, marginLeft: number, hasNowrap: boolean): void { + sourceView.style.setProperty("--cbc-expand-margin-left", `${marginLeft}px`); + sourceView.classList.toggle("cbc-expand-has-nowrap", hasNowrap); + sourceView.classList.add(READY_CLASS); +} + +function layoutExpandBlocks( + view: EditorView, + settings: CodeblockCustomizerSettings, + last: { margin: number; hasNowrap: boolean }, + scheduleFollowUp?: () => void +): void { + const sourceView = view.dom.closest(".markdown-source-view") as HTMLElement | null; + if (!sourceView) { + return; + } + + if (!settings.pluginSettings.common.enableInSourceMode && isSourceMode(view.state)) { + clearExpandLayout(sourceView); + last.margin = -1; + last.hasNowrap = false; + return; + } + + // Edit-mode expand CSS is RLL-scoped; without it expand is excluded from normal + // nowrap scroll, so clear rather than leave a dead zone. + if (!sourceView.classList.contains("is-readable-line-width")) { + clearExpandLayout(sourceView); + last.margin = -1; + last.hasNowrap = false; + return; + } + + if (view.dom.offsetWidth < MIN_EXPAND_WIDTH) { + clearExpandLayout(sourceView); + last.margin = -1; + last.hasNowrap = false; + return; + } + + const expandBegins = findExpandBeginLines(sourceView); + if (expandBegins.length === 0) { + clearExpandLayout(sourceView); + last.margin = -1; + last.hasNowrap = false; + return; + } + + const marginLeft = measureMarginLeft(sourceView); + if (marginLeft === null) { + clearExpandLayout(sourceView); + last.margin = -1; + last.hasNowrap = false; + return; + } + + const hasNowrap = expandBegins.some(begin => begin.classList.contains("codeblock-customizer-nowrap")); + const unchanged = Math.abs(last.margin - marginLeft) < 1 + && last.hasNowrap === hasNowrap + && sourceView.classList.contains(READY_CLASS); + + if (!unchanged) { + applyExpandLayout(sourceView, marginLeft, hasNowrap); + last.margin = marginLeft; + last.hasNowrap = hasNowrap; + // Header width needs a frame after nowrap --line-width CSS applies + scheduleFollowUp?.(); + } + + syncExpandHeaders(sourceView, marginLeft); + clampExpandButtons(sourceView); +} + +export function expandExtension( + codeBlockPositionsField: StateField, + settings: CodeblockCustomizerSettings +): Extension[] { + const viewPlugin = ViewPlugin.define(view => { + const last = { margin: -1, hasNowrap: false }; + let rafId = 0; + let followUpRafId = 0; + let retryAttempts = 0; + let scroller: HTMLElement | null = null; + let scrollTimer = 0; + let resizeObserver: ResizeObserver | null = null; + + const onScroll = () => { + if (scrollTimer !== 0) { + return; + } + + scrollTimer = window.setTimeout(() => { + scrollTimer = 0; + const sourceView = view.dom.closest(".markdown-source-view") as HTMLElement | null; + if (sourceView?.classList.contains(READY_CLASS)) { + syncExpandHeaders(sourceView, last.margin >= 0 ? last.margin : 0); + clampExpandButtons(sourceView); + } + }, SCROLL_CLAMP_MS); + }; + + const bindScroller = () => { + const next = view.dom.closest(".markdown-source-view")?.querySelector(".cm-scroller") as HTMLElement | null; + if (next === scroller) { + return; + } + + scroller?.removeEventListener("scroll", onScroll); + resizeObserver?.disconnect(); + scroller = next; + scroller?.addEventListener("scroll", onScroll, { passive: true }); + + if (scroller && typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(() => { + last.margin = -1; + scheduleLayout(); + }); + resizeObserver.observe(scroller); + } + }; + + const scheduleFollowUp = () => { + if (followUpRafId !== 0) { + return; + } + + followUpRafId = requestAnimationFrame(() => { + followUpRafId = 0; + const sourceView = view.dom.closest(".markdown-source-view") as HTMLElement | null; + if (sourceView?.classList.contains(READY_CLASS) && last.margin >= 0) { + syncExpandHeaders(sourceView, last.margin); + clampExpandButtons(sourceView); + } + }); + }; + + const scheduleLayout = () => { + if (rafId !== 0) { + return; + } + + rafId = requestAnimationFrame(() => { + rafId = 0; + bindScroller(); + layoutExpandBlocks(view, settings, last, scheduleFollowUp); + + const sourceView = view.dom.closest(".markdown-source-view") as HTMLElement | null; + const mainExpands = sourceView ? findExpandBeginLines(sourceView).length : 0; + const positions = view.state.field(codeBlockPositionsField, false) ?? []; + const positionsHaveExpand = positions.some(p => p.parameters.expand); + + // Retry while main-column expands exist but layout isn't ready yet. + // Cap short when positions claim expand but DOM has none (callouts-only / first paint). + const needsRetry = !!sourceView + && !sourceView.classList.contains(READY_CLASS) + && ( + (mainExpands > 0 && retryAttempts < 60) + || (mainExpands === 0 && positionsHaveExpand && retryAttempts < 10) + ); + + if (needsRetry) { + retryAttempts++; + scheduleLayout(); + } else { + retryAttempts = 0; + } + }); + }; + + scheduleLayout(); + + return { + update(u) { + if (u.docChanged || u.geometryChanged) { + last.margin = -1; + retryAttempts = 0; + scheduleLayout(); + return; + } + + const sourceView = u.view.dom.closest(".markdown-source-view") as HTMLElement | null; + const positions = u.view.state.field(codeBlockPositionsField, false) ?? []; + const hasExpand = positions.some(p => p.parameters.expand); + + if (hasExpand && sourceView && !sourceView.classList.contains(READY_CLASS)) { + scheduleLayout(); + return; + } + + // RLL may flip without a CM geometry event - re-check each update when ready looks wrong + if (sourceView?.classList.contains(READY_CLASS) + && !sourceView.classList.contains("is-readable-line-width")) { + clearExpandLayout(sourceView); + last.margin = -1; + last.hasNowrap = false; + return; + } + + if (u.transactions.some(tr => tr.effects.some(effect => effect.is(wrapEffect)))) { + last.margin = -1; + last.hasNowrap = false; + scheduleLayout(); + } + }, + destroy() { + if (rafId !== 0) { + cancelAnimationFrame(rafId); + } + + if (followUpRafId !== 0) { + cancelAnimationFrame(followUpRafId); + } + + if (scrollTimer !== 0) { + clearTimeout(scrollTimer); + } + + scroller?.removeEventListener("scroll", onScroll); + resizeObserver?.disconnect(); + resizeObserver = null; + scroller = null; + + const sourceView = view.dom.closest(".markdown-source-view") as HTMLElement | null; + if (sourceView) { + clearExpandLayout(sourceView); + } + } + }; + }); + + return [viewPlugin]; +} diff --git a/src/EditorView/Wrapping.ts b/src/EditorView/Wrapping.ts index 681f6dc..cc0d93b 100644 --- a/src/EditorView/Wrapping.ts +++ b/src/EditorView/Wrapping.ts @@ -31,7 +31,20 @@ export function wrapExtension(codeBlockPositionsField: StateField(); + for (const pos of startUnwrapped) { + mapped.add(tr.changes.mapPos(pos)); + } + + return buildDecorations(tr.state, mapped, codeBlockPositionsField); } return value; @@ -271,7 +284,9 @@ function buildDecorations(state: EditorState, unwrapped: Set, codeBlockP decorations.push(nowrapDecoration.range(state.doc.line(i).from)); } - decorations.push(Decoration.widget({ widget: new ScrollbarWidget(maxLength), block: true, side: 1 }).range(codeBlockEndPos)); + if (!parameters.expand) { + decorations.push(Decoration.widget({ widget: new ScrollbarWidget(maxLength), block: true, side: 1 }).range(codeBlockEndPos)); + } } return RangeSet.of(decorations, true); diff --git a/src/css/code-block.scss b/src/css/code-block.scss index 8eda1ca..9022393 100644 --- a/src/css/code-block.scss +++ b/src/css/code-block.scss @@ -84,26 +84,93 @@ margin-left: var(--cbc-expand-margin, 0); } -/* expand - editing mode */ -.is-readable-line-width .cm-editor:has(.cm-line.codeblock-customizer-expand) { - .cm-sizer, - //.cm-contentContainer, - .cm-content { - max-width: unset !important; - } +/* expand - editing mode + * + * Ready flag on .markdown-source-view (cbc-expand-ready). Do not put state on + * .cm-line - CodeMirror rebuilds line DOM and would wipe classes/inline styles. + * + * Obsidian readable-line-width styles .cm-content children via: + * margin-inline: var(--content-margin) !important; + * width: var(--line-width); + * max-width: var(--max-width); + * Override those vars so we cooperate with Obsidian instead of fighting it. + * + * Wrapped: same column width as a normal code block (left-aligned). + * Unwrapped: hug via --cbc-min-scroll-width from Wrapping.ts (per-block); + * .cm-scroller scrolls when content exceeds the pane. + */ +.markdown-source-view.is-readable-line-width.cbc-expand-ready.cbc-expand-has-nowrap .cm-scroller { + overflow-x: auto; +} - .cm-content > *:not(.codeblock-customizer-expand) { - max-width: var(--file-line-width) !important; - margin-inline: auto !important; - } +.markdown-source-view.is-readable-line-width.cbc-expand-ready .HyperMD-codeblock.codeblock-customizer-expand:not(.codeblock-customizer-nowrap) { + /* wrapped: left-align to column; keep Obsidian --line-width / --max-width */ + --content-margin: var(--cbc-expand-margin-left) 0; + white-space: pre-wrap !important; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .HyperMD-codeblock.codeblock-customizer-expand:not(.codeblock-customizer-nowrap) > .cm-hmd-codeblock { + white-space: pre-wrap !important; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .HyperMD-codeblock.codeblock-customizer-expand.codeblock-customizer-nowrap { + --content-margin: var(--cbc-expand-margin-left) 0; + /* per-block hug from Wrapping.ts --cbc-min-scroll-width (e.g. 80ch) */ + --line-width: max(var(--file-line-width), calc(var(--cbc-min-scroll-width, 0px) + var(--line-number-gutter-width, 0px) + 2em)); + --max-width: none; + white-space: pre !important; + overflow-x: visible !important; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .HyperMD-codeblock.codeblock-customizer-expand.codeblock-customizer-nowrap > .cm-hmd-codeblock { + white-space: pre !important; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .HyperMD-codeblock-begin.codeblock-customizer-expand .codeblock-customizer-button-container { + /* same vertical placement as a normal block; horizontal clamp via right in Expand.ts */ + position: absolute !important; + top: 6px !important; + right: 6px; + left: auto !important; + width: max-content; + max-width: none; + white-space: nowrap; + float: none; + z-index: 50; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .codeblock-customizer-header-container.codeblock-customizer-expand, +.markdown-source-view.is-readable-line-width.cbc-expand-ready .codeblock-customizer-header-container-specific.codeblock-customizer-expand { + --content-margin: var(--cbc-expand-margin-left) 0; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .codeblock-customizer-header-container-specific.codeblock-customizer-expand .codeblock-customizer-header-button-container { + position: absolute !important; + top: 0 !important; + right: 6px; + left: auto !important; + width: max-content; + max-width: none; + white-space: nowrap; + z-index: 50; +} + +/* Callouts / admonitions: reading mode skips expand; keep edit mode neutral too */ +.markdown-source-view.is-readable-line-width.cbc-expand-ready :is(.callout, .admonition) .HyperMD-codeblock.codeblock-customizer-expand, +.markdown-source-view.is-readable-line-width.cbc-expand-ready :is(.callout, .admonition) .codeblock-customizer-header-container.codeblock-customizer-expand, +.markdown-source-view.is-readable-line-width.cbc-expand-ready :is(.callout, .admonition) .codeblock-customizer-header-container-specific.codeblock-customizer-expand { + --content-margin: unset; + --line-width: unset; + --max-width: unset; + white-space: unset; } /* Prevent Line Wraps */ -.HyperMD-codeblock:has(> .cm-widgetBuffer):not(.codeblock-customizer-nowrap) { +.HyperMD-codeblock:has(> .cm-widgetBuffer):not(.codeblock-customizer-nowrap):not(.codeblock-customizer-expand) { white-space: nowrap; } -.HyperMD-codeblock:has(> .cm-widgetBuffer):not(.codeblock-customizer-nowrap)>.cm-hmd-codeblock { +.HyperMD-codeblock:has(> .cm-widgetBuffer):not(.codeblock-customizer-nowrap):not(.codeblock-customizer-expand) > .cm-hmd-codeblock { white-space: break-spaces; } @@ -141,12 +208,7 @@ padding-bottom: 1.5em; } - &.codeblock-customizer-expand { - max-width: unset; - margin-inline: 0; - } - - &.codeblock-customizer-nowrap { + &.codeblock-customizer-nowrap:not(.codeblock-customizer-expand) { padding-left: 0 !important; overflow-wrap: normal !important; overflow-x: auto; @@ -378,6 +440,12 @@ body.codeblock-customizer .markdown-source-view :not(pre.codeblock-customizer-pr } } +/* expand blocks scroll on the line itself, not via the shared scrollbar widget */ +.HyperMD-codeblock-end.codeblock-customizer-expand ~ .codeblock-customizer-scrollbar, +.codeblock-customizer-expand ~ .codeblock-customizer-scrollbar { + display: none !important; +} + /* hide scrollbar when code block is collapsed/semi-collapsed */ .codeblock-customizer-header-container-specific.collapsed ~ .codeblock-customizer-scrollbar, .codeblock-customizer-header-container-specific.semi-collapsed ~ .codeblock-customizer-scrollbar { diff --git a/styles.css b/styles.css index 0a31989..54915e6 100644 --- a/styles.css +++ b/styles.css @@ -1389,22 +1389,93 @@ pre.codeblock-customizer-pre.codeblock-customizer-codeblock-semi-collapsed .code margin-left: var(--cbc-expand-margin, 0); } -/* expand - editing mode */ -.is-readable-line-width .cm-editor:has(.cm-line.codeblock-customizer-expand) .cm-sizer, -.is-readable-line-width .cm-editor:has(.cm-line.codeblock-customizer-expand) .cm-content { - max-width: unset !important; +/* expand - editing mode + * + * Ready flag on .markdown-source-view (cbc-expand-ready). Do not put state on + * .cm-line - CodeMirror rebuilds line DOM and would wipe classes/inline styles. + * + * Obsidian readable-line-width styles .cm-content children via: + * margin-inline: var(--content-margin) !important; + * width: var(--line-width); + * max-width: var(--max-width); + * Override those vars so we cooperate with Obsidian instead of fighting it. + * + * Wrapped: same column width as a normal code block (left-aligned). + * Unwrapped: hug via --cbc-min-scroll-width from Wrapping.ts (per-block); + * .cm-scroller scrolls when content exceeds the pane. + */ +.markdown-source-view.is-readable-line-width.cbc-expand-ready.cbc-expand-has-nowrap .cm-scroller { + overflow-x: auto; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .HyperMD-codeblock.codeblock-customizer-expand:not(.codeblock-customizer-nowrap) { + /* wrapped: left-align to column; keep Obsidian --line-width / --max-width */ + --content-margin: var(--cbc-expand-margin-left) 0; + white-space: pre-wrap !important; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .HyperMD-codeblock.codeblock-customizer-expand:not(.codeblock-customizer-nowrap) > .cm-hmd-codeblock { + white-space: pre-wrap !important; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .HyperMD-codeblock.codeblock-customizer-expand.codeblock-customizer-nowrap { + --content-margin: var(--cbc-expand-margin-left) 0; + /* per-block hug from Wrapping.ts --cbc-min-scroll-width (e.g. 80ch) */ + --line-width: max(var(--file-line-width), calc(var(--cbc-min-scroll-width, 0px) + var(--line-number-gutter-width, 0px) + 2em)); + --max-width: none; + white-space: pre !important; + overflow-x: visible !important; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .HyperMD-codeblock.codeblock-customizer-expand.codeblock-customizer-nowrap > .cm-hmd-codeblock { + white-space: pre !important; } -.is-readable-line-width .cm-editor:has(.cm-line.codeblock-customizer-expand) .cm-content > *:not(.codeblock-customizer-expand) { - max-width: var(--file-line-width) !important; - margin-inline: auto !important; + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .HyperMD-codeblock-begin.codeblock-customizer-expand .codeblock-customizer-button-container { + /* same vertical placement as a normal block; horizontal clamp via right in Expand.ts */ + position: absolute !important; + top: 6px !important; + right: 6px; + left: auto !important; + width: max-content; + max-width: none; + white-space: nowrap; + float: none; + z-index: 50; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .codeblock-customizer-header-container.codeblock-customizer-expand, +.markdown-source-view.is-readable-line-width.cbc-expand-ready .codeblock-customizer-header-container-specific.codeblock-customizer-expand { + --content-margin: var(--cbc-expand-margin-left) 0; +} + +.markdown-source-view.is-readable-line-width.cbc-expand-ready .codeblock-customizer-header-container-specific.codeblock-customizer-expand .codeblock-customizer-header-button-container { + position: absolute !important; + top: 0 !important; + right: 6px; + left: auto !important; + width: max-content; + max-width: none; + white-space: nowrap; + z-index: 50; +} + +/* Callouts / admonitions: reading mode skips expand; keep edit mode neutral too */ +.markdown-source-view.is-readable-line-width.cbc-expand-ready :is(.callout, .admonition) .HyperMD-codeblock.codeblock-customizer-expand, +.markdown-source-view.is-readable-line-width.cbc-expand-ready :is(.callout, .admonition) .codeblock-customizer-header-container.codeblock-customizer-expand, +.markdown-source-view.is-readable-line-width.cbc-expand-ready :is(.callout, .admonition) .codeblock-customizer-header-container-specific.codeblock-customizer-expand { + --content-margin: unset; + --line-width: unset; + --max-width: unset; + white-space: unset; } /* Prevent Line Wraps */ -.HyperMD-codeblock:has(> .cm-widgetBuffer):not(.codeblock-customizer-nowrap) { +.HyperMD-codeblock:has(> .cm-widgetBuffer):not(.codeblock-customizer-nowrap):not(.codeblock-customizer-expand) { white-space: nowrap; } -.HyperMD-codeblock:has(> .cm-widgetBuffer):not(.codeblock-customizer-nowrap) > .cm-hmd-codeblock { +.HyperMD-codeblock:has(> .cm-widgetBuffer):not(.codeblock-customizer-nowrap):not(.codeblock-customizer-expand) > .cm-hmd-codeblock { white-space: break-spaces; } @@ -1435,11 +1506,7 @@ pre.codeblock-customizer-pre.codeblock-customizer-codeblock-semi-collapsed .code position: relative; padding-bottom: 1.5em; } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-expand { - max-width: unset; - margin-inline: 0; -} -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand) { padding-left: 0 !important; overflow-wrap: normal !important; overflow-x: auto; @@ -1447,24 +1514,24 @@ pre.codeblock-customizer-pre.codeblock-customizer-codeblock-semi-collapsed .code white-space: pre !important; word-wrap: normal; } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap.has-prompt-output { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand).has-prompt-output { padding-bottom: 0; } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap .cbc-line-num { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand) .cbc-line-num { position: sticky; left: 0; margin-right: var(--line-number-gutter-padding); box-shadow: 0 1px 0 0 var(--gutter-background-color, var(--codeblock-customizer-gutter-background-color)); } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap.HyperMD-codeblock-begin { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand).HyperMD-codeblock-begin { overflow: clip visible; text-indent: var(--cbc-fence-scroll, 0); } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap.HyperMD-codeblock-begin .cbc-line-num { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand).HyperMD-codeblock-begin .cbc-line-num { position: relative; left: calc(-1 * var(--cbc-fence-scroll, 0px)); } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap .codeblock-customizer-prompt-cmd-output { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand) .codeblock-customizer-prompt-cmd-output { position: static !important; display: flex !important; overflow: visible; @@ -1473,7 +1540,7 @@ pre.codeblock-customizer-pre.codeblock-customizer-codeblock-semi-collapsed .code flex-wrap: nowrap; float: left !important; } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap .codeblock-customizer-prompt-cmd-output::before { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand) .codeblock-customizer-prompt-cmd-output::before { position: sticky; left: 0; display: inline-block; @@ -1486,31 +1553,31 @@ pre.codeblock-customizer-pre.codeblock-customizer-codeblock-semi-collapsed .code color: transparent; content: " "; } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap::-webkit-scrollbar { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand)::-webkit-scrollbar { display: none; height: 0; } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap::after { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand)::after { display: block; width: var(--cbc-min-scroll-width, 0); height: 0; content: ""; } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:has(.cbc-line-num)::after { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand):has(.cbc-line-num)::after { width: calc(var(--cbc-min-scroll-width, 0px) + var(--line-number-gutter-width, 0px) + var(--line-number-gutter-padding, 0px)); } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap .codeblock-customizer-hidden-line-container, -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap .codeblock-customizer-line-separator { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand) .codeblock-customizer-hidden-line-container, +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand) .codeblock-customizer-line-separator { overflow: visible; width: max(100%, var(--cbc-min-scroll-width, 0px)); } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:has(.cbc-line-num) .codeblock-customizer-hidden-line-container, -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:has(.cbc-line-num) .codeblock-customizer-line-separator { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand):has(.cbc-line-num) .codeblock-customizer-hidden-line-container, +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand):has(.cbc-line-num) .codeblock-customizer-line-separator { width: max(100%, var(--cbc-min-scroll-width, 0px) + var(--line-number-gutter-width, 0px) + var(--line-number-gutter-padding, 0px)); } -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap .codeblock-customizer-hidden-line-gutter, -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap .codeblock-customizer-hidden-line-gutter-specific, -.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap .codeblock-customizer-line-separator-gutter { +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand) .codeblock-customizer-hidden-line-gutter, +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand) .codeblock-customizer-hidden-line-gutter-specific, +.codeblock-customizer .markdown-source-view [class*=codeblock-customizer-line].codeblock-customizer-nowrap:not(.codeblock-customizer-expand) .codeblock-customizer-line-separator-gutter { position: sticky; z-index: 2; left: 0; @@ -1649,6 +1716,12 @@ body.codeblock-customizer .markdown-source-view :not(pre.codeblock-customizer-pr display: inline-block; } +/* expand blocks scroll on the line itself, not via the shared scrollbar widget */ +.HyperMD-codeblock-end.codeblock-customizer-expand ~ .codeblock-customizer-scrollbar, +.codeblock-customizer-expand ~ .codeblock-customizer-scrollbar { + display: none !important; +} + /* hide scrollbar when code block is collapsed/semi-collapsed */ .codeblock-customizer-header-container-specific.collapsed ~ .codeblock-customizer-scrollbar, .codeblock-customizer-header-container-specific.semi-collapsed ~ .codeblock-customizer-scrollbar {