first commit

This commit is contained in:
2025-04-24 13:11:28 +08:00
commit ff9c54d5e4
5960 changed files with 834111 additions and 0 deletions

View File

@@ -0,0 +1,134 @@
import { EditorSelection, StateEffect, StateField } from '@codemirror/state'
import { Highlight } from '../services/types/doc'
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
import { highlightDecorationsField } from './highlights'
import { throttle, isEqual } from 'lodash'
import { updateHasEffect } from '../../source-editor/utils/effects'
export type HighlightLocations = {
before: number
after: number
next?: Highlight
previous?: Highlight
}
const setHighlightLocationsEffect = StateEffect.define<HighlightLocations>()
const hasSetHighlightLocationsEffect = updateHasEffect(
setHighlightLocationsEffect
)
// Returns the range within the document that is currently visible to the user
function visibleRange(view: EditorView) {
const { top, bottom } = view.scrollDOM.getBoundingClientRect()
const first = view.lineBlockAtHeight(top - view.documentTop)
const last = view.lineBlockAtHeight(bottom - view.documentTop)
return { from: first.from, to: last.to }
}
function calculateHighlightLocations(view: EditorView): HighlightLocations {
const highlightsBefore: Highlight[] = []
const highlightsAfter: Highlight[] = []
let next
let previous
const highlights =
view.state.field(highlightDecorationsField)?.highlights || []
if (highlights.length === 0) {
return { before: 0, after: 0 }
}
const { from: visibleFrom, to: visibleTo } = visibleRange(view)
for (const highlight of highlights) {
if (highlight.range.to <= visibleFrom) {
highlightsBefore.push(highlight)
} else if (highlight.range.from >= visibleTo) {
highlightsAfter.push(highlight)
}
}
const before = highlightsBefore.length
const after = highlightsAfter.length
if (before > 0) {
previous = highlightsBefore[highlightsBefore.length - 1]
}
if (after > 0) {
next = highlightsAfter[0]
}
return {
before,
after,
previous,
next,
}
}
const plugin = ViewPlugin.fromClass(
class {
// eslint-disable-next-line no-useless-constructor
constructor(readonly view: EditorView) {}
dispatchIfChanged() {
const oldLocations = this.view.state.field(highlightLocationsField)
const newLocations = calculateHighlightLocations(this.view)
if (!isEqual(oldLocations, newLocations)) {
this.view.dispatch({
effects: setHighlightLocationsEffect.of(newLocations),
})
}
}
update(update: ViewUpdate) {
if (!hasSetHighlightLocationsEffect(update)) {
// Normally, a timeout is a poor choice, but in this case it doesn't
// matter that there is a slight delay or that it might run after the
// viewer has been torn down
window.setTimeout(() => this.dispatchIfChanged())
}
}
},
{
eventHandlers: {
scroll: throttle(
(event, view: EditorView) => {
view.plugin(plugin)?.dispatchIfChanged()
},
120,
{ trailing: true }
),
},
}
)
export const highlightLocationsField = StateField.define<HighlightLocations>({
create() {
return { before: 0, visible: 0, after: 0 }
},
update(highlightLocations, tr) {
for (const effect of tr.effects) {
if (effect.is(setHighlightLocationsEffect)) {
return effect.value
}
}
return highlightLocations
},
provide: () => [plugin],
})
export function highlightLocations() {
return highlightLocationsField
}
export function scrollToHighlight(view: EditorView, highlight: Highlight) {
view.dispatch({
effects: EditorView.scrollIntoView(
EditorSelection.range(highlight.range.from, highlight.range.to),
{
y: 'center',
}
),
})
}

View File

