mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2025-08-03 21:08:18 +00:00
Implement the Text Tool and text layer MVP (#492)
* Add text tool * Double click with the select tool to edit text * Fix (I think?) transitioning to select tool * Commit and abort text editing * Transition to a contenteditable div and autosize * Fix right click blocking * Cleanup hints * Ctrl + enter leaves text edit mode * Render indervidual bounding boxes for text * Re-format space indents * Reflect font size in the textarea * Fix change tool behaviour * Remove starting text * Populate the cache (caused doc load bug) * Remove console log * Chrome display the flashing text entry cursor * Update overlay on input * Cleanup input.ts * Fix bounding boxes * Apply review feedback * Remove manual test * Remove svg from gitignore Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
parent
1d2768c26d
commit
121a68ad3c
33 changed files with 1152 additions and 56 deletions
|
@ -266,6 +266,7 @@ export default defineComponent({
|
|||
dialog: this.dialog,
|
||||
documents: this.documents,
|
||||
fullscreen: this.fullscreen,
|
||||
inputManager: this.inputManager,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
|
|
|
@ -78,7 +78,7 @@
|
|||
|
||||
<Separator :type="'Section'" :direction="'Vertical'" />
|
||||
|
||||
<ShelfItemInput icon="ParametricTextTool" title="Text Tool (T)" :active="activeTool === 'Text'" :action="() => (dialog.comingSoon(153), false) && selectTool('Text')" />
|
||||
<ShelfItemInput icon="ParametricTextTool" title="Text Tool (T)" :active="activeTool === 'Text'" :action="() => selectTool('Text')" />
|
||||
<ShelfItemInput icon="ParametricFillTool" title="Fill Tool (F)" :active="activeTool === 'Fill'" :action="() => selectTool('Fill')" />
|
||||
<ShelfItemInput
|
||||
icon="ParametricGradientTool"
|
||||
|
@ -246,6 +246,32 @@
|
|||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
foreignObject {
|
||||
overflow: visible;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
|
||||
div {
|
||||
color: black;
|
||||
background: none;
|
||||
cursor: text;
|
||||
border: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: visible;
|
||||
white-space: pre-wrap;
|
||||
display: inline-block;
|
||||
// Workaround to force Chrome to display the flashing text entry cursor when text is empty
|
||||
padding-left: 1px;
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
div:focus {
|
||||
border: none;
|
||||
outline: none;
|
||||
margin: -1px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -253,7 +279,7 @@
|
|||
</style>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { defineComponent, nextTick } from "vue";
|
||||
|
||||
import {
|
||||
UpdateDocumentArtwork,
|
||||
|
@ -266,6 +292,9 @@ import {
|
|||
ToolName,
|
||||
UpdateDocumentArtboards,
|
||||
UpdateMouseCursor,
|
||||
TriggerTextCommit,
|
||||
DisplayRemoveEditableTextbox,
|
||||
DisplayEditableTextbox,
|
||||
} from "@/dispatcher/js-messages";
|
||||
|
||||
import LayoutCol from "@/components/layout/LayoutCol.vue";
|
||||
|
@ -347,13 +376,46 @@ export default defineComponent({
|
|||
this.editor.instance.reset_colors();
|
||||
},
|
||||
canvasPointerDown(e: PointerEvent) {
|
||||
const canvas = this.$refs.canvas as HTMLElement;
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
const onEditbox = e.target instanceof HTMLDivElement && e.target.contentEditable;
|
||||
if (!onEditbox) {
|
||||
const canvas = this.$refs.canvas as HTMLElement;
|
||||
canvas.setPointerCapture(e.pointerId);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentArtwork, (UpdateDocumentArtwork) => {
|
||||
this.artworkSvg = UpdateDocumentArtwork.svg;
|
||||
|
||||
nextTick((): void => {
|
||||
if (this.textInput) {
|
||||
const canvas = this.$refs.canvas as HTMLElement;
|
||||
const foreignObject = canvas.getElementsByTagName("foreignObject")[0] as SVGForeignObjectElement;
|
||||
if (foreignObject.children.length > 0) return;
|
||||
|
||||
const addedInput = foreignObject.appendChild(this.textInput);
|
||||
|
||||
nextTick((): void => {
|
||||
// Necessary to select contenteditable: https://stackoverflow.com/questions/6139107/programmatically-select-text-in-a-contenteditable-html-element/6150060#6150060
|
||||
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(addedInput);
|
||||
const selection = window.getSelection();
|
||||
if (selection) {
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
addedInput.focus();
|
||||
addedInput.click();
|
||||
});
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("modifyinputfield", {
|
||||
detail: addedInput,
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(UpdateDocumentOverlays, (updateDocumentOverlays) => {
|
||||
|
@ -393,6 +455,31 @@ export default defineComponent({
|
|||
this.editor.dispatcher.subscribeJsMessage(UpdateMouseCursor, (updateMouseCursor) => {
|
||||
this.canvasCursor = updateMouseCursor.cursor;
|
||||
});
|
||||
this.editor.dispatcher.subscribeJsMessage(TriggerTextCommit, () => {
|
||||
if (this.textInput) this.editor.instance.on_change_text(this.textInput.textContent || "");
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(DisplayEditableTextbox, (displayEditableTextbox) => {
|
||||
this.textInput = document.createElement("DIV") as HTMLDivElement;
|
||||
this.textInput.id = "editable-textbox";
|
||||
this.textInput.textContent = displayEditableTextbox.text;
|
||||
this.textInput.contentEditable = "true";
|
||||
this.textInput.style.width = displayEditableTextbox.line_width ? `${displayEditableTextbox.line_width}px` : "max-content";
|
||||
this.textInput.style.height = "auto";
|
||||
this.textInput.style.fontSize = `${displayEditableTextbox.font_size}px`;
|
||||
this.textInput.oninput = (): void => {
|
||||
if (this.textInput) this.editor.instance.update_bounds(this.textInput.textContent || "");
|
||||
};
|
||||
});
|
||||
|
||||
this.editor.dispatcher.subscribeJsMessage(DisplayRemoveEditableTextbox, () => {
|
||||
this.textInput = undefined;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("modifyinputfield", {
|
||||
detail: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
window.addEventListener("resize", this.viewportResize);
|
||||
window.addEventListener("DOMContentLoaded", this.viewportResize);
|
||||
|
@ -435,6 +522,7 @@ export default defineComponent({
|
|||
rulerOrigin: { x: 0, y: 0 },
|
||||
rulerSpacing: 100,
|
||||
rulerInterval: 100,
|
||||
textInput: undefined as undefined | HTMLDivElement,
|
||||
};
|
||||
},
|
||||
components: {
|
||||
|
|
|
@ -154,7 +154,7 @@ export default defineComponent({
|
|||
Crop: [],
|
||||
Navigate: [],
|
||||
Eyedropper: [],
|
||||
Text: [],
|
||||
Text: [{ kind: "NumberInput", optionPath: ["font_size"], props: { min: 1, isInteger: true, unit: " px", label: "Font size" } }],
|
||||
Fill: [],
|
||||
Gradient: [],
|
||||
Brush: [],
|
||||
|
|
|
@ -201,7 +201,7 @@ export class UpdateDocumentRulers extends JsMessage {
|
|||
readonly interval!: number;
|
||||
}
|
||||
|
||||
export type MouseCursorIcon = "default" | "zoom-in" | "zoom-out" | "grabbing" | "crosshair";
|
||||
export type MouseCursorIcon = "default" | "zoom-in" | "zoom-out" | "grabbing" | "crosshair" | "text";
|
||||
|
||||
const ToCssCursorProperty = Transform(({ value }) => {
|
||||
const cssNames: Record<string, MouseCursorIcon> = {
|
||||
|
@ -209,6 +209,7 @@ const ToCssCursorProperty = Transform(({ value }) => {
|
|||
ZoomOut: "zoom-out",
|
||||
Grabbing: "grabbing",
|
||||
Crosshair: "crosshair",
|
||||
Text: "text",
|
||||
};
|
||||
|
||||
return cssNames[value] || "default";
|
||||
|
@ -294,6 +295,16 @@ export function newDisplayDocumentLayerTreeStructure(input: { data_buffer: DataB
|
|||
return currentFolder;
|
||||
}
|
||||
|
||||
export class DisplayEditableTextbox extends JsMessage {
|
||||
readonly text!: string;
|
||||
|
||||
readonly line_width!: undefined | number;
|
||||
|
||||
readonly font_size!: number;
|
||||
}
|
||||
|
||||
export class DisplayRemoveEditableTextbox extends JsMessage {}
|
||||
|
||||
export class UpdateDocumentLayer extends JsMessage {
|
||||
@Type(() => LayerPanelEntry)
|
||||
readonly data!: LayerPanelEntry;
|
||||
|
@ -375,6 +386,8 @@ export class TriggerIndexedDbRemoveDocument extends JsMessage {
|
|||
document_id!: string;
|
||||
}
|
||||
|
||||
export class TriggerTextCommit extends JsMessage {}
|
||||
|
||||
// Any is used since the type of the object should be known from the rust side
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type JSMessageFactory = (data: any, wasm: WasmInstance, instance: RustEditorInstance) => JsMessage;
|
||||
|
@ -388,6 +401,8 @@ export const messageConstructors: Record<string, MessageMaker> = {
|
|||
TriggerFileDownload,
|
||||
TriggerFileUpload,
|
||||
DisplayDocumentLayerTreeStructure: newDisplayDocumentLayerTreeStructure,
|
||||
DisplayEditableTextbox,
|
||||
DisplayRemoveEditableTextbox,
|
||||
UpdateDocumentLayer,
|
||||
UpdateActiveTool,
|
||||
UpdateActiveDocument,
|
||||
|
@ -404,6 +419,7 @@ export const messageConstructors: Record<string, MessageMaker> = {
|
|||
DisplayDialogAboutGraphite,
|
||||
TriggerIndexedDbWriteDocument,
|
||||
TriggerIndexedDbRemoveDocument,
|
||||
TriggerTextCommit,
|
||||
UpdateDocumentArtboards,
|
||||
} as const;
|
||||
export type JsMessageType = keyof typeof messageConstructors;
|
||||
|
|
|
@ -3,7 +3,7 @@ import { DocumentsState } from "@/state/documents";
|
|||
import { FullscreenState } from "@/state/fullscreen";
|
||||
import { EditorState } from "@/state/wasm-loader";
|
||||
|
||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap;
|
||||
type EventName = keyof HTMLElementEventMap | keyof WindowEventHandlersEventMap | "modifyinputfield";
|
||||
interface EventListenerTarget {
|
||||
addEventListener: typeof window.addEventListener;
|
||||
removeEventListener: typeof window.removeEventListener;
|
||||
|
@ -22,25 +22,29 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
{ target: window, eventName: "pointermove", action: (e: PointerEvent): void => onPointerMove(e) },
|
||||
{ target: window, eventName: "pointerdown", action: (e: PointerEvent): void => onPointerDown(e) },
|
||||
{ target: window, eventName: "pointerup", action: (e: PointerEvent): void => onPointerUp(e) },
|
||||
{ target: window, eventName: "dblclick", action: (e: PointerEvent): void => onDoubleClick(e) },
|
||||
{ target: window, eventName: "mousedown", action: (e: MouseEvent): void => onMouseDown(e) },
|
||||
{ target: window, eventName: "wheel", action: (e: WheelEvent): void => onMouseScroll(e), options: { passive: false } },
|
||||
{ target: window, eventName: "modifyinputfield", action: (e: CustomEvent): void => onmodifyinputfiled(e) },
|
||||
];
|
||||
|
||||
let viewportPointerInteractionOngoing = false;
|
||||
let textInput = undefined as undefined | HTMLDivElement;
|
||||
|
||||
// Keyboard events
|
||||
|
||||
const shouldRedirectKeyboardEventToBackend = (e: KeyboardEvent): boolean => {
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
const { target } = e;
|
||||
if (target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable)) return false;
|
||||
|
||||
// Don't redirect when a modal is covering the workspace
|
||||
if (dialog.dialogIsVisible()) return false;
|
||||
|
||||
const key = getLatinKey(e);
|
||||
if (!key) return false;
|
||||
|
||||
// Don't redirect user input from text entry into HTML elements
|
||||
const { target } = e;
|
||||
if (key !== "escape" && !(key === "enter" && e.ctrlKey) && target instanceof HTMLElement && (target.nodeName === "INPUT" || target.nodeName === "TEXTAREA" || target.isContentEditable))
|
||||
return false;
|
||||
|
||||
// Don't redirect a fullscreen request
|
||||
if (key === "f11" && e.type === "keydown" && !e.repeat) {
|
||||
e.preventDefault();
|
||||
|
@ -107,6 +111,7 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
const { target } = e;
|
||||
const inCanvas = target instanceof Element && target.closest("[data-canvas]");
|
||||
const inDialog = target instanceof Element && target.closest("[data-dialog-modal] [data-floating-menu-content]");
|
||||
const inTextInput = target === textInput;
|
||||
|
||||
if (dialog.dialogIsVisible() && !inDialog) {
|
||||
dialog.dismissDialog();
|
||||
|
@ -114,7 +119,9 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
e.stopPropagation();
|
||||
}
|
||||
|
||||
if (inCanvas) viewportPointerInteractionOngoing = true;
|
||||
if (textInput && !inTextInput) {
|
||||
editor.instance.on_change_text(textInput.textContent || "");
|
||||
} else if (inCanvas && !inTextInput) viewportPointerInteractionOngoing = true;
|
||||
|
||||
if (viewportPointerInteractionOngoing) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
|
@ -125,8 +132,19 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
const onPointerUp = (e: PointerEvent): void => {
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
if (!textInput) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_mouse_up(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
};
|
||||
|
||||
const onDoubleClick = (e: PointerEvent): void => {
|
||||
if (!e.buttons) viewportPointerInteractionOngoing = false;
|
||||
|
||||
if (!textInput) {
|
||||
const modifiers = makeModifiersBitfield(e);
|
||||
editor.instance.on_double_click(e.clientX, e.clientY, e.buttons, modifiers);
|
||||
}
|
||||
};
|
||||
|
||||
// Mouse events
|
||||
|
@ -154,6 +172,10 @@ export function createInputManager(editor: EditorState, container: HTMLElement,
|
|||
}
|
||||
};
|
||||
|
||||
const onmodifyinputfiled = (e: CustomEvent): void => {
|
||||
textInput = e.detail;
|
||||
};
|
||||
|
||||
// Window events
|
||||
|
||||
const onWindowResize = (container: HTMLElement): void => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue