kaplay-plugin-text
Version:
KAPLAY plugin for styled text
1 lines • 12.7 kB
Source Map (JSON)
{"version":3,"file":"plugin.mjs","names":[],"sources":["../src/plugin.ts"],"sourcesContent":["import type {\n Anchor,\n Color,\n Comp,\n KAPLAYCtx,\n Outline,\n Rect,\n SpriteData,\n TextAlign,\n Uniform,\n} from 'kaplay';\n\n/** Drop shadow style for styled text. */\nexport interface StyledTextShadow {\n color: Color;\n offsetX: number;\n offsetY: number;\n blur: number;\n}\n\n/** Gradient style for styled text. */\nexport interface StyledTextGradient {\n from: Color;\n to: Color;\n direction: 'horizontal' | 'vertical';\n}\n\n/** Options for the {@link styledText} component. */\nexport interface StyledTextCompOpt {\n size?: number;\n font?: string;\n fill?: Color;\n outline?: Outline;\n shadow?: StyledTextShadow;\n gradient?: StyledTextGradient;\n align?: TextAlign;\n width?: number;\n lineSpacing?: number;\n}\n\n/** The {@link styledText} component. */\nexport interface StyledTextComp extends Comp {\n id: 'styledText';\n text: string;\n width: number;\n height: number;\n renderArea(): Rect;\n setStyle(opt: Partial<StyledTextCompOpt>): void;\n}\n\ndeclare module 'kaplay' {\n interface KAPLAYCtx {\n styledText: (txt?: string, opt?: StyledTextCompOpt) => StyledTextComp;\n }\n}\n\n/** Convert a KAPLAY {@link Color} to a CSS string. */\nfunction colorToCss(color: Color): string {\n return `rgb(${String(color.r)}, ${String(color.g)}, ${String(color.b)})`;\n}\n\n/** Resolve the font from options, KAPLAY init, or default. */\nfunction resolveFont(k: KAPLAYCtx, font?: string): string {\n if (font) return font;\n const initFont = (k as { _k?: { globalOpt?: { font?: string } } })._k\n ?.globalOpt?.font;\n if (initFont) return initFont;\n return 'monospace';\n}\n\n/** Styled text plugin for KAPLAY. */\nexport function styledTextPlugin(k: KAPLAYCtx) {\n const styledText = (\n txt = '',\n opt: StyledTextCompOpt = {},\n ): StyledTextComp => {\n let currentText = txt;\n let currentOpt: StyledTextCompOpt = { ...opt };\n let canvas: HTMLCanvasElement | null = null;\n let ctx: CanvasRenderingContext2D | null = null;\n let spriteData: SpriteData | null = null;\n let textWidth = 0;\n let textHeight = 0;\n let lastCanvasWidth = 0;\n let lastCanvasHeight = 0;\n let currentPadding = 0;\n\n const getFont = (): string => {\n return resolveFont(k, currentOpt.font);\n };\n\n const render = (): void => {\n if (!canvas || !ctx) return;\n\n const font = getFont();\n const fontSize = currentOpt.size ?? 48;\n const textAlign = currentOpt.align ?? 'left';\n const ls = currentOpt.lineSpacing ?? 0;\n const outlineWidth = currentOpt.outline?.width ?? 0;\n const shadowOffsetX = currentOpt.shadow?.offsetX ?? 0;\n const shadowOffsetY = currentOpt.shadow?.offsetY ?? 0;\n const shadowBlur = currentOpt.shadow?.blur ?? 0;\n const padding =\n Math.max(outlineWidth, shadowBlur) +\n Math.abs(shadowOffsetX) +\n Math.abs(shadowOffsetY) +\n 2;\n currentPadding = padding;\n\n ctx.font = `${String(fontSize)}px ${font}`;\n ctx.textAlign = textAlign;\n ctx.textBaseline = 'top';\n\n // Handle wrapping\n const wrapW = currentOpt.width;\n const lines = wrapText(ctx, currentText, wrapW);\n const lineHeight = fontSize + ls;\n\n // Measure widest line\n let maxLineWidth = 0;\n for (const line of lines) {\n const metrics = ctx.measureText(line);\n maxLineWidth = Math.max(maxLineWidth, metrics.width);\n }\n\n const canvasWidth = Math.ceil(maxLineWidth + padding * 2);\n const canvasHeight = Math.ceil(lines.length * lineHeight + padding * 2);\n\n // Resize canvas if dimensions changed\n if (canvas.width !== canvasWidth || canvas.height !== canvasHeight) {\n canvas.width = canvasWidth;\n canvas.height = canvasHeight;\n // Re-set context properties after resize (they reset)\n ctx.font = `${String(fontSize)}px ${font}`;\n ctx.textAlign = textAlign;\n ctx.textBaseline = 'top';\n }\n\n // Clear canvas\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n\n // Draw each line\n for (let i = 0; i < lines.length; i++) {\n const line = lines[i];\n const x = padding;\n const y = padding + i * lineHeight;\n\n // Apply shadow\n if (currentOpt.shadow) {\n ctx.shadowColor = colorToCss(currentOpt.shadow.color);\n ctx.shadowOffsetX = currentOpt.shadow.offsetX;\n ctx.shadowOffsetY = currentOpt.shadow.offsetY;\n ctx.shadowBlur = currentOpt.shadow.blur;\n }\n\n // Draw outline (stroke)\n if (currentOpt.outline) {\n ctx.strokeStyle = currentOpt.outline.color\n ? colorToCss(currentOpt.outline.color)\n : 'black';\n ctx.lineWidth = currentOpt.outline.width ?? 1;\n ctx.lineJoin = 'round';\n ctx.strokeText(line, x, y);\n\n // Reset shadow before fill to avoid double shadow\n ctx.shadowColor = 'transparent';\n ctx.shadowOffsetX = 0;\n ctx.shadowOffsetY = 0;\n ctx.shadowBlur = 0;\n }\n\n // Apply fill or gradient\n if (currentOpt.gradient) {\n const grad =\n currentOpt.gradient.direction === 'vertical'\n ? ctx.createLinearGradient(0, padding, 0, canvasHeight - padding)\n : ctx.createLinearGradient(padding, 0, canvasWidth - padding, 0);\n grad.addColorStop(0, colorToCss(currentOpt.gradient.from));\n grad.addColorStop(1, colorToCss(currentOpt.gradient.to));\n ctx.fillStyle = grad;\n } else if (currentOpt.fill) {\n ctx.fillStyle = colorToCss(currentOpt.fill);\n } else {\n ctx.fillStyle = 'white';\n }\n\n ctx.fillText(line, x, y);\n }\n\n textWidth = canvasWidth;\n textHeight = canvasHeight;\n\n // Update or create sprite data\n if (\n spriteData &&\n lastCanvasWidth === canvasWidth &&\n lastCanvasHeight === canvasHeight\n ) {\n const frame = spriteData.frames[0];\n const texX = Math.round(frame.x * spriteData.tex.width);\n const texY = Math.round(frame.y * spriteData.tex.height);\n spriteData.tex.update(canvas, texX, texY);\n } else {\n if (spriteData) {\n spriteData.tex.free();\n }\n spriteData = k.SpriteData.fromImage(canvas, { singular: true });\n lastCanvasWidth = canvasWidth;\n lastCanvasHeight = canvasHeight;\n }\n };\n\n const wrapText = (\n c: CanvasRenderingContext2D,\n text: string,\n maxWidth?: number,\n ): string[] => {\n if (!maxWidth) return text.split('\\n');\n const allLines: string[] = [];\n for (const paragraph of text.split('\\n')) {\n const words = paragraph.split(' ');\n let currentLine = '';\n for (const word of words) {\n const testLine = currentLine ? `${currentLine} ${word}` : word;\n const metrics = c.measureText(testLine);\n if (metrics.width > maxWidth && currentLine) {\n allLines.push(currentLine);\n currentLine = word;\n } else {\n currentLine = testLine;\n }\n }\n allLines.push(currentLine);\n }\n return allLines;\n };\n\n const comp: StyledTextComp = {\n id: 'styledText',\n get text() {\n return currentText;\n },\n set text(value: string) {\n currentText = value;\n render();\n },\n get width() {\n return textWidth;\n },\n get height() {\n return textHeight;\n },\n renderArea(): Rect {\n return new k.Rect(k.vec2(0), textWidth, textHeight);\n },\n setStyle(opt: Partial<StyledTextCompOpt>): void {\n currentOpt = { ...currentOpt, ...opt };\n render();\n },\n add() {\n canvas = document.createElement('canvas');\n ctx = canvas.getContext('2d');\n if (ctx) {\n render();\n }\n },\n draw() {\n if (spriteData) {\n const obj = this as unknown as {\n anchor?: Anchor;\n color?: Color;\n opacity?: number;\n outline?: Outline;\n shader?: string;\n uniform?: Uniform;\n flipX?: boolean;\n flipY?: boolean;\n };\n k.drawSprite({\n sprite: spriteData,\n pos: k.vec2(-currentPadding, -currentPadding),\n anchor: obj.anchor,\n color: obj.color,\n opacity: obj.opacity,\n outline: obj.outline,\n shader: obj.shader,\n uniform: obj.uniform,\n flipX: obj.flipX,\n flipY: obj.flipY,\n });\n }\n },\n destroy() {\n if (spriteData) {\n spriteData.tex.free();\n spriteData = null;\n }\n canvas = null;\n ctx = null;\n },\n };\n\n return comp;\n };\n\n return { styledText };\n}\n"],"mappings":"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"}