refactor: highlight synced content instead of markers

When highlight mode is on, hide markers as before but wrap the synced
content itself in a highlight decoration:
- Inline sections: background + outline on text between start/end markers
- Line sections: same treatment on text after the line marker
- Block sections: left accent border + tinted background on each content line

Reading mode wraps the same content in a jira-sync-content span.
Uses color-mix() for theme-aware accent tinting.
This commit is contained in:
slips 2026-04-22 11:20:32 -04:00
parent f4732263f8
commit 1562f4ff52
3 changed files with 128 additions and 108 deletions

View file

@ -25,123 +25,96 @@ export function createJiraSyncExtension(plugin: JiraPlugin): Extension {
passDecoration() {
const mdView = plugin.app.workspace.getActiveViewOfType(MarkdownView);
if (!mdView) return false;
const current_state = mdView.getState()
const current_state = mdView.getState();
return current_state.mode === 'source' && current_state.source;
}
buildDecorations(view: EditorView) {
const builder = new RangeSetBuilder<Decoration>();
const isHighlight = plugin.settings.global.highlightSyncSections;
const sel = view.state.selection;
for (let { from, to } of view.visibleRanges) {
const allDecs: Array<{ from: number; to: number; dec: Decoration }> = [];
for (const { from, to } of view.visibleRanges) {
const text = view.state.doc.sliceString(from, to);
const codeBlocks = this.findCodeBlocks(text, from);
const allDecorations: Array<{from: number, to: number, dec: Decoration}> = [];
// Collect marker decorations
// Collect all jira-sync marker positions
const markers: Array<{ start: number; end: number; name: string }> = [];
for (const match of text.matchAll(/`(jira-sync-[^`]+)`/g)) {
const start = from + match.index!;
const end = start + match[0].length;
const end = start + match[0].length;
if (this.isInsideCodeBlock(start, end, codeBlocks)) continue;
markers.push({ start, end, name: match[1] });
}
const sel = view.state.selection;
// Always hide the markers themselves, show when cursor is inside
for (const { start, end } of markers) {
const isActive = sel.ranges.some(r => r.from <= end && r.to >= start);
let className: string;
if (isHighlight) {
className = isActive ? "jira-sync-marker jira-sync-active" : "jira-sync-marker";
} else {
className = isActive ? "jira-sync-hidden jira-sync-active" : "jira-sync-hidden";
}
allDecorations.push({ from: start, to: end, dec: Decoration.mark({ class: className }) });
const cls = isActive ? "jira-sync-hidden jira-sync-active" : "jira-sync-hidden";
allDecs.push({ from: start, to: end, dec: Decoration.mark({ class: cls }) });
}
// Collect block content line decorations when highlight is on
if (isHighlight) {
const blockContentLines = this.findBlockContentLines(view, text, from, codeBlocks);
for (const linePos of blockContentLines) {
allDecorations.push({ from: linePos, to: linePos, dec: Decoration.line({ class: "jira-sync-block-content" }) });
if (!isHighlight) continue;
// Inline content: `jira-sync-inline-start-*` ... `jira-sync-end`
for (let i = 0; i < markers.length; i++) {
const m = markers[i];
if (!m.name.startsWith('jira-sync-inline-start-')) continue;
const endMarker = markers.find((em, j) => j > i && em.name === 'jira-sync-end');
if (!endMarker || endMarker.start <= m.end) continue;
allDecs.push({ from: m.end, to: endMarker.start, dec: Decoration.mark({ class: "jira-sync-content" }) });
}
// Line content: `jira-sync-line-*` followed by text to end of line
for (const m of markers) {
if (!m.name.startsWith('jira-sync-line-')) continue;
const line = view.state.doc.lineAt(m.end);
const nextOnLine = markers.find(nm => nm.start > m.end && nm.start <= line.to);
const contentEnd = nextOnLine ? nextOnLine.start : line.to;
if (contentEnd > m.end) {
allDecs.push({ from: m.end, to: contentEnd, dec: Decoration.mark({ class: "jira-sync-content" }) });
}
}
// RangeSetBuilder requires ranges in sorted order
allDecorations.sort((a, b) => a.from - b.from);
for (const { from: f, to: t, dec } of allDecorations) {
builder.add(f, t, dec);
// Block content: lines between `jira-sync-block-start-*` and `jira-sync-end`
for (let i = 0; i < markers.length; i++) {
const m = markers[i];
if (!m.name.startsWith('jira-sync-block-start-')) continue;
const endMarker = markers.find((em, j) => j > i && em.name === 'jira-sync-end');
if (!endMarker) continue;
const startLine = view.state.doc.lineAt(m.end);
const endLine = view.state.doc.lineAt(endMarker.start);
for (let lineNum = startLine.number + 1; lineNum < endLine.number; lineNum++) {
if (lineNum > view.state.doc.lines) break;
const line = view.state.doc.line(lineNum);
allDecs.push({ from: line.from, to: line.from, dec: Decoration.line({ class: "jira-sync-block-content" }) });
}
}
}
// RangeSetBuilder requires ranges in ascending order of `from`, then `to`
allDecs.sort((a, b) => a.from - b.from || a.to - b.to);
for (const { from, to, dec } of allDecs) {
builder.add(from, to, dec);
}
return builder.finish();
}
findBlockContentLines(view: EditorView, text: string, offset: number, codeBlocks: Array<{from: number, to: number}>): number[] {
const linePositions: number[] = [];
const blockStartRegex = /`jira-sync-block-start-[^`]+`/g;
const blockEndRegex = /`jira-sync-end`/g;
const starts: number[] = [];
const ends: number[] = [];
for (const m of text.matchAll(blockStartRegex)) {
const pos = offset + m.index! + m[0].length;
if (!this.isInsideCodeBlock(offset + m.index!, pos, codeBlocks)) {
starts.push(pos);
}
}
for (const m of text.matchAll(blockEndRegex)) {
const pos = offset + m.index!;
if (!this.isInsideCodeBlock(pos, pos + m[0].length, codeBlocks)) {
ends.push(pos);
}
}
for (const startPos of starts) {
const matchingEnd = ends.find(e => e > startPos);
if (matchingEnd === undefined) continue;
// Iterate over lines between the start marker line end and the end marker line start
let lineStart = view.state.doc.lineAt(startPos).from;
const endLine = view.state.doc.lineAt(matchingEnd).from;
// Move to line after the block-start marker
const startLine = view.state.doc.lineAt(startPos);
if (startLine.number < view.state.doc.lines) {
lineStart = view.state.doc.line(startLine.number + 1).from;
}
while (lineStart < endLine) {
linePositions.push(lineStart);
const line = view.state.doc.lineAt(lineStart);
if (line.number >= view.state.doc.lines) break;
lineStart = view.state.doc.line(line.number + 1).from;
}
}
return linePositions;
}
findCodeBlocks(text: string, offset: number) {
const blocks = [];
const blocks: Array<{ from: number; to: number }> = [];
const regex = /```[\s\S]*?```/g;
let match;
while ((match = regex.exec(text)) !== null) {
blocks.push({
from: offset + match.index,
to: offset + match.index + match[0].length
});
blocks.push({ from: offset + match.index, to: offset + match.index + match[0].length });
}
return blocks;
}
isInsideCodeBlock(start: number, end: number, codeBlocks: Array<{from: number, to: number}>) {
return codeBlocks.some(block =>
start >= block.from && end <= block.to
);
isInsideCodeBlock(start: number, end: number, codeBlocks: Array<{ from: number; to: number }>) {
return codeBlocks.some(block => start >= block.from && end <= block.to);
}
}, {

View file

@ -1,20 +1,73 @@
import JiraPlugin from "../main";
// For Reading mode - targets <code> elements in <p> tags
export function hideJiraPointersReading(plugin: JiraPlugin) {
return function(element: HTMLElement, _: any) {
const codeElements = element.querySelectorAll(':not(pre) > code');
const isHighlight = plugin.settings.global.highlightSyncSections;
const codeElements = Array.from(element.querySelectorAll(':not(pre) > code'))
.filter(el => el.textContent?.startsWith('jira-sync-')) as HTMLElement[];
codeElements.forEach(codeEl => {
for (const codeEl of codeElements) {
const text = codeEl.textContent || '';
codeEl.addClass('jira-sync-hidden');
if (text.startsWith('jira-sync-')) {
if (plugin.settings.global.highlightSyncSections) {
codeEl.addClass('jira-sync-marker');
} else {
codeEl.addClass('jira-sync-hidden');
}
if (!isHighlight) continue;
if (text.startsWith('jira-sync-inline-start-')) {
wrapContentBetween(codeEl, 'jira-sync-end');
} else if (text.startsWith('jira-sync-line-')) {
wrapContentAfter(codeEl);
}
});
}
};
}
// Wrap sibling nodes between this element and the next jira-sync-end code element
function wrapContentBetween(startEl: HTMLElement, endMarker: string) {
const parent = startEl.parentElement;
if (!parent) return;
const nodesToWrap: Node[] = [];
let node: Node | null = startEl.nextSibling;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.tagName === 'CODE' && (el.textContent || '').startsWith(endMarker)) break;
}
nodesToWrap.push(node);
node = node.nextSibling;
}
if (nodesToWrap.length === 0) return;
const wrapper = createSpanWrapper(parent, nodesToWrap[0]);
for (const n of nodesToWrap) wrapper.appendChild(n);
}
// Wrap all sibling nodes after this element (until next jira-sync marker)
function wrapContentAfter(lineEl: HTMLElement) {
const parent = lineEl.parentElement;
if (!parent) return;
const nodesToWrap: Node[] = [];
let node: Node | null = lineEl.nextSibling;
while (node) {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
if (el.tagName === 'CODE' && (el.textContent || '').startsWith('jira-sync-')) break;
}
nodesToWrap.push(node);
node = node.nextSibling;
}
if (nodesToWrap.length === 0) return;
const wrapper = createSpanWrapper(parent, nodesToWrap[0]);
for (const n of nodesToWrap) wrapper.appendChild(n);
}
function createSpanWrapper(parent: HTMLElement, insertBefore: Node): HTMLElement {
const wrapper = document.createElement('span');
wrapper.addClass('jira-sync-content');
parent.insertBefore(wrapper, insertBefore);
return wrapper;
}

View file

@ -953,26 +953,20 @@ code.hljs {
padding: 0 !important;
}
/* Highlighted sync section markers */
.jira-sync-marker {
background: var(--tag-background);
color: var(--tag-color);
border-radius: var(--tag-radius, 4px);
padding: var(--tag-padding-y, 1px) var(--tag-padding-x, 6px);
font-size: var(--tag-size, 0.8em);
font-weight: 500;
box-shadow: none;
}
.jira-sync-marker.jira-sync-active {
outline: 1px solid var(--interactive-accent);
/* Inline / line synced content highlight */
.jira-sync-content {
background: color-mix(in srgb, var(--interactive-accent) 12%, transparent);
border-radius: 3px;
padding: 0 2px;
outline: 1px solid color-mix(in srgb, var(--interactive-accent) 35%, transparent);
outline-offset: 1px;
}
/* Block content highlight — subtle left border between block-start and block-end */
/* Block content lines — left accent border */
.jira-sync-block-content {
border-left: 2px solid var(--interactive-accent) !important;
border-left: 3px solid var(--interactive-accent) !important;
padding-left: 8px !important;
background: color-mix(in srgb, var(--interactive-accent) 5%, transparent) !important;
}
/* JQL Preview Container */