diff --git a/docs/development/canvas-text-tool.md b/docs/development/canvas-text-tool.md
new file mode 100644
index 00000000000..1d9b71109c2
--- /dev/null
+++ b/docs/development/canvas-text-tool.md
@@ -0,0 +1,35 @@
+# Canvas Text Tool
+
+## Overview
+
+The canvas text workflow is split between a Konva module that owns tool state and a React overlay that handles text entry.
+
+- `invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts`
+ - Owns the tool, cursor preview, and text session state (including the cursor "T" marker).
+ - Manages dynamic cursor contrast, starts sessions on pointer down, and commits sessions by rasterizing the active text block into a new raster layer.
+- `invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx`
+ - Renders the on-canvas editor as a `contentEditable` overlay positioned in canvas space.
+ - Syncs keyboard input, suppresses app hotkeys, and forwards commits/cancels to the Konva module.
+- `invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx`
+ - Provides the font dropdown, size slider/input, formatting toggles, and alignment buttons that appear when the Text tool is active.
+
+## Rasterization pipeline
+
+`renderTextToCanvas()` (`invokeai/frontend/web/src/features/controlLayers/text/textRenderer.ts`) converts the editor contents into a transparent canvas. The Text tool module configures the renderer with the active font stack, weight, styling flags, alignment, and the active canvas color. The resulting canvas is encoded to a PNG data URL and stored in a new raster layer (`image` object) with a transparent background.
+
+Layer placement preserves the original click location:
+
+- The session stores the anchor coordinate (where the user clicked) and current alignment.
+- `calculateLayerPosition()` calculates the top-left position for the raster layer after applying the configured padding and alignment offsets.
+- New layers are inserted directly above the currently-selected raster layer (when present) and selected automatically.
+
+## Font stacks
+
+Font definitions live in `invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts` as ten deterministic stacks (sans, serif, mono, rounded, script, humanist, slab serif, display, narrow, UI serif). Each stack lists system-safe fallbacks so the editor can choose the first available font per platform.
+
+To add or adjust fonts:
+
+1. Update `TEXT_FONT_STACKS` with the new `id`, `label`, and CSS `font-family` stack.
+2. If you add a new stack, extend the `TEXT_FONT_IDS` tuple and update the `canvasTextSlice` schema default (`TEXT_DEFAULT_FONT_ID`).
+3. Provide translation strings for any new labels in `public/locales/*`.
+4. The editor and renderer will automatically pick up the new stack via `getFontStackById()`.
diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json
index 881d7253270..4323cb1835e 100644
--- a/invokeai/frontend/web/public/locales/en.json
+++ b/invokeai/frontend/web/public/locales/en.json
@@ -2371,7 +2371,19 @@
"bbox": "Bbox",
"move": "Move",
"view": "View",
- "colorPicker": "Color Picker"
+ "colorPicker": "Color Picker",
+ "text": "Text"
+ },
+ "text": {
+ "font": "Font",
+ "size": "Size",
+ "bold": "Bold",
+ "italic": "Italic",
+ "underline": "Underline",
+ "strikethrough": "Strikethrough",
+ "alignLeft": "Align Left",
+ "alignCenter": "Align Center",
+ "alignRight": "Align Right"
},
"filter": {
"filter": "Filter",
diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts
index 3babf2404ae..232ab091470 100644
--- a/invokeai/frontend/web/src/app/store/store.ts
+++ b/invokeai/frontend/web/src/app/store/store.ts
@@ -23,6 +23,7 @@ import { changeBoardModalSliceConfig } from 'features/changeBoardModal/store/sli
import { canvasSettingsSliceConfig } from 'features/controlLayers/store/canvasSettingsSlice';
import { canvasSliceConfig } from 'features/controlLayers/store/canvasSlice';
import { canvasSessionSliceConfig } from 'features/controlLayers/store/canvasStagingAreaSlice';
+import { canvasTextSliceConfig } from 'features/controlLayers/store/canvasTextSlice';
import { lorasSliceConfig } from 'features/controlLayers/store/lorasSlice';
import { paramsSliceConfig } from 'features/controlLayers/store/paramsSlice';
import { refImagesSliceConfig } from 'features/controlLayers/store/refImagesSlice';
@@ -62,6 +63,7 @@ const log = logger('system');
const SLICE_CONFIGS = {
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig,
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig,
+ [canvasTextSliceConfig.slice.reducerPath]: canvasTextSliceConfig,
[canvasSliceConfig.slice.reducerPath]: canvasSliceConfig,
[changeBoardModalSliceConfig.slice.reducerPath]: changeBoardModalSliceConfig,
[dynamicPromptsSliceConfig.slice.reducerPath]: dynamicPromptsSliceConfig,
@@ -87,6 +89,7 @@ const ALL_REDUCERS = {
[api.reducerPath]: api.reducer,
[canvasSessionSliceConfig.slice.reducerPath]: canvasSessionSliceConfig.slice.reducer,
[canvasSettingsSliceConfig.slice.reducerPath]: canvasSettingsSliceConfig.slice.reducer,
+ [canvasTextSliceConfig.slice.reducerPath]: canvasTextSliceConfig.slice.reducer,
// Undoable!
[canvasSliceConfig.slice.reducerPath]: undoable(
canvasSliceConfig.slice.reducer,
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx
new file mode 100644
index 00000000000..3231531724f
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Text/CanvasTextOverlay.tsx
@@ -0,0 +1,327 @@
+import { Box, Flex } from '@invoke-ai/ui-library';
+import { useStore } from '@nanostores/react';
+import { useAppSelector } from 'app/store/storeHooks';
+import { rgbaColorToString } from 'common/util/colorCodeTransformers';
+import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate';
+import { selectCanvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice';
+import type { CanvasTextSettingsState } from 'features/controlLayers/store/canvasTextSlice';
+import { selectCanvasTextSlice } from 'features/controlLayers/store/canvasTextSlice';
+import { getFontStackById, TEXT_RASTER_PADDING } from 'features/controlLayers/text/textConstants';
+import { isAllowedTextShortcut } from 'features/controlLayers/text/textHotkeys';
+import { hasVisibleGlyphs, measureTextContent, type TextMeasureConfig } from 'features/controlLayers/text/textRenderer';
+import {
+ type ClipboardEvent as ReactClipboardEvent,
+ type KeyboardEvent as ReactKeyboardEvent,
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+
+export const CanvasTextOverlay = memo(() => {
+ const canvasManager = useCanvasManager();
+ const session = useStore(canvasManager.tool.tools.text.$session);
+ const stageAttrs = useStore(canvasManager.stage.$stageAttrs);
+
+ if (!session) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+ );
+});
+
+CanvasTextOverlay.displayName = 'CanvasTextOverlay';
+
+const buildMeasureConfig = (text: string, settings: CanvasTextSettingsState): TextMeasureConfig => {
+ const fontStyle: TextMeasureConfig['fontStyle'] = settings.italic ? 'italic' : 'normal';
+ return {
+ text,
+ fontSize: settings.fontSize,
+ fontFamily: getFontStackById(settings.fontId),
+ fontWeight: settings.bold ? 700 : 400,
+ fontStyle,
+ lineHeight: settings.lineHeight,
+ };
+};
+
+const TextEditor = ({
+ sessionId,
+ anchor,
+ initialText,
+}: {
+ sessionId: string;
+ anchor: { x: number; y: number };
+ initialText: string;
+}) => {
+ const canvasManager = useCanvasManager();
+ const textSettings = useAppSelector(selectCanvasTextSlice);
+ const canvasSettings = useAppSelector(selectCanvasSettingsSlice);
+ const editorRef = useRef(null);
+ const lastSessionIdRef = useRef(null);
+ const lastFocusedSessionIdRef = useRef(null);
+ const focusRafIdRef = useRef(null);
+ const [isComposing, setIsComposing] = useState(false);
+ const [textValue, setTextValue] = useState(initialText);
+ const [contentMetrics, setContentMetrics] = useState(() =>
+ measureTextContent(buildMeasureConfig(initialText, textSettings))
+ );
+ const [isEmpty, setIsEmpty] = useState(() => !hasVisibleGlyphs(initialText));
+
+ const focusEditor = useCallback(() => {
+ const node = editorRef.current;
+ if (!node) {
+ return;
+ }
+ node.focus({ preventScroll: true });
+ const selection = window.getSelection();
+ if (!selection) {
+ return;
+ }
+ const range = document.createRange();
+ range.selectNodeContents(node);
+ range.collapse(false);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ }, []);
+
+ const setEditorRef = useCallback((node: HTMLDivElement | null) => {
+ editorRef.current = node;
+ }, []);
+
+ useEffect(() => {
+ const node = editorRef.current;
+ if (!node) {
+ return;
+ }
+ const isNewSession = lastSessionIdRef.current !== sessionId;
+ if (isNewSession) {
+ lastSessionIdRef.current = sessionId;
+ lastFocusedSessionIdRef.current = null;
+ node.textContent = initialText;
+ const syncedText = (node.innerText ?? '').replace(/\r/g, '');
+ setIsEmpty(!hasVisibleGlyphs(syncedText));
+ setTextValue(syncedText);
+ setContentMetrics(measureTextContent(buildMeasureConfig(syncedText, textSettings)));
+ canvasManager.tool.tools.text.updateSessionText(sessionId, syncedText);
+ }
+ if (lastFocusedSessionIdRef.current !== sessionId) {
+ if (focusRafIdRef.current !== null) {
+ cancelAnimationFrame(focusRafIdRef.current);
+ }
+ focusRafIdRef.current = requestAnimationFrame(() => {
+ canvasManager.tool.tools.text.markSessionEditing(sessionId);
+ focusEditor();
+ lastFocusedSessionIdRef.current = sessionId;
+ focusRafIdRef.current = null;
+ });
+ }
+ return () => {
+ if (focusRafIdRef.current !== null) {
+ cancelAnimationFrame(focusRafIdRef.current);
+ focusRafIdRef.current = null;
+ }
+ };
+ }, [canvasManager.tool.tools.text, focusEditor, initialText, sessionId, textSettings]);
+
+ useEffect(() => {
+ setContentMetrics(measureTextContent(buildMeasureConfig(textValue, textSettings)));
+ }, [textSettings, textValue]);
+
+ useEffect(() => {
+ const shouldIgnorePointerDown = (event: PointerEvent) => {
+ const target = event.target as HTMLElement | null;
+ if (!target) {
+ return false;
+ }
+ const path = event.composedPath?.() ?? [];
+ for (const node of path) {
+ if (!(node instanceof HTMLElement)) {
+ continue;
+ }
+ const role = node.getAttribute('role');
+ if (role === 'listbox' || role === 'option') {
+ return true;
+ }
+ if (editorRef.current && editorRef.current.contains(node)) {
+ return true;
+ }
+ if (node.dataset?.textToolSafezone === 'true') {
+ return true;
+ }
+ }
+ return editorRef.current?.contains(target) ?? false;
+ };
+
+ const handlePointerDown = (event: PointerEvent) => {
+ if (shouldIgnorePointerDown(event)) {
+ return;
+ }
+ canvasManager.tool.tools.text.requestCommit(sessionId);
+ };
+ window.addEventListener('pointerdown', handlePointerDown, true);
+ return () => window.removeEventListener('pointerdown', handlePointerDown, true);
+ }, [canvasManager.tool.tools.text, sessionId]);
+
+ const handleInput = useCallback(() => {
+ const value = (editorRef.current?.innerText ?? '').replace(/\r/g, '');
+ setIsEmpty(!hasVisibleGlyphs(value));
+ setTextValue(value);
+ setContentMetrics(measureTextContent(buildMeasureConfig(value, textSettings)));
+ canvasManager.tool.tools.text.updateSessionText(sessionId, value);
+ }, [canvasManager.tool.tools.text, sessionId, textSettings]);
+
+ const handleKeyDown = useCallback(
+ (event: ReactKeyboardEvent) => {
+ const nativeEvent = event.nativeEvent;
+ if (!isAllowedTextShortcut(nativeEvent)) {
+ event.stopPropagation();
+ nativeEvent.stopPropagation();
+ nativeEvent.stopImmediatePropagation?.();
+ }
+
+ if (event.key === 'Enter' && !event.shiftKey && !isComposing) {
+ event.preventDefault();
+ canvasManager.tool.tools.text.requestCommit(sessionId);
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault();
+ canvasManager.tool.tools.text.clearSession();
+ }
+ },
+ [canvasManager.tool.tools.text, isComposing, sessionId]
+ );
+
+ const handlePaste = useCallback((event: ReactClipboardEvent) => {
+ event.preventDefault();
+ const text = event.clipboardData.getData('text/plain');
+ document.execCommand('insertText', false, text);
+ }, []);
+
+ const handleCompositionStart = useCallback(() => setIsComposing(true), []);
+ const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
+
+ const textContainerData = useMemo(() => {
+ const padding = TEXT_RASTER_PADDING;
+ const extraRightPadding = Math.ceil(textSettings.fontSize * 0.26);
+ const extraLeftPadding = Math.ceil(textSettings.fontSize * 0.12);
+ let offsetX = -padding - extraLeftPadding;
+ if (textSettings.alignment === 'center') {
+ offsetX = -(contentMetrics.contentWidth / 2) - padding - extraLeftPadding;
+ } else if (textSettings.alignment === 'right') {
+ offsetX = -contentMetrics.contentWidth - padding - extraLeftPadding;
+ }
+ return {
+ x: anchor.x + offsetX,
+ y: anchor.y - padding,
+ padding,
+ extraLeftPadding,
+ extraRightPadding,
+ width: contentMetrics.contentWidth + padding * 2 + extraLeftPadding + extraRightPadding,
+ height: contentMetrics.contentHeight + padding * 2,
+ };
+ }, [
+ anchor.x,
+ anchor.y,
+ contentMetrics.contentHeight,
+ contentMetrics.contentWidth,
+ textSettings.alignment,
+ textSettings.fontSize,
+ ]);
+
+ useEffect(() => {
+ canvasManager.tool.tools.text.updateSessionPosition(sessionId, {
+ x: textContainerData.x,
+ y: textContainerData.y,
+ });
+ }, [canvasManager.tool.tools.text, sessionId, textContainerData]);
+
+ const containerStyle = useMemo(() => {
+ return {
+ left: `${textContainerData.x}px`,
+ top: `${textContainerData.y}px`,
+ paddingTop: `${textContainerData.padding}px`,
+ paddingBottom: `${textContainerData.padding}px`,
+ paddingLeft: `${textContainerData.padding + textContainerData.extraLeftPadding}px`,
+ paddingRight: `${textContainerData.padding + textContainerData.extraRightPadding}px`,
+ width: `${Math.max(textContainerData.width, textSettings.fontSize)}px`,
+ height: `${Math.max(textContainerData.height, textSettings.fontSize)}px`,
+ textAlign: textSettings.alignment,
+ };
+ }, [textContainerData, textSettings.alignment, textSettings.fontSize]);
+
+ const textStyle = useMemo(() => {
+ const color =
+ canvasSettings.activeColor === 'fgColor'
+ ? rgbaColorToString(canvasSettings.fgColor)
+ : rgbaColorToString(canvasSettings.bgColor);
+ const decorations: string[] = [];
+ if (textSettings.underline) {
+ decorations.push('underline');
+ }
+ if (textSettings.strikethrough) {
+ decorations.push('line-through');
+ }
+ return {
+ fontFamily: getFontStackById(textSettings.fontId),
+ fontWeight: textSettings.bold ? 700 : 400,
+ fontStyle: textSettings.italic ? 'italic' : 'normal',
+ textDecorationLine: decorations.length ? decorations.join(' ') : 'none',
+ fontSize: `${textSettings.fontSize}px`,
+ lineHeight: `${contentMetrics.lineHeightPx}px`,
+ color,
+ textAlign: textSettings.alignment,
+ } as const;
+ }, [canvasSettings, contentMetrics.lineHeightPx, textSettings]);
+
+ return (
+
+
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx
new file mode 100644
index 00000000000..d756d435fdf
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Text/TextToolOptions.tsx
@@ -0,0 +1,309 @@
+import {
+ Box,
+ ButtonGroup,
+ Combobox,
+ CompositeSlider,
+ Flex,
+ IconButton,
+ NumberInput,
+ NumberInputField,
+ Popover,
+ PopoverAnchor,
+ PopoverArrow,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Portal,
+ Text,
+ Tooltip,
+} from '@invoke-ai/ui-library';
+import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
+import {
+ selectTextAlignment,
+ selectTextFontId,
+ selectTextFontSize,
+ textAlignmentChanged,
+ textBoldToggled,
+ textFontChanged,
+ textFontSizeChanged,
+ textItalicToggled,
+ textStrikethroughToggled,
+ textUnderlineToggled,
+} from 'features/controlLayers/store/canvasTextSlice';
+import {
+ resolveAvailableFont,
+ TEXT_FONT_STACKS,
+ TEXT_MAX_FONT_SIZE,
+ TEXT_MIN_FONT_SIZE,
+ type TextFontId,
+} from 'features/controlLayers/text/textConstants';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ PiCaretDownBold,
+ PiTextAlignCenterBold,
+ PiTextAlignLeftBold,
+ PiTextAlignRightBold,
+ PiTextBBold,
+ PiTextItalicBold,
+ PiTextStrikethroughBold,
+ PiTextUnderlineBold,
+} from 'react-icons/pi';
+
+const formatPx = (value: number | string) => {
+ if (isNaN(Number(value))) {
+ return '';
+ }
+ return `${value} px`;
+};
+
+const formatSliderValue = (value: number) => String(value);
+
+export const TextToolOptions = () => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+const FontSelect = () => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const fontId = useAppSelector(selectTextFontId);
+ const options = useMemo(() => {
+ return TEXT_FONT_STACKS.map(({ id, label, stack }) => {
+ const resolved = resolveAvailableFont(stack);
+ return {
+ value: id,
+ label: `${label} (${resolved})`,
+ };
+ });
+ }, []);
+ const selectedOption = options.find((option) => option.value === fontId) ?? null;
+ const handleFontChange = useCallback(
+ (option: { value: string } | null) => {
+ if (!option) {
+ return;
+ }
+ dispatch(textFontChanged(option.value as TextFontId));
+ },
+ [dispatch]
+ );
+
+ return (
+
+
+ {t('controlLayers.text.font', { defaultValue: 'Font' })}
+
+
+
+
+
+ );
+};
+
+const FontSizeControl = () => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const fontSize = useAppSelector(selectTextFontSize);
+ const [localFontSize, setLocalFontSize] = useState(fontSize);
+ const marks = useMemo(
+ () =>
+ [5, 50, 100, 200, 300, 400, 500].filter((value) => value >= TEXT_MIN_FONT_SIZE && value <= TEXT_MAX_FONT_SIZE),
+ []
+ );
+ const onChangeNumberInput = useCallback(
+ (valueAsString: string, valueAsNumber: number) => {
+ setLocalFontSize(valueAsNumber);
+ if (!isNaN(valueAsNumber)) {
+ dispatch(textFontSizeChanged(valueAsNumber));
+ }
+ },
+ [dispatch]
+ );
+ const onChangeSlider = useCallback(
+ (value: number) => {
+ setLocalFontSize(value);
+ dispatch(textFontSizeChanged(value));
+ },
+ [dispatch]
+ );
+ const onBlur = useCallback(() => {
+ if (isNaN(Number(localFontSize))) {
+ setLocalFontSize(fontSize);
+ return;
+ }
+ dispatch(textFontSizeChanged(localFontSize));
+ }, [dispatch, fontSize, localFontSize]);
+
+ useEffect(() => {
+ setLocalFontSize(fontSize);
+ }, [fontSize]);
+
+ return (
+
+
+ {t('controlLayers.text.size', { defaultValue: 'Size' })}
+
+
+
+
+
+
+
+
+
+ }
+ size="sm"
+ variant="link"
+ position="absolute"
+ insetInlineEnd={0}
+ h="full"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const FormatControls = () => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const isBold = useAppSelector((state) => state.canvasText.bold);
+ const isItalic = useAppSelector((state) => state.canvasText.italic);
+ const isUnderline = useAppSelector((state) => state.canvasText.underline);
+ const isStrikethrough = useAppSelector((state) => state.canvasText.strikethrough);
+ const handleBoldToggle = useCallback(() => dispatch(textBoldToggled()), [dispatch]);
+ const handleItalicToggle = useCallback(() => dispatch(textItalicToggled()), [dispatch]);
+ const handleUnderlineToggle = useCallback(() => dispatch(textUnderlineToggled()), [dispatch]);
+ const handleStrikethroughToggle = useCallback(() => dispatch(textStrikethroughToggled()), [dispatch]);
+
+ return (
+
+
+ }
+ size="sm"
+ />
+
+
+ }
+ size="sm"
+ />
+
+
+ }
+ size="sm"
+ />
+
+
+ }
+ size="sm"
+ />
+
+
+ );
+};
+
+const AlignmentControls = () => {
+ const { t } = useTranslation();
+ const dispatch = useAppDispatch();
+ const alignment = useAppSelector(selectTextAlignment);
+ const handleAlignLeft = useCallback(() => dispatch(textAlignmentChanged('left')), [dispatch]);
+ const handleAlignCenter = useCallback(() => dispatch(textAlignmentChanged('center')), [dispatch]);
+ const handleAlignRight = useCallback(() => dispatch(textAlignmentChanged('right')), [dispatch]);
+
+ return (
+
+
+ }
+ size="sm"
+ />
+
+
+ }
+ size="sm"
+ />
+
+
+ }
+ size="sm"
+ />
+
+
+ );
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx
index 92f7320fbc1..38cf2d81b21 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/PinnedFillColorPickerOverlay.tsx
@@ -51,7 +51,7 @@ export const PinnedFillColorPickerOverlay = memo(() => {
}
return (
-
+
{
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx
index 231296eec7b..13bd3b30086 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolFillColorPicker.tsx
@@ -101,7 +101,15 @@ export const ToolFillColorPicker = memo(() => {
returnFocusOnClose={true}
>
-
+
{
-
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolOptionsRowContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolOptionsRowContainer.tsx
new file mode 100644
index 00000000000..9d4620ac538
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolOptionsRowContainer.tsx
@@ -0,0 +1,21 @@
+import { Flex, type FlexProps } from '@invoke-ai/ui-library';
+import { forwardRef } from 'react';
+
+export const ToolOptionsRowContainer = forwardRef((props, ref) => {
+ return (
+
+ );
+});
+
+ToolOptionsRowContainer.displayName = 'ToolOptionsRowContainer';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolTextButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolTextButton.tsx
new file mode 100644
index 00000000000..f1cabbc7954
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolTextButton.tsx
@@ -0,0 +1,25 @@
+import { IconButton, Tooltip } from '@invoke-ai/ui-library';
+import { useSelectTool, useToolIsSelected } from 'features/controlLayers/components/Tool/hooks';
+import { memo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { PiTextTBold } from 'react-icons/pi';
+
+export const ToolTextButton = memo(() => {
+ const { t } = useTranslation();
+ const isSelected = useToolIsSelected('text');
+ const selectText = useSelectTool('text');
+
+ return (
+
+ }
+ colorScheme={isSelected ? 'invokeBlue' : 'base'}
+ variant="solid"
+ onClick={selectText}
+ />
+
+ );
+});
+
+ToolTextButton.displayName = 'ToolTextButton';
diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx
index 3fa270893a3..9c69eedd058 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/Tool/ToolWidthPicker.tsx
@@ -309,7 +309,7 @@ export const ToolWidthPicker = memo(() => {
});
return (
-
+
{componentType === 'slider' && (
{
const isBrushSelected = useToolIsSelected('brush');
const isEraserSelected = useToolIsSelected('eraser');
+ const isTextSelected = useToolIsSelected('text');
const showToolWithPicker = useMemo(() => {
- return isBrushSelected || isEraserSelected;
- }, [isBrushSelected, isEraserSelected]);
+ return !isTextSelected && (isBrushSelected || isEraserSelected);
+ }, [isBrushSelected, isEraserSelected, isTextSelected]);
useCanvasResetLayerHotkey();
useCanvasDeleteLayerHotkey();
@@ -43,10 +46,10 @@ export const CanvasToolbar = memo(() => {
return (
-
+
- {showToolWithPicker && }
-
+ {isTextSelected ? : showToolWithPicker && }
+
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts
new file mode 100644
index 00000000000..b0748746acb
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasTextToolModule.ts
@@ -0,0 +1,393 @@
+import type { CanvasManager } from 'features/controlLayers/konva/CanvasManager';
+import { CanvasModuleBase } from 'features/controlLayers/konva/CanvasModuleBase';
+import type { CanvasToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasToolModule';
+import { getPrefixedId } from 'features/controlLayers/konva/util';
+import { type CanvasTextSettingsState, selectCanvasTextSlice } from 'features/controlLayers/store/canvasTextSlice';
+import type { CanvasImageState, Coordinate, RgbaColor, Tool } from 'features/controlLayers/store/types';
+import { getFontStackById, TEXT_RASTER_PADDING } from 'features/controlLayers/text/textConstants';
+import {
+ buildFontDescriptor,
+ calculateLayerPosition,
+ hasVisibleGlyphs,
+ measureTextContent,
+ renderTextToCanvas,
+ type TextMeasureConfig,
+} from 'features/controlLayers/text/textRenderer';
+import { type TextSessionStatus, transitionTextSessionStatus } from 'features/controlLayers/text/textSessionMachine';
+import Konva from 'konva';
+import type { KonvaEventObject } from 'konva/lib/Node';
+import { atom } from 'nanostores';
+import type { Logger } from 'roarr';
+
+type CanvasTextSessionState = {
+ id: string;
+ anchor: Coordinate;
+ position: Coordinate | null;
+ status: CanvasTextSessionStatus;
+ createdAt: number;
+ text: string;
+};
+
+type CanvasTextToolModuleConfig = {
+ CURSOR_MIN_WIDTH_PX: number;
+};
+
+type CanvasTextSessionStatus = Exclude;
+
+const coerceSessionStatus = (status: TextSessionStatus): CanvasTextSessionStatus => {
+ if (status === 'idle') {
+ return 'pending';
+ }
+ return status;
+};
+
+const DEFAULT_CONFIG: CanvasTextToolModuleConfig = {
+ CURSOR_MIN_WIDTH_PX: 1.5,
+};
+
+export class CanvasTextToolModule extends CanvasModuleBase {
+ readonly type = 'text_tool';
+ readonly id: string;
+ readonly path: string[];
+ readonly parent: CanvasToolModule;
+ readonly manager: CanvasManager;
+ readonly log: Logger;
+
+ config: CanvasTextToolModuleConfig = DEFAULT_CONFIG;
+
+ konva: {
+ group: Konva.Group;
+ cursor: Konva.Rect;
+ label: Konva.Text;
+ };
+
+ $session = atom(null);
+ private subscriptions = new Set<() => void>();
+ private cursorHeight = 0;
+ private cursorMetricsKey: string | null = null;
+
+ constructor(parent: CanvasToolModule) {
+ super();
+ this.id = getPrefixedId(this.type);
+ this.parent = parent;
+ this.manager = parent.manager;
+ this.path = this.manager.buildPath(this);
+ this.log = this.manager.buildLogger(this);
+
+ this.konva = {
+ group: new Konva.Group({ name: `${this.type}:group`, listening: false }),
+ cursor: new Konva.Rect({
+ name: `${this.type}:cursor`,
+ width: 1,
+ height: 10,
+ listening: false,
+ perfectDrawEnabled: false,
+ }),
+ label: new Konva.Text({
+ name: `${this.type}:label`,
+ text: 'T',
+ listening: false,
+ perfectDrawEnabled: false,
+ }),
+ };
+
+ this.konva.group.add(this.konva.cursor);
+ this.konva.group.add(this.konva.label);
+ this.konva.label.visible(true);
+
+ this.subscriptions.add(
+ this.manager.stateApi.createStoreSubscription(selectCanvasTextSlice, () => {
+ this.render();
+ })
+ );
+ this.subscriptions.add(
+ this.parent.$cursorPos.listen(() => {
+ this.render();
+ })
+ );
+ }
+
+ destroy = () => {
+ this.subscriptions.forEach((unsubscribe) => unsubscribe());
+ this.subscriptions.clear();
+ this.konva.group.destroy();
+ };
+
+ syncCursorStyle = () => {
+ const session = this.$session.get();
+ if (session?.status === 'editing') {
+ this.manager.stage.setCursor('default');
+ return;
+ }
+ this.manager.stage.setCursor('none');
+ };
+
+ render = () => {
+ const textSettings = this.manager.stateApi.runSelector(selectCanvasTextSlice);
+ const cursorPos = this.parent.$cursorPos.get();
+ const session = this.$session.get();
+
+ if (this.parent.$tool.get() !== 'text' || !cursorPos || (session && session.status === 'editing')) {
+ this.setVisibility(false);
+ return;
+ }
+
+ this.setVisibility(true);
+ this.setCursorDimensions(textSettings);
+ this.setCursorPosition(cursorPos.relative, textSettings);
+ };
+
+ private setCursorDimensions = (settings: CanvasTextSettingsState) => {
+ const onePixel = this.manager.stage.unscale(this.config.CURSOR_MIN_WIDTH_PX);
+ const cursorWidth = Math.max(onePixel * 2, onePixel);
+ const metricsKey = `${settings.fontId}|${settings.fontSize}|${settings.bold}|${settings.italic}|${settings.lineHeight}`;
+ if (this.cursorMetricsKey !== metricsKey) {
+ const measureConfig: TextMeasureConfig = {
+ text: 'Mg',
+ fontSize: settings.fontSize,
+ fontFamily: getFontStackById(settings.fontId),
+ fontWeight: settings.bold ? 700 : 400,
+ fontStyle: settings.italic ? 'italic' : 'normal',
+ lineHeight: settings.lineHeight,
+ };
+ const metrics = measureTextContent(measureConfig);
+ this.cursorHeight = Math.max(metrics.lineHeightPx, settings.fontSize) + TEXT_RASTER_PADDING * 2;
+ this.cursorMetricsKey = metricsKey;
+ }
+ const height = this.cursorHeight || settings.fontSize + TEXT_RASTER_PADDING * 2;
+ this.konva.cursor.setAttrs({
+ width: cursorWidth,
+ height,
+ });
+ this.konva.label.setAttrs({
+ fontFamily: getFontStackById('uiSerif'),
+ fontSize: Math.max(12, height * 0.35),
+ fontStyle: settings.bold ? '700' : '400',
+ fill: 'rgba(0, 0, 0, 1)',
+ stroke: 'rgba(255, 255, 255, 1)',
+ strokeWidth: Math.max(1, onePixel),
+ });
+ this.konva.cursor.fill('rgba(0, 0, 0, 1)');
+ this.konva.cursor.stroke('rgba(255, 255, 255, 1)');
+ this.konva.cursor.strokeWidth(onePixel);
+ };
+
+ private setCursorPosition = (cursor: Coordinate, _settings: CanvasTextSettingsState) => {
+ const top = cursor.y - TEXT_RASTER_PADDING;
+ this.konva.cursor.setAttrs({
+ x: cursor.x,
+ y: top,
+ });
+ const labelFontSize = this.konva.label.fontSize();
+ this.konva.label.setAttrs({
+ x: cursor.x + this.konva.cursor.width() * 1.5,
+ y: top + this.konva.cursor.height() - labelFontSize * 0.6,
+ });
+ };
+
+ private setVisibility = (visible: boolean) => {
+ this.konva.group.visible(visible);
+ this.konva.label.visible(visible);
+ };
+
+ onStagePointerMove = (e: KonvaEventObject) => {
+ if (e.target !== this.parent.konva.stage) {
+ return;
+ }
+ const cursorPos = this.parent.$cursorPos.get();
+ if (!cursorPos) {
+ return;
+ }
+ };
+
+ onStagePointerEnter = (e: KonvaEventObject) => {
+ if (e.target !== this.parent.konva.stage) {
+ return;
+ }
+ const cursorPos = this.parent.$cursorPos.get();
+ if (!cursorPos) {
+ return;
+ }
+ };
+
+ onStagePointerDown = (e: KonvaEventObject) => {
+ // Only allow left-click/primary pointer to begin typing sessions.
+ if (e.target !== this.parent.konva.stage || e.evt.button !== 0) {
+ return;
+ }
+ const cursorPos = this.parent.$cursorPos.get();
+ if (!cursorPos) {
+ return;
+ }
+ if (this.$session.get()) {
+ this.commitExistingSession();
+ }
+ this.beginSession(cursorPos.relative);
+ };
+
+ beginSession = (anchor: Coordinate) => {
+ const current = this.$session.get();
+ if (current && current.status === 'editing') {
+ return;
+ }
+ const id = getPrefixedId('text_session');
+ const status = coerceSessionStatus(transitionTextSessionStatus('idle', 'BEGIN'));
+ this.$session.set({
+ id,
+ anchor,
+ position: null,
+ status,
+ createdAt: Date.now(),
+ text: '',
+ });
+ };
+
+ markSessionEditing = (id: string) => {
+ const current = this.$session.get();
+ if (!current || current.id !== id) {
+ return;
+ }
+ const nextStatus = coerceSessionStatus(transitionTextSessionStatus(current.status, 'EDIT'));
+ this.$session.set({
+ ...current,
+ status: nextStatus,
+ });
+ };
+
+ clearSession = () => {
+ this.$session.set(null);
+ };
+
+ updateSessionText = (sessionId: string, text: string) => {
+ const current = this.$session.get();
+ if (!current || current.id !== sessionId) {
+ return;
+ }
+ this.$session.set({ ...current, text });
+ };
+
+ updateSessionPosition = (sessionId: string, position: Coordinate) => {
+ const current = this.$session.get();
+ if (!current || current.id !== sessionId) {
+ return;
+ }
+ this.$session.set({ ...current, position });
+ };
+
+ commitExistingSession = () => {
+ const session = this.$session.get();
+ if (!session) {
+ return;
+ }
+ this.requestCommit(session.id);
+ };
+
+ onToolChanged = (prevTool: Tool, nextTool: Tool) => {
+ if (prevTool === 'text' && nextTool !== 'text') {
+ this.commitExistingSession();
+ }
+ };
+
+ requestCommit = (sessionId: string) => {
+ const session = this.$session.get();
+ if (!session || session.id !== sessionId) {
+ return;
+ }
+ const rawText = session.text.replace(/\r/g, '');
+ if (!hasVisibleGlyphs(rawText)) {
+ this.clearSession();
+ return;
+ }
+
+ const textSettings = this.manager.stateApi.runSelector(selectCanvasTextSlice);
+ const canvasSettings = this.manager.stateApi.getSettings();
+ const color = canvasSettings.activeColor === 'fgColor' ? canvasSettings.fgColor : canvasSettings.bgColor;
+
+ this.$session.set({
+ ...session,
+ status: coerceSessionStatus(transitionTextSessionStatus(session.status, 'COMMIT')),
+ });
+
+ void this.commitSession(session, rawText, textSettings, color);
+ };
+
+ private commitSession = async (
+ session: CanvasTextSessionState,
+ rawText: string,
+ textSettings: CanvasTextSettingsState,
+ color: RgbaColor
+ ) => {
+ if (typeof document !== 'undefined' && document.fonts?.load) {
+ const fontSpec = buildFontDescriptor({
+ fontFamily: getFontStackById(textSettings.fontId),
+ fontWeight: textSettings.bold ? 700 : 400,
+ fontStyle: textSettings.italic ? 'italic' : 'normal',
+ fontSize: textSettings.fontSize,
+ });
+ try {
+ await document.fonts.load(fontSpec);
+ await document.fonts.ready;
+ } catch {
+ // Ignore font load failures and proceed with available metrics.
+ }
+ }
+
+ const renderResult = renderTextToCanvas({
+ text: rawText,
+ fontSize: textSettings.fontSize,
+ fontFamily: getFontStackById(textSettings.fontId),
+ fontWeight: textSettings.bold ? 700 : 400,
+ fontStyle: textSettings.italic ? 'italic' : 'normal',
+ underline: textSettings.underline,
+ strikethrough: textSettings.strikethrough,
+ lineHeight: textSettings.lineHeight,
+ color,
+ alignment: textSettings.alignment,
+ padding: TEXT_RASTER_PADDING,
+ devicePixelRatio: window.devicePixelRatio ?? 1,
+ });
+
+ const dataURL = renderResult.canvas.toDataURL('image/png');
+ const imageState: CanvasImageState = {
+ id: getPrefixedId('image'),
+ type: 'image',
+ image: {
+ dataURL,
+ width: renderResult.totalWidth,
+ height: renderResult.totalHeight,
+ },
+ };
+
+ const fallbackPosition = calculateLayerPosition(
+ session.anchor,
+ textSettings.alignment,
+ renderResult.contentWidth,
+ TEXT_RASTER_PADDING
+ );
+ const position = session.position ? { x: session.position.x, y: session.position.y } : fallbackPosition;
+
+ const selectedAdapter = this.manager.stateApi.getSelectedEntityAdapter();
+ const addAfter =
+ selectedAdapter && selectedAdapter.state.type === 'raster_layer' ? selectedAdapter.state.id : undefined;
+
+ this.manager.stateApi.addRasterLayer({
+ overrides: {
+ objects: [imageState],
+ position,
+ name: this.buildLayerName(rawText),
+ },
+ isSelected: true,
+ addAfter,
+ });
+
+ this.clearSession();
+ };
+
+ private buildLayerName = (text: string) => {
+ const flattened = text.replace(/\s+/g, ' ').trim();
+ if (!flattened) {
+ return 'Text';
+ }
+ return flattened.length > 32 ? `${flattened.slice(0, 29)}…` : flattened;
+ };
+}
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts
index b9b8adae9b8..6ede5c5d5bc 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasTool/CanvasToolModule.ts
@@ -6,6 +6,7 @@ import { CanvasColorPickerToolModule } from 'features/controlLayers/konva/Canvas
import { CanvasEraserToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasEraserToolModule';
import { CanvasMoveToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasMoveToolModule';
import { CanvasRectToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasRectToolModule';
+import { CanvasTextToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasTextToolModule';
import { CanvasViewToolModule } from 'features/controlLayers/konva/CanvasTool/CanvasViewToolModule';
import {
calculateNewBrushSizeFromWheelDelta,
@@ -64,6 +65,7 @@ export class CanvasToolModule extends CanvasModuleBase {
bbox: CanvasBboxToolModule;
view: CanvasViewToolModule;
move: CanvasMoveToolModule;
+ text: CanvasTextToolModule;
};
/**
@@ -119,6 +121,7 @@ export class CanvasToolModule extends CanvasModuleBase {
rect: new CanvasRectToolModule(this),
colorPicker: new CanvasColorPickerToolModule(this),
bbox: new CanvasBboxToolModule(this),
+ text: new CanvasTextToolModule(this),
view: new CanvasViewToolModule(this),
move: new CanvasMoveToolModule(this),
};
@@ -131,16 +134,20 @@ export class CanvasToolModule extends CanvasModuleBase {
this.konva.group.add(this.tools.brush.konva.group);
this.konva.group.add(this.tools.eraser.konva.group);
this.konva.group.add(this.tools.colorPicker.konva.group);
+ this.konva.group.add(this.tools.text.konva.group);
this.konva.group.add(this.tools.bbox.konva.group);
this.subscriptions.add(this.manager.stage.$stageAttrs.listen(this.render));
this.subscriptions.add(this.manager.$isBusy.listen(this.render));
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSettingsSlice, this.render));
this.subscriptions.add(this.manager.stateApi.createStoreSubscription(selectCanvasSlice, this.render));
+ let previousTool: Tool = this.$tool.get();
this.subscriptions.add(
- this.$tool.listen(() => {
+ this.$tool.listen((nextTool) => {
// On tool switch, reset mouse state
this.manager.tool.$isPrimaryPointerDown.set(false);
+ void this.tools.text.onToolChanged(previousTool, nextTool);
+ previousTool = nextTool;
this.render();
})
);
@@ -179,6 +186,8 @@ export class CanvasToolModule extends CanvasModuleBase {
this.tools.bbox.syncCursorStyle();
} else if (tool === 'colorPicker') {
this.tools.colorPicker.syncCursorStyle();
+ } else if (tool === 'text') {
+ this.tools.text.syncCursorStyle();
} else if (selectedEntityAdapter) {
if (selectedEntityAdapter.$isDisabled.get()) {
stage.setCursor('not-allowed');
@@ -208,6 +217,7 @@ export class CanvasToolModule extends CanvasModuleBase {
this.tools.brush.render();
this.tools.eraser.render();
this.tools.colorPicker.render();
+ this.tools.text.render();
this.tools.bbox.render();
};
@@ -290,6 +300,19 @@ export class CanvasToolModule extends CanvasModuleBase {
* @returns Whether the user is allowed to draw on the canvas.
*/
getCanDraw = (): boolean => {
+ const tool = this.$tool.get();
+ if (tool === 'text') {
+ if (this.manager.$isBusy.get()) {
+ return false;
+ }
+
+ if (this.manager.stage.getIsDragging()) {
+ return false;
+ }
+
+ return true;
+ }
+
if (this.manager.stateApi.getRenderedEntityCount() === 0) {
return false;
}
@@ -345,6 +368,8 @@ export class CanvasToolModule extends CanvasModuleBase {
await this.tools.brush.onStagePointerEnter(e);
} else if (tool === 'eraser') {
await this.tools.eraser.onStagePointerEnter(e);
+ } else if (tool === 'text') {
+ await this.tools.text.onStagePointerEnter(e);
}
} finally {
this.render();
@@ -375,6 +400,8 @@ export class CanvasToolModule extends CanvasModuleBase {
await this.tools.eraser.onStagePointerDown(e);
} else if (tool === 'rect') {
await this.tools.rect.onStagePointerDown(e);
+ } else if (tool === 'text') {
+ await this.tools.text.onStagePointerDown(e);
}
} finally {
this.render();
@@ -424,6 +451,8 @@ export class CanvasToolModule extends CanvasModuleBase {
if (tool === 'colorPicker') {
this.tools.colorPicker.onStagePointerMove(e);
+ } else if (tool === 'text') {
+ this.tools.text.onStagePointerMove(e);
}
if (!this.getCanDraw()) {
@@ -436,6 +465,8 @@ export class CanvasToolModule extends CanvasModuleBase {
await this.tools.eraser.onStagePointerMove(e);
} else if (tool === 'rect') {
await this.tools.rect.onStagePointerMove(e);
+ } else if (tool === 'text') {
+ // Already handled above
} else {
this.manager.stateApi.getSelectedEntityAdapter()?.bufferRenderer.clearBuffer();
}
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasTextSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasTextSlice.ts
new file mode 100644
index 00000000000..eab044a4bd7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasTextSlice.ts
@@ -0,0 +1,104 @@
+import { createSelector, createSlice, type PayloadAction } from '@reduxjs/toolkit';
+import type { RootState } from 'app/store/store';
+import type { SliceConfig } from 'app/store/types';
+import {
+ TEXT_DEFAULT_ALIGNMENT,
+ TEXT_DEFAULT_FONT_ID,
+ TEXT_DEFAULT_FONT_SIZE,
+ TEXT_DEFAULT_LINE_HEIGHT,
+ TEXT_MAX_FONT_SIZE,
+ TEXT_MAX_LINE_HEIGHT,
+ TEXT_MIN_FONT_SIZE,
+ TEXT_MIN_LINE_HEIGHT,
+ type TextAlignment,
+ type TextFontId,
+ zTextAlignment,
+ zTextFontId,
+} from 'features/controlLayers/text/textConstants';
+import { z } from 'zod';
+
+const zCanvasTextSettingsState = z.object({
+ fontId: zTextFontId,
+ fontSize: z.number().int().min(TEXT_MIN_FONT_SIZE).max(TEXT_MAX_FONT_SIZE),
+ bold: z.boolean(),
+ italic: z.boolean(),
+ underline: z.boolean(),
+ strikethrough: z.boolean(),
+ alignment: zTextAlignment,
+ lineHeight: z.number().min(TEXT_MIN_LINE_HEIGHT).max(TEXT_MAX_LINE_HEIGHT),
+});
+export type CanvasTextSettingsState = z.infer;
+
+const getInitialState = (): CanvasTextSettingsState => ({
+ fontId: TEXT_DEFAULT_FONT_ID,
+ fontSize: TEXT_DEFAULT_FONT_SIZE,
+ bold: false,
+ italic: false,
+ underline: false,
+ strikethrough: false,
+ alignment: TEXT_DEFAULT_ALIGNMENT,
+ lineHeight: TEXT_DEFAULT_LINE_HEIGHT,
+});
+
+const slice = createSlice({
+ name: 'canvasText',
+ initialState: getInitialState(),
+ reducers: {
+ textFontChanged: (state, action: PayloadAction) => {
+ state.fontId = action.payload;
+ },
+ textFontSizeChanged: (state, action: PayloadAction) => {
+ const next = Math.round(action.payload);
+ state.fontSize = Math.min(TEXT_MAX_FONT_SIZE, Math.max(TEXT_MIN_FONT_SIZE, next));
+ },
+ textBoldToggled: (state) => {
+ state.bold = !state.bold;
+ },
+ textItalicToggled: (state) => {
+ state.italic = !state.italic;
+ },
+ textUnderlineToggled: (state) => {
+ state.underline = !state.underline;
+ },
+ textStrikethroughToggled: (state) => {
+ state.strikethrough = !state.strikethrough;
+ },
+ textAlignmentChanged: (state, action: PayloadAction) => {
+ state.alignment = action.payload;
+ },
+ textLineHeightChanged: (state, action: PayloadAction) => {
+ const next = action.payload;
+ state.lineHeight = Math.min(TEXT_MAX_LINE_HEIGHT, Math.max(TEXT_MIN_LINE_HEIGHT, next));
+ },
+ textSettingsReset: () => {
+ return getInitialState();
+ },
+ },
+});
+
+export const {
+ textFontChanged,
+ textFontSizeChanged,
+ textBoldToggled,
+ textItalicToggled,
+ textUnderlineToggled,
+ textStrikethroughToggled,
+ textAlignmentChanged,
+} = slice.actions;
+
+export const canvasTextSliceConfig: SliceConfig = {
+ slice,
+ schema: zCanvasTextSettingsState,
+ getInitialState,
+ persistConfig: {
+ migrate: (state) => zCanvasTextSettingsState.parse(state),
+ },
+};
+
+export const selectCanvasTextSlice = (state: RootState) => state.canvasText;
+const createCanvasTextSelector = (selector: (state: CanvasTextSettingsState) => T) =>
+ createSelector(selectCanvasTextSlice, selector);
+
+export const selectTextFontId = createCanvasTextSelector((state) => state.fontId);
+export const selectTextFontSize = createCanvasTextSelector((state) => state.fontSize);
+export const selectTextAlignment = createCanvasTextSelector((state) => state.alignment);
diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
index 70732263f4a..716e9768711 100644
--- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts
@@ -102,7 +102,7 @@ const zIPMethodV2 = z.enum(['full', 'style', 'composition', 'style_strong', 'sty
export type IPMethodV2 = z.infer;
export const isIPMethodV2 = (v: unknown): v is IPMethodV2 => zIPMethodV2.safeParse(v).success;
-const _zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'colorPicker']);
+const _zTool = z.enum(['brush', 'eraser', 'move', 'rect', 'view', 'bbox', 'colorPicker', 'text']);
export type Tool = z.infer;
const zPoints = z.array(z.number()).refine((points) => points.length % 2 === 0, {
diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts b/invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts
new file mode 100644
index 00000000000..276bd6f61f7
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/text/textConstants.ts
@@ -0,0 +1,144 @@
+import { z } from 'zod';
+
+const TEXT_FONT_IDS = [
+ 'sans',
+ 'serif',
+ 'mono',
+ 'rounded',
+ 'script',
+ 'humanist',
+ 'slab',
+ 'display',
+ 'narrow',
+ 'uiSerif',
+] as const;
+export const zTextFontId = z.enum(TEXT_FONT_IDS);
+export type TextFontId = z.infer;
+
+export const TEXT_FONT_STACKS: Array<{ id: TextFontId; label: string; stack: string }> = [
+ {
+ id: 'sans',
+ label: 'Sans',
+ stack: 'system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif',
+ },
+ {
+ id: 'serif',
+ label: 'Serif',
+ stack: 'Georgia,"Times New Roman",Times,serif',
+ },
+ {
+ id: 'mono',
+ label: 'Monospace',
+ stack: 'ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace',
+ },
+ {
+ id: 'rounded',
+ label: 'Rounded',
+ stack: '"Trebuchet MS",Verdana,"Segoe UI",sans-serif',
+ },
+ {
+ id: 'script',
+ label: 'Script',
+ stack: '"Comic Sans MS","Comic Sans","Segoe UI",sans-serif',
+ },
+ {
+ id: 'humanist',
+ label: 'Handwritten',
+ stack:
+ '"Savoye LET","Zapfino","Snell Roundhand","Apple Chancery","Edwardian Script ITC","Palace Script MT","URW Chancery L","Brush Script MT","Lucida Handwriting","Segoe Script","Segoe Print","Comic Sans MS","Comic Sans","Segoe UI",cursive',
+ },
+ {
+ id: 'slab',
+ label: 'Slab Serif',
+ stack: '"Rockwell","Cambria","Georgia","Times New Roman",serif',
+ },
+ {
+ id: 'display',
+ label: 'Display',
+ stack: '"Impact","Haettenschweiler","Franklin Gothic Medium",Arial,sans-serif',
+ },
+ {
+ id: 'narrow',
+ label: 'Narrow',
+ stack: '"Arial Narrow","Roboto Condensed","Segoe UI",Arial,sans-serif',
+ },
+ {
+ id: 'uiSerif',
+ label: 'UI Serif',
+ stack: '"Iowan Old Style","Palatino","Book Antiqua","Times New Roman",serif',
+ },
+];
+
+export const TEXT_DEFAULT_FONT_ID: TextFontId = 'sans';
+export const TEXT_DEFAULT_FONT_SIZE = 48;
+export const TEXT_MIN_FONT_SIZE = 8;
+export const TEXT_MAX_FONT_SIZE = 500;
+export const TEXT_DEFAULT_LINE_HEIGHT = 1.25;
+export const TEXT_MIN_LINE_HEIGHT = 1;
+export const TEXT_MAX_LINE_HEIGHT = 2;
+export const TEXT_RASTER_PADDING = 4;
+
+const TEXT_ALIGNMENTS = ['left', 'center', 'right'] as const;
+export const zTextAlignment = z.enum(TEXT_ALIGNMENTS);
+export type TextAlignment = z.infer;
+export const TEXT_DEFAULT_ALIGNMENT: TextAlignment = 'left';
+
+const stripQuotes = (fontName: string) => fontName.replace(/^['"]+|['"]+$/g, '');
+
+const splitFontStack = (stack: string) => stack.split(',').map((font) => stripQuotes(font.trim()));
+
+const isGenericFont = (fontName: string) =>
+ fontName === 'serif' || fontName === 'sans-serif' || fontName === 'monospace' || fontName === 'cursive';
+
+const FONT_PROBE_TEXT = 'abcdefghijklmnopqrstuvwxyz0123456789';
+
+const getFontProbeContext = () => {
+ if (typeof document === 'undefined') {
+ return null;
+ }
+ const canvas = document.createElement('canvas');
+ return canvas.getContext('2d');
+};
+
+const isFontAvailable = (fontName: string): boolean => {
+ const ctx = getFontProbeContext();
+ if (!ctx) {
+ return false;
+ }
+ const fontSize = 72;
+ const fallbackFonts = ['monospace', 'serif', 'sans-serif'];
+ for (const fallback of fallbackFonts) {
+ ctx.font = `${fontSize}px ${fallback}`;
+ const baseline = ctx.measureText(FONT_PROBE_TEXT).width;
+ ctx.font = `${fontSize}px "${fontName}",${fallback}`;
+ const measured = ctx.measureText(FONT_PROBE_TEXT).width;
+ if (measured !== baseline) {
+ return true;
+ }
+ }
+ return false;
+};
+
+/**
+ * Attempts to resolve the first available font in the stack. Falls back to the first entry if availability cannot be
+ * determined (e.g. server-side rendering or older browsers).
+ */
+export const resolveAvailableFont = (stack: string): string => {
+ const fontCandidates = splitFontStack(stack);
+ if (typeof document === 'undefined') {
+ return fontCandidates[0] ?? 'sans-serif';
+ }
+ for (const candidate of fontCandidates) {
+ if (isGenericFont(candidate)) {
+ return candidate;
+ }
+ if (isFontAvailable(candidate)) {
+ return candidate;
+ }
+ }
+ return fontCandidates[0] ?? 'sans-serif';
+};
+
+export const getFontStackById = (fontId: TextFontId): string => {
+ return TEXT_FONT_STACKS.find((font) => font.id === fontId)?.stack ?? TEXT_FONT_STACKS[0]?.stack ?? 'sans-serif';
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.test.ts b/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.test.ts
new file mode 100644
index 00000000000..1cbc2d63086
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+
+import { isAllowedTextShortcut } from './textHotkeys';
+
+describe('text hotkey suppression', () => {
+ const buildEvent = (key: string, options?: Partial) =>
+ ({
+ key,
+ ctrlKey: options?.ctrlKey ?? false,
+ metaKey: options?.metaKey ?? false,
+ }) as KeyboardEvent;
+
+ it('allows copy/paste/undo/redo shortcuts', () => {
+ expect(isAllowedTextShortcut(buildEvent('c', { ctrlKey: true }))).toBe(true);
+ expect(isAllowedTextShortcut(buildEvent('v', { metaKey: true }))).toBe(true);
+ expect(isAllowedTextShortcut(buildEvent('z', { ctrlKey: true }))).toBe(true);
+ expect(isAllowedTextShortcut(buildEvent('y', { metaKey: true }))).toBe(true);
+ });
+
+ it('blocks other hotkeys by default', () => {
+ expect(isAllowedTextShortcut(buildEvent('b', { ctrlKey: true }))).toBe(false);
+ expect(isAllowedTextShortcut(buildEvent('Escape'))).toBe(false);
+ });
+});
diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.ts b/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.ts
new file mode 100644
index 00000000000..bd30c7a84f2
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/text/textHotkeys.ts
@@ -0,0 +1,7 @@
+export const isAllowedTextShortcut = (event: KeyboardEvent): boolean => {
+ if (event.metaKey || event.ctrlKey) {
+ const key = event.key.toLowerCase();
+ return key === 'c' || key === 'v' || key === 'z' || key === 'y';
+ }
+ return false;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.test.ts b/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.test.ts
new file mode 100644
index 00000000000..7883ca863fe
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest';
+
+import { calculateLayerPosition, computeAlignedX } from './textRenderer';
+
+describe('text alignment helpers', () => {
+ it('computes x offsets for different alignments', () => {
+ expect(computeAlignedX(50, 100, 'left', 4)).toBe(4);
+ expect(computeAlignedX(50, 100, 'center', 4)).toBe(4 + (100 - 50) / 2);
+ expect(computeAlignedX(50, 100, 'right', 4)).toBe(4 + (100 - 50));
+ });
+
+ it('calculates layer positions relative to anchor', () => {
+ const anchor = { x: 200, y: 300 };
+ expect(calculateLayerPosition(anchor, 'left', 100, 4)).toEqual({ x: 196, y: 296 });
+ expect(calculateLayerPosition(anchor, 'center', 100, 4)).toEqual({ x: 146, y: 296 });
+ expect(calculateLayerPosition(anchor, 'right', 100, 4)).toEqual({ x: 96, y: 296 });
+ });
+
+ it('uses top anchor regardless of line height', () => {
+ const anchor = { x: 200, y: 300 };
+ expect(calculateLayerPosition(anchor, 'left', 100, 4)).toEqual({ x: 196, y: 296 });
+ });
+});
diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.ts
new file mode 100644
index 00000000000..7c2b13e2883
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/text/textRenderer.ts
@@ -0,0 +1,189 @@
+import { rgbaColorToString } from 'common/util/colorCodeTransformers';
+import type { Coordinate, RgbaColor } from 'features/controlLayers/store/types';
+import type { TextAlignment } from 'features/controlLayers/text/textConstants';
+
+type TextRenderConfig = {
+ text: string;
+ fontSize: number;
+ fontFamily: string;
+ fontWeight: number;
+ fontStyle: 'normal' | 'italic';
+ underline: boolean;
+ strikethrough: boolean;
+ lineHeight: number;
+ color: RgbaColor;
+ alignment: TextAlignment;
+ padding: number;
+ devicePixelRatio: number;
+};
+
+export type TextMeasureConfig = {
+ text: string;
+ fontSize: number;
+ fontFamily: string;
+ fontWeight: number;
+ fontStyle: 'normal' | 'italic';
+ lineHeight: number;
+};
+
+type TextMetrics = {
+ lines: string[];
+ lineWidths: number[];
+ lineHeightPx: number;
+ contentWidth: number;
+ contentHeight: number;
+ ascent: number;
+ descent: number;
+ actualAscent: number;
+ actualDescent: number;
+ baselineOffset: number;
+};
+
+type TextRenderResult = {
+ canvas: HTMLCanvasElement;
+ contentWidth: number;
+ contentHeight: number;
+ totalWidth: number;
+ totalHeight: number;
+};
+
+export const renderTextToCanvas = (config: TextRenderConfig): TextRenderResult => {
+ const measurement = measureTextContent(config);
+ const extraRightPadding = Math.ceil(config.fontSize * 0.26);
+ const extraLeftPadding = Math.ceil(config.fontSize * 0.12);
+ const totalWidth = Math.ceil(measurement.contentWidth + config.padding * 2 + extraRightPadding + extraLeftPadding);
+ const totalHeight = Math.ceil(measurement.contentHeight + config.padding * 2);
+
+ const canvas = document.createElement('canvas');
+ const ctx = canvas.getContext('2d');
+ if (!ctx) {
+ throw new Error('Unable to acquire 2D context');
+ }
+ const dpr = Math.max(1, config.devicePixelRatio);
+ canvas.width = Math.max(1, Math.ceil(totalWidth * dpr));
+ canvas.height = Math.max(1, Math.ceil(totalHeight * dpr));
+ canvas.style.width = `${totalWidth}px`;
+ canvas.style.height = `${totalHeight}px`;
+ ctx.scale(dpr, dpr);
+ ctx.font = buildFontDescriptor(config);
+ ctx.textBaseline = 'alphabetic';
+ ctx.fillStyle = rgbaColorToString(config.color);
+ const dprScale = Math.max(1, config.devicePixelRatio);
+
+ measurement.lines.forEach((line, index) => {
+ const text = line === '' ? ' ' : line;
+ const lineWidth = measurement.lineWidths[index] ?? 0;
+ const x = computeAlignedX(lineWidth, measurement.contentWidth, config.alignment, config.padding + extraLeftPadding);
+ const y = config.padding + measurement.baselineOffset + index * measurement.lineHeightPx;
+ const snappedX = snapToDpr(x, dprScale);
+ const snappedY = snapToDpr(y, dprScale);
+ ctx.fillText(text, snappedX, snappedY);
+ if (config.underline) {
+ const underlineY = snapToDpr(snappedY + config.fontSize * 0.08, dprScale);
+ ctx.fillRect(snappedX, underlineY, lineWidth, Math.max(1.5, config.fontSize * 0.1));
+ }
+ if (config.strikethrough) {
+ const strikeY = snapToDpr(snappedY - measurement.actualAscent * 0.55, dprScale);
+ ctx.fillRect(snappedX, strikeY, lineWidth, Math.max(1.5, config.fontSize * 0.1));
+ }
+ });
+
+ return {
+ canvas,
+ contentWidth: measurement.contentWidth,
+ contentHeight: measurement.contentHeight,
+ totalWidth,
+ totalHeight,
+ };
+};
+
+export const measureTextContent = (config: TextMeasureConfig): TextMetrics => {
+ const lines = config.text.split(/\r?\n/);
+ const fontDescriptor = buildFontDescriptor(config);
+ const measurementCanvas = document.createElement('canvas');
+ const measureCtx = measurementCanvas.getContext('2d');
+ if (!measureCtx) {
+ throw new Error('Failed to build 2D context');
+ }
+ measureCtx.font = fontDescriptor;
+ const sampleMetrics = measureCtx.measureText('Mg');
+ const fallbackAscent = config.fontSize * 0.8;
+ const fallbackDescent = config.fontSize * 0.2;
+ const ascent =
+ sampleMetrics.fontBoundingBoxAscent ||
+ sampleMetrics.actualBoundingBoxAscent ||
+ sampleMetrics.emHeightAscent ||
+ fallbackAscent;
+ const descent =
+ sampleMetrics.fontBoundingBoxDescent ||
+ sampleMetrics.actualBoundingBoxDescent ||
+ sampleMetrics.emHeightDescent ||
+ fallbackDescent;
+ const actualAscent = sampleMetrics.actualBoundingBoxAscent || ascent;
+ const actualDescent = sampleMetrics.actualBoundingBoxDescent || descent;
+ const lineHeightPx = (ascent + descent) * config.lineHeight;
+ const extraLeading = Math.max(0, lineHeightPx - (ascent + descent));
+ const baselineOffset = ascent + extraLeading / 2;
+ const lineWidths = lines.map((line) => measureCtx.measureText(line === '' ? ' ' : line).width);
+ const contentWidth = Math.max(...lineWidths, config.fontSize);
+ const contentHeight = Math.max(lines.length, 1) * lineHeightPx;
+ return {
+ lines,
+ lineWidths,
+ lineHeightPx,
+ contentWidth,
+ contentHeight,
+ ascent,
+ descent,
+ actualAscent,
+ actualDescent,
+ baselineOffset,
+ };
+};
+
+export const computeAlignedX = (lineWidth: number, contentWidth: number, alignment: TextAlignment, padding: number) => {
+ if (alignment === 'center') {
+ return padding + (contentWidth - lineWidth) / 2;
+ }
+ if (alignment === 'right') {
+ return padding + (contentWidth - lineWidth);
+ }
+ return padding;
+};
+
+export const buildFontDescriptor = (config: {
+ fontStyle: 'normal' | 'italic';
+ fontWeight: number;
+ fontSize: number;
+ fontFamily: string;
+}) => {
+ const weight = config.fontWeight || 400;
+ return `${config.fontStyle === 'italic' ? 'italic ' : ''}${weight} ${config.fontSize}px ${config.fontFamily}`;
+};
+
+export const calculateLayerPosition = (
+ anchor: Coordinate,
+ alignment: TextAlignment,
+ contentWidth: number,
+ padding: number,
+ extraLeftPadding: number = 0
+) => {
+ let offsetX = -padding - extraLeftPadding;
+ if (alignment === 'center') {
+ offsetX = -(contentWidth / 2) - padding - extraLeftPadding;
+ } else if (alignment === 'right') {
+ offsetX = -contentWidth - padding - extraLeftPadding;
+ }
+ return {
+ x: anchor.x + offsetX,
+ y: anchor.y - padding,
+ };
+};
+
+export const hasVisibleGlyphs = (text: string): boolean => {
+ return text.replace(/\s+/g, '').length > 0;
+};
+
+const snapToDpr = (value: number, dpr: number): number => {
+ return Math.round(value * dpr) / dpr;
+};
diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.test.ts b/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.test.ts
new file mode 100644
index 00000000000..82768bf7b7e
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+
+import { transitionTextSessionStatus } from './textSessionMachine';
+
+describe('textSessionMachine', () => {
+ it('transitions from idle -> pending -> editing -> committed', () => {
+ let status = transitionTextSessionStatus('idle', 'BEGIN');
+ expect(status).toBe('pending');
+ status = transitionTextSessionStatus(status, 'EDIT');
+ expect(status).toBe('editing');
+ status = transitionTextSessionStatus(status, 'COMMIT');
+ expect(status).toBe('committed');
+ });
+
+ it('resets to idle on cancel from any state', () => {
+ expect(transitionTextSessionStatus('pending', 'CANCEL')).toBe('idle');
+ expect(transitionTextSessionStatus('editing', 'CANCEL')).toBe('idle');
+ expect(transitionTextSessionStatus('committed', 'CANCEL')).toBe('idle');
+ });
+
+ it('ignores invalid transitions', () => {
+ expect(transitionTextSessionStatus('pending', 'BEGIN')).toBe('pending');
+ expect(transitionTextSessionStatus('editing', 'EDIT')).toBe('editing');
+ });
+});
diff --git a/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.ts b/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.ts
new file mode 100644
index 00000000000..bf1e8f53fa9
--- /dev/null
+++ b/invokeai/frontend/web/src/features/controlLayers/text/textSessionMachine.ts
@@ -0,0 +1,38 @@
+export type TextSessionStatus = 'idle' | 'pending' | 'editing' | 'committed';
+type TextSessionEvent = 'BEGIN' | 'EDIT' | 'COMMIT' | 'CANCEL';
+
+export const transitionTextSessionStatus = (status: TextSessionStatus, event: TextSessionEvent): TextSessionStatus => {
+ switch (status) {
+ case 'idle':
+ if (event === 'BEGIN') {
+ return 'pending';
+ }
+ return status;
+ case 'pending':
+ if (event === 'EDIT') {
+ return 'editing';
+ }
+ if (event === 'CANCEL') {
+ return 'idle';
+ }
+ return status;
+ case 'editing':
+ if (event === 'COMMIT') {
+ return 'committed';
+ }
+ if (event === 'CANCEL') {
+ return 'idle';
+ }
+ return status;
+ case 'committed':
+ if (event === 'BEGIN') {
+ return 'pending';
+ }
+ if (event === 'CANCEL') {
+ return 'idle';
+ }
+ return status;
+ default:
+ return status;
+ }
+};
diff --git a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx
index 6b46d4ce803..9e7e7d3bf42 100644
--- a/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx
+++ b/invokeai/frontend/web/src/features/ui/layouts/CanvasWorkspacePanel.tsx
@@ -14,6 +14,7 @@ import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD';
import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent';
import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject';
import { StagingAreaContextProvider } from 'features/controlLayers/components/StagingArea/context';
+import { CanvasTextOverlay } from 'features/controlLayers/components/Text/CanvasTextOverlay';
import { PinnedFillColorPickerOverlay } from 'features/controlLayers/components/Tool/PinnedFillColorPickerOverlay';
import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar';
import { Transform } from 'features/controlLayers/components/Transform/Transform';
@@ -80,6 +81,7 @@ export const CanvasWorkspacePanel = memo(() => {
+