Files
outline/shared/editor/marks/Highlight.ts
T
24f95cadc3 perf: Reduce server memory usage and add container memory best practices (#13042)
* Reduce server memory usage and add container memory best practices

Profiling a full-service boot showed ~300MB RSS per service process, much
of it client-only dependencies pulled into the server module graph, and no
heap limits applied in containers:

- Import date-fns locales individually rather than via the locale index,
  which loaded all ~90 locales into every process
- Load @sentry/react dynamically in insertFiles so the browser Sentry SDK
  stays out of the server-side editor graph
- Replace class-validator isHexColor with the existing validateColorHex
  in the Highlight mark
- Extract icon names into a standalone IconNames module so server-side
  schema validation no longer imports Font Awesome icon packs, with a
  test to keep it in sync with IconLibrary
- Require S3Storage lazily so the AWS SDK and native CRT binding are only
  loaded when S3 file storage is configured
- Default each forked service process to a V8 heap limit derived from the
  cgroup memory constraint, preventing several processes from together
  committing more memory than the container allows
- Cap glibc malloc arenas in the Docker image to reduce resident memory
  fragmentation in multi-threaded processes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa

* Require both file storage backends lazily for consistency

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa

* Load AWS SDK via dynamic imports compatible with test environment

Requiring the storage TypeScript modules lazily broke under Vitest, which
cannot resolve require() of source files and does not apply vi.mock to
bare requires. Move the laziness into S3Storage instead: the module is
imported statically, and the AWS SDK packages are loaded with dynamic
imports on first use, including deferred creation of the S3 client.

Also address review feedback: handle rejection of the dynamic Sentry
import, and warn when the minimum per-process heap limit exceeds the
memory budget of the container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa

* Consolidate S3 SDK loading into a single lazy accessor

The commands are loaded together with the client in getS3() rather than
as separate dynamic imports per method. The global S3Client test mock is
now a class, as a mock function is not constructable when reached through
a dynamic import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016YMX5ReGHyxrR4ZE5ZbaXa

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-18 18:34:32 -04:00

147 lines
4.1 KiB
TypeScript

import { parseToRgb, rgba } from "polished";
import type { MarkSpec, MarkType } from "prosemirror-model";
import { toggleMark } from "../commands/toggleMark";
import { markInputRuleForPattern } from "../lib/markInputRule";
import markRule from "../rules/mark";
import Mark from "./Mark";
import { presetColors, hexToRgba, validateColorHex } from "@shared/utils/color";
export default class Highlight extends Mark {
/** The default opacity of the highlight */
static opacity = 0.4;
/** Preset colors available for highlighting */
static presetColors = presetColors;
/**
* Checks if a color is one of the highlight preset colors.
*
* @param color - A hex color string to check.
* @returns true if the color matches a preset color's hex value.
*/
static isPresetColor(color: string): boolean {
return Highlight.presetColors.some((c) => c.hex === color);
}
/**
* Finds the closest matching preset color for a given CSS color value.
*
* @param cssColor - A CSS color value (hex, rgb, rgba, etc.).
* @returns The matching preset color hex, or null if no close match found.
*/
static findMatchingPresetColor(cssColor: string): string | null {
try {
const parsed = parseToRgb(cssColor);
const inputRgb = { r: parsed.red, g: parsed.green, b: parsed.blue };
for (const preset of Highlight.presetColors) {
const presetRgb = hexToRgba(preset.hex);
// Allow some tolerance for color matching (e.g., due to opacity differences)
const tolerance = 30;
if (
Math.abs(inputRgb.r - presetRgb.red) <= tolerance &&
Math.abs(inputRgb.g - presetRgb.green) <= tolerance &&
Math.abs(inputRgb.b - presetRgb.blue) <= tolerance
) {
return preset.hex;
}
}
} catch {
// Failed to parse the color
}
return null;
}
get name() {
return "highlight";
}
get schema(): MarkSpec {
return {
attrs: {
color: {
default: null,
validate: "string|null",
},
},
parseDOM: [
{
tag: "mark",
getAttrs: (dom) => {
const color = dom.getAttribute("data-color") || "";
return {
color: validateColorHex(color) ? color : null,
};
},
},
{
tag: "span[style]",
getAttrs: (dom) => {
const style = dom.style.backgroundColor;
if (!style) {
return false;
}
const matchedColor = Highlight.findMatchingPresetColor(style);
// Only apply highlight if we found a matching preset color
// or if the color is clearly a highlight (not white/transparent)
if (matchedColor) {
return { color: matchedColor };
}
// Check if it's a meaningful background color (not white/transparent)
try {
const parsed = parseToRgb(style);
// Skip very light colors that are likely page backgrounds
const isLight =
parsed.red > 250 && parsed.green > 250 && parsed.blue > 250;
if (!isLight) {
return { color: null };
}
} catch {
// Failed to parse
}
return false;
},
},
],
toDOM: (node) => [
"mark",
{
"data-color": node.attrs.color,
style: `background-color: ${rgba(
node.attrs.color || Highlight.presetColors[0].hex,
Highlight.opacity
)}`,
},
],
};
}
inputRules({ type }: { type: MarkType }) {
return [markInputRuleForPattern("==", type)];
}
keys({ type }: { type: MarkType }) {
return {
"Mod-Shift-h": toggleMark(type),
};
}
get rulePlugins() {
return [markRule({ delim: "==", mark: "highlight" })];
}
toMarkdown() {
return {
open: "==",
close: "==",
mixable: true,
expelEnclosingWhitespace: true,
};
}
parseMarkdown() {
return { mark: "highlight" };
}
}