mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-28 06:22:33 +00:00
refac
This commit is contained in:
parent
ffbe0ac12d
commit
596cdbb6ca
3 changed files with 223 additions and 74 deletions
|
|
@ -10,7 +10,7 @@
|
|||
import { LanguageDescription, indentUnit } from '@codemirror/language';
|
||||
import { languages } from '@codemirror/language-data';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import type { Chunk, MergeView } from '@codemirror/merge';
|
||||
import type { Chunk } from '@codemirror/merge';
|
||||
import Dropdown from './Dropdown.svelte';
|
||||
import DropdownMenu from './DropdownMenu.svelte';
|
||||
import EllipsisHorizontal from '$lib/components/icons/EllipsisHorizontal.svelte';
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
chunkOverview,
|
||||
changedLineCounts,
|
||||
nearestOverviewChunk,
|
||||
diffEditorConfig,
|
||||
DiffEditorView,
|
||||
type MergeDirection
|
||||
} from './diffEditor';
|
||||
|
||||
|
|
@ -78,7 +78,7 @@
|
|||
let working = '';
|
||||
let proposal = '';
|
||||
let comparing = false;
|
||||
let merge: MergeView | null = null;
|
||||
let merge: DiffEditorView | null = null;
|
||||
let visibleChunks: readonly Chunk[] = [];
|
||||
let showOptions = false;
|
||||
let optionsDropdown: Dropdown;
|
||||
|
|
@ -323,7 +323,6 @@
|
|||
activeChunk = 0;
|
||||
if (merge) {
|
||||
clearLineAlignment(merge.a, merge.b);
|
||||
merge.reconfigure({ gutter: !ignoreWhitespace && !ignoreEmphasis });
|
||||
}
|
||||
updatePresentation?.(true);
|
||||
refresh();
|
||||
|
|
@ -331,10 +330,21 @@
|
|||
|
||||
const mountMerge = (node: HTMLElement) => {
|
||||
let disposed = false;
|
||||
let view: MergeView | null = null;
|
||||
let view: DiffEditorView | null = null;
|
||||
let observer: MutationObserver | undefined;
|
||||
let alignmentFrame = 0;
|
||||
let presentationFrame = 0;
|
||||
let scrollAnchor: { editor: EditorView; position: number; top: number } | null = null;
|
||||
const cancelScrollAnchor = () => {
|
||||
scrollAnchor = null;
|
||||
};
|
||||
for (const event of ['touchmove', 'pointerdown'])
|
||||
node.addEventListener(event, cancelScrollAnchor, { passive: true });
|
||||
const advanceScrollAnchor = (event: WheelEvent) => {
|
||||
if (scrollAnchor && event.deltaMode === 0) scrollAnchor.top -= event.deltaY;
|
||||
else cancelScrollAnchor();
|
||||
};
|
||||
node.addEventListener('wheel', advanceScrollAnchor, { passive: true });
|
||||
let expanded: CollapsedPair[] = [];
|
||||
const schedulePresentation = (reset = false) => {
|
||||
if (reset) expanded = [];
|
||||
|
|
@ -396,6 +406,7 @@
|
|||
themes[index].of(document.documentElement.classList.contains('dark') ? oneDark : []),
|
||||
language[index].of([]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged || update.selectionSet) scrollAnchor = null;
|
||||
for (const transaction of update.transactions) {
|
||||
if (transaction.docChanged) {
|
||||
for (const pair of expanded) {
|
||||
|
|
@ -417,6 +428,14 @@
|
|||
alignmentFrame = requestAnimationFrame(() => {
|
||||
alignmentFrame = 0;
|
||||
if (!disposed && view) {
|
||||
// Restore a visual row after both panes measure new spacers. Anchoring
|
||||
// to the paragraph start jumps backwards inside long wrapped lines.
|
||||
if (scrollAnchor) {
|
||||
const { editor, position, top } = scrollAnchor;
|
||||
scrollAnchor = null;
|
||||
const coords = editor.coordsAtPos(position);
|
||||
if (coords) view.dom.scrollTop += coords.top - top;
|
||||
}
|
||||
const documents = [view.a.state.doc, view.b.state.doc] as const;
|
||||
const geometry = [
|
||||
view.a.contentDOM.clientWidth,
|
||||
|
|
@ -433,28 +452,39 @@
|
|||
resized);
|
||||
alignmentDocuments = documents;
|
||||
if (!alignmentGeometry || resized) alignmentGeometry = geometry;
|
||||
const top = view.dom.getBoundingClientRect().top + 1;
|
||||
for (const editor of [view.a, view.b]) {
|
||||
const position = editor.posAtCoords({
|
||||
x: editor.contentDOM.getBoundingClientRect().left + 4,
|
||||
y: top
|
||||
});
|
||||
const coords = position === null ? null : editor.coordsAtPos(position);
|
||||
if (position !== null && coords) {
|
||||
scrollAnchor = { editor, position, top: coords.top };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!reset || !clearLineAlignment(view.a, view.b))
|
||||
alignChangedLines(view.a, view.b, view.chunks);
|
||||
if (!alignmentFrame) scrollAnchor = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': { fontSize: '0.75rem' },
|
||||
'&, .cm-scroller': { height: 'auto', overflowY: 'visible' },
|
||||
'.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace' },
|
||||
'&.cm-focused': { outline: 'none' }
|
||||
})
|
||||
];
|
||||
void (async () => {
|
||||
try {
|
||||
const { MergeView } = await import('@codemirror/merge');
|
||||
if (disposed) return;
|
||||
view = new MergeView({
|
||||
view = new DiffEditorView({
|
||||
a: { doc: working, extensions: extensions('a', 0) },
|
||||
b: { doc: proposal, extensions: extensions('b', 1) },
|
||||
parent: node,
|
||||
gutter: !ignoreWhitespace && !ignoreEmphasis,
|
||||
diffConfig: diffEditorConfig
|
||||
parent: node
|
||||
});
|
||||
merge = view;
|
||||
refresh();
|
||||
|
|
@ -489,6 +519,9 @@
|
|||
return {
|
||||
destroy: () => {
|
||||
disposed = true;
|
||||
for (const event of ['touchmove', 'pointerdown'])
|
||||
node.removeEventListener(event, cancelScrollAnchor);
|
||||
node.removeEventListener('wheel', advanceScrollAnchor);
|
||||
cancelAnimationFrame(alignmentFrame);
|
||||
cancelAnimationFrame(presentationFrame);
|
||||
if (updatePresentation === schedulePresentation) updatePresentation = null;
|
||||
|
|
@ -962,6 +995,8 @@
|
|||
overflow: auto;
|
||||
}
|
||||
.merge-container :global(.cm-mergeViewEditor) {
|
||||
flex: 1 1 0;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
.merge-container :global(.cm-merge-a .cm-changedLine),
|
||||
|
|
@ -1022,6 +1057,8 @@
|
|||
background: #101413 !important;
|
||||
}
|
||||
.merge-container :global(.cm-mergeViewEditors) {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 1rem;
|
||||
}
|
||||
.diff-editor :global(.cm-gutters) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,82 @@
|
|||
import { StateEffect, StateField, type ChangeSpec, type Text } from '@codemirror/state';
|
||||
import { Decoration, EditorView, WidgetType, type DecorationSet } from '@codemirror/view';
|
||||
import {
|
||||
StateEffect,
|
||||
StateField,
|
||||
RangeSet,
|
||||
ChangeSet,
|
||||
EditorState,
|
||||
type EditorStateConfig,
|
||||
type Transaction,
|
||||
type ChangeSpec,
|
||||
type Text
|
||||
} from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
GutterMarker,
|
||||
gutter,
|
||||
WidgetType,
|
||||
type DecorationSet
|
||||
} from '@codemirror/view';
|
||||
import { Chunk, type DiffConfig } from '@codemirror/merge';
|
||||
|
||||
// A scanLimit also gives up based on document length, even for sparse, tiny edits.
|
||||
export const diffEditorConfig: DiffConfig = { timeout: 100 };
|
||||
|
||||
// The existing row alignment owns spacing; MergeView's second spacer pass would fight it.
|
||||
export class DiffEditorView {
|
||||
readonly a: EditorView;
|
||||
readonly b: EditorView;
|
||||
readonly dom: HTMLDivElement;
|
||||
chunks: readonly Chunk[];
|
||||
constructor(config: { a: EditorStateConfig; b: EditorStateConfig; parent: HTMLElement }) {
|
||||
const states = (['a', 'b'] as const).map((side) =>
|
||||
EditorState.create({
|
||||
...config[side],
|
||||
extensions: [
|
||||
config[side].extensions ?? [],
|
||||
EditorView.editorAttributes.of({ class: `cm-merge-${side}` })
|
||||
]
|
||||
})
|
||||
);
|
||||
this.chunks = Chunk.build(states[0].doc, states[1].doc, diffEditorConfig);
|
||||
this.dom = document.createElement('div');
|
||||
this.dom.className = 'cm-mergeView';
|
||||
const editors = this.dom.appendChild(document.createElement('div'));
|
||||
editors.className = 'cm-mergeViewEditors';
|
||||
const create = (side: 'a' | 'b', index: number) => {
|
||||
const wrapper = editors.appendChild(document.createElement('div'));
|
||||
wrapper.className = 'cm-mergeViewEditor';
|
||||
return new EditorView({
|
||||
state: states[index],
|
||||
parent: wrapper,
|
||||
dispatchTransactions: (transactions) => this.dispatch(transactions, side)
|
||||
});
|
||||
};
|
||||
this.a = create('a', 0);
|
||||
this.b = create('b', 1);
|
||||
config.parent.appendChild(this.dom);
|
||||
}
|
||||
private dispatch(transactions: readonly Transaction[], side: 'a' | 'b') {
|
||||
if (transactions.some((transaction) => transaction.docChanged)) {
|
||||
const document = transactions[transactions.length - 1].newDoc;
|
||||
const changes = transactions.reduce(
|
||||
(changes, transaction) => changes.compose(transaction.changes),
|
||||
ChangeSet.empty(transactions[0].startState.doc.length)
|
||||
);
|
||||
this.chunks =
|
||||
side === 'a'
|
||||
? Chunk.updateA(this.chunks, document, this.b.state.doc, changes, diffEditorConfig)
|
||||
: Chunk.updateB(this.chunks, this.a.state.doc, document, changes, diffEditorConfig);
|
||||
}
|
||||
this[side].update(transactions);
|
||||
}
|
||||
destroy() {
|
||||
this.a.destroy();
|
||||
this.b.destroy();
|
||||
this.dom.remove();
|
||||
}
|
||||
}
|
||||
|
||||
export type MergeDirection = 'to-working' | 'to-proposal';
|
||||
|
||||
// Match rows by the text preserved inside a chunk, not their ordinal position.
|
||||
|
|
@ -217,8 +289,29 @@ const presentation = StateField.define<DecorationSet>({
|
|||
},
|
||||
provide: (field) => EditorView.decorations.from(field)
|
||||
});
|
||||
class ChangedLineMarker extends GutterMarker {
|
||||
elementClass = 'cm-changedLineGutter';
|
||||
}
|
||||
const changedLineMarker = new ChangedLineMarker();
|
||||
|
||||
export const diffPresentation = [
|
||||
presentation,
|
||||
gutter({
|
||||
class: 'cm-changeGutter',
|
||||
renderEmptyElements: true,
|
||||
markers: (view) => {
|
||||
const markers = [];
|
||||
for (let cursor = view.state.field(presentation).iter(); cursor.value; cursor.next())
|
||||
if (cursor.value.spec.class === 'cm-visibleChangedLine')
|
||||
markers.push(changedLineMarker.range(cursor.from));
|
||||
return RangeSet.of(markers, true);
|
||||
}
|
||||
}),
|
||||
EditorView.baseTheme({
|
||||
'.cm-changeGutter': { width: '3px' },
|
||||
'&.cm-merge-a .cm-changedLineGutter': { backgroundColor: '#f0a394' },
|
||||
'&.cm-merge-b .cm-changedLineGutter': { backgroundColor: '#7dd3c5' }
|
||||
}),
|
||||
EditorView.atomicRanges.of((view) =>
|
||||
view.state
|
||||
.field(presentation)
|
||||
|
|
@ -237,34 +330,41 @@ export function presentationDecorations(
|
|||
expand: (pair: CollapsedPair) => void
|
||||
) {
|
||||
const decorations = [];
|
||||
if (ignoreWhitespace || ignoreEmphasis) {
|
||||
const visible = ignoreWhitespace ? (ignoreEmphasis ? /[^\s_*]+/g : /\S+/g) : /[^_*]+/g;
|
||||
const rows = new Set<number>();
|
||||
for (const chunk of chunks) {
|
||||
const offset = side === 'a' ? chunk.fromA : chunk.fromB;
|
||||
for (const change of chunk.changes) {
|
||||
const from = offset + (side === 'a' ? change.fromA : change.fromB);
|
||||
const to = offset + (side === 'a' ? change.toA : change.toB);
|
||||
for (const match of doc.sliceString(from, to).matchAll(visible)) {
|
||||
const start = from + match.index;
|
||||
decorations.push(
|
||||
Decoration.mark({ class: 'cm-visibleChangedText' }).range(
|
||||
start,
|
||||
start + match[0].length
|
||||
)
|
||||
);
|
||||
for (
|
||||
let line = doc.lineAt(start).number;
|
||||
line <= doc.lineAt(start + match[0].length - 1).number;
|
||||
line++
|
||||
)
|
||||
rows.add(doc.line(line).from);
|
||||
}
|
||||
const visible = ignoreWhitespace
|
||||
? ignoreEmphasis
|
||||
? /[^\s_*]+/g
|
||||
: /\S+/g
|
||||
: ignoreEmphasis
|
||||
? /[^_*]+/g
|
||||
: /[\s\S]+/g;
|
||||
const rows = new Set<number>();
|
||||
for (const chunk of chunks) {
|
||||
const offset = side === 'a' ? chunk.fromA : chunk.fromB;
|
||||
if (!ignoreWhitespace && !ignoreEmphasis) {
|
||||
const end = side === 'a' ? chunk.toA : chunk.toB;
|
||||
for (let from = offset; from < end && from <= doc.length; from = doc.lineAt(from).to + 1)
|
||||
rows.add(from);
|
||||
}
|
||||
for (const change of chunk.changes) {
|
||||
const from = offset + (side === 'a' ? change.fromA : change.fromB);
|
||||
const to = offset + (side === 'a' ? change.toA : change.toB);
|
||||
for (const match of doc.sliceString(from, to).matchAll(visible)) {
|
||||
const start = from + match.index;
|
||||
decorations.push(
|
||||
Decoration.mark({ class: 'cm-visibleChangedText' }).range(start, start + match[0].length)
|
||||
);
|
||||
for (
|
||||
let line = doc.lineAt(start).number;
|
||||
line <= doc.lineAt(start + match[0].length - 1).number;
|
||||
line++
|
||||
)
|
||||
rows.add(doc.line(line).from);
|
||||
}
|
||||
}
|
||||
for (const from of rows)
|
||||
decorations.push(Decoration.line({ class: 'cm-visibleChangedLine' }).range(from));
|
||||
}
|
||||
for (const from of rows)
|
||||
decorations.push(Decoration.line({ class: 'cm-visibleChangedLine' }).range(from));
|
||||
|
||||
for (const pair of folds) {
|
||||
const range = pair[side];
|
||||
decorations.push(
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import {
|
|||
import type { Chunk } from '@codemirror/merge';
|
||||
import { changedLinePairs } from './diffEditor';
|
||||
|
||||
// Font measurements can differ by fractions of a pixel between virtualized lines.
|
||||
export function alignmentGeometryChanged(previous: readonly number[], next: readonly number[]) {
|
||||
// DOM line-height measurements can vary by a fraction of a pixel while scrolling.
|
||||
return next.some((value, index) => Math.abs(value - previous[index]) > (index < 2 ? 0 : 0.5));
|
||||
}
|
||||
|
||||
|
|
@ -52,8 +52,8 @@ export function clearLineAlignment(a: EditorView, b: EditorView) {
|
|||
return changed;
|
||||
}
|
||||
|
||||
// MergeView owns alignment between chunks. Only align rows inside each changed
|
||||
// chunk, using local heights so its spacers cannot feed back into ours.
|
||||
// Keep spacers outside both viewports fixed. Include pairs visible on either side
|
||||
// so resizing or a large insertion cannot leave the two viewports disconnected.
|
||||
export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly Chunk[]) {
|
||||
const previous = (view: EditorView) => {
|
||||
const result = new Map<number, number>();
|
||||
|
|
@ -65,6 +65,8 @@ export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly
|
|||
oldB = previous(b);
|
||||
const nextA = new Map(oldA),
|
||||
nextB = new Map(oldB);
|
||||
let offsetA = 0,
|
||||
offsetB = 0;
|
||||
const textTop = (view: EditorView, pos: number) => {
|
||||
const block = view.lineBlockAt(pos);
|
||||
const text = Array.isArray(block.type)
|
||||
|
|
@ -74,43 +76,53 @@ export function alignChangedLines(a: EditorView, b: EditorView, chunks: readonly
|
|||
: null;
|
||||
return text?.from === pos ? text.top : null;
|
||||
};
|
||||
for (const chunk of chunks) {
|
||||
const align = (posA: number, posB: number) => {
|
||||
if (
|
||||
chunk.toA < a.viewport.from ||
|
||||
chunk.toB < b.viewport.from ||
|
||||
chunk.fromA > a.viewport.to ||
|
||||
chunk.fromB > b.viewport.to
|
||||
(posA < a.viewport.from || posA > a.viewport.to) &&
|
||||
(posB < b.viewport.from || posB > b.viewport.to)
|
||||
)
|
||||
continue;
|
||||
let previousA = chunk.fromA,
|
||||
previousB = chunk.fromB,
|
||||
first = true;
|
||||
for (const [posA, posB] of changedLinePairs(a.state.doc, b.state.doc, chunk)) {
|
||||
// Both ends of the interval must be rendered; offscreen estimates are not row heights.
|
||||
if (
|
||||
previousA >= a.viewport.from &&
|
||||
previousB >= b.viewport.from &&
|
||||
posA <= a.viewport.to &&
|
||||
posB <= b.viewport.to
|
||||
) {
|
||||
const topA = textTop(a, posA),
|
||||
topB = textTop(b, posB);
|
||||
const startA = first ? a.lineBlockAt(previousA).top : textTop(a, previousA);
|
||||
const startB = first ? b.lineBlockAt(previousB).top : textTop(b, previousB);
|
||||
if (topA !== null && topB !== null && startA !== null && startB !== null) {
|
||||
const heightA = topA - startA - (oldA.get(posA) ?? 0);
|
||||
const heightB = topB - startB - (oldB.get(posB) ?? 0);
|
||||
const difference = heightA - heightB;
|
||||
nextA.delete(posA);
|
||||
nextB.delete(posB);
|
||||
if (difference < -1) nextA.set(posA, -difference);
|
||||
if (difference > 1) nextB.set(posB, difference);
|
||||
}
|
||||
}
|
||||
previousA = posA;
|
||||
previousB = posB;
|
||||
first = false;
|
||||
return;
|
||||
const topA = textTop(a, posA),
|
||||
topB = textTop(b, posB);
|
||||
if (topA === null || topB === null) return;
|
||||
const previousA = oldA.get(posA) ?? 0,
|
||||
previousB = oldB.get(posB) ?? 0;
|
||||
const difference = topA - previousA + offsetA - topB + previousB - offsetB;
|
||||
const sizeA = difference < -1 ? -difference : 0,
|
||||
sizeB = difference > 1 ? difference : 0;
|
||||
nextA.delete(posA);
|
||||
nextB.delete(posB);
|
||||
if (sizeA) nextA.set(posA, sizeA);
|
||||
if (sizeB) nextB.set(posB, sizeB);
|
||||
offsetA += sizeA - previousA;
|
||||
offsetB += sizeB - previousB;
|
||||
};
|
||||
let fromA = 0,
|
||||
fromB = 0;
|
||||
for (let index = 0; index <= chunks.length; index++) {
|
||||
const chunk = chunks[index];
|
||||
const endA = chunk ? chunk.fromA : a.state.doc.length + 1;
|
||||
let posA = Math.max(
|
||||
fromA,
|
||||
a.state.doc.lineAt(
|
||||
Math.min(
|
||||
a.state.doc.length,
|
||||
Math.max(fromA, Math.min(a.viewport.from, fromA + b.viewport.from - fromB))
|
||||
)
|
||||
).from
|
||||
);
|
||||
for (
|
||||
;
|
||||
posA < endA && posA <= Math.max(a.viewport.to, fromA + b.viewport.to - fromB);
|
||||
posA = a.state.doc.lineAt(posA).to + 1
|
||||
) {
|
||||
const posB = fromB + posA - fromA;
|
||||
align(posA, posB);
|
||||
}
|
||||
if (!chunk) break;
|
||||
for (const [posA, posB] of changedLinePairs(a.state.doc, b.state.doc, chunk)) align(posA, posB);
|
||||
fromA = chunk.toA;
|
||||
fromB = chunk.toB;
|
||||
}
|
||||
const apply = (view: EditorView, old: Map<number, number>, next: Map<number, number>) => {
|
||||
if (
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue