Oxlint's JS plugin loader uses Node.js, not Bun — .ts fails in CI
with ERR_UNKNOWN_FILE_EXTENSION.
New rules:
- no-hand-rolled-color: flags rgba()/rgb() in template literals outside
color.ts — use colorToCSS()/colorToHex() helpers instead
- typescript/no-unnecessary-boolean-literal-compare
- typescript/no-unnecessary-template-expression
- typescript/no-unnecessary-type-arguments
- unicorn/no-useless-fallback-in-spread
- unicorn/no-length-as-slice-end
- unicorn/no-unnecessary-await
- unicorn/prefer-string-starts-ends-with
Fixed violations: visible !== false → fill.visible in analyze.ts,
removed ?? {} fallbacks in style-runs.ts spread.
234 lines
6.2 KiB
TypeScript
234 lines
6.2 KiB
TypeScript
import type { CharacterStyleOverride, StyleRun, TextDecoration } from './scene-graph'
|
|
|
|
export function getStyleAt(runs: StyleRun[], index: number): CharacterStyleOverride {
|
|
for (const run of runs) {
|
|
if (index >= run.start && index < run.start + run.length) {
|
|
return run.style
|
|
}
|
|
}
|
|
return {}
|
|
}
|
|
|
|
function expandRuns(runs: StyleRun[], textLength: number): (CharacterStyleOverride | null)[] {
|
|
const chars: (CharacterStyleOverride | null)[] = new Array(textLength).fill(null)
|
|
for (const run of runs) {
|
|
for (let i = run.start; i < run.start + run.length && i < textLength; i++) {
|
|
chars[i] = { ...chars[i], ...run.style }
|
|
}
|
|
}
|
|
return chars
|
|
}
|
|
|
|
export function applyStyleToRange(
|
|
runs: StyleRun[],
|
|
start: number,
|
|
end: number,
|
|
patch: CharacterStyleOverride,
|
|
textLength: number
|
|
): StyleRun[] {
|
|
const chars = expandRuns(runs, textLength)
|
|
|
|
for (let i = start; i < end && i < textLength; i++) {
|
|
chars[i] = { ...chars[i], ...patch }
|
|
}
|
|
|
|
return compactRuns(chars)
|
|
}
|
|
|
|
export function removeStyleFromRange(
|
|
runs: StyleRun[],
|
|
start: number,
|
|
end: number,
|
|
keys: (keyof CharacterStyleOverride)[],
|
|
textLength: number
|
|
): StyleRun[] {
|
|
const chars = expandRuns(runs, textLength)
|
|
|
|
for (let i = start; i < end && i < textLength; i++) {
|
|
if (chars[i]) {
|
|
const copy = { ...chars[i]! }
|
|
for (const k of keys) delete copy[k]
|
|
chars[i] = Object.keys(copy).length > 0 ? copy : null
|
|
}
|
|
}
|
|
|
|
return compactRuns(chars)
|
|
}
|
|
|
|
export function selectionHasStyle(
|
|
runs: StyleRun[],
|
|
start: number,
|
|
end: number,
|
|
key: keyof CharacterStyleOverride,
|
|
value: unknown
|
|
): boolean {
|
|
for (let i = start; i < end; i++) {
|
|
const style = getStyleAt(runs, i)
|
|
if (style[key] !== value) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function stylesEqual(a: CharacterStyleOverride | null, b: CharacterStyleOverride | null): boolean {
|
|
if (a === b) return true
|
|
if (!a || !b) return a === b
|
|
const aKeys = Object.keys(a) as (keyof CharacterStyleOverride)[]
|
|
const bKeys = Object.keys(b) as (keyof CharacterStyleOverride)[]
|
|
if (aKeys.length !== bKeys.length) return false
|
|
for (const k of aKeys) {
|
|
if (a[k] !== b[k]) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
function isEmptyStyle(style: CharacterStyleOverride | null): boolean {
|
|
return !style || Object.keys(style).length === 0
|
|
}
|
|
|
|
function compactRuns(chars: (CharacterStyleOverride | null)[]): StyleRun[] {
|
|
const result: StyleRun[] = []
|
|
let i = 0
|
|
while (i < chars.length) {
|
|
if (isEmptyStyle(chars[i])) {
|
|
i++
|
|
continue
|
|
}
|
|
const start = i
|
|
const style = chars[i]!
|
|
while (i < chars.length && stylesEqual(chars[i], style)) i++
|
|
result.push({ start, length: i - start, style: { ...style } })
|
|
}
|
|
return result
|
|
}
|
|
|
|
export function adjustRunsForInsert(
|
|
runs: StyleRun[],
|
|
pos: number,
|
|
insertLength: number
|
|
): StyleRun[] {
|
|
return runs.map((run) => {
|
|
if (pos <= run.start) {
|
|
return { ...run, start: run.start + insertLength }
|
|
}
|
|
if (pos > run.start && pos < run.start + run.length) {
|
|
return { ...run, length: run.length + insertLength }
|
|
}
|
|
return run
|
|
})
|
|
}
|
|
|
|
export function adjustRunsForDelete(
|
|
runs: StyleRun[],
|
|
start: number,
|
|
deleteLength: number
|
|
): StyleRun[] {
|
|
const end = start + deleteLength
|
|
const result: StyleRun[] = []
|
|
for (const run of runs) {
|
|
const runEnd = run.start + run.length
|
|
if (runEnd <= start) {
|
|
result.push(run)
|
|
} else if (run.start >= end) {
|
|
result.push({ ...run, start: run.start - deleteLength })
|
|
} else {
|
|
const newStart = Math.max(run.start, start)
|
|
const newEnd = Math.min(runEnd, end)
|
|
const removed = newEnd - newStart
|
|
const newLength = run.length - removed
|
|
if (newLength > 0) {
|
|
result.push({
|
|
...run,
|
|
start: Math.min(run.start, start),
|
|
length: newLength
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
export function toggleBoldInRange(
|
|
runs: StyleRun[],
|
|
start: number,
|
|
end: number,
|
|
nodeWeight: number,
|
|
textLength: number
|
|
): { runs: StyleRun[]; newWeight: number } {
|
|
const allBold = selectionAllBold(runs, start, end, nodeWeight)
|
|
const targetWeight = allBold ? 400 : 700
|
|
const newRuns = allBold
|
|
? removeStyleFromRange(runs, start, end, ['fontWeight'], textLength)
|
|
: applyStyleToRange(runs, start, end, { fontWeight: 700 }, textLength)
|
|
return { runs: newRuns, newWeight: targetWeight }
|
|
}
|
|
|
|
function selectionAllBold(
|
|
runs: StyleRun[],
|
|
start: number,
|
|
end: number,
|
|
nodeWeight: number
|
|
): boolean {
|
|
for (let i = start; i < end; i++) {
|
|
const style = getStyleAt(runs, i)
|
|
const weight = style.fontWeight ?? nodeWeight
|
|
if (weight < 700) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
export function toggleItalicInRange(
|
|
runs: StyleRun[],
|
|
start: number,
|
|
end: number,
|
|
nodeItalic: boolean,
|
|
textLength: number
|
|
): { runs: StyleRun[]; newItalic: boolean } {
|
|
const allItalic = selectionAllItalic(runs, start, end, nodeItalic)
|
|
const newRuns = allItalic
|
|
? removeStyleFromRange(runs, start, end, ['italic'], textLength)
|
|
: applyStyleToRange(runs, start, end, { italic: true }, textLength)
|
|
return { runs: newRuns, newItalic: !allItalic }
|
|
}
|
|
|
|
function selectionAllItalic(
|
|
runs: StyleRun[],
|
|
start: number,
|
|
end: number,
|
|
nodeItalic: boolean
|
|
): boolean {
|
|
for (let i = start; i < end; i++) {
|
|
const style = getStyleAt(runs, i)
|
|
if (!(style.italic ?? nodeItalic)) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
export function toggleDecorationInRange(
|
|
runs: StyleRun[],
|
|
start: number,
|
|
end: number,
|
|
deco: TextDecoration,
|
|
nodeDeco: TextDecoration,
|
|
textLength: number
|
|
): { runs: StyleRun[]; newDeco: TextDecoration } {
|
|
const allHave = selectionAllHasDecoration(runs, start, end, deco, nodeDeco)
|
|
const newRuns = allHave
|
|
? removeStyleFromRange(runs, start, end, ['textDecoration'], textLength)
|
|
: applyStyleToRange(runs, start, end, { textDecoration: deco }, textLength)
|
|
return { runs: newRuns, newDeco: allHave ? 'NONE' : deco }
|
|
}
|
|
|
|
function selectionAllHasDecoration(
|
|
runs: StyleRun[],
|
|
start: number,
|
|
end: number,
|
|
deco: TextDecoration,
|
|
nodeDeco: TextDecoration
|
|
): boolean {
|
|
for (let i = start; i < end; i++) {
|
|
const style = getStyleAt(runs, i)
|
|
if ((style.textDecoration ?? nodeDeco) !== deco) return false
|
|
}
|
|
return true
|
|
}
|