diff --git a/addons/anthonyec.camera_preview/GuiResizerTopLeft.svg b/addons/anthonyec.camera_preview/GuiResizerTopLeft.svg
new file mode 100644
index 0000000..fe4dbf5
--- /dev/null
+++ b/addons/anthonyec.camera_preview/GuiResizerTopLeft.svg
@@ -0,0 +1 @@
+
diff --git a/addons/anthonyec.camera_preview/GuiResizerTopLeft.svg.import b/addons/anthonyec.camera_preview/GuiResizerTopLeft.svg.import
new file mode 100644
index 0000000..9584d3b
--- /dev/null
+++ b/addons/anthonyec.camera_preview/GuiResizerTopLeft.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://btc01wc11tiid"
+path="res://.godot/imported/GuiResizerTopLeft.svg-eb563f557424c74239e878a1213a5bf4.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/anthonyec.camera_preview/GuiResizerTopLeft.svg"
+dest_files=["res://.godot/imported/GuiResizerTopLeft.svg-eb563f557424c74239e878a1213a5bf4.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=2.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/anthonyec.camera_preview/GuiResizerTopRight.svg b/addons/anthonyec.camera_preview/GuiResizerTopRight.svg
new file mode 100644
index 0000000..dd00953
--- /dev/null
+++ b/addons/anthonyec.camera_preview/GuiResizerTopRight.svg
@@ -0,0 +1 @@
+
diff --git a/addons/anthonyec.camera_preview/GuiResizerTopRight.svg.import b/addons/anthonyec.camera_preview/GuiResizerTopRight.svg.import
new file mode 100644
index 0000000..4a1fa5d
--- /dev/null
+++ b/addons/anthonyec.camera_preview/GuiResizerTopRight.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://04l05jxuyt7k"
+path="res://.godot/imported/GuiResizerTopRight.svg-cc1dc8556d51357c5eb0b01d09d8f049.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/anthonyec.camera_preview/GuiResizerTopRight.svg"
+dest_files=["res://.godot/imported/GuiResizerTopRight.svg-cc1dc8556d51357c5eb0b01d09d8f049.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=2.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/anthonyec.camera_preview/Pin.svg b/addons/anthonyec.camera_preview/Pin.svg
new file mode 100644
index 0000000..8e5935c
--- /dev/null
+++ b/addons/anthonyec.camera_preview/Pin.svg
@@ -0,0 +1 @@
+
diff --git a/addons/anthonyec.camera_preview/Pin.svg.import b/addons/anthonyec.camera_preview/Pin.svg.import
new file mode 100644
index 0000000..27d274f
--- /dev/null
+++ b/addons/anthonyec.camera_preview/Pin.svg.import
@@ -0,0 +1,37 @@
+[remap]
+
+importer="texture"
+type="CompressedTexture2D"
+uid="uid://do6d60od41vmg"
+path="res://.godot/imported/Pin.svg-83b09f5c00a829c5d8b136bf5bae65bc.ctex"
+metadata={
+"vram_texture": false
+}
+
+[deps]
+
+source_file="res://addons/anthonyec.camera_preview/Pin.svg"
+dest_files=["res://.godot/imported/Pin.svg-83b09f5c00a829c5d8b136bf5bae65bc.ctex"]
+
+[params]
+
+compress/mode=0
+compress/high_quality=false
+compress/lossy_quality=0.7
+compress/hdr_compression=1
+compress/normal_map=0
+compress/channel_pack=0
+mipmaps/generate=false
+mipmaps/limit=-1
+roughness/mode=0
+roughness/src_normal=""
+process/fix_alpha_border=true
+process/premult_alpha=false
+process/normal_map_invert_y=false
+process/hdr_as_srgb=false
+process/hdr_clamp_exposure=false
+process/size_limit=0
+detect_3d/compress_to=1
+svg/scale=2.0
+editor/scale_with_editor_scale=false
+editor/convert_colors_with_editor_theme=false
diff --git a/addons/anthonyec.camera_preview/plugin.cfg b/addons/anthonyec.camera_preview/plugin.cfg
new file mode 100644
index 0000000..4ad0d4c
--- /dev/null
+++ b/addons/anthonyec.camera_preview/plugin.cfg
@@ -0,0 +1,7 @@
+[plugin]
+
+name="Little Camera Preview"
+description="Shows a picture-in-picture preview of the selected 2D or 3D camera"
+author="Anthony Cossins"
+version="1.3"
+script="plugin.gd"
diff --git a/addons/anthonyec.camera_preview/plugin.gd b/addons/anthonyec.camera_preview/plugin.gd
new file mode 100644
index 0000000..4e74dd8
--- /dev/null
+++ b/addons/anthonyec.camera_preview/plugin.gd
@@ -0,0 +1,87 @@
+@tool
+extends EditorPlugin
+
+const preview_scene = preload("res://addons/anthonyec.camera_preview/preview.tscn")
+
+var preview: CameraPreview
+var current_main_screen_name: String
+
+func _enter_tree() -> void:
+ main_screen_changed.connect(_on_main_screen_changed)
+ EditorInterface.get_selection().selection_changed.connect(_on_editor_selection_changed)
+
+ # Initialise preview panel and add to main screen.
+ preview = preview_scene.instantiate() as CameraPreview
+ preview.request_hide()
+
+ var main_screen = EditorInterface.get_editor_main_screen()
+ main_screen.add_child(preview)
+
+func _exit_tree() -> void:
+ if preview:
+ preview.queue_free()
+
+func _ready() -> void:
+ # TODO: Currently there is no API to get the main screen name without
+ # listening to the `EditorPlugin.main_screen_changed` signal:
+ # https://github.com/godotengine/godot-proposals/issues/2081
+ EditorInterface.set_main_screen_editor("Script")
+ EditorInterface.set_main_screen_editor("3D")
+
+func _on_main_screen_changed(screen_name: String) -> void:
+ current_main_screen_name = screen_name
+
+ # TODO: Bit of a hack to prevent pinned staying between view changes on the same scene.
+ preview.unlink_camera()
+ _on_editor_selection_changed()
+
+func _on_editor_selection_changed() -> void:
+ if not is_main_screen_viewport():
+ # This hides the preview "container" and not the preview itself, allowing
+ # any locked previews to remain visible once switching back to 3D tab.
+ preview.visible = false
+ return
+
+ preview.visible = true
+
+ var selected_nodes = EditorInterface.get_selection().get_selected_nodes()
+
+ var selected_camera_3d: Camera3D = find_camera_3d_or_null(selected_nodes)
+ var selected_camera_2d: Camera2D = find_camera_2d_or_null(selected_nodes)
+
+ if selected_camera_3d and current_main_screen_name == "3D":
+ preview.link_with_camera_3d(selected_camera_3d)
+ preview.request_show()
+
+ elif selected_camera_2d and current_main_screen_name == "2D":
+ preview.link_with_camera_2d(selected_camera_2d)
+ preview.request_show()
+
+ else:
+ preview.request_hide()
+
+func is_main_screen_viewport() -> bool:
+ return current_main_screen_name == "3D" or current_main_screen_name == "2D"
+
+func find_camera_3d_or_null(nodes: Array[Node]) -> Camera3D:
+ var camera: Camera3D
+
+ for node in nodes:
+ if node is Camera3D:
+ camera = node as Camera3D
+ break
+
+ return camera
+
+func find_camera_2d_or_null(nodes: Array[Node]) -> Camera2D:
+ var camera: Camera2D
+
+ for node in nodes:
+ if node is Camera2D:
+ camera = node as Camera2D
+ break
+
+ return camera
+
+func _on_selected_camera_3d_tree_exiting() -> void:
+ preview.unlink_camera()
diff --git a/addons/anthonyec.camera_preview/preview.gd b/addons/anthonyec.camera_preview/preview.gd
new file mode 100644
index 0000000..3c07d04
--- /dev/null
+++ b/addons/anthonyec.camera_preview/preview.gd
@@ -0,0 +1,404 @@
+@tool
+
+class_name CameraPreview
+extends Control
+
+enum CameraType {
+ CAMERA_2D,
+ CAMERA_3D
+}
+
+enum PinnedPosition {
+ LEFT,
+ RIGHT,
+}
+
+enum InteractionState {
+ NONE,
+ RESIZE,
+ DRAG,
+
+ # Animation is split into 2 seperate states so that the tween is only
+ # invoked once in the "start" state.
+ START_ANIMATE_INTO_PLACE,
+ ANIMATE_INTO_PLACE,
+}
+
+const margin_3d: Vector2 = Vector2(10, 10)
+const margin_2d: Vector2 = Vector2(20, 15)
+const panel_margin: float = 2
+const min_panel_size: float = 250
+
+@onready var panel: Panel = %Panel
+@onready var placeholder: Panel = %Placeholder
+@onready var preview_camera_3d: Camera3D = %Camera3D
+@onready var preview_camera_2d: Camera2D = %Camera2D
+@onready var sub_viewport: SubViewport = %SubViewport
+@onready var sub_viewport_text_rect: TextureRect = %TextureRect
+@onready var resize_left_handle: Button = %ResizeLeftHandle
+@onready var resize_right_handle: Button = %ResizeRightHandle
+@onready var lock_button: Button = %LockButton
+@onready var gradient: TextureRect = %Gradient
+@onready var viewport_margin_container: MarginContainer = %ViewportMarginContainer
+@onready var overlay_margin_container: MarginContainer = %OverlayMarginContainer
+@onready var overlay_container: Control = %OverlayContainer
+
+var camera_type: CameraType = CameraType.CAMERA_3D
+var pinned_position: PinnedPosition = PinnedPosition.RIGHT
+var viewport_ratio: float = 1
+var editor_scale: float = EditorInterface.get_editor_scale()
+var is_locked: bool
+var show_controls: bool
+var selected_camera_3d: Camera3D
+var selected_camera_2d: Camera2D
+
+var state: InteractionState = InteractionState.NONE
+var initial_mouse_position: Vector2
+var initial_panel_size: Vector2
+var initial_panel_position: Vector2
+
+func _ready() -> void:
+ # Set initial width.
+ panel.size.x = min_panel_size * editor_scale
+
+ # Setting texture to viewport in code instead of directly in the editor
+ # because otherwise an error "Path to node is invalid: Panel/SubViewport"
+ # on first load. This is harmless but doesn't look great.
+ #
+ # This is a known issue:
+ # https://github.com/godotengine/godot/issues/27790#issuecomment-499740220
+ sub_viewport_text_rect.texture = sub_viewport.get_texture()
+
+ # From what I can tell there's something wrong with how an editor theme
+ # scales when used within a plugin. It seems to ignore the screen scale.
+ # For instance, a 30x30px button will appear tiny on a retina display.
+ #
+ # Someone else had the issue with no luck:
+ # https://forum.godotengine.org/t/how-to-scale-plugin-controls-to-look-the-same-in-4k-as-1080p/36151
+ #
+ # And seems Dialogic also scales buttons manually:
+ # https://github.com/dialogic-godot/dialogic/blob/master/addons/dialogic/Editor/Common/sidebar.gd#L25C6-L38
+ #
+ # Maybe I don't know the correct way to do it, so for now the workaround is
+ # to set the correct size in code using screen scale.
+ var button_size = Vector2(30, 30) * editor_scale
+ var margin_size: float = panel_margin * editor_scale
+
+ resize_left_handle.size = button_size
+ resize_left_handle.pivot_offset = Vector2(0, 0) * editor_scale
+
+ resize_right_handle.size = button_size
+ resize_right_handle.pivot_offset = Vector2(30, 30) * editor_scale
+
+ lock_button.size = button_size
+ lock_button.pivot_offset = Vector2(0, 30) * editor_scale
+
+ viewport_margin_container.add_theme_constant_override("margin_left", margin_size)
+ viewport_margin_container.add_theme_constant_override("margin_top", margin_size)
+ viewport_margin_container.add_theme_constant_override("margin_right", margin_size)
+ viewport_margin_container.add_theme_constant_override("margin_bottom", margin_size)
+
+ overlay_margin_container.add_theme_constant_override("margin_left", margin_size)
+ overlay_margin_container.add_theme_constant_override("margin_top", margin_size)
+ overlay_margin_container.add_theme_constant_override("margin_right", margin_size)
+ overlay_margin_container.add_theme_constant_override("margin_bottom", margin_size)
+
+ # Parent node overlay size is not available on first ready, need to wait a
+ # frame for it to be drawn.
+ await get_tree().process_frame
+
+ # Anchors are set in code because setting them in the editor UI doesn't take
+ # editor scale into account.
+ resize_left_handle.position = Vector2(0, 0)
+ resize_right_handle.set_anchors_preset(Control.PRESET_TOP_LEFT)
+
+ resize_right_handle.position = Vector2(overlay_container.size.x - button_size.x, 0)
+ resize_right_handle.set_anchors_preset(Control.PRESET_TOP_RIGHT)
+
+ lock_button.position = Vector2(0, overlay_container.size.y - button_size.y)
+ lock_button.set_anchors_preset(Control.PRESET_BOTTOM_LEFT)
+
+func _process(_delta: float) -> void:
+ if not visible: return
+
+ match state:
+ InteractionState.NONE:
+ panel.size = get_clamped_size(panel.size)
+ panel.position = get_pinned_position(pinned_position)
+
+ InteractionState.RESIZE:
+ var delta_mouse_position = initial_mouse_position - get_global_mouse_position()
+ var resized_size = panel.size
+
+ if pinned_position == PinnedPosition.LEFT:
+ resized_size = initial_panel_size - delta_mouse_position
+
+ if pinned_position == PinnedPosition.RIGHT:
+ resized_size = initial_panel_size + delta_mouse_position
+
+ panel.size = get_clamped_size(resized_size)
+ panel.position = get_pinned_position(pinned_position)
+
+ InteractionState.DRAG:
+ placeholder.size = panel.size
+
+ var global_mouse_position = get_global_mouse_position()
+ var offset = initial_mouse_position - initial_panel_position
+
+ panel.global_position = global_mouse_position - offset
+
+ if global_mouse_position.x < global_position.x + size.x / 2:
+ pinned_position = PinnedPosition.LEFT
+ else:
+ pinned_position = PinnedPosition.RIGHT
+
+ placeholder.position = get_pinned_position(pinned_position)
+
+ InteractionState.START_ANIMATE_INTO_PLACE:
+ var final_position: Vector2 = get_pinned_position(pinned_position)
+ var tween = get_tree().create_tween()
+
+ tween.set_ease(Tween.EASE_OUT)
+ tween.set_trans(Tween.TRANS_CUBIC)
+ tween.tween_property(panel, "position", final_position, 0.3)
+
+ tween.finished.connect(func():
+ panel.position = final_position
+ state = InteractionState.NONE
+ )
+
+ state = InteractionState.ANIMATE_INTO_PLACE
+
+ # I couldn't get `mouse_entered` and `mouse_exited` events to work
+ # nicely, so I use rect method instead. Plus using this method it's easy to
+ # grow the hit area size.
+ var panel_hover_rect = Rect2(panel.global_position, panel.size)
+ panel_hover_rect = panel_hover_rect.grow(40)
+
+ var mouse_position = get_global_mouse_position()
+
+ show_controls = state != InteractionState.NONE or panel_hover_rect.has_point(mouse_position)
+
+ # UI visibility.
+ resize_left_handle.visible = show_controls and pinned_position == PinnedPosition.RIGHT
+ resize_right_handle.visible = show_controls and pinned_position == PinnedPosition.LEFT
+ lock_button.visible = show_controls or is_locked
+ placeholder.visible = state == InteractionState.DRAG or state == InteractionState.ANIMATE_INTO_PLACE
+ gradient.visible = show_controls
+
+ # Sync camera settings.
+ if camera_type == CameraType.CAMERA_3D and selected_camera_3d:
+ sub_viewport.size = panel.size
+
+ # Sync position and rotation without using a `RemoteTransform` node
+ # because if you save a camera as a scene, the remote transform node will
+ # be stored within the scene. Also it's harder to keep the remote
+ # transform `remote_path` up-to-date with scene changes, which causes
+ # many errors.
+ preview_camera_3d.global_position = selected_camera_3d.global_position
+ preview_camera_3d.global_rotation = selected_camera_3d.global_rotation
+
+ preview_camera_3d.fov = selected_camera_3d.fov
+ preview_camera_3d.projection = selected_camera_3d.projection
+ preview_camera_3d.size = selected_camera_3d.size
+ preview_camera_3d.cull_mask = selected_camera_3d.cull_mask
+ preview_camera_3d.keep_aspect = selected_camera_3d.keep_aspect
+ preview_camera_3d.near = selected_camera_3d.near
+ preview_camera_3d.far = selected_camera_3d.far
+ preview_camera_3d.h_offset = selected_camera_3d.h_offset
+ preview_camera_3d.v_offset = selected_camera_3d.v_offset
+ preview_camera_3d.attributes = selected_camera_3d.attributes
+ preview_camera_3d.environment = selected_camera_3d.environment
+
+ if camera_type == CameraType.CAMERA_2D and selected_camera_2d:
+ var project_window_size = get_project_window_size()
+ var ratio = project_window_size.x / panel.size.x
+
+ # TODO: Is there a better way to fix this?
+ # The camera border is visible sometimes due to pixel rounding.
+ # Subtract 1px from right and bottom to hide this.
+ var hide_camera_border_fix = Vector2(1, 1)
+
+ sub_viewport.size = panel.size
+ sub_viewport.size_2d_override = (panel.size - hide_camera_border_fix) * ratio
+ sub_viewport.size_2d_override_stretch = true
+
+ preview_camera_2d.global_position = selected_camera_2d.global_position
+ preview_camera_2d.global_rotation = selected_camera_2d.global_rotation
+
+ preview_camera_2d.offset = selected_camera_2d.offset
+ preview_camera_2d.zoom = selected_camera_2d.zoom
+ preview_camera_2d.ignore_rotation = selected_camera_2d.ignore_rotation
+ preview_camera_2d.anchor_mode = selected_camera_2d.anchor_mode
+ preview_camera_2d.limit_left = selected_camera_2d.limit_left
+ preview_camera_2d.limit_right = selected_camera_2d.limit_right
+ preview_camera_2d.limit_top = selected_camera_2d.limit_top
+ preview_camera_2d.limit_bottom = selected_camera_2d.limit_bottom
+
+func link_with_camera_3d(camera_3d: Camera3D) -> void:
+ # TODO: Camera may not be ready since this method is called in `_enter_tree`
+ # in the plugin because of a workaround for:
+ # https://github.com/godotengine/godot-proposals/issues/2081
+ if not preview_camera_3d:
+ return request_hide()
+
+ var is_different_camera = camera_3d != preview_camera_3d
+
+ # TODO: A bit messy.
+ if is_different_camera:
+ if preview_camera_3d.tree_exiting.is_connected(unlink_camera):
+ preview_camera_3d.tree_exiting.disconnect(unlink_camera)
+
+ if not camera_3d.tree_exiting.is_connected(unlink_camera):
+ camera_3d.tree_exiting.connect(unlink_camera)
+
+ sub_viewport.disable_3d = false
+ sub_viewport.world_3d = camera_3d.get_world_3d()
+
+ selected_camera_3d = camera_3d
+ camera_type = CameraType.CAMERA_3D
+
+func link_with_camera_2d(camera_2d: Camera2D) -> void:
+ if not preview_camera_2d:
+ return request_hide()
+
+ var is_different_camera = camera_2d != preview_camera_2d
+
+ # TODO: A bit messy.
+ if is_different_camera:
+ if preview_camera_2d.tree_exiting.is_connected(unlink_camera):
+ preview_camera_2d.tree_exiting.disconnect(unlink_camera)
+
+ if not camera_2d.tree_exiting.is_connected(unlink_camera):
+ camera_2d.tree_exiting.connect(unlink_camera)
+
+ sub_viewport.disable_3d = true
+ sub_viewport.world_2d = camera_2d.get_world_2d()
+
+ selected_camera_2d = camera_2d
+ camera_type = CameraType.CAMERA_2D
+
+func unlink_camera() -> void:
+ if selected_camera_3d:
+ selected_camera_3d = null
+
+ if selected_camera_2d:
+ selected_camera_2d = null
+
+ is_locked = false
+ lock_button.button_pressed = false
+
+func request_hide() -> void:
+ if is_locked: return
+ visible = false
+
+func request_show() -> void:
+ visible = true
+
+func get_pinned_position(pinned_position: PinnedPosition) -> Vector2:
+ var margin: Vector2 = margin_3d * editor_scale
+
+ if camera_type == CameraType.CAMERA_2D:
+ margin = margin_2d * editor_scale
+
+ match pinned_position:
+ PinnedPosition.LEFT:
+ return Vector2.ZERO - Vector2(0, panel.size.y) - Vector2(-margin.x, margin.y)
+ PinnedPosition.RIGHT:
+ return size - panel.size - margin
+ _:
+ assert(false, "Unknown pinned position %s" % str(pinned_position))
+
+ return Vector2.ZERO
+
+func get_clamped_size(desired_size: Vector2) -> Vector2:
+ var viewport_ratio = get_project_window_ratio()
+ var editor_viewport_size = get_editor_viewport_size()
+
+ var max_bounds = Vector2(
+ editor_viewport_size.x * 0.6,
+ editor_viewport_size.y * 0.8
+ )
+
+ var clamped_size = desired_size
+
+ # Apply aspect ratio.
+ clamped_size = Vector2(clamped_size.x, clamped_size.x * viewport_ratio)
+
+ # Clamp the max size while respecting the aspect ratio.
+ if clamped_size.y >= max_bounds.y:
+ clamped_size.x = max_bounds.y / viewport_ratio
+ clamped_size.y = max_bounds.y
+
+ if clamped_size.x >= max_bounds.x:
+ clamped_size.x = max_bounds.x
+ clamped_size.y = max_bounds.x * viewport_ratio
+
+ # Clamp the min size based on if it's portrait or landscape. Portrait min
+ # size should be based on it's height. Landscape min size is based on it's
+ # width instead. Applying min width to a portrait size would make it too big.
+ var is_portrait = viewport_ratio > 1
+
+ if is_portrait and clamped_size.y <= min_panel_size * editor_scale:
+ clamped_size.x = min_panel_size / viewport_ratio
+ clamped_size.y = min_panel_size
+ clamped_size = clamped_size * editor_scale
+
+ if not is_portrait and clamped_size.x <= min_panel_size * editor_scale:
+ clamped_size.x = min_panel_size
+ clamped_size.y = min_panel_size * viewport_ratio
+ clamped_size = clamped_size * editor_scale
+
+ # Round down to avoid sub-pixel artifacts, mainly seen around the margins.
+ return clamped_size.floor()
+
+func get_project_window_size() -> Vector2:
+ var window_width = float(ProjectSettings.get_setting("display/window/size/viewport_width"))
+ var window_height = float(ProjectSettings.get_setting("display/window/size/viewport_height"))
+
+ return Vector2(window_width, window_height)
+
+func get_project_window_ratio() -> float:
+ var project_window_size = get_project_window_size()
+
+ return project_window_size.y / project_window_size.x
+
+func get_editor_viewport_size() -> Vector2:
+ var fallback_size = EditorInterface.get_editor_main_screen().size
+
+ # There isn't an API for getting the viewport node. Instead it has to be
+ # found by checking the parent's parent of the subviewport and find
+ # the correct node based on name and class.
+ var editor_sub_viewport_3d = EditorInterface.get_editor_viewport_3d(0)
+ var editor_viewport_container = editor_sub_viewport_3d.get_parent().get_parent().get_parent()
+
+ # Early return incase editor tree structure has changed.
+ if editor_viewport_container.get_class() != "Node3DEditorViewportContainer":
+ return fallback_size
+
+ return editor_viewport_container.size
+
+func _on_resize_handle_button_down() -> void:
+ if state != InteractionState.NONE: return
+
+ state = InteractionState.RESIZE
+ initial_mouse_position = get_global_mouse_position()
+ initial_panel_size = panel.size
+
+func _on_resize_handle_button_up() -> void:
+ state = InteractionState.NONE
+
+func _on_drag_handle_button_down() -> void:
+ if state != InteractionState.NONE: return
+
+ state = InteractionState.DRAG
+ initial_mouse_position = get_global_mouse_position()
+ initial_panel_position = panel.global_position
+
+func _on_drag_handle_button_up() -> void:
+ if state != InteractionState.DRAG: return
+
+ state = InteractionState.START_ANIMATE_INTO_PLACE
+
+func _on_lock_button_pressed() -> void:
+ is_locked = !is_locked
diff --git a/addons/anthonyec.camera_preview/preview.tscn b/addons/anthonyec.camera_preview/preview.tscn
new file mode 100644
index 0000000..77b3ced
--- /dev/null
+++ b/addons/anthonyec.camera_preview/preview.tscn
@@ -0,0 +1,200 @@
+[gd_scene load_steps=8 format=3 uid="uid://xybmfvufjuv"]
+
+[ext_resource type="Script" path="res://addons/anthonyec.camera_preview/preview.gd" id="1_6b32r"]
+[ext_resource type="Texture2D" uid="uid://do6d60od41vmg" path="res://addons/anthonyec.camera_preview/Pin.svg" id="2_p0pa8"]
+[ext_resource type="Texture2D" uid="uid://btc01wc11tiid" path="res://addons/anthonyec.camera_preview/GuiResizerTopLeft.svg" id="2_t64ej"]
+[ext_resource type="Texture2D" uid="uid://04l05jxuyt7k" path="res://addons/anthonyec.camera_preview/GuiResizerTopRight.svg" id="3_6yuab"]
+
+[sub_resource type="ViewportTexture" id="ViewportTexture_hchdq"]
+viewport_path = NodePath("Panel/SubViewport")
+
+[sub_resource type="Gradient" id="Gradient_11p6r"]
+offsets = PackedFloat32Array(0, 0.3, 0.6, 1)
+colors = PackedColorArray(0, 0, 0, 0.235294, 0, 0, 0, 0.0784314, 0, 0, 0, 0.0784314, 0, 0, 0, 0.235294)
+
+[sub_resource type="GradientTexture2D" id="GradientTexture2D_4dkve"]
+gradient = SubResource("Gradient_11p6r")
+width = 256
+height = 256
+fill_to = Vector2(2.08165e-12, 1)
+
+[node name="Preview" type="Control"]
+z_index = 999
+layout_mode = 3
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+script = ExtResource("1_6b32r")
+
+[node name="Placeholder" type="Panel" parent="."]
+unique_name_in_owner = true
+visible = false
+modulate = Color(1, 1, 1, 0.705882)
+layout_mode = 1
+anchors_preset = 3
+anchor_left = 1.0
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = -40.0
+offset_top = -40.0
+offset_right = 410.0
+offset_bottom = 410.0
+grow_horizontal = 0
+grow_vertical = 0
+
+[node name="Panel" type="Panel" parent="."]
+unique_name_in_owner = true
+clip_contents = true
+layout_mode = 1
+anchors_preset = 3
+anchor_left = 1.0
+anchor_top = 1.0
+anchor_right = 1.0
+anchor_bottom = 1.0
+offset_left = -520.0
+offset_top = -908.889
+offset_right = -20.0
+offset_bottom = -20.0
+grow_horizontal = 0
+grow_vertical = 0
+pivot_offset = Vector2(450, 300)
+
+[node name="SubViewport" type="SubViewport" parent="Panel"]
+unique_name_in_owner = true
+handle_input_locally = false
+gui_disable_input = true
+size_2d_override_stretch = true
+
+[node name="Camera3D" type="Camera3D" parent="Panel/SubViewport"]
+unique_name_in_owner = true
+current = true
+
+[node name="Camera2D" type="Camera2D" parent="Panel/SubViewport"]
+unique_name_in_owner = true
+ignore_rotation = false
+
+[node name="ViewportMarginContainer" type="MarginContainer" parent="Panel"]
+unique_name_in_owner = true
+clip_contents = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_constants/margin_left = 4
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 4
+theme_override_constants/margin_bottom = 4
+
+[node name="TextureRect" type="TextureRect" parent="Panel/ViewportMarginContainer"]
+unique_name_in_owner = true
+layout_mode = 2
+texture = SubResource("ViewportTexture_hchdq")
+expand_mode = 1
+
+[node name="Gradient" type="TextureRect" parent="Panel"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+texture = SubResource("GradientTexture2D_4dkve")
+
+[node name="OverlayMarginContainer" type="MarginContainer" parent="Panel"]
+unique_name_in_owner = true
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+mouse_filter = 2
+theme_override_constants/margin_left = 4
+theme_override_constants/margin_top = 4
+theme_override_constants/margin_right = 4
+theme_override_constants/margin_bottom = 4
+
+[node name="OverlayContainer" type="Control" parent="Panel/OverlayMarginContainer"]
+unique_name_in_owner = true
+clip_contents = true
+layout_mode = 2
+mouse_filter = 2
+
+[node name="DragHandle" type="Button" parent="Panel/OverlayMarginContainer/OverlayContainer"]
+layout_mode = 1
+anchors_preset = 15
+anchor_right = 1.0
+anchor_bottom = 1.0
+grow_horizontal = 2
+grow_vertical = 2
+focus_mode = 0
+flat = true
+
+[node name="ResizeLeftHandle" type="Button" parent="Panel/OverlayMarginContainer/OverlayContainer"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+offset_right = 60.0
+offset_bottom = 60.0
+size_flags_horizontal = 0
+size_flags_vertical = 0
+mouse_default_cursor_shape = 12
+icon = ExtResource("2_t64ej")
+flat = true
+icon_alignment = 1
+expand_icon = true
+
+[node name="ResizeRightHandle" type="Button" parent="Panel/OverlayMarginContainer/OverlayContainer"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 1
+anchor_left = 1.0
+anchor_right = 1.0
+offset_left = -60.0
+offset_bottom = 60.0
+pivot_offset = Vector2(60, 60)
+size_flags_horizontal = 8
+size_flags_vertical = 0
+mouse_default_cursor_shape = 11
+icon = ExtResource("3_6yuab")
+flat = true
+icon_alignment = 1
+expand_icon = true
+
+[node name="LockButton" type="Button" parent="Panel/OverlayMarginContainer/OverlayContainer"]
+unique_name_in_owner = true
+visible = false
+layout_mode = 1
+anchors_preset = 2
+anchor_top = 1.0
+anchor_bottom = 1.0
+offset_top = -60.0
+offset_right = 60.0
+pivot_offset = Vector2(0, 60)
+size_flags_horizontal = 0
+size_flags_vertical = 8
+tooltip_text = "Always Show Preview"
+toggle_mode = true
+icon = ExtResource("2_p0pa8")
+flat = true
+icon_alignment = 1
+expand_icon = true
+
+[connection signal="button_down" from="Panel/OverlayMarginContainer/OverlayContainer/DragHandle" to="." method="_on_drag_handle_button_down"]
+[connection signal="button_up" from="Panel/OverlayMarginContainer/OverlayContainer/DragHandle" to="." method="_on_drag_handle_button_up"]
+[connection signal="renamed" from="Panel/OverlayMarginContainer/OverlayContainer/DragHandle" to="." method="_on_drag_handle_renamed"]
+[connection signal="button_down" from="Panel/OverlayMarginContainer/OverlayContainer/ResizeLeftHandle" to="." method="_on_resize_handle_button_down"]
+[connection signal="button_up" from="Panel/OverlayMarginContainer/OverlayContainer/ResizeLeftHandle" to="." method="_on_resize_handle_button_up"]
+[connection signal="button_down" from="Panel/OverlayMarginContainer/OverlayContainer/ResizeRightHandle" to="." method="_on_resize_handle_button_down"]
+[connection signal="button_up" from="Panel/OverlayMarginContainer/OverlayContainer/ResizeRightHandle" to="." method="_on_resize_handle_button_up"]
+[connection signal="pressed" from="Panel/OverlayMarginContainer/OverlayContainer/LockButton" to="." method="_on_lock_button_pressed"]
diff --git a/addons/dialogic/Example Assets/Fonts/Roboto-Bold.ttf.import b/addons/dialogic/Example Assets/Fonts/Roboto-Bold.ttf.import
index 694a2ae..16d9a06 100644
--- a/addons/dialogic/Example Assets/Fonts/Roboto-Bold.ttf.import
+++ b/addons/dialogic/Example Assets/Fonts/Roboto-Bold.ttf.import
@@ -15,6 +15,7 @@ dest_files=["res://.godot/imported/Roboto-Bold.ttf-a0c3395776dbc11ee676c5f1ea9c0
Rendering=null
antialiasing=1
generate_mipmaps=false
+disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
diff --git a/addons/dialogic/Example Assets/Fonts/Roboto-Italic.ttf.import b/addons/dialogic/Example Assets/Fonts/Roboto-Italic.ttf.import
index d7c809a..9034952 100644
--- a/addons/dialogic/Example Assets/Fonts/Roboto-Italic.ttf.import
+++ b/addons/dialogic/Example Assets/Fonts/Roboto-Italic.ttf.import
@@ -15,6 +15,7 @@ dest_files=["res://.godot/imported/Roboto-Italic.ttf-844485a0171d6031f98f4829003
Rendering=null
antialiasing=1
generate_mipmaps=false
+disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48
diff --git a/addons/dialogic/Example Assets/Fonts/Roboto-Regular.ttf.import b/addons/dialogic/Example Assets/Fonts/Roboto-Regular.ttf.import
index 16d8db1..3f33bc5 100644
--- a/addons/dialogic/Example Assets/Fonts/Roboto-Regular.ttf.import
+++ b/addons/dialogic/Example Assets/Fonts/Roboto-Regular.ttf.import
@@ -15,6 +15,7 @@ dest_files=["res://.godot/imported/Roboto-Regular.ttf-d9ce0640effe9e93230b445b37
Rendering=null
antialiasing=1
generate_mipmaps=false
+disable_embedded_bitmaps=true
multichannel_signed_distance_field=false
msdf_pixel_range=8
msdf_size=48