canvas API

This commit is contained in:
Face
2025-08-31 14:17:34 +03:00
parent 322cb4072e
commit f973bb55f6
16 changed files with 3387 additions and 2 deletions

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3 uid="uid://b8x4k5man2m1p"]
[ext_resource type="Script" uid="uid://08xpof853sfh" path="res://Scripts/Tags/canvas.gd" id="1_canvas"]
[node name="canvas" type="ColorRect"]
custom_minimum_size = Vector2(300, 150)
offset_right = 300.0
offset_bottom = 150.0
size_flags_horizontal = 0
size_flags_vertical = 0
color = Color(0, 0, 0, 0)
script = ExtResource("1_canvas")

View File

@@ -1,4 +1,4 @@
[gd_scene load_steps=35 format=3 uid="uid://bytm7bt2s4ak8"]
[gd_scene load_steps=36 format=3 uid="uid://bytm7bt2s4ak8"]
[ext_resource type="Script" uid="uid://bg5iqnwic1rio" path="res://Scripts/main.gd" id="1_8q3xr"]
[ext_resource type="Texture2D" uid="uid://df1m4j7uxi63v" path="res://Assets/Icons/chevron-down.svg" id="2_6bp64"]
@@ -9,6 +9,7 @@
[ext_resource type="PackedScene" uid="uid://sqhcxhcre081" path="res://Scenes/Tab.tscn" id="4_344ge"]
[ext_resource type="Texture2D" uid="uid://cu4hjoba6etf" path="res://Assets/Icons/rotate-cw.svg" id="5_344ge"]
[ext_resource type="Texture2D" uid="uid://cehbtwq6gq0cn" path="res://Assets/Icons/plus.svg" id="5_ynf5e"]
[ext_resource type="Script" uid="uid://nve723radqih" path="res://Scripts/SearchBar.gd" id="9_gt3je"]
[ext_resource type="Texture2D" uid="uid://cklatjc4m38dy" path="res://Assets/Icons/ellipsis-vertical.svg" id="10_6iyac"]
[ext_resource type="Theme" uid="uid://bn6rbmdy60lhr" path="res://Scenes/Styles/BrowserText.tres" id="11_ee4r6"]
[ext_resource type="Script" uid="uid://vjjhljlftlbk" path="res://Scripts/OptionButton.gd" id="11_gt3je"]
@@ -201,6 +202,7 @@ size_flags_horizontal = 3
theme = SubResource("Theme_jjvhh")
placeholder_text = "Search or enter web address"
caret_blink = true
script = ExtResource("9_gt3je")
[node name="TextureRect" type="TextureRect" parent="VBoxContainer/HBoxContainer/LineEdit"]
layout_mode = 2

View File

@@ -691,6 +691,56 @@ func _handle_dom_operation(operation: Dictionary):
_handle_element_focus(operation)
"unfocus_element":
_handle_element_unfocus(operation)
"canvas_fillRect":
LuaCanvasUtils.handle_canvas_fillRect(operation, dom_parser)
"canvas_strokeRect":
LuaCanvasUtils.handle_canvas_strokeRect(operation, dom_parser)
"canvas_clearRect":
LuaCanvasUtils.handle_canvas_clearRect(operation, dom_parser)
"canvas_drawCircle":
LuaCanvasUtils.handle_canvas_drawCircle(operation, dom_parser)
"canvas_drawText":
LuaCanvasUtils.handle_canvas_drawText(operation, dom_parser)
"canvas_source":
LuaCanvasUtils.handle_canvas_source(operation, dom_parser)
"canvas_beginPath":
LuaCanvasUtils.handle_canvas_beginPath(operation, dom_parser)
"canvas_closePath":
LuaCanvasUtils.handle_canvas_closePath(operation, dom_parser)
"canvas_moveTo":
LuaCanvasUtils.handle_canvas_moveTo(operation, dom_parser)
"canvas_lineTo":
LuaCanvasUtils.handle_canvas_lineTo(operation, dom_parser)
"canvas_arc":
LuaCanvasUtils.handle_canvas_arc(operation, dom_parser)
"canvas_stroke":
LuaCanvasUtils.handle_canvas_stroke(operation, dom_parser)
"canvas_fill":
LuaCanvasUtils.handle_canvas_fill(operation, dom_parser)
# Transformation operations
"canvas_save":
LuaCanvasUtils.handle_canvas_save(operation, dom_parser)
"canvas_restore":
LuaCanvasUtils.handle_canvas_restore(operation, dom_parser)
"canvas_translate":
LuaCanvasUtils.handle_canvas_translate(operation, dom_parser)
"canvas_rotate":
LuaCanvasUtils.handle_canvas_rotate(operation, dom_parser)
"canvas_scale":
LuaCanvasUtils.handle_canvas_scale(operation, dom_parser)
"canvas_quadraticCurveTo":
LuaCanvasUtils.handle_canvas_quadraticCurveTo(operation, dom_parser)
"canvas_bezierCurveTo":
LuaCanvasUtils.handle_canvas_bezierCurveTo(operation, dom_parser)
# Style property operations
"canvas_setStrokeStyle":
LuaCanvasUtils.handle_canvas_setStrokeStyle(operation, dom_parser)
"canvas_setFillStyle":
LuaCanvasUtils.handle_canvas_setFillStyle(operation, dom_parser)
"canvas_setLineWidth":
LuaCanvasUtils.handle_canvas_setLineWidth(operation, dom_parser)
"canvas_setFont":
LuaCanvasUtils.handle_canvas_setFont(operation, dom_parser)
_:
pass # Unknown operation type, ignore

View File

@@ -0,0 +1,11 @@
extends LineEdit
# NOTE: this should be implemented to every Control element,
# so that it defocuses on click outside element, instead of focusing on another control node,
# but I find it impractical to simply paste to every script in Tags.
# Will hold onto the above for now, and only implement it in SearchBar for now
func _input(event: InputEvent):
if event is InputEventMouseButton and event.is_pressed() and event.button_index == 1:
var evLocal = make_input_local(event)
if !Rect2(Vector2(0,0), size).has_point(evLocal.position):
release_focus()

View File

@@ -0,0 +1 @@
uid://nve723radqih

View File

@@ -0,0 +1,412 @@
class_name HTMLCanvas
extends ColorRect
var canvas_element: HTMLParser.HTMLElement
var parser: HTMLParser
var canvas_width: int = 300
var canvas_height: int = 150
var draw_commands: Array = []
var context_2d: CanvasContext2D = null
var context_shader: CanvasContextShader = null
class CanvasContext2D:
var canvas: HTMLCanvas
# Drawing state
var current_path: PackedVector2Array = PackedVector2Array()
var path_started: bool = false
# Style properties
var stroke_style: String = "#000000"
var fill_style: String = "#000000"
var line_width: float = 1.0
var line_cap: String = "butt"
var line_join: String = "miter"
var font: String = "16px sans-serif"
var text_align: String = "start"
var text_baseline: String = "alphabetic"
# Transformation state
var transform_stack: Array = []
var current_transform: Transform2D = Transform2D.IDENTITY
func _init(canvas_ref: HTMLCanvas):
canvas = canvas_ref
func fillRect(x: float, y: float, width: float, height: float, color_hex: String = ""):
var color = _parse_color(fill_style if color_hex.is_empty() else color_hex)
var cmd = {
"type": "fillRect",
"x": x,
"y": y,
"width": width,
"height": height,
"color": color,
"transform": current_transform
}
canvas.draw_commands.append(cmd)
canvas.queue_redraw()
func strokeRect(x: float, y: float, width: float, height: float, color_hex: String = "", stroke_width: float = 0.0):
var color = _parse_color(stroke_style if color_hex.is_empty() else color_hex)
var width_val = line_width if stroke_width == 0.0 else stroke_width
var cmd = {
"type": "strokeRect",
"x": x,
"y": y,
"width": width,
"height": height,
"color": color,
"stroke_width": width_val,
"transform": current_transform
}
canvas.draw_commands.append(cmd)
canvas.queue_redraw()
func clearRect(x: float, y: float, width: float, height: float):
var cmd = {
"type": "clearRect",
"x": x,
"y": y,
"width": width,
"height": height
}
canvas.draw_commands.append(cmd)
canvas.queue_redraw()
func drawCircle(x: float, y: float, radius: float, color_hex: String = "#000000", filled: bool = true):
var cmd = {
"type": "circle",
"x": x,
"y": y,
"radius": radius,
"color": _parse_color(color_hex),
"filled": filled,
"transform": current_transform
}
canvas.draw_commands.append(cmd)
canvas.queue_redraw()
func drawText(x: float, y: float, text: String, color_hex: String = "#000000"):
var color = _parse_color(fill_style if color_hex == "#000000" else color_hex)
var cmd = {
"type": "text",
"x": x,
"y": y,
"text": text,
"color": color,
"font_size": _parse_font_size(font),
"transform": current_transform
}
canvas.draw_commands.append(cmd)
canvas.queue_redraw()
# Path-based drawing functions
func beginPath():
current_path.clear()
path_started = true
func closePath():
if current_path.size() > 0:
current_path.append(current_path[0])
func moveTo(x: float, y: float):
var point = current_transform * Vector2(x, y)
current_path.clear()
current_path.append(point)
path_started = true
func lineTo(x: float, y: float):
if not path_started:
moveTo(x, y)
return
var point = current_transform * Vector2(x, y)
current_path.append(point)
func arc(x: float, y: float, radius: float, start_angle: float, end_angle: float, counterclockwise: bool = false):
var segments = max(8, int(abs(end_angle - start_angle) * radius / 4))
var angle_step = (end_angle - start_angle) / segments
if counterclockwise:
angle_step = -angle_step
for i in range(segments + 1):
var angle = start_angle + i * angle_step
var point_x = x + cos(angle) * radius
var point_y = y + sin(angle) * radius
var point = current_transform * Vector2(point_x, point_y)
if i == 0 and current_path.is_empty():
current_path.append(point)
else:
current_path.append(point)
func stroke():
if current_path.size() < 2:
return
var cmd = {
"type": "stroke_path",
"path": current_path.duplicate(),
"color": _parse_color(stroke_style),
"line_width": line_width,
"line_cap": line_cap,
"line_join": line_join
}
canvas.draw_commands.append(cmd)
canvas.queue_redraw()
func fill():
if current_path.size() < 3:
return
var cmd = {
"type": "fill_path",
"path": current_path.duplicate(),
"color": _parse_color(fill_style)
}
canvas.draw_commands.append(cmd)
canvas.queue_redraw()
# Transformation functions
func save():
transform_stack.append({
"transform": current_transform,
"stroke_style": stroke_style,
"fill_style": fill_style,
"line_width": line_width,
"line_cap": line_cap,
"line_join": line_join,
"font": font,
"text_align": text_align,
"text_baseline": text_baseline
})
func restore():
if transform_stack.size() > 0:
var state = transform_stack.pop_back()
current_transform = state.transform
stroke_style = state.stroke_style
fill_style = state.fill_style
line_width = state.line_width
line_cap = state.line_cap
line_join = state.line_join
font = state.font
text_align = state.text_align
text_baseline = state.text_baseline
func translate(x: float, y: float):
current_transform = current_transform.translated(Vector2(x, y))
func rotate(angle: float):
var cos_a = cos(angle)
var sin_a = sin(angle)
var new_x = Vector2(
current_transform.x.x * cos_a - current_transform.x.y * sin_a,
current_transform.x.x * sin_a + current_transform.x.y * cos_a
)
var new_y = Vector2(
current_transform.y.x * cos_a - current_transform.y.y * sin_a,
current_transform.y.x * sin_a + current_transform.y.y * cos_a
)
current_transform = Transform2D(new_x, new_y, current_transform.origin)
func scale(x: float, y: float):
current_transform = Transform2D(
current_transform.x * x,
current_transform.y * y,
current_transform.origin
)
# Advanced drawing functions
func quadraticCurveTo(cpx: float, cpy: float, x: float, y: float):
if current_path.is_empty():
moveTo(0, 0)
var start_point = current_path[current_path.size() - 1]
var control_point = current_transform * Vector2(cpx, cpy)
var end_point = current_transform * Vector2(x, y)
# Approximate quadratic curve with line segments
var segments = 20
for i in range(1, segments + 1):
var t = float(i) / segments
var point = start_point.lerp(control_point, t).lerp(control_point.lerp(end_point, t), t)
current_path.append(point)
func bezierCurveTo(cp1x: float, cp1y: float, cp2x: float, cp2y: float, x: float, y: float):
if current_path.is_empty():
moveTo(0, 0)
var start_point = current_path[current_path.size() - 1]
var cp1 = current_transform * Vector2(cp1x, cp1y)
var cp2 = current_transform * Vector2(cp2x, cp2y)
var end_point = current_transform * Vector2(x, y)
# Approximate cubic bezier with line segments
var segments = 20
for i in range(1, segments + 1):
var t = float(i) / segments
var inv_t = 1.0 - t
var point = start_point * (inv_t * inv_t * inv_t) + \
cp1 * (3 * inv_t * inv_t * t) + \
cp2 * (3 * inv_t * t * t) + \
end_point * (t * t * t)
current_path.append(point)
# Style setters
func setFont(font_str: String):
font = font_str
func setStrokeStyle(style: String):
stroke_style = style
func setFillStyle(style: String):
fill_style = style
func setLineWidth(width: float):
line_width = width
func measureText(text: String) -> Dictionary:
var font_resource = ThemeDB.fallback_font
var font_size = _parse_font_size(font)
var text_size = font_resource.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size)
return {"width": text_size.x}
func _parse_font_size(font_str: String) -> int:
var regex = RegEx.new()
regex.compile(r"(\d+)px")
var result = regex.search(font_str)
if result:
return int(result.get_string(1))
return 16
func _parse_color(color_str: String) -> Color:
return ColorUtils.parse_color(color_str)
class CanvasContextShader:
var canvas: HTMLCanvas
func _init(canvas_ref: HTMLCanvas):
canvas = canvas_ref
func source(shader_code: String):
var shader = Shader.new()
shader.code = shader_code
var material = ShaderMaterial.new()
material.shader = shader
canvas.material = material
func init(element: HTMLParser.HTMLElement, _parser: HTMLParser):
canvas_element = element
parser = _parser
var width_attr = element.get_attribute("width", "300")
var height_attr = element.get_attribute("height", "150")
canvas_width = int(width_attr)
canvas_height = int(height_attr)
custom_minimum_size = Vector2(canvas_width, canvas_height)
size = Vector2(canvas_width, canvas_height)
color = Color.TRANSPARENT
clip_contents = true
parser.register_dom_node(element, self)
func withContext(context_type: String):
match context_type:
"2d":
if context_2d == null:
context_2d = CanvasContext2D.new(self)
return context_2d
"shader":
if context_shader == null:
context_shader = CanvasContextShader.new(self)
return context_shader
_:
return null
func _draw():
draw_rect(Rect2(Vector2.ZERO, size), Color.TRANSPARENT)
for cmd in draw_commands:
match cmd.type:
"fillRect":
var transform = cmd.get("transform", Transform2D.IDENTITY)
if transform != Transform2D.IDENTITY:
var corners = PackedVector2Array([
transform * Vector2(cmd.x, cmd.y),
transform * Vector2(cmd.x + cmd.width, cmd.y),
transform * Vector2(cmd.x + cmd.width, cmd.y + cmd.height),
transform * Vector2(cmd.x, cmd.y + cmd.height)
])
draw_colored_polygon(corners, cmd.color)
else:
var pos = Vector2(cmd.x, cmd.y)
var sz = Vector2(cmd.width, cmd.height)
draw_rect(Rect2(pos, sz), cmd.color)
"strokeRect":
var transform = cmd.get("transform", Transform2D.IDENTITY)
var stroke_width = cmd.get("stroke_width", 1.0)
if transform != Transform2D.IDENTITY:
# Draw as stroke polygon for transformed rectangles
var corners = PackedVector2Array([
transform * Vector2(cmd.x, cmd.y),
transform * Vector2(cmd.x + cmd.width, cmd.y),
transform * Vector2(cmd.x + cmd.width, cmd.y + cmd.height),
transform * Vector2(cmd.x, cmd.y + cmd.height),
transform * Vector2(cmd.x, cmd.y) # Close the path
])
for i in range(corners.size() - 1):
draw_line(corners[i], corners[i + 1], cmd.color, stroke_width)
else:
var pos = Vector2(cmd.x, cmd.y)
# Draw stroke as four rectangles
draw_rect(Rect2(pos.x, pos.y, cmd.width, stroke_width), cmd.color)
draw_rect(Rect2(pos.x, pos.y + cmd.height - stroke_width, cmd.width, stroke_width), cmd.color)
draw_rect(Rect2(pos.x, pos.y, stroke_width, cmd.height), cmd.color)
draw_rect(Rect2(pos.x + cmd.width - stroke_width, pos.y, stroke_width, cmd.height), cmd.color)
"clearRect":
# Clear a rectangular area by painting with background color
var clear_rect = Rect2(cmd.x, cmd.y, cmd.width, cmd.height)
# Most canvases have a white or light background - paint with that
draw_rect(clear_rect, Color.WHITE)
"circle":
var transform = cmd.get("transform", Transform2D.IDENTITY)
var center = transform * Vector2(cmd.x, cmd.y)
if cmd.filled:
draw_circle(center, cmd.radius, cmd.color)
else:
# For stroke circle, we need to draw a ring - approximated with arc segments
var segments = 32
var points = PackedVector2Array()
for i in range(segments + 1):
var angle = (i * TAU) / segments
var point = Vector2(cmd.x + cos(angle) * cmd.radius, cmd.y + sin(angle) * cmd.radius)
points.append(transform * point)
if points.size() > 1:
for i in range(points.size() - 1):
draw_line(points[i], points[i + 1], cmd.color, 1.0)
"text":
var font = ThemeDB.fallback_font
var transform = cmd.get("transform", Transform2D.IDENTITY)
var pos = transform * Vector2(cmd.x, cmd.y)
var font_size = cmd.get("font_size", 16)
draw_string(font, pos, cmd.text, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, cmd.color)
"stroke_path":
var path = cmd.path
var clr = cmd.color
var line_width = cmd.get("line_width", 1.0)
if path.size() > 1:
for i in range(path.size() - 1):
draw_line(path[i], path[i + 1], clr, line_width)
"fill_path":
var path = cmd.path
var clr = cmd.color
if path.size() > 2:
draw_colored_polygon(path, clr)
func clear():
draw_commands.clear()
queue_redraw()