@@ -0,0 +1,413 @@
import {
EditorState,
Line,
Range,
RangeSet,
StateEffect,
StateField,
} from '@codemirror/state'
import {
Decoration,
DecorationSet,
EditorView,
showTooltip,
gutter,
gutterLineClass,
GutterMarker,
Tooltip,
ViewPlugin,
WidgetType,
} from '@codemirror/view'
import { Highlight, HighlightType } from '../services/types/doc'
export const setHighlightsEffect = StateEffect.define<Highlight[]>()
const ADDITION_MARKER_CLASS = 'ol-cm-addition-marker'
const DELETION_MARKER_CLASS = 'ol-cm-deletion-marker'
function highlightToMarker(highlight: Highlight) {
const className =
highlight.type === 'addition'
? ADDITION_MARKER_CLASS
: DELETION_MARKER_CLASS
const { from, to } = highlight.range
return Decoration.mark({
class: className,
attributes: {
style: `--hue: ${highlight.hue}`,
},
}).range(from, to)
}
type LineStatus = {
line: Line
highlights: Highlight[]
empty: boolean
changeType: HighlightType | 'mixed'
}
type LineStatuses = Map<number, LineStatus>
function highlightedLines(highlights: Highlight[], state: EditorState) {
const lineStatuses = new Map<number, LineStatus>()
for (const highlight of highlights) {
const fromLine = state.doc.lineAt(highlight.range.from).number
const toLine = state.doc.lineAt(highlight.range.to).number
for (let lineNum = fromLine; lineNum <= toLine; ++lineNum) {
const status = lineStatuses.get(lineNum)
if (status) {
status.highlights.push(highlight)
if (status.changeType !== highlight.type) {
status.changeType = 'mixed'
}
} else {
const line = state.doc.line(lineNum)
lineStatuses.set(lineNum, {
line,
highlights: [highlight],
empty: line.length === 0,
changeType: highlight.type,
})
}
}
}
return lineStatuses
}
const tooltipTheme = EditorView.theme({
'.cm-tooltip': {
backgroundColor: 'transparent',
borderWidth: 0,
// Prevent a tooltip getting in the way of hovering over a line that it
// obscures
pointerEvents: 'none',
},
})
const theme = EditorView.baseTheme({
['.' + ADDITION_MARKER_CLASS]: {
paddingTop: 'var(--half-leading)',
paddingBottom: 'var(--half-leading)',
backgroundColor: 'hsl(var(--hue), 70%, 85%)',
},
['.' + DELETION_MARKER_CLASS]: {
textDecoration: 'line-through',
color: 'hsl(var(--hue), 70%, 40%)',
},
'.cm-tooltip.ol-cm-highlight-tooltip': {
backgroundColor: 'hsl(var(--hue), 70%, 50%)',
borderRadius: '4px',
padding: '4px',
color: '#fff',
},
'.ol-cm-empty-line-addition-marker': {
padding: 'var(--half-leading) 2px',
},
'.ol-cm-changed-line': {
backgroundColor: 'rgba(0, 0, 0, 0.03)',
},
'.ol-cm-change-gutter': {
width: '3px',
paddingLeft: '1px',
},
'.ol-cm-changed-line-gutter': {
backgroundColor: 'hsl(var(--hue), 70%, 40%)',
height: '100%',
},
'.ol-cm-highlighted-line-gutter': {
backgroundColor: 'rgba(0, 0, 0, 0.03)',
},
})
function createHighlightTooltip(pos: number, highlight: Highlight) {
return {
pos,
above: true,
create: () => {
const dom = document.createElement('div')
dom.classList.add('ol-cm-highlight-tooltip')
dom.style.setProperty('--hue', String(highlight.hue))
dom.textContent = highlight.label
return { dom }
},
}
}
const setHighlightTooltipEffect = StateEffect.define<Tooltip | null>()
const tooltipField = StateField.define<Tooltip | null>({
create() {
return null
},
update(tooltip, transaction) {
for (const effect of transaction.effects) {
if (effect.is(setHighlightTooltipEffect)) {
return effect.value
}
}
return tooltip
},
provide: field => showTooltip.from(field),
})
function highlightAtPos(state: EditorState, pos: number) {
const highlights = state.field(highlightDecorationsField).highlights
return highlights.find(highlight => {
const { from, to } = highlight.range
return pos >= from && pos <= to
})
}
const highlightTooltipPlugin = ViewPlugin.fromClass(
class {
private lastTooltipPos: number | null = null
// eslint-disable-next-line no-useless-constructor
constructor(readonly view: EditorView) {}
setHighlightTooltip(tooltip: Tooltip | null) {
this.view.dispatch({
effects: setHighlightTooltipEffect.of(tooltip),
})
}
setTooltipFromEvent(event: MouseEvent) {
const pos = this.view.posAtCoords({ x: event.clientX, y: event.clientY })
if (pos !== this.lastTooltipPos) {
let tooltip = null
if (pos !== null) {
const highlight = highlightAtPos(this.view.state, pos)
if (highlight) {
tooltip = createHighlightTooltip(pos, highlight)
}
}
this.setHighlightTooltip(tooltip)
this.lastTooltipPos = pos
}
}
handleMouseMove(event: MouseEvent) {
this.setTooltipFromEvent(event)
}
startHover(event: MouseEvent, el: HTMLElement) {
const handleMouseMove = this.handleMouseMove.bind(this)
this.view.contentDOM.addEventListener('mousemove', handleMouseMove)
const handleMouseLeave = () => {
this.setHighlightTooltip(null)
this.lastTooltipPos = null
this.view.contentDOM.removeEventListener('mousemove', handleMouseMove)
el.removeEventListener('mouseleave', handleMouseLeave)
}
el.addEventListener('mouseleave', handleMouseLeave)
this.setTooltipFromEvent(event)
}
},
{
eventHandlers: {
mouseover(event) {
const el = event.target as HTMLElement
const classList = el.classList
if (
classList.contains(ADDITION_MARKER_CLASS) ||
classList.contains(DELETION_MARKER_CLASS) ||
// An empty line widget doesn't trigger a mouseover event, so detect
// an event on a line element that contains one instead
(classList.contains('cm-line') &&
el.querySelector(
`.ol-cm-empty-line-addition-marker, .ol-cm-empty-line-deletion-marker`
))
) {
this.startHover(event, el)
}
},
},
provide() {
return tooltipField
},
}
)
class EmptyLineAdditionMarkerWidget extends WidgetType {
constructor(readonly hue: number) {
super()
}
toDOM(view: EditorView): HTMLElement {
const element = document.createElement('span')
element.classList.add(
'ol-cm-empty-line-addition-marker',
ADDITION_MARKER_CLASS
)
element.style.setProperty('--hue', this.hue.toString())
return element
}
}
class EmptyLineDeletionMarkerWidget extends WidgetType {
constructor(readonly hue: number) {
super()
}
toDOM(view: EditorView): HTMLElement {
const element = document.createElement('span')
element.classList.add(
'ol-cm-empty-line-deletion-marker',
DELETION_MARKER_CLASS
)
element.style.setProperty('--hue', this.hue.toString())
element.textContent = ' '
return element
}
}
function createMarkers(highlights: Highlight[]) {
return RangeSet.of(highlights.map(highlight => highlightToMarker(highlight)))
}
function createEmptyLineHighlightMarkers(lineStatuses: LineStatuses) {
const markers: Range<Decoration>[] = []
for (const lineStatus of lineStatuses.values()) {
if (lineStatus.line.length === 0) {
const highlight = lineStatus.highlights[0]
const widget =
highlight.type === 'addition'
? new EmptyLineAdditionMarkerWidget(highlight.hue)
: new EmptyLineDeletionMarkerWidget(highlight.hue)
markers.push(
Decoration.widget({
widget,
}).range(lineStatus.line.from)
)
}
}
return RangeSet.of(markers)
}
class ChangeGutterMarker extends GutterMarker {
constructor(readonly hue: number) {
super()
}
toDOM(view: EditorView) {
const el = document.createElement('div')
el.className = 'ol-cm-changed-line-gutter'
el.style.setProperty('--hue', this.hue.toString())
return el
}
}
function createGutterMarkers(lineStatuses: LineStatuses) {
const gutterMarkers: Range<GutterMarker>[] = []
for (const lineStatus of lineStatuses.values()) {
gutterMarkers.push(
new ChangeGutterMarker(lineStatus.highlights[0].hue).range(
lineStatus.line.from
)
)
}
return RangeSet.of(gutterMarkers)
}
const lineHighlight = Decoration.line({ class: 'ol-cm-changed-line' })
function createLineHighlights(lineStatuses: LineStatuses) {
const lineHighlights: Range<Decoration>[] = []
for (const lineStatus of lineStatuses.values()) {
lineHighlights.push(lineHighlight.range(lineStatus.line.from))
}
return RangeSet.of(lineHighlights)
}
const changeLineGutterMarker = new (class extends GutterMarker {
elementClass = 'ol-cm-highlighted-line-gutter'
})()
function createGutterHighlights(lineStatuses: LineStatuses) {
const gutterMarkers: Range<GutterMarker>[] = []
for (const lineStatus of lineStatuses.values()) {
gutterMarkers.push(changeLineGutterMarker.range(lineStatus.line.from))
}
return RangeSet.of(gutterMarkers, true)
}
type HighlightDecorations = {
highlights: Highlight[]
highlightMarkers: DecorationSet
emptyLineHighlightMarkers: DecorationSet
lineHighlights: DecorationSet
gutterMarkers: RangeSet<GutterMarker>
gutterHighlights: RangeSet<GutterMarker>
}
export const highlightDecorationsField =
StateField.define<HighlightDecorations>({
create() {
return {
highlights: [],
highlightMarkers: Decoration.none,
emptyLineHighlightMarkers: Decoration.none,
lineHighlights: Decoration.none,
gutterMarkers: RangeSet.empty,
gutterHighlights: RangeSet.empty,
}
},
update(highlightDecorations, tr) {
for (const effect of tr.effects) {
if (effect.is(setHighlightsEffect)) {
const highlights = effect.value
const lineStatuses = highlightedLines(highlights, tr.state)
const highlightMarkers = createMarkers(highlights)
const emptyLineHighlightMarkers =
createEmptyLineHighlightMarkers(lineStatuses)
const lineHighlights = createLineHighlights(lineStatuses)
const gutterMarkers = createGutterMarkers(lineStatuses)
const gutterHighlights = createGutterHighlights(lineStatuses)
return {
highlights,
highlightMarkers,
emptyLineHighlightMarkers,
lineHighlights,
gutterMarkers,
gutterHighlights,
}
}
}
return highlightDecorations
},
provide: field => [
EditorView.decorations.from(field, value => value.highlightMarkers),
EditorView.decorations.from(
field,
value => value.emptyLineHighlightMarkers
),
EditorView.decorations.from(field, value => value.lineHighlights),
theme,
tooltipTheme,
highlightTooltipPlugin,
],
})
const changeGutter = gutter({
class: 'ol-cm-change-gutter',
markers: view => view.state.field(highlightDecorationsField).gutterMarkers,
renderEmptyElements: false,
})
const gutterHighlighter = gutterLineClass.from(
highlightDecorationsField,
value => value.gutterHighlights
)
export function highlights() {
return [highlightDecorationsField, changeGutter, gutterHighlighter]
}

