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:
0HyperCube 2022-01-30 15:10:10 +00:00 committed by Keavon Chambers
parent 1d2768c26d
commit 121a68ad3c
33 changed files with 1152 additions and 56 deletions

View file

@ -266,6 +266,7 @@ export default defineComponent({
dialog: this.dialog,
documents: this.documents,
fullscreen: this.fullscreen,
inputManager: this.inputManager,
};
},
data() {

View file

@ -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: {

View file

@ -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: [],

View file

@ -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;

View file

@ -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 => {