openpencil/apps/web/src/utils/document-assets.ts
Kayshen Xu e0b606833b V0.7.3 (#111)
* fix(ai): stop white section bands on dark-themed pages

- role-resolver: skip fixSectionAlternation when parent fill luminance < 0.5, so we no longer paint #FFFFFF/#F8FAFC over a dark root
- strip-redundant-section-fills: add SAFE_LIGHT_HEXES so stale whites from earlier runs (or weak-model hedges) are cleaned up on the sink side
- regression tests for both layers

* feat(ai): design.md-driven background + sidebar color pipeline

- orchestrator-sidebar-color: extract sidebar surface picker; prefer design.md palette role (sidebar/panel/surface) over catalog style-guide legacy cell
- orchestrator-planning: force rootFrame fill from design.md background when a user spec is provided, so sections don't inherit a bright catalog default
- orchestrator-prompt-optimizer: infer design.md background + neutral theme fallback for sub-agent prompts
- orchestrator-sub-agent / ai-prompts: tell sub-agents to leave section root fills unset when design.md drives the palette
- design-md-style-policy: surface-colors policy block keeps MCP and web pipeline aligned
- add planning + prompt-optimizer regression tests

* chore: ignore .omx/ directory

* Enable local OS fonts with vector rendering and proper permission handling (#110)

* docs(readme): update cover screenshot

* fix(renderer): enable local OS fonts with vector rendering and proper permission handling

* test(renderer): refactoring names and creating vi.stubGlobal for the navigator as it's not available in the test environment.

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Daniel Chettiar <danielc@snapwork.com>

* feat(types): add AppendContext and SubTask.existingSectionLabels

* feat(ai): add detectAppendIntent for continue/append prompts

* feat(ai): detect append intent before generate_design dispatch

* feat(ai): add applyAppendContextToPlan helper

* feat(ai): reuse existing content-root in append mode

* feat(ai): sub-agent APPEND MODE preamble for existing siblings

* docs(ai): teach horizontal scroll card-row pattern

* chore(ai): enable incremental-add skill in generation phase

* fix(canvas): render synchronously on resize to prevent white flash

Setting canvas.width/height clears the pixel buffer to transparent.
resize() previously only marked dirty, leaving the canvas transparent
until the next RAF and showing the container bg-muted through for one
frame whenever the flex layout shifted (e.g. RightPanel mount on first
selection after idle). Rendering inline after recreateSurface fills
the new surface before the browser paints, closing that window.

* style: apply oxfmt formatting drift across web and renderer files

Non-semantic line-break and wrapping adjustments picked up by oxfmt.
No behavior changes.

* fix(mcp): run codex via shell on Windows to handle .cmd shims

Since Node 18.20/20.12 (CVE-2024-27980) execFileSync refuses to spawn
.cmd/.bat files directly and throws EINVAL. On Windows route through
execSync with shell resolution so PATHEXT picks whichever shim exists
(codex.exe / codex.cmd / codex.ps1).

* feat(editor): anchor paste to selected container or sibling

Pressing Cmd/Ctrl+V now inserts pasted nodes into the selected
container (if it can hold children) or immediately after the selected
node as a sibling, falling back to the root when nothing is selected.
Previously every paste landed at document root, which broke expected
behavior when working inside nested frames.

* docs(ai): expand horizontal scroll card-row example in overflow skill

Flesh out the inline JSON example so the generation-phase skill shows
the full clipContent + nested fit_content row pattern, instead of a
truncated snippet that left model output inconsistent.

* style(lint): clear 7 oxlint warnings from recent commits

- orchestrator-planning.test.ts: narrow fill-array type to
  Array<{...}> | undefined and use ?.[0] instead of unchecked [0]
  so optional chain does not throw on short-circuit
- mcp-install.ts: drop `?? {}` fallbacks when spreading
  config.mcpServers; spread of undefined in an object literal
  is a no-op (ES2018+)

* style(lint): clear remaining 15 oxlint warnings across repo

Removes pre-existing warnings not related to any single feature:

- no-useless-fallback-in-spread (6): drop `?? {}` when spreading
  possibly-undefined records (document-store-variable-actions,
  pen-mcp/tools/{variables,theme-presets}, variable-theme-manager)
- no-useless-spread (2): replace `[...iterable]` with `Array.from`
  in for-of snapshots (document-events, agent-indicator), keeping
  the re-entry-safe copy intent explicit
- no-control-regex (2): use `\P{ASCII}` unicode property escape
  instead of `[^\x00-\x7F]` to express "non-ASCII" without
  referencing U+0000 (opencode clients)
- no-new-array (1): `Array.from({ length }, () => '..')` in
  document-assets
- no-unused-vars (3): drop unused catch params (agent.ts,
  code-generation-pipeline) and unused globSync import
  (patch-srvx-bun)
- no-useless-escape (1): `[[{]` instead of `[\[{]` in
  chat-message-content regex

---------

Co-authored-by: Fini <fini.yang@gmail.com>
Co-authored-by: Daniel Chettiar <74943095+1MochaChan1@users.noreply.github.com>
Co-authored-by: Daniel Chettiar <danielc@snapwork.com>
2026-04-15 22:19:12 +08:00

250 lines
7.7 KiB
TypeScript

const EXTERNAL_ASSET_RE = /^(?:data:|https?:|blob:)/i;
const FILE_URL_RE = /^file:\/\//i;
const HTTP_PROTOCOL_RE = /^https?:$/i;
const LOCAL_IMAGE_EXT_RE = /\.(?:png|jpe?g|gif|webp|bmp|svg|avif)$/i;
export interface RuntimeAssetSource {
sourcePath: string | null;
runtimeUrl: string | null;
isLocal: boolean;
unresolved: boolean;
}
export function isLocalAssetPath(assetPath: string | null | undefined): boolean {
if (!assetPath) return false;
return !EXTERNAL_ASSET_RE.test(assetPath.trim());
}
export function resolveRuntimeAssetSource(
assetPath: string | null | undefined,
documentPath: string | null | undefined,
): RuntimeAssetSource {
if (!assetPath) {
return {
sourcePath: null,
runtimeUrl: null,
isLocal: false,
unresolved: false,
};
}
if (!isLocalAssetPath(assetPath)) {
return {
sourcePath: null,
runtimeUrl: assetPath,
isLocal: false,
unresolved: false,
};
}
if (FILE_URL_RE.test(assetPath)) {
const sourcePath = normalizePath(decodeFileUrl(assetPath));
return createLocalRuntimeAssetSource(sourcePath);
}
if (isAbsolutePath(assetPath)) {
const sourcePath = normalizePath(assetPath);
return createLocalRuntimeAssetSource(sourcePath);
}
if (!documentPath) {
return {
sourcePath: null,
runtimeUrl: null,
isLocal: true,
unresolved: true,
};
}
const sourcePath = resolveDocumentAssetPath(documentPath, assetPath);
return createLocalRuntimeAssetSource(sourcePath);
}
export function resolveDocumentAssetPath(documentPath: string, assetPath: string): string {
if (FILE_URL_RE.test(assetPath)) {
return normalizePath(decodeFileUrl(assetPath));
}
if (isAbsolutePath(assetPath)) {
return normalizePath(assetPath);
}
const baseDir = getDirname(documentPath);
return joinNormalizedPath(baseDir, assetPath);
}
export function toStoredAssetPath(
assetPath: string,
documentPath: string | null | undefined,
): string {
if (!assetPath) return assetPath;
if (!isLocalAssetPath(assetPath)) return assetPath;
const normalizedAssetPath = FILE_URL_RE.test(assetPath)
? normalizePath(decodeFileUrl(assetPath))
: normalizePath(assetPath);
if (!isAbsolutePath(normalizedAssetPath) || !documentPath) {
return normalizedAssetPath;
}
const relative = toRelativePath(getDirname(documentPath), normalizedAssetPath);
return relative ?? normalizedAssetPath;
}
export function toFileUrl(filePath: string): string {
const normalized = normalizePath(filePath);
if (normalized.startsWith('//')) {
return `file:${encodeURI(normalized)}`;
}
if (/^[A-Za-z]:\//.test(normalized)) {
return `file:///${encodeURI(normalized)}`;
}
if (normalized.startsWith('/')) {
return `file://${encodeURI(normalized)}`;
}
return encodeURI(normalized);
}
export function toRuntimeLocalAssetUrl(filePath: string): string {
return toLocalAssetBridgeUrl(filePath) ?? toFileUrl(filePath);
}
export function toLocalAssetBridgeUrl(filePath: string): string | null {
const runtimeOrigin = getRuntimeOrigin();
if (!runtimeOrigin) return null;
const normalized = normalizePath(filePath);
return `${runtimeOrigin}/api/local-asset?path=${encodeURIComponent(normalized)}`;
}
function toRelativePath(baseDir: string, targetPath: string): string | null {
const normalizedBase = normalizePath(baseDir);
const normalizedTarget = normalizePath(targetPath);
const basePrefix = extractPathPrefix(normalizedBase);
const targetPrefix = extractPathPrefix(normalizedTarget);
if (basePrefix.toLowerCase() !== targetPrefix.toLowerCase()) {
return null;
}
const baseParts = stripPrefix(normalizedBase, basePrefix).split('/').filter(Boolean);
const targetParts = stripPrefix(normalizedTarget, targetPrefix).split('/').filter(Boolean);
let shared = 0;
while (
shared < baseParts.length &&
shared < targetParts.length &&
baseParts[shared].toLowerCase() === targetParts[shared].toLowerCase()
) {
shared += 1;
}
const up: string[] = Array.from({ length: baseParts.length - shared }, () => '..');
const down = targetParts.slice(shared);
const relative = [...up, ...down].join('/');
return relative || '.';
}
function decodeFileUrl(url: string): string {
const decoded = decodeURIComponent(url.replace(/^file:\/\/+/i, ''));
return decoded.replace(/^\/([A-Za-z]:)/, '$1');
}
function getRuntimeOrigin(): string | null {
if (typeof window === 'undefined' || !window.location) return null;
if (!HTTP_PROTOCOL_RE.test(window.location.protocol)) return null;
const origin = window.location.origin?.trim();
return origin && origin !== 'null' ? origin.replace(/\/+$/, '') : null;
}
function isAbsolutePath(value: string): boolean {
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('/');
}
function normalizePath(value: string): string {
const path = value.replace(/\\/g, '/');
return path.startsWith('//')
? `//${path.slice(2).replace(/\/+/g, '/')}`
: path.replace(/\/+/g, '/');
}
function createLocalRuntimeAssetSource(sourcePath: string): RuntimeAssetSource {
return {
sourcePath,
runtimeUrl: canBridgeLocalImagePath(sourcePath) ? toRuntimeLocalAssetUrl(sourcePath) : null,
isLocal: true,
unresolved: false,
};
}
function hasSupportedLocalImageExtension(filePath: string): boolean {
const normalized = normalizePath(filePath).split(/[?#]/, 1)[0];
return LOCAL_IMAGE_EXT_RE.test(normalized);
}
function canBridgeLocalImagePath(filePath: string): boolean {
if (hasSupportedLocalImageExtension(filePath)) return true;
return !hasExplicitFileExtension(filePath);
}
function hasExplicitFileExtension(filePath: string): boolean {
const normalized = normalizePath(filePath).split(/[?#]/, 1)[0];
const segments = normalized.split('/');
const lastSegment = segments[segments.length - 1] ?? '';
const dotIndex = lastSegment.lastIndexOf('.');
return dotIndex > 0 && dotIndex < lastSegment.length - 1;
}
function getDirname(filePath: string): string {
const normalized = normalizePath(filePath).replace(/\/+$/, '');
const lastSlash = normalized.lastIndexOf('/');
if (lastSlash <= 0) {
return normalized.includes(':') ? `${normalized.split(':')[0]}:/` : '/';
}
return normalized.slice(0, lastSlash);
}
function joinNormalizedPath(baseDir: string, relativePath: string): string {
const normalizedBase = normalizePath(baseDir);
const normalizedRelative = normalizePath(relativePath);
const prefix = extractPathPrefix(normalizedBase);
const baseSegments = stripPrefix(normalizedBase, prefix).split('/').filter(Boolean);
const relativeSegments = normalizedRelative.split('/').filter(Boolean);
const parts = [...baseSegments];
for (const segment of relativeSegments) {
if (segment === '.') continue;
if (segment === '..') {
if (parts.length > 0) parts.pop();
continue;
}
parts.push(segment);
}
if (!prefix) return parts.join('/');
const joined = parts.join('/');
if (prefix === '/') return joined ? `/${joined}` : '/';
if (prefix.startsWith('//')) return joined ? `${prefix}/${joined}` : prefix;
return joined ? `${prefix}/${joined}` : `${prefix}/`;
}
function extractPathPrefix(path: string): string {
if (path.startsWith('//')) {
const parts = path.split('/').filter(Boolean);
if (parts.length >= 2) {
return `//${parts[0]}/${parts[1]}`;
}
return '//';
}
const driveMatch = path.match(/^[A-Za-z]:/);
if (driveMatch) return driveMatch[0];
if (path.startsWith('/')) return '/';
return '';
}
function stripPrefix(path: string, prefix: string): string {
if (!prefix) return path;
if (prefix === '/') return path.slice(1);
if (prefix.startsWith('//')) return path.slice(prefix.length);
return path.slice(prefix.length).replace(/^\/+/, '');
}