View File

@@ -0,0 +1,71 @@
import { EditorView } from '@codemirror/view'
import { Compartment, TransactionSpec } from '@codemirror/state'
import { FontFamily, LineHeight, userStyles } from '@/shared/utils/styles'
export type Options = {
fontSize: number
fontFamily: FontFamily
lineHeight: LineHeight
}
const optionsThemeConf = new Compartment()
export const theme = (options: Options) => [
baseTheme,
optionsThemeConf.of(createThemeFromOptions(options)),
]
const createThemeFromOptions = ({
fontSize = 12,
fontFamily = 'monaco',
lineHeight = 'normal',
}: Options) => {
// Theme styles that depend on settings
const styles = userStyles({ fontSize, fontFamily, lineHeight })
return [
EditorView.editorAttributes.of({
style: Object.entries({
'--font-size': styles.fontSize,
'--source-font-family': styles.fontFamily,
'--line-height': styles.lineHeight,
})
.map(([key, value]) => `${key}: ${value}`)
.join(';'),
}),
// Set variables for tooltips, which are outside the editor
// TODO: set these on document.body, or a new container element for the tooltips, without using a style mod
EditorView.theme({
'.cm-tooltip': {
'--font-size': styles.fontSize,
'--source-font-family': styles.fontFamily,
},
}),
]
}
const baseTheme = EditorView.theme({
'.cm-content': {
fontSize: 'var(--font-size)',
fontFamily: 'var(--source-font-family)',
lineHeight: 'var(--line-height)',
color: '#000',
},
'.cm-gutters': {
fontSize: 'var(--font-size)',
lineHeight: 'var(--line-height)',
},
'.cm-lineNumbers': {
fontFamily: 'var(--source-font-family)',
},
'.cm-tooltip': {
// NOTE: fontFamily is not set here, as most tooltips use the UI font
fontSize: 'var(--font-size)',
},
})
export const setOptionsTheme = (options: Options): TransactionSpec => {
return {
effects: optionsThemeConf.reconfigure(createThemeFromOptions(options)),
}
}