User-reported 2026-05-10 "圆角元素的尖角阴影" — rounded cards / hero images had visibly square-cornered drop shadows poking out from under the rounded shape. Forensic root cause: applyShadowDirect at node-renderer.ts:447 was always drawing the shadow as a plain `canvas.drawRect(...)`, completely ignoring the node's cornerRadius. A frame with cornerRadius=24 + a subtle drop shadow would render the rounded body cleanly but stamp a sharp-cornered shadow rectangle just behind it, with the rectangle corners visible past the rounded outline. Fix: pass the node's cornerRadius into applyShadowDirect; when > 0 use `drawRRect` with `RRectXY(rect, cornerRadius+spread, ...)` so the shadow's rounding stays parallel to the node's rounding (the +spread correction keeps the visible curve aligned when spread expands / contracts the bounds). Ellipse / circle nodes (avatars, status dots) get cornerRadius = min(w,h)/2 from the call site so their shadows render as stadium / circle. Asymmetric-aspect ellipses get a stadium approximation rather than a true ellipse — accepted simplification, the common case is symmetric (avatar / dot). Path / line / polygon nodes have no cornerRadius and fall through with cr=0 — rectangular shadow stays correct for them. This is a renderer-layer fix that detector-only paths can't reach; ships in the same session as the typography / spacing detectors so the user sees end-to-end aesthetic improvement on the next rebuild.
1206 lines
36 KiB
TypeScript
1206 lines
36 KiB
TypeScript
import type { CanvasKit, Canvas, Paint, Font, Typeface } from 'canvaskit-wasm';
|
|
import type {
|
|
PenNode,
|
|
ContainerProps,
|
|
EllipseNode,
|
|
LineNode,
|
|
PolygonNode,
|
|
PathNode,
|
|
ImageNode,
|
|
IconFontNode,
|
|
} from '@zseven-w/pen-types';
|
|
import type { PenFill, PenStroke, PenEffect, ShadowEffect, ImageFill } from '@zseven-w/pen-types';
|
|
import {
|
|
DEFAULT_FILL,
|
|
DEFAULT_STROKE,
|
|
DEFAULT_STROKE_WIDTH,
|
|
buildEllipseArcPath,
|
|
getPathBoundsFromAnchors,
|
|
isArcEllipse,
|
|
pathDataToAnchors,
|
|
} from '@zseven-w/pen-core';
|
|
import {
|
|
parseColor,
|
|
cornerRadiusValue,
|
|
cornerRadii,
|
|
resolveFillColor,
|
|
shouldUseTransparentFallbackFill,
|
|
resolveStrokeColor,
|
|
resolveStrokeWidth,
|
|
} from './paint-utils.js';
|
|
import { sanitizeSvgPath, hasInvalidNumbers, tryManualPathParse } from './path-utils.js';
|
|
import { SkiaImageLoader } from './image-loader.js';
|
|
import { SkiaTextRenderer } from './text-renderer.js';
|
|
import type { SkiaFontManager, FontManagerOptions } from './font-manager.js';
|
|
import type { RenderNode, IconLookupFn } from './types.js';
|
|
|
|
const FALLBACK_ICON_D = 'M12 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0';
|
|
|
|
/**
|
|
* Core node renderer for CanvasKit/Skia. Draws PenNode shapes, fills,
|
|
* strokes, effects, text, and images. No editor overlays or store dependencies.
|
|
*/
|
|
export class SkiaNodeRenderer {
|
|
protected ck: CanvasKit;
|
|
private defaultTypeface: Typeface | null = null;
|
|
private defaultFont: Font | null = null;
|
|
|
|
// Current viewport zoom (set by engine before each render frame)
|
|
zoom = 1;
|
|
|
|
// Device pixel ratio
|
|
devicePixelRatio: number | undefined;
|
|
|
|
// Sub-renderers
|
|
private textRenderer: SkiaTextRenderer;
|
|
imageLoader: SkiaImageLoader;
|
|
|
|
// Injectable icon lookup
|
|
private iconLookup: IconLookupFn | null = null;
|
|
|
|
/** Font manager — delegates to text renderer */
|
|
get fontManager(): SkiaFontManager {
|
|
return this.textRenderer.fontManager;
|
|
}
|
|
|
|
constructor(ck: CanvasKit, fontOptions?: FontManagerOptions) {
|
|
this.ck = ck;
|
|
this.imageLoader = new SkiaImageLoader(ck);
|
|
this.textRenderer = new SkiaTextRenderer(ck, fontOptions);
|
|
}
|
|
|
|
init() {
|
|
this.defaultFont = new this.ck.Font(null, 16);
|
|
}
|
|
|
|
/** Set callback to trigger re-render when async images finish loading. */
|
|
setRedrawCallback(cb: () => void) {
|
|
this.imageLoader.setOnLoaded(cb);
|
|
}
|
|
|
|
/** Set injectable icon lookup function. */
|
|
setIconLookup(fn: IconLookupFn) {
|
|
this.iconLookup = fn;
|
|
}
|
|
|
|
setImageSourceResolver(resolver: (src: string) => { cacheKey: string; loadUrl: string | null }) {
|
|
this.imageLoader.setSourceResolver(resolver);
|
|
}
|
|
|
|
dispose() {
|
|
this.defaultFont?.delete();
|
|
this.defaultFont = null;
|
|
this.defaultTypeface?.delete();
|
|
this.defaultTypeface = null;
|
|
this.textRenderer.dispose();
|
|
this.imageLoader.dispose();
|
|
}
|
|
|
|
clearTextCache() {
|
|
this.textRenderer.clearTextCache();
|
|
}
|
|
clearParaCache() {
|
|
this.textRenderer.clearParaCache();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fill paint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private makeFillPaint(
|
|
fills: PenFill[] | string | undefined,
|
|
w: number,
|
|
h: number,
|
|
opacity: number,
|
|
absX: number,
|
|
absY: number,
|
|
): {
|
|
paint: Paint;
|
|
imageFillDraw?: {
|
|
fill: ImageFill;
|
|
w: number;
|
|
h: number;
|
|
absX: number;
|
|
absY: number;
|
|
opacity: number;
|
|
};
|
|
} {
|
|
const ck = this.ck;
|
|
const paint = new ck.Paint();
|
|
paint.setStyle(ck.PaintStyle.Fill);
|
|
paint.setAntiAlias(true);
|
|
|
|
if (typeof fills === 'string') {
|
|
const c = parseColor(ck, fills);
|
|
c[3] *= opacity;
|
|
paint.setColor(c);
|
|
return { paint };
|
|
}
|
|
if (!fills || fills.length === 0) {
|
|
const c = parseColor(ck, DEFAULT_FILL);
|
|
c[3] *= opacity;
|
|
paint.setColor(c);
|
|
return { paint };
|
|
}
|
|
|
|
const first = fills[0];
|
|
if (first.type === 'solid') {
|
|
const c = parseColor(ck, first.color);
|
|
c[3] *= (first.opacity ?? 1) * opacity;
|
|
paint.setColor(c);
|
|
} else if (first.type === 'linear_gradient') {
|
|
const stops = first.stops ?? [];
|
|
const fillOpacity = (first.opacity ?? 1) * opacity;
|
|
if (stops.length >= 2) {
|
|
const rad = (((first.angle ?? 0) - 90) * Math.PI) / 180;
|
|
const cos = Math.cos(rad),
|
|
sin = Math.sin(rad);
|
|
const x1 = absX + w / 2 - (cos * w) / 2,
|
|
y1 = absY + h / 2 - (sin * h) / 2;
|
|
const x2 = absX + w / 2 + (cos * w) / 2,
|
|
y2 = absY + h / 2 + (sin * h) / 2;
|
|
const colors = stops.map((s) => {
|
|
const c = parseColor(ck, s.color);
|
|
c[3] *= fillOpacity;
|
|
return c;
|
|
});
|
|
const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)));
|
|
const shader = ck.Shader.MakeLinearGradient(
|
|
[x1, y1],
|
|
[x2, y2],
|
|
colors,
|
|
positions,
|
|
ck.TileMode.Clamp,
|
|
);
|
|
if (shader) paint.setShader(shader);
|
|
} else {
|
|
const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL);
|
|
c[3] *= fillOpacity;
|
|
paint.setColor(c);
|
|
}
|
|
} else if (first.type === 'radial_gradient') {
|
|
const stops = first.stops ?? [];
|
|
const fillOpacity = (first.opacity ?? 1) * opacity;
|
|
if (stops.length >= 2) {
|
|
const cx = absX + (first.cx ?? 0.5) * w,
|
|
cy = absY + (first.cy ?? 0.5) * h;
|
|
const r = (first.radius ?? 0.5) * Math.max(w, h);
|
|
const colors = stops.map((s) => {
|
|
const c = parseColor(ck, s.color);
|
|
c[3] *= fillOpacity;
|
|
return c;
|
|
});
|
|
const positions = stops.map((s) => Math.max(0, Math.min(1, s.offset)));
|
|
const shader = ck.Shader.MakeRadialGradient(
|
|
[cx, cy],
|
|
r,
|
|
colors,
|
|
positions,
|
|
ck.TileMode.Clamp,
|
|
);
|
|
if (shader) paint.setShader(shader);
|
|
} else {
|
|
const c = parseColor(ck, stops[0]?.color ?? DEFAULT_FILL);
|
|
c[3] *= fillOpacity;
|
|
paint.setColor(c);
|
|
}
|
|
} else if (first.type === 'image') {
|
|
const result = this.applyImageFillToPaint(paint, first, w, h, opacity, absX, absY);
|
|
if (result.needsDrawImageRect && result.fill) {
|
|
return {
|
|
paint,
|
|
imageFillDraw: {
|
|
fill: result.fill,
|
|
w: result.w!,
|
|
h: result.h!,
|
|
absX: result.absX!,
|
|
absY: result.absY!,
|
|
opacity: result.opacity!,
|
|
},
|
|
};
|
|
}
|
|
}
|
|
|
|
return { paint };
|
|
}
|
|
|
|
private applyImageFillToPaint(
|
|
paint: Paint,
|
|
fill: ImageFill,
|
|
w: number,
|
|
h: number,
|
|
opacity: number,
|
|
absX: number,
|
|
absY: number,
|
|
): {
|
|
needsDrawImageRect: boolean;
|
|
fill?: ImageFill;
|
|
w?: number;
|
|
h?: number;
|
|
absX?: number;
|
|
absY?: number;
|
|
opacity?: number;
|
|
} {
|
|
const ck = this.ck;
|
|
const fillOpacity = (fill.opacity ?? 1) * opacity;
|
|
const url = fill.url;
|
|
if (!url) {
|
|
const c = parseColor(ck, '#e5e7eb');
|
|
c[3] *= fillOpacity;
|
|
paint.setColor(c);
|
|
return { needsDrawImageRect: false };
|
|
}
|
|
|
|
const cached = this.imageLoader.get(url);
|
|
if (cached === undefined) this.imageLoader.request(url);
|
|
if (!cached) {
|
|
const isMissing = this.imageLoader.getStatus(url)?.state === 'missing';
|
|
const c = parseColor(ck, isMissing ? '#f1d7d7' : '#e5e7eb');
|
|
c[3] *= fillOpacity;
|
|
paint.setColor(c);
|
|
return { needsDrawImageRect: false };
|
|
}
|
|
|
|
const imgW = cached.width(),
|
|
imgH = cached.height();
|
|
if (imgW <= 0 || imgH <= 0) return { needsDrawImageRect: false };
|
|
|
|
const mode = fill.mode ?? 'fill';
|
|
if (mode === 'tile') {
|
|
const dispX = absX + (w - imgW) / 2,
|
|
dispY = absY + (h - imgH) / 2;
|
|
const localMatrix = Float32Array.of(1, 0, -dispX, 0, 1, -dispY, 0, 0, 1);
|
|
const shader = cached.makeShaderOptions(
|
|
ck.TileMode.Repeat,
|
|
ck.TileMode.Repeat,
|
|
ck.FilterMode.Linear,
|
|
ck.MipmapMode.None,
|
|
localMatrix,
|
|
);
|
|
if (shader) {
|
|
paint.setShader(shader);
|
|
if (fillOpacity < 1) paint.setAlphaf(fillOpacity);
|
|
const cf = this.buildImageAdjustmentFilter(fill);
|
|
if (cf) paint.setColorFilter(cf);
|
|
}
|
|
return { needsDrawImageRect: false };
|
|
}
|
|
|
|
paint.setColor(Float32Array.of(0, 0, 0, 0));
|
|
return { needsDrawImageRect: true, fill, w, h, absX, absY, opacity: fillOpacity };
|
|
}
|
|
|
|
private drawImageFillRect(
|
|
canvas: Canvas,
|
|
fill: ImageFill,
|
|
w: number,
|
|
h: number,
|
|
absX: number,
|
|
absY: number,
|
|
fillOpacity: number,
|
|
) {
|
|
const ck = this.ck;
|
|
const url = fill.url;
|
|
if (!url) return;
|
|
const cached = this.imageLoader.get(url);
|
|
if (!cached) return;
|
|
const imgW = cached.width(),
|
|
imgH = cached.height();
|
|
if (imgW <= 0 || imgH <= 0) return;
|
|
|
|
const mode = fill.mode ?? 'fill';
|
|
const paint = new ck.Paint();
|
|
paint.setAntiAlias(true);
|
|
if (fillOpacity < 1) paint.setAlphaf(fillOpacity);
|
|
const adjFilter = this.buildImageAdjustmentFilter(fill);
|
|
if (adjFilter) paint.setColorFilter(adjFilter);
|
|
|
|
if (mode === 'fit') {
|
|
const scale = Math.min(w / imgW, h / imgH);
|
|
const dw = imgW * scale,
|
|
dh = imgH * scale;
|
|
const dx = absX + (w - dw) / 2,
|
|
dy = absY + (h - dh) / 2;
|
|
canvas.drawImageRect(
|
|
cached,
|
|
ck.LTRBRect(0, 0, imgW, imgH),
|
|
ck.LTRBRect(dx, dy, dx + dw, dy + dh),
|
|
paint,
|
|
);
|
|
} else if (mode === 'stretch') {
|
|
canvas.drawImageRect(
|
|
cached,
|
|
ck.LTRBRect(0, 0, imgW, imgH),
|
|
ck.LTRBRect(absX, absY, absX + w, absY + h),
|
|
paint,
|
|
);
|
|
} else {
|
|
const scale = Math.max(w / imgW, h / imgH);
|
|
const dw = imgW * scale,
|
|
dh = imgH * scale;
|
|
const dx = absX + (w - dw) / 2,
|
|
dy = absY + (h - dh) / 2;
|
|
canvas.drawImageRect(
|
|
cached,
|
|
ck.LTRBRect(0, 0, imgW, imgH),
|
|
ck.LTRBRect(dx, dy, dx + dw, dy + dh),
|
|
paint,
|
|
);
|
|
}
|
|
paint.delete();
|
|
}
|
|
|
|
private buildImageAdjustmentFilter(adj: {
|
|
exposure?: number;
|
|
contrast?: number;
|
|
saturation?: number;
|
|
temperature?: number;
|
|
tint?: number;
|
|
highlights?: number;
|
|
shadows?: number;
|
|
}) {
|
|
const ck = this.ck;
|
|
const exp = (adj.exposure ?? 0) / 100,
|
|
con = (adj.contrast ?? 0) / 100,
|
|
sat = (adj.saturation ?? 0) / 100;
|
|
const temp = (adj.temperature ?? 0) / 100,
|
|
tintVal = (adj.tint ?? 0) / 100;
|
|
const hi = (adj.highlights ?? 0) / 100,
|
|
sh = (adj.shadows ?? 0) / 100;
|
|
if (exp === 0 && con === 0 && sat === 0 && temp === 0 && tintVal === 0 && hi === 0 && sh === 0)
|
|
return null;
|
|
|
|
const e = 1 + exp * 1.5,
|
|
c = 1 + con,
|
|
cOff = 0.5 * (1 - c);
|
|
const s = 1 + sat;
|
|
const lr = 0.2126,
|
|
lg = 0.7152,
|
|
lb = 0.0722;
|
|
const sr = (1 - s) * lr,
|
|
sg = (1 - s) * lg,
|
|
sb = (1 - s) * lb;
|
|
const f = c * e;
|
|
const offR = cOff + temp * 0.15 + (hi + sh * 0.5) * 0.1;
|
|
const offG = cOff + tintVal * 0.15 + (hi + sh * 0.5) * 0.1;
|
|
const offB = cOff - temp * 0.15 + (hi + sh * 0.5) * 0.1;
|
|
|
|
return ck.ColorFilter.MakeMatrix([
|
|
f * (sr + s),
|
|
f * sg,
|
|
f * sb,
|
|
0,
|
|
offR,
|
|
f * sr,
|
|
f * (sg + s),
|
|
f * sb,
|
|
0,
|
|
offG,
|
|
f * sr,
|
|
f * sg,
|
|
f * (sb + s),
|
|
0,
|
|
offB,
|
|
0,
|
|
0,
|
|
0,
|
|
1,
|
|
0,
|
|
]);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stroke paint
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private makeStrokePaint(stroke: PenStroke | undefined, opacity: number): Paint | null {
|
|
if (!stroke) return null;
|
|
const strokeColor = resolveStrokeColor(stroke);
|
|
const strokeWidth = resolveStrokeWidth(stroke);
|
|
if (!strokeColor || strokeWidth <= 0) return null;
|
|
|
|
const ck = this.ck;
|
|
const paint = new ck.Paint();
|
|
paint.setStyle(ck.PaintStyle.Stroke);
|
|
paint.setAntiAlias(true);
|
|
paint.setStrokeWidth(strokeWidth);
|
|
const c = parseColor(ck, strokeColor);
|
|
c[3] *= opacity;
|
|
paint.setColor(c);
|
|
|
|
if (stroke.join === 'round') paint.setStrokeJoin(ck.StrokeJoin.Round);
|
|
else if (stroke.join === 'bevel') paint.setStrokeJoin(ck.StrokeJoin.Bevel);
|
|
if (stroke.cap === 'round') paint.setStrokeCap(ck.StrokeCap.Round);
|
|
else if (stroke.cap === 'square') paint.setStrokeCap(ck.StrokeCap.Square);
|
|
if (stroke.dashPattern && stroke.dashPattern.length >= 2) {
|
|
const effect = ck.PathEffect.MakeDash(stroke.dashPattern, stroke.dashOffset ?? 0);
|
|
if (effect) paint.setPathEffect(effect);
|
|
}
|
|
|
|
return paint;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shadow
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private applyShadowDirect(
|
|
canvas: Canvas,
|
|
effects: PenEffect[] | undefined,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
cornerRadius: number = 0,
|
|
): boolean {
|
|
if (!effects) return false;
|
|
const shadow = effects.find((e): e is ShadowEffect => e.type === 'shadow');
|
|
if (!shadow) return false;
|
|
|
|
const ck = this.ck;
|
|
const paint = new ck.Paint();
|
|
paint.setStyle(ck.PaintStyle.Fill);
|
|
paint.setAntiAlias(true);
|
|
paint.setColor(parseColor(ck, shadow.color));
|
|
paint.setMaskFilter(ck.MaskFilter.MakeBlur(ck.BlurStyle.Normal, shadow.blur / 2, true));
|
|
const rect = ck.LTRBRect(
|
|
x + shadow.offsetX - shadow.spread,
|
|
y + shadow.offsetY - shadow.spread,
|
|
x + w + shadow.offsetX + shadow.spread,
|
|
y + h + shadow.offsetY + shadow.spread,
|
|
);
|
|
if (cornerRadius > 0) {
|
|
// 2026-05-10 user-reported "圆角元素的尖角阴影" — drawRect was producing
|
|
// a sharp-cornered shadow under rounded frames / cards because the
|
|
// shadow geometry never inherited the node's cornerRadius. Drop
|
|
// shadows MUST follow the node's outline, otherwise the corners poke
|
|
// out from under the rounded shape.
|
|
//
|
|
// Spread expands (or contracts) the visible shadow edge by `spread`
|
|
// px on each side, so the visible corner radius needs the same
|
|
// offset to stay parallel to the node — clamp to ≥ 0 so a negative
|
|
// spread doesn't invert the curve.
|
|
const shadowR = Math.max(0, cornerRadius + shadow.spread);
|
|
canvas.drawRRect(ck.RRectXY(rect, shadowR, shadowR), paint);
|
|
} else {
|
|
canvas.drawRect(rect, paint);
|
|
}
|
|
paint.delete();
|
|
return true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Draw a single render node (no selection/overlay logic)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
drawNode(canvas: Canvas, rn: RenderNode) {
|
|
const { node, absX, absY, absW, absH, clipStack } = rn;
|
|
const ck = this.ck;
|
|
const opacity = typeof node.opacity === 'number' ? node.opacity : 1;
|
|
|
|
if (('visible' in node ? node.visible : undefined) === false) return;
|
|
|
|
// Pass zoom to text renderer
|
|
this.textRenderer.zoom = this.zoom;
|
|
this.textRenderer.devicePixelRatio = this.devicePixelRatio;
|
|
|
|
// Apply ancestor-to-self clip stack. Each ancestor with `clipContent: true`
|
|
// (and the root frame artboard) pushed a ClipInfo onto this stack at
|
|
// flatten time. Pushing each as a separate canvas.save+clip preserves
|
|
// every level's rounded corner — a single ClipInfo can't encode the
|
|
// intersection of two rrects when one cuts inside the other's corner.
|
|
let clipsPushed = 0;
|
|
if (clipStack && clipStack.length > 0) {
|
|
for (const clip of clipStack) {
|
|
canvas.save();
|
|
clipsPushed++;
|
|
if (clip.rx > 0) {
|
|
canvas.clipRRect(
|
|
ck.RRectXY(
|
|
ck.LTRBRect(clip.x, clip.y, clip.x + clip.w, clip.y + clip.h),
|
|
clip.rx,
|
|
clip.rx,
|
|
),
|
|
ck.ClipOp.Intersect,
|
|
true,
|
|
);
|
|
} else {
|
|
canvas.clipRect(
|
|
ck.LTRBRect(clip.x, clip.y, clip.x + clip.w, clip.y + clip.h),
|
|
ck.ClipOp.Intersect,
|
|
true,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply flip
|
|
const flipX = node.flipX === true,
|
|
flipY = node.flipY === true;
|
|
if (flipX || flipY) {
|
|
canvas.save();
|
|
canvas.translate(absX + absW / 2, absY + absH / 2);
|
|
canvas.scale(flipX ? -1 : 1, flipY ? -1 : 1);
|
|
canvas.translate(-(absX + absW / 2), -(absY + absH / 2));
|
|
}
|
|
|
|
// Apply rotation
|
|
const rotation = node.rotation ?? 0;
|
|
if (rotation !== 0) {
|
|
canvas.save();
|
|
canvas.rotate(rotation, absX + absW / 2, absY + absH / 2);
|
|
}
|
|
|
|
// Apply shadow (text uses glyph-shaped shadow, not rectangle)
|
|
const effects =
|
|
'effects' in node ? (node as PenNode & { effects?: PenEffect[] }).effects : undefined;
|
|
if (node.type !== 'text') {
|
|
// Pass the node's cornerRadius so the drop-shadow follows the rounded
|
|
// outline. Path / line / polygon don't carry cornerRadius and fall
|
|
// through with cr=0 (rectangular shadow, which is fine for them).
|
|
// Ellipse maps to cornerRadius = min(w,h)/2 — RRect with rx=ry=
|
|
// half-min produces a stadium / circle shadow that matches a
|
|
// symmetric ellipse outline; asymmetric ellipses get a stadium
|
|
// approximation rather than a true ellipse, accepted as known
|
|
// simplification (avatar / status-dot use circles).
|
|
const cr =
|
|
node.type === 'ellipse'
|
|
? Math.min(absW, absH) / 2
|
|
: cornerRadiusValue(
|
|
(node as PenNode & { cornerRadius?: number | [number, number, number, number] })
|
|
.cornerRadius,
|
|
);
|
|
this.applyShadowDirect(canvas, effects, absX, absY, absW, absH, cr);
|
|
}
|
|
|
|
switch (node.type) {
|
|
case 'frame':
|
|
case 'rectangle':
|
|
case 'group':
|
|
this.drawRect(canvas, node, absX, absY, absW, absH, opacity);
|
|
break;
|
|
case 'ellipse':
|
|
this.drawEllipse(canvas, node, absX, absY, absW, absH, opacity);
|
|
break;
|
|
case 'line':
|
|
this.drawLine(canvas, node, absX, absY, opacity);
|
|
break;
|
|
case 'polygon':
|
|
this.drawPolygon(canvas, node, absX, absY, absW, absH, opacity);
|
|
break;
|
|
case 'path':
|
|
this.drawPath(canvas, node, absX, absY, absW, absH, opacity);
|
|
break;
|
|
case 'icon_font':
|
|
this.drawIconFont(canvas, node, absX, absY, absW, absH, opacity);
|
|
break;
|
|
case 'text':
|
|
this.textRenderer.drawText(canvas, node, absX, absY, absW, absH, opacity, effects);
|
|
break;
|
|
case 'image':
|
|
this.drawImage(canvas, node, absX, absY, absW, absH, opacity);
|
|
break;
|
|
}
|
|
|
|
if (rotation !== 0) canvas.restore();
|
|
if (flipX || flipY) canvas.restore();
|
|
for (let i = 0; i < clipsPushed; i++) canvas.restore();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shape drawing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private drawRect(
|
|
canvas: Canvas,
|
|
node: PenNode,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
opacity: number,
|
|
) {
|
|
const ck = this.ck;
|
|
const container = node as PenNode & ContainerProps;
|
|
const cr = cornerRadii(container.cornerRadius);
|
|
const fills = container.fill;
|
|
const stroke = container.stroke;
|
|
const hasFill = fills && fills.length > 0;
|
|
const isContainer = node.type === 'frame' || node.type === 'group';
|
|
|
|
const { paint: fillPaint, imageFillDraw } = this.makeFillPaint(
|
|
hasFill || !shouldUseTransparentFallbackFill(fills, stroke, isContainer)
|
|
? fills
|
|
: 'transparent',
|
|
w,
|
|
h,
|
|
opacity,
|
|
x,
|
|
y,
|
|
);
|
|
const hasRoundedCorners = cr.some((r) => r > 0);
|
|
if (hasRoundedCorners) {
|
|
const maxR = Math.min(w / 2, h / 2);
|
|
canvas.drawRRect(
|
|
ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)),
|
|
fillPaint,
|
|
);
|
|
} else {
|
|
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fillPaint);
|
|
}
|
|
fillPaint.delete();
|
|
|
|
if (imageFillDraw) {
|
|
canvas.save();
|
|
if (hasRoundedCorners) {
|
|
const maxR = Math.min(w / 2, h / 2);
|
|
canvas.clipRRect(
|
|
ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)),
|
|
ck.ClipOp.Intersect,
|
|
true,
|
|
);
|
|
} else {
|
|
canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true);
|
|
}
|
|
this.drawImageFillRect(
|
|
canvas,
|
|
imageFillDraw.fill,
|
|
imageFillDraw.w,
|
|
imageFillDraw.h,
|
|
imageFillDraw.absX,
|
|
imageFillDraw.absY,
|
|
imageFillDraw.opacity,
|
|
);
|
|
canvas.restore();
|
|
}
|
|
|
|
const strokePaint = this.makeStrokePaint(stroke, opacity);
|
|
if (strokePaint) {
|
|
if (hasRoundedCorners) {
|
|
const maxR = Math.min(w / 2, h / 2);
|
|
canvas.drawRRect(
|
|
ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), Math.min(cr[0], maxR), Math.min(cr[0], maxR)),
|
|
strokePaint,
|
|
);
|
|
} else {
|
|
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), strokePaint);
|
|
}
|
|
strokePaint.delete();
|
|
}
|
|
}
|
|
|
|
private drawEllipse(
|
|
canvas: Canvas,
|
|
node: PenNode,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
opacity: number,
|
|
) {
|
|
const ck = this.ck;
|
|
const eNode = node as EllipseNode;
|
|
const fills = eNode.fill,
|
|
stroke = eNode.stroke;
|
|
const cr = cornerRadiusValue(eNode.cornerRadius);
|
|
const fillSource = shouldUseTransparentFallbackFill(fills, stroke) ? 'transparent' : fills;
|
|
|
|
if (isArcEllipse(eNode.startAngle, eNode.sweepAngle, eNode.innerRadius)) {
|
|
const arcD = buildEllipseArcPath(
|
|
w,
|
|
h,
|
|
eNode.startAngle ?? 0,
|
|
eNode.sweepAngle ?? 360,
|
|
eNode.innerRadius ?? 0,
|
|
);
|
|
const path = ck.Path.MakeFromSVGString(arcD);
|
|
if (path) {
|
|
path.offset(x, y);
|
|
const { paint: fillPaint } = this.makeFillPaint(fillSource, w, h, opacity, x, y);
|
|
if (cr > 0) {
|
|
const effect = ck.PathEffect.MakeCorner(cr);
|
|
if (effect) fillPaint.setPathEffect(effect);
|
|
}
|
|
canvas.drawPath(path, fillPaint);
|
|
fillPaint.delete();
|
|
const strokePaint = this.makeStrokePaint(stroke, opacity);
|
|
if (strokePaint) {
|
|
if (cr > 0) {
|
|
const effect = ck.PathEffect.MakeCorner(cr);
|
|
if (effect) strokePaint.setPathEffect(effect);
|
|
}
|
|
canvas.drawPath(path, strokePaint);
|
|
strokePaint.delete();
|
|
}
|
|
path.delete();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const { paint: fillPaint } = this.makeFillPaint(fillSource, w, h, opacity, x, y);
|
|
canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), fillPaint);
|
|
fillPaint.delete();
|
|
const strokePaint = this.makeStrokePaint(stroke, opacity);
|
|
if (strokePaint) {
|
|
canvas.drawOval(ck.LTRBRect(x, y, x + w, y + h), strokePaint);
|
|
strokePaint.delete();
|
|
}
|
|
}
|
|
|
|
private drawLine(canvas: Canvas, node: PenNode, x: number, y: number, opacity: number) {
|
|
const ck = this.ck;
|
|
const lNode = node as LineNode;
|
|
const x2 = lNode.x2 ?? x + 100,
|
|
y2 = lNode.y2 ?? y;
|
|
const strokeColor = resolveStrokeColor(lNode.stroke) ?? DEFAULT_STROKE;
|
|
const strokeWidth = resolveStrokeWidth(lNode.stroke) || DEFAULT_STROKE_WIDTH;
|
|
const paint = new ck.Paint();
|
|
paint.setStyle(ck.PaintStyle.Stroke);
|
|
paint.setAntiAlias(true);
|
|
paint.setStrokeWidth(strokeWidth);
|
|
const c = parseColor(ck, strokeColor);
|
|
c[3] *= opacity;
|
|
paint.setColor(c);
|
|
canvas.drawLine(x, y, x2, y2, paint);
|
|
paint.delete();
|
|
}
|
|
|
|
private drawPolygon(
|
|
canvas: Canvas,
|
|
node: PenNode,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
opacity: number,
|
|
) {
|
|
const ck = this.ck;
|
|
const pNode = node as PolygonNode;
|
|
const count = pNode.polygonCount || 6;
|
|
const fills = pNode.fill,
|
|
stroke = pNode.stroke;
|
|
const cr = cornerRadiusValue(pNode.cornerRadius);
|
|
const fillSource = shouldUseTransparentFallbackFill(fills, stroke) ? 'transparent' : fills;
|
|
|
|
const raw: [number, number][] = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const angle = (i * 2 * Math.PI) / count - Math.PI / 2;
|
|
raw.push([Math.cos(angle), Math.sin(angle)]);
|
|
}
|
|
let minX = Infinity,
|
|
maxX = -Infinity,
|
|
minY = Infinity,
|
|
maxY = -Infinity;
|
|
for (const [rx, ry] of raw) {
|
|
if (rx < minX) minX = rx;
|
|
if (rx > maxX) maxX = rx;
|
|
if (ry < minY) minY = ry;
|
|
if (ry > maxY) maxY = ry;
|
|
}
|
|
const rawW = maxX - minX,
|
|
rawH = maxY - minY;
|
|
|
|
const path = new ck.Path();
|
|
for (let i = 0; i < count; i++) {
|
|
const px = x + ((raw[i][0] - minX) / rawW) * w,
|
|
py = y + ((raw[i][1] - minY) / rawH) * h;
|
|
if (i === 0) path.moveTo(px, py);
|
|
else path.lineTo(px, py);
|
|
}
|
|
path.close();
|
|
|
|
const { paint: fillPaint } = this.makeFillPaint(fillSource, w, h, opacity, x, y);
|
|
if (cr > 0) {
|
|
const effect = ck.PathEffect.MakeCorner(cr);
|
|
if (effect) fillPaint.setPathEffect(effect);
|
|
}
|
|
canvas.drawPath(path, fillPaint);
|
|
fillPaint.delete();
|
|
const strokePaint = this.makeStrokePaint(stroke, opacity);
|
|
if (strokePaint) {
|
|
if (cr > 0) {
|
|
const effect = ck.PathEffect.MakeCorner(cr);
|
|
if (effect) strokePaint.setPathEffect(effect);
|
|
}
|
|
canvas.drawPath(path, strokePaint);
|
|
strokePaint.delete();
|
|
}
|
|
path.delete();
|
|
}
|
|
|
|
private drawPath(
|
|
canvas: Canvas,
|
|
node: PenNode,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
opacity: number,
|
|
) {
|
|
const ck = this.ck;
|
|
const pNode = node as PathNode;
|
|
const rawD = typeof pNode.d === 'string' && pNode.d.trim().length > 0 ? pNode.d : 'M0 0 L0 0';
|
|
const fills = pNode.fill,
|
|
stroke = pNode.stroke;
|
|
|
|
let path: ReturnType<typeof ck.Path.MakeFromSVGString> = null;
|
|
if (hasInvalidNumbers(rawD)) {
|
|
path = tryManualPathParse(ck, rawD);
|
|
} else {
|
|
const d = sanitizeSvgPath(rawD);
|
|
path = ck.Path.MakeFromSVGString(d);
|
|
if (!path && d !== rawD) path = ck.Path.MakeFromSVGString(rawD);
|
|
if (!path) path = tryManualPathParse(ck, rawD);
|
|
}
|
|
if (!path) {
|
|
if (w > 0 && h > 0) {
|
|
const { paint: fp } = this.makeFillPaint(fills, w, h, opacity, x, y);
|
|
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), fp);
|
|
fp.delete();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const parsedAnchors = pNode.anchors
|
|
? {
|
|
anchors: pNode.anchors,
|
|
closed: pNode.closed ?? /[Zz]\s*$/.test(rawD),
|
|
}
|
|
: pathDataToAnchors(rawD);
|
|
const geometryBounds = parsedAnchors
|
|
? getPathBoundsFromAnchors(parsedAnchors.anchors, parsedAnchors.closed)
|
|
: null;
|
|
const bounds = geometryBounds
|
|
? Float32Array.of(
|
|
geometryBounds.x,
|
|
geometryBounds.y,
|
|
geometryBounds.x + geometryBounds.width,
|
|
geometryBounds.y + geometryBounds.height,
|
|
)
|
|
: path.getBounds();
|
|
const nativeW = bounds[2] - bounds[0],
|
|
nativeH = bounds[3] - bounds[1];
|
|
if (w > 0 && h > 0 && nativeW > 0.01 && nativeH > 0.01) {
|
|
const isIcon = !!pNode.iconId;
|
|
const sx = isIcon ? Math.min(w / nativeW, h / nativeH) : w / nativeW;
|
|
const sy = isIcon ? sx : h / nativeH;
|
|
path.transform(
|
|
ck.Matrix.multiply(
|
|
ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy),
|
|
ck.Matrix.scaled(sx, sy),
|
|
),
|
|
);
|
|
} else if (nativeW > 0.01 || nativeH > 0.01) {
|
|
const sx = nativeW > 0.01 && w > 0 ? w / nativeW : 1,
|
|
sy = nativeH > 0.01 && h > 0 ? h / nativeH : 1;
|
|
path.transform(
|
|
ck.Matrix.multiply(
|
|
ck.Matrix.translated(x - bounds[0] * sx, y - bounds[1] * sy),
|
|
ck.Matrix.scaled(sx, sy),
|
|
),
|
|
);
|
|
} else {
|
|
path.offset(x, y);
|
|
}
|
|
|
|
const hasExplicitFill = fills && fills.length > 0;
|
|
const strokeColor = resolveStrokeColor(stroke),
|
|
strokeWidth = resolveStrokeWidth(stroke);
|
|
const hasVisibleStroke = strokeWidth > 0 && !!strokeColor;
|
|
|
|
if (hasExplicitFill || !hasVisibleStroke) {
|
|
const { paint: fillPaint } = this.makeFillPaint(
|
|
hasExplicitFill ? fills : undefined,
|
|
w,
|
|
h,
|
|
opacity,
|
|
x,
|
|
y,
|
|
);
|
|
const closeCount = (rawD.match(/Z/gi) || []).length;
|
|
path.setFillType(closeCount > 1 ? ck.FillType.EvenOdd : ck.FillType.Winding);
|
|
canvas.drawPath(path, fillPaint);
|
|
fillPaint.delete();
|
|
}
|
|
if (hasVisibleStroke) {
|
|
const sp = this.makeStrokePaint(stroke, opacity);
|
|
if (sp) {
|
|
canvas.drawPath(path, sp);
|
|
sp.delete();
|
|
}
|
|
}
|
|
path.delete();
|
|
}
|
|
|
|
private drawIconFont(
|
|
canvas: Canvas,
|
|
node: PenNode,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
opacity: number,
|
|
) {
|
|
const ck = this.ck;
|
|
const iNode = node as IconFontNode;
|
|
const iconName = iNode.iconFontName ?? iNode.name ?? '';
|
|
const iconMatch = this.iconLookup?.(iconName) ?? null;
|
|
const iconD = iconMatch?.d ?? FALLBACK_ICON_D;
|
|
const iconStyle = iconMatch?.style ?? 'stroke';
|
|
|
|
const rawFill = iNode.fill;
|
|
const iconFillColor =
|
|
typeof rawFill === 'string'
|
|
? rawFill
|
|
: Array.isArray(iNode.fill) && iNode.fill.length > 0
|
|
? resolveFillColor(iNode.fill)
|
|
: '#64748B';
|
|
|
|
const sanitizedIconD = sanitizeSvgPath(iconD);
|
|
let path = ck.Path.MakeFromSVGString(sanitizedIconD);
|
|
if (!path && sanitizedIconD !== iconD) path = ck.Path.MakeFromSVGString(iconD);
|
|
if (!path) path = tryManualPathParse(ck, iconD);
|
|
if (!path) return;
|
|
|
|
const bounds = path.getBounds();
|
|
const nativeW = bounds[2] - bounds[0],
|
|
nativeH = bounds[3] - bounds[1];
|
|
if (w > 0 && h > 0 && nativeW > 0 && nativeH > 0) {
|
|
const s = Math.min(w / nativeW, h / nativeH);
|
|
path.transform(
|
|
ck.Matrix.multiply(
|
|
ck.Matrix.translated(x - bounds[0] * s, y - bounds[1] * s),
|
|
ck.Matrix.scaled(s, s),
|
|
),
|
|
);
|
|
} else {
|
|
path.offset(x, y);
|
|
}
|
|
|
|
const paint = new ck.Paint();
|
|
paint.setAntiAlias(true);
|
|
const c = parseColor(ck, iconFillColor);
|
|
c[3] *= opacity;
|
|
paint.setColor(c);
|
|
if (iconStyle === 'stroke') {
|
|
paint.setStyle(ck.PaintStyle.Stroke);
|
|
paint.setStrokeWidth(2);
|
|
paint.setStrokeCap(ck.StrokeCap.Round);
|
|
paint.setStrokeJoin(ck.StrokeJoin.Round);
|
|
} else {
|
|
paint.setStyle(ck.PaintStyle.Fill);
|
|
path.setFillType(ck.FillType.EvenOdd);
|
|
}
|
|
canvas.drawPath(path, paint);
|
|
paint.delete();
|
|
path.delete();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Image drawing
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private drawImage(
|
|
canvas: Canvas,
|
|
node: PenNode,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
opacity: number,
|
|
) {
|
|
const ck = this.ck;
|
|
const iNode = node as ImageNode;
|
|
const src: string | undefined = iNode.src;
|
|
const cr = cornerRadiusValue(iNode.cornerRadius);
|
|
|
|
if (!src) {
|
|
this.drawImageFallback(canvas, x, y, w, h, cr, opacity, false);
|
|
return;
|
|
}
|
|
|
|
const cached = this.imageLoader.get(src);
|
|
if (cached === undefined) {
|
|
this.imageLoader.request(src);
|
|
this.drawImageFallback(canvas, x, y, w, h, cr, opacity, false);
|
|
return;
|
|
}
|
|
if (!cached) {
|
|
const status = this.imageLoader.getStatus(src);
|
|
this.drawImageFallback(
|
|
canvas,
|
|
x,
|
|
y,
|
|
w,
|
|
h,
|
|
cr,
|
|
opacity,
|
|
status?.state === 'missing' || status?.state === 'error',
|
|
);
|
|
return;
|
|
}
|
|
|
|
const imgW = cached.width(),
|
|
imgH = cached.height();
|
|
|
|
if (cr > 0) {
|
|
canvas.save();
|
|
const maxR = Math.min(cr, w / 2, h / 2);
|
|
canvas.clipRRect(
|
|
ck.RRectXY(ck.LTRBRect(x, y, x + w, y + h), maxR, maxR),
|
|
ck.ClipOp.Intersect,
|
|
true,
|
|
);
|
|
} else {
|
|
canvas.save();
|
|
canvas.clipRect(ck.LTRBRect(x, y, x + w, y + h), ck.ClipOp.Intersect, true);
|
|
}
|
|
|
|
const paint = new ck.Paint();
|
|
paint.setAntiAlias(true);
|
|
if (opacity < 1) paint.setAlphaf(opacity);
|
|
const adjFilter = this.buildImageAdjustmentFilter(iNode);
|
|
if (adjFilter) paint.setColorFilter(adjFilter);
|
|
|
|
const fit = iNode.objectFit ?? 'fill';
|
|
if (fit === 'tile') {
|
|
const tileMatrix = Float32Array.of(1, 0, -x, 0, 1, -y, 0, 0, 1);
|
|
const shader = cached.makeShaderOptions(
|
|
ck.TileMode.Repeat,
|
|
ck.TileMode.Repeat,
|
|
ck.FilterMode.Linear,
|
|
ck.MipmapMode.None,
|
|
tileMatrix,
|
|
);
|
|
if (shader) {
|
|
paint.setShader(shader);
|
|
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), paint);
|
|
}
|
|
} else if (fit === 'fit') {
|
|
const bgPaint = new ck.Paint();
|
|
bgPaint.setStyle(ck.PaintStyle.Fill);
|
|
bgPaint.setColor(parseColor(ck, '#f3f4f6'));
|
|
if (opacity < 1) bgPaint.setAlphaf(opacity * 0.3);
|
|
else bgPaint.setAlphaf(0.3);
|
|
canvas.drawRect(ck.LTRBRect(x, y, x + w, y + h), bgPaint);
|
|
bgPaint.delete();
|
|
const scale = Math.min(w / imgW, h / imgH),
|
|
dw = imgW * scale,
|
|
dh = imgH * scale;
|
|
const dx = x + (w - dw) / 2,
|
|
dy = y + (h - dh) / 2;
|
|
canvas.drawImageRect(
|
|
cached,
|
|
ck.LTRBRect(0, 0, imgW, imgH),
|
|
ck.LTRBRect(dx, dy, dx + dw, dy + dh),
|
|
paint,
|
|
);
|
|
} else {
|
|
const scale = Math.max(w / imgW, h / imgH),
|
|
dw = imgW * scale,
|
|
dh = imgH * scale;
|
|
const dx = x + (w - dw) / 2,
|
|
dy = y + (h - dh) / 2;
|
|
canvas.drawImageRect(
|
|
cached,
|
|
ck.LTRBRect(0, 0, imgW, imgH),
|
|
ck.LTRBRect(dx, dy, dx + dw, dy + dh),
|
|
paint,
|
|
);
|
|
}
|
|
paint.delete();
|
|
canvas.restore();
|
|
}
|
|
|
|
private drawImageFallback(
|
|
canvas: Canvas,
|
|
x: number,
|
|
y: number,
|
|
w: number,
|
|
h: number,
|
|
cr: number,
|
|
opacity: number,
|
|
emphasizeMissing: boolean,
|
|
) {
|
|
const ck = this.ck;
|
|
const rect = ck.LTRBRect(x, y, x + w, y + h);
|
|
const maxR = Math.min(cr, w / 2, h / 2);
|
|
|
|
const fillPaint = new ck.Paint();
|
|
fillPaint.setStyle(ck.PaintStyle.Fill);
|
|
fillPaint.setAntiAlias(true);
|
|
const fillColor = parseColor(ck, emphasizeMissing ? '#f8d7da' : '#e5e7eb');
|
|
fillColor[3] *= opacity;
|
|
fillPaint.setColor(fillColor);
|
|
|
|
if (cr > 0) {
|
|
canvas.drawRRect(ck.RRectXY(rect, maxR, maxR), fillPaint);
|
|
} else {
|
|
canvas.drawRect(rect, fillPaint);
|
|
}
|
|
fillPaint.delete();
|
|
|
|
const strokePaint = new ck.Paint();
|
|
strokePaint.setStyle(ck.PaintStyle.Stroke);
|
|
strokePaint.setAntiAlias(true);
|
|
strokePaint.setStrokeWidth(Math.max(1, Math.min(w, h) * 0.02));
|
|
const strokeColor = parseColor(ck, emphasizeMissing ? '#c2410c' : '#94a3b8');
|
|
strokeColor[3] *= opacity;
|
|
strokePaint.setColor(strokeColor);
|
|
const dash = ck.PathEffect.MakeDash([8, 6], 0);
|
|
if (dash) strokePaint.setPathEffect(dash);
|
|
|
|
if (cr > 0) {
|
|
canvas.drawRRect(ck.RRectXY(rect, maxR, maxR), strokePaint);
|
|
} else {
|
|
canvas.drawRect(rect, strokePaint);
|
|
}
|
|
|
|
const iconPaint = new ck.Paint();
|
|
iconPaint.setStyle(ck.PaintStyle.Stroke);
|
|
iconPaint.setAntiAlias(true);
|
|
iconPaint.setStrokeWidth(Math.max(1.25, Math.min(w, h) * 0.03));
|
|
iconPaint.setStrokeCap(ck.StrokeCap.Round);
|
|
iconPaint.setStrokeJoin(ck.StrokeJoin.Round);
|
|
iconPaint.setColor(strokeColor);
|
|
|
|
const inset = Math.max(10, Math.min(w, h) * 0.18);
|
|
const iconLeft = x + inset;
|
|
const iconTop = y + inset;
|
|
const iconRight = x + w - inset;
|
|
const iconBottom = y + h - inset;
|
|
const iconPath = new ck.Path();
|
|
iconPath.moveTo(iconLeft, iconBottom);
|
|
iconPath.lineTo(x + w * 0.42, y + h * 0.58);
|
|
iconPath.lineTo(x + w * 0.58, y + h * 0.72);
|
|
iconPath.lineTo(iconRight, y + h * 0.42);
|
|
iconPath.moveTo(iconLeft, iconTop);
|
|
iconPath.lineTo(iconRight, iconTop);
|
|
iconPath.lineTo(iconRight, iconBottom);
|
|
iconPath.lineTo(iconLeft, iconBottom);
|
|
iconPath.close();
|
|
canvas.drawPath(iconPath, iconPaint);
|
|
iconPath.delete();
|
|
|
|
const dotPaint = new ck.Paint();
|
|
dotPaint.setStyle(ck.PaintStyle.Fill);
|
|
dotPaint.setAntiAlias(true);
|
|
dotPaint.setColor(strokeColor);
|
|
canvas.drawCircle(x + w * 0.35, y + h * 0.38, Math.max(3, Math.min(w, h) * 0.05), dotPaint);
|
|
|
|
if (emphasizeMissing) {
|
|
canvas.drawLine(
|
|
x + inset * 0.9,
|
|
y + h - inset * 0.9,
|
|
x + w - inset * 0.9,
|
|
y + inset * 0.9,
|
|
iconPaint,
|
|
);
|
|
}
|
|
|
|
strokePaint.delete();
|
|
iconPaint.delete();
|
|
dotPaint.delete();
|
|
}
|
|
}
|