mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-19 02:48:12 +08:00
Viewport canvas navigation with modifier keys and zoom widget (#229)
* Add rotation around the center * Document transform centred * Fix drawing hexagon on rotated document * Format * Fix translation on rotated document * Remove logging * Rotate around centre of viewport * Rotate with shift + MMB drag * Zoom with +/- keys * Rotation input field * Implement frontend zoom buttons * Zoom with ctrl + MMB * Format * Update number inputs * Require Ctrl + Plus / Minus key * Ctrl scroll * Update zoom -> Multiply Zoom * Fix typo * More fixing typo * Remove :v-on * Add mouse scroll X * Scrolling on document * Refactor * Format * Fix ctrl + plus/minus to zoom * Reduce zoom sensitivity * Ctrl + shift + mmb drag = snap rotate * Further reduce zoom speed * Add ctrl + number key to change zoom * Switch Ctrl and Shift for zoom and rotate * Fix compile errors * Format JS * Add increment to snap angle * Edit getting layerdata functions * Pass viewport size directily into create_document_transform_from_layerdata * Add to_dvec2() * Refactor get_transform * Get -> Calculate * Add consts * Use to_radians * Remove get from function names * Use .entry when getting layerdata that does not exist * Fix distance scroll calculations * Fix zooming. * Remove 'Violation' in chrome * Fix compile errors
This commit is contained in:
@@ -88,13 +88,17 @@
|
||||
|
||||
<Separator :type="SeparatorType.Section" />
|
||||
|
||||
<IconButton :icon="'ZoomIn'" :size="24" title="Zoom In" />
|
||||
<IconButton :icon="'ZoomOut'" :size="24" title="Zoom Out" />
|
||||
<IconButton :icon="'ZoomReset'" :size="24" title="Zoom to 100%" />
|
||||
<NumberInput :callback="setRotation" :initial_value="0" :step="15" :unit="`°`" :update_on_callback="false" ref="rotation" />
|
||||
|
||||
<Separator :type="SeparatorType.Section" />
|
||||
|
||||
<IconButton :icon="'ZoomIn'" :size="24" title="Zoom In" @click="this.$refs.zoom.onIncrement(1)" />
|
||||
<IconButton :icon="'ZoomOut'" :size="24" title="Zoom Out" @click="this.$refs.zoom.onIncrement(-1)" />
|
||||
<IconButton :icon="'ZoomReset'" :size="24" title="Zoom to 100%" @click="this.$refs.zoom.updateValue(100)" />
|
||||
|
||||
<Separator :type="SeparatorType.Related" />
|
||||
|
||||
<NumberInput :value="25" :unit="`%`" />
|
||||
<NumberInput :callback="setZoom" :initial_value="100" :min="0.001" :increaseMultiplier="1.25" :decreaseMultiplier="0.8" :unit="`%`" :update_on_callback="false" ref="zoom" />
|
||||
</div>
|
||||
</LayoutRow>
|
||||
<LayoutRow :class="'shelf-and-viewport'">
|
||||
@@ -135,7 +139,7 @@
|
||||
<WorkingColors />
|
||||
</LayoutCol>
|
||||
<LayoutCol :class="'viewport'">
|
||||
<div class="canvas" @mousedown="canvasMouseDown" @mouseup="canvasMouseUp" @mousemove="canvasMouseMove">
|
||||
<div class="canvas" @mousedown="canvasMouseDown" @mouseup="canvasMouseUp" @mousemove="canvasMouseMove" ref="canvas">
|
||||
<svg v-html="viewportSvg"></svg>
|
||||
</div>
|
||||
</LayoutCol>
|
||||
@@ -188,7 +192,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { ResponseType, registerResponseHandler, Response, UpdateCanvas, SetActiveTool, ExportDocument } from "../../response-handler";
|
||||
import { ResponseType, registerResponseHandler, Response, UpdateCanvas, SetActiveTool, ExportDocument, SetZoom, SetRotation } from "../../response-handler";
|
||||
import LayoutRow from "../layout/LayoutRow.vue";
|
||||
import LayoutCol from "../layout/LayoutCol.vue";
|
||||
import WorkingColors from "../widgets/WorkingColors.vue";
|
||||
@@ -235,6 +239,11 @@ function makeModifiersBitfield(control: boolean, shift: boolean, alt: boolean):
|
||||
|
||||
export default defineComponent({
|
||||
methods: {
|
||||
async viewportResize() {
|
||||
const { on_viewport_resize } = await wasm;
|
||||
const canvas = this.$refs.canvas as HTMLDivElement;
|
||||
on_viewport_resize(canvas.clientWidth, canvas.clientHeight);
|
||||
},
|
||||
async canvasMouseDown(e: MouseEvent) {
|
||||
const { on_mouse_down } = await wasm;
|
||||
const modifiers = makeModifiersBitfield(e.ctrlKey, e.shiftKey, e.altKey);
|
||||
@@ -250,6 +259,20 @@ export default defineComponent({
|
||||
const modifiers = makeModifiersBitfield(e.ctrlKey, e.shiftKey, e.altKey);
|
||||
on_mouse_move(e.offsetX, e.offsetY, modifiers);
|
||||
},
|
||||
async canvasMouseScroll(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
const { on_mouse_scroll } = await wasm;
|
||||
const modifiers = makeModifiersBitfield(e.ctrlKey, e.shiftKey, e.altKey);
|
||||
on_mouse_scroll(e.deltaX, e.deltaY, e.deltaZ, modifiers);
|
||||
},
|
||||
async setZoom(newZoom: number) {
|
||||
const { on_set_zoom } = await wasm;
|
||||
on_set_zoom(newZoom / 100);
|
||||
},
|
||||
async setRotation(newRotation: number) {
|
||||
const { on_set_rotation } = await wasm;
|
||||
on_set_rotation(newRotation * (Math.PI / 180));
|
||||
},
|
||||
async keyDown(e: KeyboardEvent) {
|
||||
if (redirectKeyboardEventToBackend(e)) {
|
||||
e.preventDefault();
|
||||
@@ -302,9 +325,28 @@ export default defineComponent({
|
||||
const toolData = responseData as SetActiveTool;
|
||||
if (toolData) this.activeTool = toolData.tool_name;
|
||||
});
|
||||
registerResponseHandler(ResponseType.SetZoom, (responseData: Response) => {
|
||||
const updateData = responseData as SetZoom;
|
||||
if (updateData) {
|
||||
const zoomWidget = this.$refs.zoom as typeof NumberInput;
|
||||
zoomWidget.setValue(updateData.new_zoom * 100);
|
||||
}
|
||||
});
|
||||
registerResponseHandler(ResponseType.SetRotation, (responseData: Response) => {
|
||||
const updateData = responseData as SetRotation;
|
||||
if (updateData) {
|
||||
const rotationWidget = this.$refs.rotation as typeof NumberInput;
|
||||
const newRotation = updateData.new_radians * (180 / Math.PI);
|
||||
rotationWidget.setValue((360 + (newRotation % 360)) % 360);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("keyup", (e: KeyboardEvent) => this.keyUp(e));
|
||||
const canvas = this.$refs.canvas as HTMLDivElement;
|
||||
canvas.addEventListener("wheel", this.canvasMouseScroll, { passive: false });
|
||||
window.addEventListener("keydown", (e: KeyboardEvent) => this.keyDown(e));
|
||||
window.addEventListener("resize", () => this.viewportResize());
|
||||
window.addEventListener("DOMContentLoaded", () => this.viewportResize());
|
||||
|
||||
this.$watch("viewModeIndex", this.viewModeChanged);
|
||||
},
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div class="number-input">
|
||||
<button class="arrow left" @click="onIncrement(-1)"></button>
|
||||
<button class="arrow right" @click="onIncrement(1)"></button>
|
||||
<input type="text" spellcheck="false" :value="displayValue" />
|
||||
<input type="text" spellcheck="false" v-model="text" @change="updateText($event.target.value)" /> />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.number-input {
|
||||
width: 64px;
|
||||
width: 80px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
border-radius: 2px;
|
||||
@@ -98,37 +98,57 @@ import { defineComponent } from "vue";
|
||||
export default defineComponent({
|
||||
components: {},
|
||||
props: {
|
||||
value: { type: Number, required: true },
|
||||
initial_value: { type: Number, default: 0, required: false },
|
||||
unit: { type: String, default: "", required: false },
|
||||
step: { type: Number, default: 1, required: false },
|
||||
increaseMultiplier: { type: Number, default: null, required: false },
|
||||
decreaseMultiplier: { type: Number, default: null, required: false },
|
||||
min: { type: Number, required: false },
|
||||
max: { type: Number, required: false },
|
||||
callback: { type: Function, required: false },
|
||||
update_on_callback: { type: Boolean, default: true, required: false },
|
||||
},
|
||||
computed: {
|
||||
displayValue(): string {
|
||||
if (!this.unit) return this.value.toString();
|
||||
return `${this.value}${this.unit}`;
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
value: this.initial_value,
|
||||
text: this.initial_value.toString() + this.unit,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onIncrement(direction: number) {
|
||||
const step = this.step * direction;
|
||||
const newValue = this.value + step;
|
||||
this.updateValue(newValue);
|
||||
if (direction === 1 && this.increaseMultiplier) this.updateValue(this.value * this.increaseMultiplier, true);
|
||||
else if (direction === -1 && this.decreaseMultiplier) this.updateValue(this.value * this.decreaseMultiplier, true);
|
||||
else this.updateValue(this.value + this.step * direction, true);
|
||||
},
|
||||
|
||||
updateValue(newValue: number) {
|
||||
let value = newValue;
|
||||
updateText(newText: string) {
|
||||
const newValue = parseInt(newText, 10);
|
||||
this.updateValue(newValue, true);
|
||||
},
|
||||
|
||||
clampValue(newValue: number, resetOnClamp: boolean) {
|
||||
if (!Number.isFinite(newValue)) return this.value;
|
||||
let result = newValue;
|
||||
if (Number.isFinite(this.min) && typeof this.min === "number") {
|
||||
value = Math.max(value, this.min);
|
||||
if (resetOnClamp && newValue < this.min) return this.value;
|
||||
result = Math.max(result, this.min);
|
||||
}
|
||||
|
||||
if (Number.isFinite(this.max) && typeof this.max === "number") {
|
||||
value = Math.min(value, this.max);
|
||||
if (resetOnClamp && newValue > this.max) return this.value;
|
||||
result = Math.min(result, this.max);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
setValue(newValue: number) {
|
||||
this.value = newValue;
|
||||
this.text = `${Math.round(this.value)}${this.unit}`;
|
||||
},
|
||||
updateValue(inValue: number, resetOnClamp: boolean) {
|
||||
const newValue = this.clampValue(inValue, resetOnClamp);
|
||||
|
||||
this.$emit("update:value", value);
|
||||
if (this.callback) this.callback(newValue);
|
||||
|
||||
if (this.update_on_callback) this.setValue(newValue);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ export enum ResponseType {
|
||||
CloseDocument = "CloseDocument",
|
||||
UpdateWorkingColors = "UpdateWorkingColors",
|
||||
PromptCloseConfirmationModal = "PromptCloseConfirmationModal",
|
||||
SetZoom = "SetZoom",
|
||||
SetRotation = "SetRotation",
|
||||
}
|
||||
|
||||
export function attachResponseHandlerToPage() {
|
||||
@@ -64,6 +66,10 @@ function parseResponse(responseType: string, data: any): Response {
|
||||
return newCloseDocument(data.CloseDocument);
|
||||
case "UpdateCanvas":
|
||||
return newUpdateCanvas(data.UpdateCanvas);
|
||||
case "SetZoom":
|
||||
return newSetZoom(data.SetZoom);
|
||||
case "SetRotation":
|
||||
return newSetRotation(data.SetRotation);
|
||||
case "ExportDocument":
|
||||
return newExportDocument(data.ExportDocument);
|
||||
case "UpdateWorkingColors":
|
||||
@@ -71,11 +77,11 @@ function parseResponse(responseType: string, data: any): Response {
|
||||
case "PromptCloseConfirmationModal":
|
||||
return {};
|
||||
default:
|
||||
throw new Error(`Unrecognized origin/responseType pair: ${origin}, ${responseType}`);
|
||||
throw new Error(`Unrecognized origin/responseType pair: ${origin}, '${responseType}'`);
|
||||
}
|
||||
}
|
||||
|
||||
export type Response = SetActiveTool | UpdateCanvas | DocumentChanged | CollapseFolder | ExpandFolder | UpdateWorkingColors;
|
||||
export type Response = SetActiveTool | UpdateCanvas | DocumentChanged | CollapseFolder | ExpandFolder | UpdateWorkingColors | SetZoom | SetRotation;
|
||||
|
||||
export interface CloseDocument {
|
||||
document_index: number;
|
||||
@@ -175,6 +181,24 @@ function newExpandFolder(input: any): ExpandFolder {
|
||||
};
|
||||
}
|
||||
|
||||
export interface SetZoom {
|
||||
new_zoom: number;
|
||||
}
|
||||
function newSetZoom(input: any): SetZoom {
|
||||
return {
|
||||
new_zoom: input.new_zoom,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SetRotation {
|
||||
new_radians: number;
|
||||
}
|
||||
function newSetRotation(input: any): SetRotation {
|
||||
return {
|
||||
new_radians: input.new_radians,
|
||||
};
|
||||
}
|
||||
|
||||
export interface LayerPanelEntry {
|
||||
name: string;
|
||||
visible: boolean;
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::shims::Error;
|
||||
use crate::wrappers::{translate_key, translate_tool, Color};
|
||||
use crate::EDITOR_STATE;
|
||||
use editor_core::input::input_preprocessor::ModifierKeys;
|
||||
use editor_core::input::mouse::ScrollDelta;
|
||||
use editor_core::message_prelude::*;
|
||||
use editor_core::{
|
||||
input::mouse::{MouseState, ViewportPosition},
|
||||
@@ -37,6 +38,14 @@ pub fn new_document() -> Result<(), JsValue> {
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::NewDocument).map_err(convert_error))
|
||||
}
|
||||
|
||||
// TODO: Call event when the panels are resized
|
||||
/// Viewport resized
|
||||
#[wasm_bindgen]
|
||||
pub fn on_viewport_resize(new_width: u32, new_height: u32) -> Result<(), JsValue> {
|
||||
let ev = InputPreprocessorMessage::ViewportResize(ViewportPosition { x: new_width, y: new_height });
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(ev)).map_err(convert_error)
|
||||
}
|
||||
|
||||
// TODO: When a mouse button is down that started in the viewport, this should trigger even when the mouse is outside the viewport (or even the browser window if the browser supports it)
|
||||
/// Mouse movement within the screenspace bounds of the viewport
|
||||
#[wasm_bindgen]
|
||||
@@ -47,6 +56,15 @@ pub fn on_mouse_move(x: u32, y: u32, modifiers: u8) -> Result<(), JsValue> {
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(ev)).map_err(convert_error)
|
||||
}
|
||||
|
||||
/// Mouse scrolling within the screenspace bounds of the viewport
|
||||
#[wasm_bindgen]
|
||||
pub fn on_mouse_scroll(delta_x: i32, delta_y: i32, delta_z: i32, modifiers: u8) -> Result<(), JsValue> {
|
||||
// TODO: Convert these screenspace viewport coordinates to canvas coordinates based on the current zoom and pan
|
||||
let mods = ModifierKeys::from_bits(modifiers).expect("invalid modifier keys");
|
||||
let ev = InputPreprocessorMessage::MouseScroll(ScrollDelta::new(delta_x, delta_y, delta_z), mods);
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(ev)).map_err(convert_error)
|
||||
}
|
||||
|
||||
/// A mouse button depressed within screenspace the bounds of the viewport
|
||||
#[wasm_bindgen]
|
||||
pub fn on_mouse_down(x: u32, y: u32, mouse_keys: u8, modifiers: u8) -> Result<(), JsValue> {
|
||||
@@ -139,6 +157,20 @@ pub fn export_document() -> Result<(), JsValue> {
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(DocumentMessage::ExportDocument)).map_err(convert_error)
|
||||
}
|
||||
|
||||
/// Sets the zoom to the value
|
||||
#[wasm_bindgen]
|
||||
pub fn on_set_zoom(new_zoom: f64) -> Result<(), JsValue> {
|
||||
let ev = DocumentMessage::SetZoom(new_zoom);
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(ev)).map_err(convert_error)
|
||||
}
|
||||
|
||||
/// Sets the rotation to the new value (in radians)
|
||||
#[wasm_bindgen]
|
||||
pub fn on_set_rotation(new_radians: f64) -> Result<(), JsValue> {
|
||||
let ev = DocumentMessage::SetRotation(new_radians);
|
||||
EDITOR_STATE.with(|editor| editor.borrow_mut().handle_message(ev)).map_err(convert_error)
|
||||
}
|
||||
|
||||
/// Update the list of selected layers. The layer paths have to be stored in one array and are separated by LayerId::MAX
|
||||
#[wasm_bindgen]
|
||||
pub fn select_layers(paths: Vec<LayerId>) -> Result<(), JsValue> {
|
||||
|
||||
@@ -115,6 +115,9 @@ pub fn translate_key(name: &str) -> Key {
|
||||
"8" => Key8,
|
||||
"9" => Key9,
|
||||
"enter" => KeyEnter,
|
||||
"=" => KeyEquals,
|
||||
"+" => KeyPlus,
|
||||
"-" => KeyMinus,
|
||||
"shift" => KeyShift,
|
||||
// When using linux + chrome + the neo keyboard layout, the shift key is recognized as caps
|
||||
"capslock" => KeyShift,
|
||||
|
||||
Reference in New Issue
Block a user