View File

@@ -0,0 +1 @@
uid://08xpof853sfh

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://cyngvqm6wghoh

View File

@@ -645,6 +645,9 @@ static func add_element_methods(vm: LuauVM, lua_api: LuaAPI) -> void:
vm.lua_pushcallable(LuaDOMUtils._element_unfocus_wrapper, "element.unfocus")
vm.lua_setfield(-2, "unfocus")
vm.lua_pushcallable(LuaCanvasUtils._element_withContext_wrapper, "element.withContext")
vm.lua_setfield(-2, "withContext")
add_classlist_support(vm)
vm.lua_newtable()

View File

@@ -30,6 +30,7 @@ const TEXTAREA = preload("res://Scenes/Tags/textarea.tscn")
const DIV = preload("res://Scenes/Tags/div.tscn")
const AUDIO = preload("res://Scenes/Tags/audio.tscn")
const POSTPROCESS = preload("res://Scenes/Tags/postprocess.tscn")
const CANVAS = preload("res://Scenes/Tags/canvas.tscn")
const MIN_SIZE = Vector2i(750, 200)
@@ -324,7 +325,7 @@ func render_content(html_bytes: PackedByteArray) -> void:
if element_node:
# Input elements register their own DOM nodes in their init() function
if element.tag_name not in ["input", "textarea", "select", "button", "audio"]:
if element.tag_name not in ["input", "textarea", "select", "button", "audio", "canvas"]:
parser.register_dom_node(element, element_node)
# ul/ol handle their own adding
@@ -606,6 +607,9 @@ func create_element_node_internal(element: HTMLParser.HTMLElement, parser: HTMLP
"audio":
node = AUDIO.instantiate()
node.init(element, parser)
"canvas":
node = CANVAS.instantiate()
node.init(element, parser)
"div":
var styles = parser.get_element_styles_with_inheritance(element, "", [])
var hover_styles = parser.get_element_styles_with_inheritance(element, "hover", [])