2205 lines
83 KiB
JavaScript
2205 lines
83 KiB
JavaScript
/*
|
|
* Copyright 2019 Abakkk
|
|
* Copyright 2024 Dave Prowse
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
*
|
|
* SPDX-FileCopyrightText: 2019 Abakkk
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
* SPDX-FileContributor: Modified by Dave Prowse
|
|
*/
|
|
|
|
/* jslint esversion: 6 (2019) */
|
|
/* eslint version: 9.16 (2024) */
|
|
/* exported Tool, DrawingArea */
|
|
|
|
import Cairo from 'cairo';
|
|
import System from 'system';
|
|
|
|
import Clutter from 'gi://Clutter';
|
|
import Cogl from 'gi://Cogl';
|
|
|
|
// This line is not necessary for GNOME 47, but is necessary for GNOME 46 backward compatibility (if variables are used properly elsewhere)
|
|
// Old nullish coalescing operator: const Color = Clutter.Color ?? Cogl.Color;
|
|
// GNOME 46/47 compatibility: Use ternary operator instead
|
|
const Color = Clutter.Color ? Clutter.Color : Cogl.Color;
|
|
|
|
import Gio from 'gi://Gio';
|
|
import GLib from 'gi://GLib';
|
|
import GObject from 'gi://GObject';
|
|
import Pango from 'gi://Pango';
|
|
import PangoCairo from 'gi://PangoCairo';
|
|
import Shell from 'gi://Shell';
|
|
import St from 'gi://St';
|
|
|
|
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
|
import * as Screenshot from 'resource:///org/gnome/shell/ui/screenshot.js';
|
|
|
|
import { gettext as _, pgettext } from 'resource:///org/gnome/shell/extensions/extension.js';
|
|
|
|
import { CURATED_UUID as UUID } from './utils.js';
|
|
import * as Elements from './elements.js'
|
|
import { Image } from './files.js'
|
|
import * as Menu from './menu.js'
|
|
|
|
import Meta from 'gi://Meta';
|
|
|
|
const MOTION_TIME = 1; // ms, time accuracy for free drawing, max is about 33 ms. The lower it is, the smoother the drawing is.
|
|
const TEXT_CURSOR_TIME = 600; // ms
|
|
const ELEMENT_GRABBER_TIME = 80; // ms, default is about 16 ms
|
|
const TOGGLE_ANIMATION_DURATION = 300; // ms
|
|
const GRID_TILES_HORIZONTAL_NUMBER = 30;
|
|
const COLOR_PICKER_EXTENSION_UUID = 'color-picker@tuberry';
|
|
|
|
const HIGHLIGHTER_YELLOW = Color.from_string('#ffff00')[1];
|
|
|
|
const { Shape, StaticColor, TextAlignment, Transformation } = Elements;
|
|
const { DisplayStrings } = Menu;
|
|
|
|
const FontGenericFamilies = ['Sans-Serif', 'Serif', 'Monospace', 'Cursive', 'Fantasy'];
|
|
const Manipulation = { MOVE: 100, RESIZE: 101, MIRROR: 102 };
|
|
export const Tool = {
|
|
getNameOf: function (value) {
|
|
return Object.keys(this).find(key => this[key] == value);
|
|
},
|
|
...Shape,
|
|
...Manipulation
|
|
};
|
|
Object.defineProperty(Tool, 'getNameOf', { enumerable: false });
|
|
|
|
|
|
|
|
// Drawing layers are the proper drawing area widgets (painted thanks to Cairo).
|
|
const DrawingLayer = GObject.registerClass({
|
|
GTypeName: `${UUID}-DrawingLayer`,
|
|
}, class DrawingArea extends St.DrawingArea {
|
|
_init(repaintFunction, getHasImageFunction) {
|
|
this._repaint = repaintFunction;
|
|
this._getHasImage = getHasImageFunction || (() => false);
|
|
super._init();
|
|
}
|
|
|
|
// Bind the size of layers and layer container.
|
|
vfunc_parent_set() {
|
|
this.clear_constraints();
|
|
|
|
if (this.get_parent())
|
|
this.add_constraint(new Clutter.BindConstraint({ coordinate: Clutter.BindCoordinate.SIZE, source: this.get_parent() }));
|
|
}
|
|
|
|
vfunc_repaint() {
|
|
let cr = this.get_context();
|
|
|
|
try {
|
|
this._repaint(cr);
|
|
} catch (e) {
|
|
logError(e, "An error occured while painting");
|
|
}
|
|
|
|
cr.$dispose();
|
|
if (this._getHasImage())
|
|
System.gc();
|
|
}
|
|
});
|
|
|
|
// Darwing area is a container that manages drawing elements and drawing layers.
|
|
// There is a drawing element for each "brushstroke".
|
|
// There is a separated layer for the current element so only the current element is redisplayed when drawing.
|
|
// It handles pointer/mouse/(touch?) events and some keyboard events.
|
|
export const DrawingArea = GObject.registerClass({
|
|
GTypeName: `${UUID}-DrawingArea`,
|
|
Signals: {
|
|
'show-osd': { param_types: [Gio.Icon.$gtype, GObject.TYPE_STRING, GObject.TYPE_STRING, GObject.TYPE_DOUBLE, GObject.TYPE_BOOLEAN] },
|
|
'pointer-cursor-changed': { param_types: [GObject.TYPE_STRING] },
|
|
'leave-drawing-mode': {}
|
|
},
|
|
}, class DrawingArea extends St.Widget {
|
|
|
|
_init(extension, params, monitor, helper, areaManagerUtils, loadPersistent, toolConf) {
|
|
super._init({ style_class: 'draw-on-gnome', name: params.name });
|
|
this._extension = extension;
|
|
this.monitor = monitor;
|
|
this.helper = helper;
|
|
this.areaManagerUtils = areaManagerUtils;
|
|
|
|
this.layerContainer = new St.Widget({ width: monitor.width, height: monitor.height });
|
|
this.add_child(this.layerContainer);
|
|
this.add_child(this.helper);
|
|
|
|
this.backLayer = new DrawingLayer(this._repaintBack.bind(this), this._getHasImageBack.bind(this));
|
|
this.layerContainer.add_child(this.backLayer);
|
|
this.foreLayer = new DrawingLayer(this._repaintFore.bind(this), this._getHasImageFore.bind(this));
|
|
this.layerContainer.add_child(this.foreLayer);
|
|
|
|
this.gridLayer = new DrawingLayer(this._repaintGrid.bind(this));
|
|
this.gridLayer.hide();
|
|
this.gridLayer.opacity = 0;
|
|
this.layerContainer.add_child(this.gridLayer);
|
|
|
|
// Ruler layer - shows with grid
|
|
this.rulerLayer = new DrawingLayer(this._repaintRulers.bind(this));
|
|
this.rulerLayer.hide();
|
|
this.rulerLayer.opacity = 0;
|
|
this.rulerLayer.reactive = false; // Clicks pass through
|
|
this.layerContainer.add_child(this.rulerLayer);
|
|
|
|
// Track mouse position for ruler highlights
|
|
this.rulerMouseX = 0;
|
|
this.rulerMouseY = 0;
|
|
|
|
// Laser pointer layer and state
|
|
this.laserLayer = new DrawingLayer(this._repaintLaser.bind(this));
|
|
this.laserLayer.hide();
|
|
this.laserLayer.opacity = 0;
|
|
this.laserLayer.reactive = false; // ADD THIS LINE - allows clicks to pass through
|
|
this.layerContainer.add_child(this.laserLayer);
|
|
|
|
// Laser pointer state variables
|
|
this.laserPointerActive = false;
|
|
this.laserPointerX = 0;
|
|
this.laserPointerY = 0;
|
|
this.laserTrailPoints = [];
|
|
this.laserTrailMaxLength = 15;
|
|
this.laserAnimationTimeoutId = null;
|
|
this.laserKeyPressed = false;
|
|
|
|
// Laser pointer state variables
|
|
this.laserPointerActive = false;
|
|
this.laserPointerX = 0;
|
|
this.laserPointerY = 0;
|
|
this.laserTrailPoints = [];
|
|
this.laserTrailMaxLength = 15;
|
|
this.laserAnimationTimeoutId = null;
|
|
this.laserKeyPressed = false;
|
|
this.laserTrailTimeoutId = null; // ADD THIS LINE
|
|
// End laser pointer code
|
|
|
|
this.elements = [];
|
|
|
|
this.undoneElements = [];
|
|
this.currentElement = null;
|
|
this.currentTool = Shape.NONE;
|
|
if (toolConf["toolPalette"][0] != "") {
|
|
this.currentPalette = toolConf["toolPalette"]
|
|
}
|
|
if (toolConf["toolColor"] != "") {
|
|
this.currentColor = this.getColorFromString(toolConf["toolColor"], "White");
|
|
}
|
|
this.currentImage = null;
|
|
this.currentTextAlignment = Clutter.get_default_text_direction() == Clutter.TextDirection.RTL ? TextAlignment.RIGHT : TextAlignment.LEFT;
|
|
let fontName = St.Settings && St.Settings.get().font_name || this._extension.getSettings('org.gnome.desktop.interface').get_string('font-name');
|
|
this.currentFont = Pango.FontDescription.from_string(fontName);
|
|
this.currentFont.unset_fields(Pango.FontMask.SIZE);
|
|
this.defaultFontFamily = this.currentFont.get_family();
|
|
this.currentLineWidth = toolConf["toolSize"];
|
|
this.currentLineJoin = Cairo.LineJoin.ROUND;
|
|
this.currentLineCap = Cairo.LineCap.ROUND;
|
|
this.currentFillRule = Cairo.FillRule.WINDING;
|
|
this.isSquareArea = false;
|
|
this.hasBackground = false;
|
|
this.textHasCursor = false;
|
|
this.dashedLine = false;
|
|
this.fill = false;
|
|
|
|
this.connect('notify::reactive', this._onReactiveChanged.bind(this));
|
|
this.drawingSettingsChangedHandler = this._extension.drawingSettings.connect('changed', this._onDrawingSettingsChanged.bind(this));
|
|
this._onDrawingSettingsChanged();
|
|
|
|
if (loadPersistent)
|
|
this._loadPersistent();
|
|
}
|
|
|
|
get menu() {
|
|
if (!this._menu)
|
|
this._menu = new Menu.DrawingMenu(this._extension, this, this.monitor, Tool, this.areaManagerUtils);
|
|
return this._menu;
|
|
}
|
|
|
|
closeMenu() {
|
|
if (this._menu)
|
|
this._menu.close();
|
|
}
|
|
|
|
get isWriting() {
|
|
return this.textEntry;
|
|
}
|
|
|
|
get currentTool() {
|
|
return this._currentTool;
|
|
}
|
|
|
|
set currentTool(tool) {
|
|
this._currentTool = tool;
|
|
if (this.hasManipulationTool)
|
|
this._startElementGrabber();
|
|
else
|
|
this._stopElementGrabber();
|
|
}
|
|
|
|
get currentPalette() {
|
|
return this._currentPalette;
|
|
}
|
|
|
|
set currentPalette(palette) {
|
|
this._currentPalette = palette;
|
|
this.colors = palette[1].map(colorString => this.getColorFromString(colorString, 'White'));
|
|
if (!this.colors[0])
|
|
this.colors.push(StaticColor.WHITE);
|
|
this._extension.drawingSettings.set_value("tool-palette", new GLib.Variant('(sas)', palette))
|
|
}
|
|
|
|
get currentImage() {
|
|
if (!this._currentImage)
|
|
this._currentImage = this._extension.FILES.IMAGES.getNext(this._currentImage);
|
|
|
|
return this._currentImage;
|
|
}
|
|
|
|
set currentImage(image) {
|
|
this._currentImage = image;
|
|
}
|
|
|
|
get currentFontFamily() {
|
|
return this.currentFont.get_family();
|
|
}
|
|
|
|
set currentFontFamily(family) {
|
|
this.currentFont.set_family(family);
|
|
}
|
|
|
|
get currentFontStyle() {
|
|
return this.currentFont.get_style();
|
|
}
|
|
|
|
set currentFontStyle(style) {
|
|
this.currentFont.set_style(style);
|
|
}
|
|
|
|
get currentFontWeight() {
|
|
return this.currentFont.get_weight();
|
|
}
|
|
|
|
set currentFontWeight(weight) {
|
|
this.currentFont.set_weight(weight);
|
|
}
|
|
|
|
get hasManipulationTool() {
|
|
// No Object.values method in GS 3.24.
|
|
return Object.keys(Manipulation).map(key => Manipulation[key]).indexOf(this.currentTool) != -1;
|
|
}
|
|
|
|
// Boolean wrapper for switch menu item.
|
|
get currentEvenodd() {
|
|
return this.currentFillRule == Cairo.FillRule.EVEN_ODD;
|
|
}
|
|
|
|
set currentEvenodd(evenodd) {
|
|
this.currentFillRule = evenodd ? Cairo.FillRule.EVEN_ODD : Cairo.FillRule.WINDING;
|
|
}
|
|
|
|
get fontFamilies() {
|
|
if (!this._fontFamilies) {
|
|
let otherFontFamilies = Elements.getAllFontFamilies().filter(family => {
|
|
return family != this.defaultFontFamily && FontGenericFamilies.indexOf(family) == -1;
|
|
});
|
|
this._fontFamilies = [this.defaultFontFamily].concat(FontGenericFamilies, otherFontFamilies);
|
|
}
|
|
return this._fontFamilies;
|
|
}
|
|
|
|
_onDrawingSettingsChanged() {
|
|
this.palettes = this._extension.drawingSettings.get_value('palettes').deep_unpack();
|
|
if (!this.colors) {
|
|
if (this.palettes[0])
|
|
this.currentPalette = this.palettes[0];
|
|
else
|
|
this.currentPalette = ['Palette', ['White']];
|
|
}
|
|
if (!this.currentColor) {
|
|
this.currentColor = this.colors[0];
|
|
this.currentColorIndex = 0; // Track that we're using the first color
|
|
}
|
|
|
|
if (this._extension.drawingSettings.get_boolean('square-area-auto')) {
|
|
this.squareAreaSize = Math.pow(2, 6);
|
|
while (this.squareAreaSize * 2 < Math.min(this.monitor.width, this.monitor.height))
|
|
this.squareAreaSize *= 2;
|
|
} else {
|
|
this.squareAreaSize = this._extension.drawingSettings.get_uint('square-area-size');
|
|
}
|
|
|
|
this.areaBackgroundColor = this.getColorFromString(this._extension.drawingSettings.get_string('background-color'), 'Black');
|
|
|
|
this.gridColor = this.getColorFromString(this._extension.drawingSettings.get_string('grid-color'), 'Gray');
|
|
if (this._extension.drawingSettings.get_boolean('grid-line-auto')) {
|
|
this.gridLineSpacing = Math.round(this.monitor.width / (5 * GRID_TILES_HORIZONTAL_NUMBER));
|
|
this.gridLineWidth = this.gridLineSpacing / 20;
|
|
} else {
|
|
this.gridLineSpacing = this._extension.drawingSettings.get_uint('grid-line-spacing');
|
|
this.gridLineWidth = Math.round(this._extension.drawingSettings.get_double('grid-line-width') * 100) / 100;
|
|
}
|
|
|
|
this.dashOffset = Math.round(this._extension.drawingSettings.get_double('dash-offset') * 100) / 100;
|
|
if (this._extension.drawingSettings.get_boolean('dash-array-auto')) {
|
|
this.dashArray = [0,0];
|
|
} else {
|
|
let on = Math.round(this._extension.drawingSettings.get_double('dash-array-on') * 100) / 100;
|
|
let off = Math.round(this._extension.drawingSettings.get_double('dash-array-off') * 100) / 100;
|
|
this.dashArray = [on, off];
|
|
}
|
|
}
|
|
|
|
_repaintBack(cr) {
|
|
this.elements.forEach(element => {
|
|
cr.save();
|
|
element.buildCairo(cr, {
|
|
showElementBounds: this.grabbedElement && this.grabbedElement == element,
|
|
drawElementBounds: this.grabPoint ? true : false
|
|
});
|
|
|
|
if (this.grabPoint)
|
|
this._searchElementToGrab(cr, element);
|
|
|
|
if (element.fill && !element.isStraightLine) {
|
|
cr.fillPreserve();
|
|
if (element.shape == Shape.NONE || element.shape == Shape.LINE)
|
|
cr.closePath();
|
|
}
|
|
|
|
// Don't stroke images - they're already painted
|
|
if (element.shape !== Shape.IMAGE) {
|
|
cr.stroke();
|
|
}
|
|
element._addMarks(cr);
|
|
cr.restore();
|
|
|
|
});
|
|
|
|
if (this.currentElement && this.currentElement.eraser) {
|
|
this.currentElement.buildCairo(cr, {
|
|
showTextCursor: this.textHasCursor,
|
|
showElementBounds: this.currentElement.shape != Shape.TEXT || !this.isWriting,
|
|
dummyStroke: this.currentElement.fill && this.currentElement.line.lineWidth == 0
|
|
});
|
|
cr.stroke();
|
|
}
|
|
}
|
|
|
|
_repaintFore(cr) {
|
|
if (!this.currentElement || this.currentElement.eraser)
|
|
return;
|
|
|
|
this.currentElement.buildCairo(cr, {
|
|
showTextCursor: this.textHasCursor,
|
|
showElementBounds: this.currentElement.shape != Shape.TEXT || !this.isWriting,
|
|
dummyStroke: this.currentElement.fill && this.currentElement.line.lineWidth == 0
|
|
});
|
|
cr.stroke();
|
|
}
|
|
|
|
_repaintGrid(cr) {
|
|
if (!this.reactive)
|
|
return;
|
|
|
|
cr.setSourceColor(this.gridColor);
|
|
|
|
let [gridX, gridY] = [0, 0];
|
|
while (gridX < this.monitor.width / 2) {
|
|
cr.setLineWidth((gridX / this.gridLineSpacing) % 5 ? this.gridLineWidth / 2 : this.gridLineWidth);
|
|
cr.moveTo(this.monitor.width / 2 + gridX, 0);
|
|
cr.lineTo(this.monitor.width / 2 + gridX, this.monitor.height);
|
|
cr.moveTo(this.monitor.width / 2 - gridX, 0);
|
|
cr.lineTo(this.monitor.width / 2 - gridX, this.monitor.height);
|
|
gridX += this.gridLineSpacing;
|
|
cr.stroke();
|
|
}
|
|
while (gridY < this.monitor.height / 2) {
|
|
cr.setLineWidth((gridY / this.gridLineSpacing) % 5 ? this.gridLineWidth / 2 : this.gridLineWidth);
|
|
cr.moveTo(0, this.monitor.height / 2 + gridY);
|
|
cr.lineTo(this.monitor.width, this.monitor.height / 2 + gridY);
|
|
cr.moveTo(0, this.monitor.height / 2 - gridY);
|
|
cr.lineTo(this.monitor.width, this.monitor.height / 2 - gridY);
|
|
gridY += this.gridLineSpacing;
|
|
cr.stroke();
|
|
}
|
|
}
|
|
|
|
_repaintRulers(cr) {
|
|
if (!this.reactive || !this.hasGrid)
|
|
return;
|
|
|
|
const RULER_WIDTH = 40;
|
|
const RULER_OPACITY = 0.7;
|
|
|
|
// Calculate lighter color (30% lighter than grid)
|
|
let lighterColor = new Color({
|
|
red: Math.min(255, this.gridColor.red + 77),
|
|
green: Math.min(255, this.gridColor.green + 77),
|
|
blue: Math.min(255, this.gridColor.blue + 77),
|
|
alpha: Math.floor(this.gridColor.alpha * RULER_OPACITY)
|
|
});
|
|
|
|
// Create Pango layout for text
|
|
let layout = this.rulerLayer.create_pango_layout('');
|
|
let fontDesc = Pango.FontDescription.from_string('Sans Bold 9');
|
|
layout.set_font_description(fontDesc);
|
|
|
|
// === LEFT RULER (Y-axis) ===
|
|
cr.save();
|
|
|
|
// Draw ruler background
|
|
cr.setSourceRGBA(
|
|
lighterColor.red / 255,
|
|
lighterColor.green / 255,
|
|
lighterColor.blue / 255,
|
|
lighterColor.alpha / 255
|
|
);
|
|
cr.rectangle(0, 0, RULER_WIDTH, this.monitor.height);
|
|
cr.fill();
|
|
|
|
// Draw Y-axis tick marks
|
|
cr.setSourceColor(this.gridColor);
|
|
let centerY = this.monitor.height / 2;
|
|
let gridUnit = 0;
|
|
let y = 0;
|
|
|
|
while (y < this.monitor.height / 2) {
|
|
let isMainMark = (gridUnit % 5 === 0);
|
|
cr.setLineWidth(isMainMark ? 2 : 1);
|
|
|
|
let tickLength = isMainMark ? 12 : 6;
|
|
// Marks above center
|
|
cr.moveTo(RULER_WIDTH - tickLength, centerY - y);
|
|
cr.lineTo(RULER_WIDTH, centerY - y);
|
|
cr.stroke();
|
|
|
|
// Marks below center
|
|
cr.moveTo(RULER_WIDTH - tickLength, centerY + y);
|
|
cr.lineTo(RULER_WIDTH, centerY + y);
|
|
cr.stroke();
|
|
|
|
y += this.gridLineSpacing;
|
|
gridUnit++;
|
|
}
|
|
|
|
// Draw Y-axis numbers (separate pass for proper rendering)
|
|
gridUnit = 0;
|
|
y = 0;
|
|
cr.setSourceRGBA(0.1, 0.1, 0.1, 0.9); // Dark gray text
|
|
|
|
while (y < this.monitor.height / 2) {
|
|
let isMainMark = (gridUnit % 5 === 0);
|
|
|
|
if (isMainMark && gridUnit > 0) {
|
|
layout.set_text(String(gridUnit), -1);
|
|
let [textWidth, textHeight] = layout.get_pixel_size();
|
|
|
|
// Above center
|
|
cr.moveTo(5, centerY - y - textHeight / 2);
|
|
PangoCairo.show_layout(cr, layout);
|
|
|
|
// Below center
|
|
cr.moveTo(5, centerY + y - textHeight / 2);
|
|
PangoCairo.show_layout(cr, layout);
|
|
}
|
|
|
|
y += this.gridLineSpacing;
|
|
gridUnit++;
|
|
}
|
|
|
|
// Highlight current Y position - darker and more visible
|
|
if (this.rulerMouseY >= 0 && this.rulerMouseY <= this.monitor.height) {
|
|
cr.setSourceRGBA(0.2, 0.5, 0.8, 0.6); // Blue highlight, more opaque
|
|
cr.rectangle(0, this.rulerMouseY - 2, RULER_WIDTH, 4);
|
|
cr.fill();
|
|
}
|
|
|
|
cr.restore();
|
|
|
|
// === BOTTOM RULER (X-axis) ===
|
|
cr.save();
|
|
|
|
// Draw ruler background
|
|
cr.setSourceRGBA(
|
|
lighterColor.red / 255,
|
|
lighterColor.green / 255,
|
|
lighterColor.blue / 255,
|
|
lighterColor.alpha / 255
|
|
);
|
|
cr.rectangle(0, this.monitor.height - RULER_WIDTH, this.monitor.width, RULER_WIDTH);
|
|
cr.fill();
|
|
|
|
// Draw X-axis tick marks
|
|
cr.setSourceColor(this.gridColor);
|
|
let centerX = this.monitor.width / 2;
|
|
gridUnit = 0;
|
|
let x = 0;
|
|
|
|
while (x < this.monitor.width / 2) {
|
|
let isMainMark = (gridUnit % 5 === 0);
|
|
cr.setLineWidth(isMainMark ? 2 : 1);
|
|
|
|
let tickLength = isMainMark ? 12 : 6;
|
|
// Marks left of center
|
|
cr.moveTo(centerX - x, this.monitor.height - RULER_WIDTH);
|
|
cr.lineTo(centerX - x, this.monitor.height - RULER_WIDTH + tickLength);
|
|
cr.stroke();
|
|
|
|
// Marks right of center
|
|
cr.moveTo(centerX + x, this.monitor.height - RULER_WIDTH);
|
|
cr.lineTo(centerX + x, this.monitor.height - RULER_WIDTH + tickLength);
|
|
cr.stroke();
|
|
|
|
x += this.gridLineSpacing;
|
|
gridUnit++;
|
|
}
|
|
|
|
// Draw X-axis numbers (separate pass for proper rendering)
|
|
gridUnit = 0;
|
|
x = 0;
|
|
cr.setSourceRGBA(0.1, 0.1, 0.1, 0.9); // Dark gray text
|
|
|
|
while (x < this.monitor.width / 2) {
|
|
let isMainMark = (gridUnit % 5 === 0);
|
|
|
|
if (isMainMark && gridUnit > 0) {
|
|
layout.set_text(String(gridUnit), -1);
|
|
let [textWidth, textHeight] = layout.get_pixel_size();
|
|
|
|
// Left of center
|
|
cr.moveTo(centerX - x - textWidth / 2, this.monitor.height - RULER_WIDTH + 15);
|
|
PangoCairo.show_layout(cr, layout);
|
|
|
|
// Right of center
|
|
cr.moveTo(centerX + x - textWidth / 2, this.monitor.height - RULER_WIDTH + 15);
|
|
PangoCairo.show_layout(cr, layout);
|
|
}
|
|
|
|
x += this.gridLineSpacing;
|
|
gridUnit++;
|
|
}
|
|
|
|
// Highlight current X position - darker and more visible
|
|
if (this.rulerMouseX >= 0 && this.rulerMouseX <= this.monitor.width) {
|
|
cr.setSourceRGBA(0.2, 0.5, 0.8, 0.6); // Blue highlight, more opaque
|
|
cr.rectangle(this.rulerMouseX - 2, this.monitor.height - RULER_WIDTH, 4, RULER_WIDTH);
|
|
cr.fill();
|
|
}
|
|
|
|
cr.restore();
|
|
}
|
|
|
|
// Laser Rendering Method
|
|
_repaintLaser(cr) {
|
|
if (!this.laserPointerActive || this.laserTrailPoints.length === 0)
|
|
return;
|
|
|
|
// Draw the laser trail with fading opacity
|
|
for (let i = 0; i < this.laserTrailPoints.length; i++) {
|
|
let point = this.laserTrailPoints[i];
|
|
let opacity = (i + 1) / this.laserTrailPoints.length;
|
|
let radius = 8 + (opacity * 4);
|
|
|
|
cr.setSourceRGBA(1.0, 0.1, 0.1, opacity * 0.7);
|
|
cr.arc(point[0], point[1], radius, 0, 2 * Math.PI);
|
|
cr.fill();
|
|
}
|
|
|
|
// Draw the main laser pointer
|
|
let mainRadius = 14;
|
|
|
|
// Outer glow
|
|
let gradient = new Cairo.RadialGradient(
|
|
this.laserPointerX, this.laserPointerY, 0,
|
|
this.laserPointerX, this.laserPointerY, mainRadius * 1.5
|
|
);
|
|
gradient.addColorStopRGBA(0, 1.0, 0.2, 0.2, 0.9);
|
|
gradient.addColorStopRGBA(0.5, 1.0, 0.1, 0.1, 0.6);
|
|
gradient.addColorStopRGBA(1, 1.0, 0.0, 0.0, 0.0);
|
|
|
|
cr.setSource(gradient);
|
|
cr.arc(this.laserPointerX, this.laserPointerY, mainRadius * 1.5, 0, 2 * Math.PI);
|
|
cr.fill();
|
|
|
|
// Inner bright core
|
|
cr.setSourceRGBA(1.0, 0.3, 0.3, 0.95);
|
|
cr.arc(this.laserPointerX, this.laserPointerY, mainRadius, 0, 2 * Math.PI);
|
|
cr.fill();
|
|
|
|
// Central hot spot
|
|
cr.setSourceRGBA(1.0, 0.9, 0.9, 1.0);
|
|
cr.arc(this.laserPointerX, this.laserPointerY, mainRadius * 0.4, 0, 2 * Math.PI);
|
|
cr.fill();
|
|
}
|
|
|
|
// End Laser Rendering Method code
|
|
|
|
_getHasImageBack() {
|
|
return this.elements.some(element => element.shape == Shape.IMAGE);
|
|
}
|
|
|
|
_getHasImageFore() {
|
|
return this.currentElement && this.currentElement.shape == Shape.IMAGE || false;
|
|
}
|
|
|
|
_redisplay() {
|
|
// force area to emit 'repaint'
|
|
this.backLayer.queue_repaint();
|
|
this.foreLayer.queue_repaint();
|
|
if (this.hasGrid) {
|
|
this.gridLayer.queue_repaint();
|
|
this.rulerLayer.queue_repaint();
|
|
}
|
|
}
|
|
|
|
_transformStagePoint(stageX, stageY) {
|
|
let [s, x, y] = this.transform_stage_point(stageX, stageY);
|
|
if (!s || !this.layerContainer.get_allocation_box().contains(x, y))
|
|
return [false, 0, 0];
|
|
|
|
return this.layerContainer.transform_stage_point(stageX, stageY);
|
|
}
|
|
|
|
_onButtonPressed(actor, event) {
|
|
if (this.spaceKeyPressed)
|
|
return Clutter.EVENT_PROPAGATE;
|
|
|
|
let button = event.get_button();
|
|
let [x, y] = event.get_coords();
|
|
let controlPressed = event.has_control_modifier();
|
|
let shiftPressed = event.has_shift_modifier();
|
|
|
|
if (this.currentElement && this.currentElement.shape == Shape.TEXT && this.isWriting)
|
|
// finish writing
|
|
this._stopWriting();
|
|
|
|
if (this.helper.visible) {
|
|
// hide helper
|
|
this.toggleHelp();
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
|
|
// Laser Button Press Handling Code
|
|
|
|
if (button == 1) {
|
|
if (this.laserPointerActive) {
|
|
this.stopLaserPointer();
|
|
}
|
|
|
|
if (this.hasManipulationTool) {
|
|
if (this.grabbedElement)
|
|
this._startTransforming(x, y, controlPressed, shiftPressed);
|
|
} else {
|
|
this._startDrawing(x, y, shiftPressed, (event.get_device ? event.get_device() : null) || event.get_source_device());
|
|
}
|
|
return Clutter.EVENT_STOP;
|
|
// End Laser Button Press Handling Code
|
|
|
|
} else if (button == 2) {
|
|
this.switchFill();
|
|
} else if (button == 3) {
|
|
this._stopAll();
|
|
|
|
this.menu.open(x, y);
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
_onKeyboardPopupMenu() {
|
|
this._stopAll();
|
|
|
|
if (this.helper.visible)
|
|
this.toggleHelp();
|
|
this.menu.popup();
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
|
|
// Laser Integration (pressed) - replaced old code
|
|
|
|
_onStageKeyPressed(actor, event) {
|
|
if (event.get_key_symbol() == Clutter.KEY_Escape) {
|
|
if (this.laserPointerActive) {
|
|
this.stopLaserPointer();
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
if (this.helper.visible)
|
|
this.toggleHelp();
|
|
else
|
|
this.emit('leave-drawing-mode');
|
|
return Clutter.EVENT_STOP;
|
|
} else if (event.get_key_symbol() == Clutter.KEY_space) {
|
|
this.spaceKeyPressed = true;
|
|
} else if (event.get_key_symbol() == Clutter.KEY_Alt_R) {
|
|
// Right Alt key for laser pointer
|
|
this.laserKeyPressed = true;
|
|
if (!this.laserPointerActive) {
|
|
let [x, y] = global.get_pointer();
|
|
let [success, localX, localY] = this._transformStagePoint(x, y);
|
|
if (success) {
|
|
this.startLaserPointer(localX, localY);
|
|
}
|
|
}
|
|
}
|
|
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
// End Laser Integration (pressed)
|
|
|
|
// Laser Integration (released)
|
|
|
|
_onStageKeyReleased(actor, event) {
|
|
if (event.get_key_symbol() == Clutter.KEY_space) {
|
|
this.spaceKeyPressed = false;
|
|
} else if (event.get_key_symbol() == Clutter.KEY_Alt_R) {
|
|
this.laserKeyPressed = false;
|
|
if (this.laserPointerActive) {
|
|
this.stopLaserPointer();
|
|
}
|
|
}
|
|
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
// End Laser Integration (released)
|
|
|
|
_onKeyPressed(actor, event) {
|
|
if (event.get_key_symbol() == Clutter.KEY_Escape) {
|
|
if (this.helper.visible) {
|
|
this.toggleHelp();
|
|
} else
|
|
this.emit('leave-drawing-mode');
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
|
|
if (this.currentElement && this.currentElement.shape == Shape.LINE &&
|
|
(event.get_key_symbol() == Clutter.KEY_Return ||
|
|
event.get_key_symbol() == Clutter.KEY_KP_Enter ||
|
|
event.get_key_symbol() == Clutter.KEY_Control_L)) {
|
|
|
|
if (this.currentElement.points.length == 2)
|
|
// Translators: %s is a key label
|
|
this.emit('show-osd', this._extension.FILES.ICONS.ARC, _("Press <i>%s</i> to get\na fourth control point")
|
|
.format(Meta.accelerator_name(0, Clutter.KEY_Return)), "", -1, true);
|
|
this.currentElement.addPoint();
|
|
this.updatePointerCursor(true);
|
|
this._redisplay();
|
|
return Clutter.EVENT_STOP;
|
|
} else if (this.currentElement &&
|
|
(this.currentElement.shape == Shape.POLYGON || this.currentElement.shape == Shape.POLYLINE) &&
|
|
(event.get_key_symbol() == Clutter.KEY_Return || event.get_key_symbol() == Clutter.KEY_KP_Enter)) {
|
|
|
|
this.currentElement.addPoint();
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
_onScroll(actor, event) {
|
|
if (this.helper.visible)
|
|
return Clutter.EVENT_PROPAGATE;
|
|
let direction = event.get_scroll_direction();
|
|
if (direction == Clutter.ScrollDirection.UP)
|
|
this.incrementLineWidth(1);
|
|
else if (direction == Clutter.ScrollDirection.DOWN)
|
|
this.incrementLineWidth(-1);
|
|
else
|
|
return Clutter.EVENT_PROPAGATE;
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
|
|
_searchElementToGrab(cr, element) {
|
|
if (element.getContainsPoint(cr, this.grabPoint[0], this.grabPoint[1]))
|
|
this.grabbedElement = element;
|
|
else if (this.grabbedElement == element)
|
|
this.grabbedElement = null;
|
|
|
|
if (element == this.elements[this.elements.length - 1])
|
|
// All elements have been tested, the winner is the last.
|
|
this.updatePointerCursor();
|
|
}
|
|
|
|
_startElementGrabber() {
|
|
if (this.elementGrabberHandler)
|
|
return;
|
|
|
|
this.elementGrabberHandler = this.connect('motion-event', (actor, event) => {
|
|
if (this.motionHandler || this.grabbedElementLocked) {
|
|
this.grabPoint = null;
|
|
return;
|
|
}
|
|
|
|
// Reduce computing without notable effect.
|
|
if (event.get_time() - (this.elementGrabberTimestamp || 0) < ELEMENT_GRABBER_TIME)
|
|
return;
|
|
this.elementGrabberTimestamp = event.get_time();
|
|
|
|
let coords = event.get_coords();
|
|
let [s, x, y] = this._transformStagePoint(coords[0], coords[1]);
|
|
if (!s)
|
|
return;
|
|
|
|
this.grabPoint = [x, y];
|
|
this.grabbedElement = null;
|
|
// this._redisplay calls this._searchElementToGrab.
|
|
this._redisplay();
|
|
});
|
|
}
|
|
|
|
_stopElementGrabber() {
|
|
if (this.elementGrabberHandler) {
|
|
this.disconnect(this.elementGrabberHandler);
|
|
this.grabPoint = null;
|
|
this.elementGrabberHandler = null;
|
|
}
|
|
}
|
|
|
|
_startTransforming(stageX, stageY, controlPressed, duplicate) {
|
|
let [success, startX, startY] = this._transformStagePoint(stageX, stageY);
|
|
|
|
if (!success)
|
|
return;
|
|
|
|
if (this.currentTool == Manipulation.MIRROR) {
|
|
this.grabbedElementLocked = !this.grabbedElementLocked;
|
|
if (this.grabbedElementLocked) {
|
|
this.updatePointerCursor();
|
|
let label = controlPressed ? _("Mark a point of symmetry") : _("Draw a line of symmetry");
|
|
this.emit('show-osd', this._extension.FILES.ICONS.TOOL_MIRROR, label, "", -1, true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.grabPoint = null;
|
|
|
|
this.buttonReleasedHandler = this.connect('button-release-event', (actor, event) => {
|
|
this._stopTransforming();
|
|
});
|
|
|
|
if (duplicate) {
|
|
// deep cloning
|
|
let copy = new this.grabbedElement.constructor(JSON.parse(JSON.stringify(this.grabbedElement)));
|
|
if (this.grabbedElement.color)
|
|
copy.color = this.grabbedElement.color;
|
|
if (this.grabbedElement.font)
|
|
copy.font = this.grabbedElement.font;
|
|
if (this.grabbedElement.image)
|
|
copy.image = this.grabbedElement.image;
|
|
this.elements.push(copy);
|
|
this.grabbedElement = copy;
|
|
}
|
|
|
|
let undoable = !duplicate;
|
|
|
|
if (this.currentTool == Manipulation.MOVE)
|
|
this.grabbedElement.startTransformation(startX, startY, controlPressed ? Transformation.ROTATION : Transformation.TRANSLATION, undoable);
|
|
else if (this.currentTool == Manipulation.RESIZE)
|
|
this.grabbedElement.startTransformation(startX, startY, controlPressed ? Transformation.STRETCH : Transformation.SCALE_PRESERVE, undoable);
|
|
else if (this.currentTool == Manipulation.MIRROR) {
|
|
this.grabbedElement.startTransformation(startX, startY, controlPressed ? Transformation.INVERSION : Transformation.REFLECTION, undoable);
|
|
this._redisplay();
|
|
}
|
|
|
|
// Laser Motion Handling
|
|
|
|
this.motionHandler = this.connect('motion-event', (actor, event) => {
|
|
let coords = event.get_coords();
|
|
let [s, x, y] = this._transformStagePoint(coords[0], coords[1]);
|
|
if (!s)
|
|
return;
|
|
|
|
if (this.spaceKeyPressed)
|
|
return;
|
|
|
|
let controlPressed = event.has_control_modifier();
|
|
this._updateTransforming(x, y, controlPressed);
|
|
});
|
|
|
|
// End Laser Motion Handling
|
|
}
|
|
|
|
_updateTransforming(x, y, controlPressed) {
|
|
let undoable = this.grabbedElement.lastTransformation.undoable || false;
|
|
|
|
if (controlPressed && this.grabbedElement.lastTransformation.type == Transformation.TRANSLATION) {
|
|
this.grabbedElement.stopTransformation();
|
|
this.grabbedElement.startTransformation(x, y, Transformation.ROTATION, undoable);
|
|
} else if (!controlPressed && this.grabbedElement.lastTransformation.type == Transformation.ROTATION) {
|
|
this.grabbedElement.stopTransformation();
|
|
this.grabbedElement.startTransformation(x, y, Transformation.TRANSLATION, undoable);
|
|
}
|
|
|
|
if (controlPressed && this.grabbedElement.lastTransformation.type == Transformation.SCALE_PRESERVE) {
|
|
this.grabbedElement.stopTransformation();
|
|
this.grabbedElement.startTransformation(x, y, Transformation.STRETCH, undoable);
|
|
} else if (!controlPressed && this.grabbedElement.lastTransformation.type == Transformation.STRETCH) {
|
|
this.grabbedElement.stopTransformation();
|
|
this.grabbedElement.startTransformation(x, y, Transformation.SCALE_PRESERVE, undoable);
|
|
}
|
|
|
|
if (controlPressed && this.grabbedElement.lastTransformation.type == Transformation.REFLECTION) {
|
|
this.grabbedElement.transformations.pop();
|
|
this.grabbedElement.startTransformation(x, y, Transformation.INVERSION, undoable);
|
|
} else if (!controlPressed && this.grabbedElement.lastTransformation.type == Transformation.INVERSION) {
|
|
this.grabbedElement.transformations.pop();
|
|
this.grabbedElement.startTransformation(x, y, Transformation.REFLECTION, undoable);
|
|
}
|
|
|
|
this.grabbedElement.updateTransformation(x, y);
|
|
this._redisplay();
|
|
}
|
|
|
|
_stopTransforming() {
|
|
if (this.motionHandler) {
|
|
this.disconnect(this.motionHandler);
|
|
this.motionHandler = null;
|
|
}
|
|
if (this.buttonReleasedHandler) {
|
|
this.disconnect(this.buttonReleasedHandler);
|
|
this.buttonReleasedHandler = null;
|
|
}
|
|
|
|
this.grabbedElement.stopTransformation();
|
|
this.grabbedElement = null;
|
|
this.grabbedElementLocked = false;
|
|
this._redisplay();
|
|
}
|
|
|
|
_startDrawing(stageX, stageY, shiftPressed, clickedDevice) {
|
|
let [success, startX, startY] = this._transformStagePoint(stageX, stageY);
|
|
|
|
if (!success)
|
|
return;
|
|
|
|
// Handle laser pointer tool
|
|
if (this.currentTool == Shape.LASER) {
|
|
if (!this.laserPointerActive) {
|
|
this.startLaserPointer(startX, startY);
|
|
}
|
|
return; // Don't create a drawing element
|
|
}
|
|
|
|
// Start Highlighter section
|
|
// Handle highlighter tool - creates semi-transparent yellow filled rectangles
|
|
if (this.currentTool == Shape.HIGHLIGHTER) {
|
|
this.buttonReleasedHandler = this.connect('button-release-event', (actor, event) => {
|
|
this._stopDrawing();
|
|
});
|
|
|
|
// Yellow highlighter with 50% transparency
|
|
let highlighterColor = HIGHLIGHTER_YELLOW.copy();
|
|
highlighterColor.alpha = 128;
|
|
|
|
// Add toJSON method so color saves properly
|
|
highlighterColor.toJSON = function() {
|
|
return this.to_string();
|
|
};
|
|
highlighterColor.toString = function() {
|
|
return this.to_string();
|
|
};
|
|
|
|
// Use RECTANGLE shape with fill enabled for highlighting
|
|
this.currentElement = new Elements.DrawingElement({
|
|
shape: Shape.RECTANGLE,
|
|
color: highlighterColor,
|
|
eraser: false,
|
|
fill: true,
|
|
fillRule: this.currentFillRule,
|
|
line: { lineWidth: 2, // ← CHANGED: 2 instead of 0
|
|
lineJoin: Cairo.LineJoin.ROUND,
|
|
lineCap: Cairo.LineCap.ROUND },
|
|
dash: { active: false, array: [0, 0], offset: 0 },
|
|
points: []
|
|
});
|
|
|
|
this.currentElement.startDrawing(startX, startY);
|
|
|
|
// Standard motion handler
|
|
if (this.motionHandler) {
|
|
this.disconnect(this.motionHandler);
|
|
this.motionHandler = null;
|
|
}
|
|
|
|
this.motionHandler = this.connect('motion-event', (actor, event) => {
|
|
let coords = event.get_coords();
|
|
let [s, x, y] = this._transformStagePoint(coords[0], coords[1]);
|
|
if (!s)
|
|
return;
|
|
|
|
if (clickedDevice != (event.get_device ? event.get_device() : null) && clickedDevice != event.get_source_device())
|
|
return Clutter.EVENT_PROPAGATE;
|
|
|
|
if (this.spaceKeyPressed)
|
|
return;
|
|
|
|
let controlPressed = event.has_control_modifier();
|
|
this._updateDrawing(x, y, controlPressed);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
// } else if (this.currentTool == Shape.HIGHLIGHTER) {
|
|
// // Highlighter uses yellow color with semi-transparency
|
|
// let highlighterColor = HIGHLIGHTER_YELLOW.copy();
|
|
// highlighterColor.alpha = 128; // 50% transparency
|
|
|
|
// this.currentElement = new Elements.DrawingElement({
|
|
// shape: Shape.RECTANGLE,
|
|
// color: highlighterColor,
|
|
// eraser: false,
|
|
// fill: true,
|
|
// line: { lineWidth: 2, lineJoin: this.currentLineJoin, lineCap: this.currentLineCap },
|
|
// points: []
|
|
// });
|
|
|
|
|
|
// End Highlighter section
|
|
|
|
this.buttonReleasedHandler = this.connect('button-release-event', (actor, event) => {
|
|
this._stopDrawing();
|
|
});
|
|
|
|
if (this.currentTool == Shape.TEXT) {
|
|
this.currentElement = new Elements.DrawingElement({
|
|
shape: this.currentTool,
|
|
color: this.currentColor,
|
|
eraser: shiftPressed,
|
|
font: this.currentFont.copy(),
|
|
// Translators: initial content of the text area
|
|
text: pgettext("text-area-content", "Text"),
|
|
textAlignment: this.currentTextAlignment,
|
|
points: []
|
|
});
|
|
|
|
} else if (this.currentTool == Shape.IMAGE) {
|
|
this.currentElement = new Elements.DrawingElement({
|
|
shape: this.currentTool,
|
|
color: this.currentColor,
|
|
colored: shiftPressed,
|
|
image: this.currentImage,
|
|
points: []
|
|
});
|
|
} else {
|
|
// Calculate dash array dynamically based on line width if in auto mode
|
|
let dashArray = this.dashArray;
|
|
if (this._extension.drawingSettings.get_boolean('dash-array-auto') && this.dashedLine) {
|
|
// Scale dash pattern with line width: dash = 3x width, gap = 2x width
|
|
let dashLength = Math.max(this.currentLineWidth * 3, 8);
|
|
let gapLength = Math.max(this.currentLineWidth * 2, 4);
|
|
// alternative formula for larger gaps: * 2,6, * 2,6
|
|
dashArray = [dashLength, gapLength];
|
|
}
|
|
|
|
this.currentElement = new Elements.DrawingElement({
|
|
shape: this.currentTool,
|
|
color: this.currentColor,
|
|
eraser: shiftPressed,
|
|
fill: this.fill,
|
|
fillRule: this.currentFillRule,
|
|
line: { lineWidth: this.currentLineWidth,
|
|
lineJoin: this.currentLineJoin,
|
|
lineCap: this.currentLineCap },
|
|
dash: { active: this.dashedLine,
|
|
array: dashArray,
|
|
offset: this.dashOffset },
|
|
points: []
|
|
});
|
|
}
|
|
|
|
this.currentElement.startDrawing(startX, startY);
|
|
|
|
if (this.currentTool == Shape.POLYGON || this.currentTool == Shape.POLYLINE) {
|
|
let icon = this._extension.FILES.ICONS[this.currentTool == Shape.POLYGON ? 'TOOL_POLYGON' : 'TOOL_POLYLINE'];
|
|
// Translators: %s is a key label
|
|
this.emit('show-osd', icon, _("Press <i>%s</i> to mark vertices")
|
|
.format(Meta.accelerator_name(0, Clutter.KEY_Return)), "", -1, true);
|
|
}
|
|
// Wayland supports two cursors so its important to disconnect motionhandler to avoid broken two cursors drawing
|
|
if (this.motionHandler) {
|
|
this.disconnect(this.motionHandler);
|
|
this.motionHandler = null;
|
|
}
|
|
|
|
// Laser Motion Handling
|
|
|
|
this.motionHandler = this.connect('motion-event', (actor, event) => {
|
|
let coords = event.get_coords();
|
|
let [s, x, y] = this._transformStagePoint(coords[0], coords[1]);
|
|
if (!s)
|
|
return;
|
|
|
|
// To avoid painting due to the wrong device (2 cursors wayland support)
|
|
//Modified for GNOME 46/47 support
|
|
if (clickedDevice != (event.get_device ? event.get_device() : null) && clickedDevice != event.get_source_device())
|
|
return Clutter.EVENT_PROPAGATE;
|
|
|
|
if (this.spaceKeyPressed)
|
|
return;
|
|
|
|
let controlPressed = event.has_control_modifier();
|
|
this._updateDrawing(x, y, controlPressed);
|
|
|
|
});
|
|
|
|
// End Laser Motion Handling
|
|
}
|
|
|
|
_updateDrawing(x, y, controlPressed) {
|
|
if (!this.currentElement)
|
|
return;
|
|
|
|
this.currentElement.updateDrawing(x, y, controlPressed);
|
|
|
|
if (this.currentElement.eraser)
|
|
this._redisplay();
|
|
else
|
|
this.foreLayer.queue_repaint();
|
|
this.updatePointerCursor(controlPressed);
|
|
}
|
|
|
|
_stopDrawing() {
|
|
if (this.motionHandler) {
|
|
this.disconnect(this.motionHandler);
|
|
this.motionHandler = null;
|
|
}
|
|
if (this.buttonReleasedHandler) {
|
|
this.disconnect(this.buttonReleasedHandler);
|
|
this.buttonReleasedHandler = null;
|
|
}
|
|
|
|
// skip when a polygon has not at least 3 points
|
|
if (this.currentElement && this.currentElement.shape == Shape.POLYGON && this.currentElement.points.length < 3)
|
|
this.currentElement = null;
|
|
|
|
if (this.currentElement)
|
|
this.currentElement.stopDrawing();
|
|
|
|
if (this.currentElement && this.currentElement.points.length >= 2) {
|
|
if (this.currentElement.shape == Shape.TEXT && !this.isWriting) {
|
|
this._startWriting();
|
|
return;
|
|
}
|
|
|
|
// For images, use original size instead of drag size
|
|
if (this.currentElement && this.currentElement.shape == Shape.IMAGE && this.currentElement.points.length >= 1) {
|
|
let pixbuf = this.currentElement.image.getPixbuf(this.currentElement.colored ? this.currentElement.color.toJSON() : null);
|
|
let [startX, startY] = this.currentElement.points[0];
|
|
// Place image at click point with original dimensions
|
|
this.currentElement.points[1] = [startX + pixbuf.get_width(), startY + pixbuf.get_height()];
|
|
this.currentElement.preserveAspectRatio = true;
|
|
}
|
|
|
|
this.elements.push(this.currentElement);
|
|
}
|
|
|
|
this.currentElement = null;
|
|
this._redisplay();
|
|
this.updatePointerCursor();
|
|
}
|
|
|
|
_startWriting() {
|
|
let [stageX, stageY] = this.get_transformed_position();
|
|
let [x, y] = [this.currentElement.x, this.currentElement.y];
|
|
this.currentElement.text = '';
|
|
this.currentElement.cursorPosition = 0;
|
|
// Translators: %s is a key label
|
|
this.emit('show-osd', this._extension.FILES.ICONS.TOOL_TEXT, _("Press <i>%s</i>\nto start a new line")
|
|
.format(Meta.accelerator_name(0, Clutter.KEY_Return)), "", -1, true);
|
|
this._updateTextCursorTimeout();
|
|
this.textHasCursor = true;
|
|
this._redisplay();
|
|
|
|
// Do not hide and do not set opacity to 0 because:
|
|
// 1. ibusCandidatePopup need a mapped text entry to init correctly its position.
|
|
// 2. 'cursor-changed' signal is no emitted if the text entry is not visible.
|
|
this.textEntry = new St.Entry({ opacity: 1, x: stageX + x, y: stageY + y });
|
|
this.insert_child_below(this.textEntry, null);
|
|
this.textEntry.grab_key_focus();
|
|
this.updatePointerCursor();
|
|
|
|
let ibusCandidatePopup = Main.layoutManager.uiGroup.get_children().find(child =>
|
|
child.has_style_class_name && child.has_style_class_name('candidate-popup-boxpointer'));
|
|
if (ibusCandidatePopup) {
|
|
this.ibusHandler = ibusCandidatePopup.connect('notify::visible', () => {
|
|
if (ibusCandidatePopup.visible) {
|
|
this.set_child_above_sibling(this.textEntry, null);
|
|
this.textEntry.opacity = 255;
|
|
}
|
|
});
|
|
this.textEntry.connect('destroy', () => ibusCandidatePopup.disconnect(this.ibusHandler));
|
|
}
|
|
|
|
this.textEntry.clutterText.set_single_line_mode(false);
|
|
this.textEntry.clutterText.set_activatable(false);
|
|
|
|
let showCursorOnPositionChanged = true;
|
|
this.textEntry.clutterText.connect('text-changed', clutterText => {
|
|
this.textEntry.y = stageY + y + (this.textEntry.clutterText.get_layout().get_line_count() - 1) * this.currentElement.height;
|
|
this.currentElement.text = clutterText.text;
|
|
showCursorOnPositionChanged = false;
|
|
this._redisplay();
|
|
});
|
|
|
|
this.textEntry.clutterText.connect('cursor-changed', clutterText => {
|
|
this.currentElement.cursorPosition = clutterText.cursorPosition;
|
|
this._updateTextCursorTimeout();
|
|
let cursorPosition = clutterText.cursorPosition == -1 ? clutterText.text.length : clutterText.cursorPosition;
|
|
this.textHasCursor = showCursorOnPositionChanged || GLib.unichar_isspace(clutterText.text.charAt(cursorPosition - 1));
|
|
showCursorOnPositionChanged = true;
|
|
this._redisplay();
|
|
});
|
|
|
|
this.textEntry.clutterText.connect('key-press-event', (clutterText, event) => {
|
|
if (event.get_key_symbol() == Clutter.KEY_Escape) {
|
|
this._stopWriting();
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
|
|
return Clutter.EVENT_PROPAGATE;
|
|
});
|
|
}
|
|
|
|
_stopWriting() {
|
|
if (this.currentElement.text.length > 0)
|
|
this.elements.push(this.currentElement);
|
|
|
|
this.currentElement = null;
|
|
this._stopTextCursorTimeout();
|
|
|
|
// Store textEntry reference before destroying
|
|
const textEntry = this.textEntry;
|
|
delete this.textEntry;
|
|
|
|
// Defer focus grab to avoid GNOME Shell 48.3/48.4 crash
|
|
// This ensures the text entry is fully destroyed before regaining focus
|
|
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
|
// Version-compatible check: is_finalized() doesn't exist in all GNOME versions
|
|
// Check if widget still exists and hasn't been destroyed
|
|
try {
|
|
if (textEntry && textEntry.get_stage()) {
|
|
// Widget still has a stage, safe to destroy
|
|
textEntry.destroy();
|
|
}
|
|
} catch (e) {
|
|
// Widget already destroyed or finalized, ignore
|
|
}
|
|
|
|
// Only grab focus if the area is still reactive
|
|
if (this.reactive) {
|
|
this.grab_key_focus();
|
|
}
|
|
|
|
this.updatePointerCursor();
|
|
this._redisplay();
|
|
|
|
return GLib.SOURCE_REMOVE;
|
|
});
|
|
}
|
|
|
|
setPointerCursor(pointerCursorName) {
|
|
if (!this.currentPointerCursorName || this.currentPointerCursorName != pointerCursorName) {
|
|
this.currentPointerCursorName = pointerCursorName;
|
|
this.emit('pointer-cursor-changed', pointerCursorName);
|
|
}
|
|
}
|
|
|
|
//Modifying MOVE_OR_RESIZE_WINDOW, to MOVE, both now function. //check in future versions of GNOME.
|
|
updatePointerCursor(controlPressed) {
|
|
// Laser cursor handling
|
|
|
|
if (this.laserPointerActive) {
|
|
this.setPointerCursor('CROSSHAIR');
|
|
return;
|
|
}
|
|
// End laser cursor handling
|
|
|
|
if (this.currentTool == Manipulation.MIRROR && this.grabbedElementLocked)
|
|
this.setPointerCursor('CROSSHAIR');
|
|
else if (this.hasManipulationTool)
|
|
this.setPointerCursor(this.grabbedElement ? 'MOVE' : 'DEFAULT');
|
|
else if (this.currentElement && this.currentElement.shape == Shape.TEXT && this.isWriting)
|
|
this.setPointerCursor('TEXT');
|
|
else if (!this.currentElement)
|
|
this.setPointerCursor(this.currentTool == Shape.NONE ? 'POINTER' : 'CROSSHAIR');
|
|
else if (this.currentElement.shape != Shape.NONE && controlPressed)
|
|
this.setPointerCursor('MOVE');
|
|
}
|
|
|
|
initPointerCursor() {
|
|
this.currentPointerCursorName = null;
|
|
this.updatePointerCursor();
|
|
}
|
|
|
|
_stopTextCursorTimeout() {
|
|
if (this.textCursorTimeoutId) {
|
|
GLib.source_remove(this.textCursorTimeoutId);
|
|
this.textCursorTimeoutId = null;
|
|
}
|
|
this.textHasCursor = false;
|
|
}
|
|
|
|
_updateTextCursorTimeout() {
|
|
this._stopTextCursorTimeout();
|
|
this.textCursorTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, TEXT_CURSOR_TIME, () => {
|
|
this.textHasCursor = !this.textHasCursor;
|
|
this._redisplay();
|
|
return GLib.SOURCE_CONTINUE;
|
|
});
|
|
}
|
|
|
|
// A priori there is nothing to stop, except transformations, if there is no current element.
|
|
// 'force' argument is passed when leaving drawing mode to ensure all is clean, as a workaround for possible bugs.
|
|
_stopAll(force) {
|
|
// Stop laser if active
|
|
if (this.laserPointerActive) {
|
|
this.stopLaserPointer();
|
|
// Also switch away from laser tool so motion doesn't restart it
|
|
if (this.currentTool === Shape.LASER) {
|
|
this.currentTool = this.previousTool || Shape.NONE;
|
|
}
|
|
}
|
|
// End laser stop method mods
|
|
|
|
if (this.grabbedElement) {
|
|
this._stopTransforming();
|
|
this.grabbedElement = null;
|
|
this.grabbedElementLocked = null;
|
|
this.updatePointerCursor();
|
|
}
|
|
|
|
if (!this.currentElement && !force)
|
|
return;
|
|
|
|
if (this.isWriting)
|
|
this._stopWriting();
|
|
|
|
this._stopDrawing();
|
|
}
|
|
|
|
erase() {
|
|
this.deleteLastElement();
|
|
this.elements = [];
|
|
this.undoneElements = [];
|
|
this._redisplay();
|
|
}
|
|
|
|
deleteLastElement() {
|
|
this._stopAll();
|
|
this.elements.pop();
|
|
|
|
if (this.elements.length)
|
|
this.elements[this.elements.length - 1].resetUndoneTransformations();
|
|
|
|
this._redisplay();
|
|
}
|
|
|
|
undo() {
|
|
if (!this.elements.length)
|
|
return;
|
|
|
|
let success = this.elements[this.elements.length - 1].undoTransformation();
|
|
if (!success) {
|
|
this.undoneElements.push(this.elements.pop());
|
|
if (this.elements.length)
|
|
this.elements[this.elements.length - 1].resetUndoneTransformations();
|
|
}
|
|
|
|
this._redisplay();
|
|
}
|
|
|
|
redo() {
|
|
let success = false;
|
|
|
|
if (this.elements.length)
|
|
success = this.elements[this.elements.length - 1].redoTransformation();
|
|
|
|
if (!success && this.undoneElements.length > 0)
|
|
this.elements.push(this.undoneElements.pop());
|
|
|
|
this._redisplay();
|
|
}
|
|
|
|
smoothLastElement() {
|
|
if (this.elements.length > 0 && this.elements[this.elements.length - 1].shape == Shape.NONE) {
|
|
this.elements[this.elements.length - 1].smoothAll();
|
|
this._redisplay();
|
|
}
|
|
}
|
|
|
|
toggleBackground() {
|
|
this.hasBackground = !this.hasBackground;
|
|
let backgroundColor = this.hasBackground ? this.areaBackgroundColor : StaticColor.TRANSPARENT;
|
|
|
|
if (this.ease) {
|
|
this.remove_all_transitions();
|
|
this.ease({
|
|
backgroundColor,
|
|
duration: TOGGLE_ANIMATION_DURATION,
|
|
transition: Clutter.AnimationMode.EASE_IN_OUT_QUAD
|
|
});
|
|
} else {
|
|
this.set_background_color(backgroundColor);
|
|
}
|
|
}
|
|
|
|
get hasGrid() {
|
|
return this.gridLayer.visible;
|
|
}
|
|
|
|
toggleGrid() {
|
|
// The grid layer is repainted when the visibility changes.
|
|
if (this.gridLayer.ease) {
|
|
this.gridLayer.remove_all_transitions();
|
|
this.rulerLayer.remove_all_transitions();
|
|
let visible = !this.gridLayer.visible;
|
|
|
|
this.gridLayer.visible = true;
|
|
this.rulerLayer.visible = true;
|
|
|
|
this.gridLayer.ease({
|
|
opacity: visible ? 255 : 0,
|
|
duration: TOGGLE_ANIMATION_DURATION,
|
|
transition: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
|
|
onStopped: () => this.gridLayer.visible = visible
|
|
});
|
|
|
|
this.rulerLayer.ease({
|
|
opacity: visible ? 255 : 0,
|
|
duration: TOGGLE_ANIMATION_DURATION,
|
|
transition: Clutter.AnimationMode.EASE_IN_OUT_QUAD,
|
|
onStopped: () => this.rulerLayer.visible = visible
|
|
});
|
|
} else {
|
|
this.gridLayer.visible = !this.gridLayer.visible;
|
|
this.rulerLayer.visible = !this.rulerLayer.visible;
|
|
}
|
|
}
|
|
|
|
toggleSquareArea() {
|
|
this.isSquareArea = !this.isSquareArea;
|
|
let x, y, width, height, onComplete;
|
|
|
|
if (this.isSquareArea) {
|
|
this.layerContainer.add_style_class_name('draw-on-gnome-square-area');
|
|
[x, y] = [(this.monitor.width - this.squareAreaSize) / 2, (this.monitor.height - this.squareAreaSize) / 2];
|
|
width = height = this.squareAreaSize;
|
|
onComplete = () => { };
|
|
} else {
|
|
x = y = 0;
|
|
[width, height] = [this.monitor.width, this.monitor.height];
|
|
onComplete = () => this.layerContainer.remove_style_class_name('draw-on-gnome-square-area');
|
|
}
|
|
|
|
if (this.layerContainer.ease) {
|
|
this.layerContainer.remove_all_transitions();
|
|
this.layerContainer.ease({
|
|
x, y, width, height, onComplete,
|
|
duration: TOGGLE_ANIMATION_DURATION,
|
|
transition: Clutter.AnimationMode.EASE_OUT_QUAD
|
|
});
|
|
} else {
|
|
this.layerContainer.set_position(x, y);
|
|
this.layerContainer.set_size(width, height);
|
|
onComplete();
|
|
}
|
|
}
|
|
|
|
selectColor(index) {
|
|
if (!this.colors[index])
|
|
return;
|
|
|
|
this.currentColorIndex = index; // Track which index is selected
|
|
this.currentColor = this.colors[index];
|
|
this._extension.drawingSettings.set_string("tool-color", this.colors[index].to_string());
|
|
if (this.currentElement) {
|
|
this.currentElement.color = this.currentColor;
|
|
this._redisplay();
|
|
}
|
|
|
|
// Use light background for Ctrl+9 (index 8) to make dark colors readable
|
|
let osdBackground = (index === 8) ? '#deddda' : this.currentColor.to_string().slice(0, 7);
|
|
|
|
// Foreground color markup is not displayed since 3.36, use style instead but the transparency is lost.
|
|
this.emit('show-osd', this._extension.FILES.ICONS.COLOR, String(this.currentColor), osdBackground, -1, false);
|
|
}
|
|
|
|
selectTool(tool) {
|
|
this.currentTool = tool;
|
|
this.emit('show-osd', this._extension.FILES.ICONS[`TOOL_${Tool.getNameOf(tool)}`] || null, DisplayStrings.Tool[tool], "", -1, false);
|
|
this.updatePointerCursor();
|
|
}
|
|
|
|
// Laser Control Methods
|
|
startLaserPointer(x, y) {
|
|
if (this.laserPointerActive)
|
|
return;
|
|
|
|
this.laserPointerActive = true;
|
|
this.laserPointerX = x;
|
|
this.laserPointerY = y;
|
|
this.laserTrailPoints = [[x, y]];
|
|
|
|
this.laserLayer.show();
|
|
this.laserLayer.ease({
|
|
opacity: 255,
|
|
duration: 100,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD
|
|
});
|
|
|
|
this._startLaserAnimation();
|
|
this.setPointerCursor('NONE');
|
|
|
|
if (!this._laserPointerShownOnce) {
|
|
this._laserPointerShownOnce = true;
|
|
this.emit('show-osd', this._extension.FILES.ICONS.TOOL_ARROW,
|
|
_("Laser Pointer Active"),
|
|
_("Release Shift to deactivate"), -1, true);
|
|
}
|
|
}
|
|
|
|
updateLaserPointer(x, y) {
|
|
if (!this.laserPointerActive)
|
|
return;
|
|
|
|
this.laserPointerX = x;
|
|
this.laserPointerY = y;
|
|
|
|
this.laserTrailPoints.push([x, y]);
|
|
|
|
if (this.laserTrailPoints.length > this.laserTrailMaxLength) {
|
|
this.laserTrailPoints.shift();
|
|
}
|
|
|
|
// Clear any existing timeout
|
|
if (this.laserTrailTimeoutId) {
|
|
GLib.source_remove(this.laserTrailTimeoutId);
|
|
this.laserTrailTimeoutId = null;
|
|
}
|
|
|
|
// Set new timeout to clear trail after 100ms of no movement
|
|
// Might need to lower this to 50 ms, need to test more...
|
|
this.laserTrailTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 100, () => {
|
|
if (this.laserPointerActive) {
|
|
// Fade out the trail by removing points
|
|
if (this.laserTrailPoints.length > 1) {
|
|
// Remove half the trail points for fade effect
|
|
let removeCount = Math.ceil(this.laserTrailPoints.length / 2);
|
|
this.laserTrailPoints.splice(0, removeCount);
|
|
return GLib.SOURCE_CONTINUE; // Keep running to fade more
|
|
} else {
|
|
// Clear all trail points
|
|
this.laserTrailPoints = [[this.laserPointerX, this.laserPointerY]];
|
|
}
|
|
}
|
|
this.laserTrailTimeoutId = null;
|
|
return GLib.SOURCE_REMOVE;
|
|
});
|
|
}
|
|
|
|
stopLaserPointer() {
|
|
if (!this.laserPointerActive)
|
|
return;
|
|
|
|
this.laserPointerActive = false;
|
|
this._stopLaserAnimation();
|
|
|
|
// Clean up trail timeout - ADD THESE LINES
|
|
if (this.laserTrailTimeoutId) {
|
|
GLib.source_remove(this.laserTrailTimeoutId);
|
|
this.laserTrailTimeoutId = null;
|
|
}
|
|
|
|
this.laserLayer.ease({
|
|
opacity: 0,
|
|
duration: 200,
|
|
mode: Clutter.AnimationMode.EASE_OUT_QUAD,
|
|
onComplete: () => {
|
|
this.laserLayer.hide();
|
|
this.laserTrailPoints = [];
|
|
}
|
|
});
|
|
|
|
this.updatePointerCursor();
|
|
}
|
|
|
|
_startLaserAnimation() {
|
|
if (this.laserAnimationTimeoutId)
|
|
return;
|
|
|
|
this.laserAnimationTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 16, () => {
|
|
if (this.laserPointerActive) {
|
|
this.laserLayer.queue_repaint();
|
|
return GLib.SOURCE_CONTINUE;
|
|
}
|
|
this.laserAnimationTimeoutId = null;
|
|
return GLib.SOURCE_REMOVE;
|
|
});
|
|
}
|
|
|
|
_stopLaserAnimation() {
|
|
if (this.laserAnimationTimeoutId) {
|
|
GLib.source_remove(this.laserAnimationTimeoutId);
|
|
this.laserAnimationTimeoutId = null;
|
|
}
|
|
}
|
|
|
|
// End Laser Control Methods
|
|
|
|
switchFill() {
|
|
this.fill = !this.fill;
|
|
let icon = this._extension.FILES.ICONS[this.fill ? 'FILL' : 'STROKE'];
|
|
this.emit('show-osd', icon, DisplayStrings.getFill(this.fill), "", -1, false);
|
|
}
|
|
|
|
switchFillRule() {
|
|
this.currentFillRule = this.currentFillRule == 1 ? 0 : this.currentFillRule + 1;
|
|
let icon = this._extension.FILES.ICONS[this.currentEvenodd ? 'FILLRULE_EVENODD' : 'FILLRULE_NONZERO'];
|
|
this.emit('show-osd', icon, DisplayStrings.FillRule[this.currentFillRule], "", -1, false);
|
|
}
|
|
|
|
switchColorPalette(reverse) {
|
|
// Find current palette by name (not reference) since palette may come from saved config
|
|
let currentPaletteName = this.currentPalette[0];
|
|
let index = this.palettes.findIndex(p => p[0] === currentPaletteName);
|
|
|
|
// If not found, default to first palette
|
|
if (index === -1) {
|
|
index = 0;
|
|
}
|
|
|
|
if (reverse)
|
|
this.currentPalette = index <= 0 ? this.palettes[this.palettes.length - 1] : this.palettes[index - 1];
|
|
else
|
|
this.currentPalette = index == this.palettes.length - 1 ? this.palettes[0] : this.palettes[index + 1];
|
|
|
|
// Preserve the color index when switching palettes
|
|
// If we had a color selected (e.g. Ctrl+1), select the same index in the new palette
|
|
if (this.currentColorIndex !== undefined && this.colors[this.currentColorIndex]) {
|
|
this.currentColor = this.colors[this.currentColorIndex];
|
|
this._extension.drawingSettings.set_string("tool-color", this.colors[this.currentColorIndex].to_string());
|
|
if (this.currentElement) {
|
|
this.currentElement.color = this.currentColor;
|
|
this._redisplay();
|
|
}
|
|
}
|
|
|
|
this.emit('show-osd', this._extension.FILES.ICONS.PALETTE, this.currentPalette[0], "", -1, false);
|
|
}
|
|
|
|
switchDash() {
|
|
this.dashedLine = !this.dashedLine;
|
|
let icon = this._extension.FILES.ICONS[this.dashedLine ? 'DASHED_LINE' : 'FULL_LINE'];
|
|
this.emit('show-osd', icon, DisplayStrings.getDashedLine(this.dashedLine), "", -1, false);
|
|
}
|
|
|
|
incrementLineWidth(increment) {
|
|
this.currentLineWidth = Math.max(this.currentLineWidth + increment, 1);
|
|
this.emit('show-osd', null, DisplayStrings.getPixels(this.currentLineWidth), "", 2 * this.currentLineWidth, false);
|
|
this._extension.drawingSettings.set_int("tool-size", this.currentLineWidth)
|
|
}
|
|
|
|
switchLineJoin() {
|
|
this.currentLineJoin = this.currentLineJoin == 2 ? 0 : this.currentLineJoin + 1;
|
|
this.emit('show-osd', this._extension.FILES.ICONS.LINEJOIN, DisplayStrings.LineJoin[this.currentLineJoin], "", -1, false);
|
|
}
|
|
|
|
switchLineCap() {
|
|
this.currentLineCap = this.currentLineCap == 2 ? 0 : this.currentLineCap + 1;
|
|
this.emit('show-osd', this._extension.FILES.ICONS.LINECAP, DisplayStrings.LineCap[this.currentLineCap], "", -1, false);
|
|
}
|
|
|
|
switchFontWeight() {
|
|
let fontWeights = Object.keys(DisplayStrings.FontWeight).map(key => Number(key));
|
|
let index = fontWeights.indexOf(this.currentFontWeight);
|
|
this.currentFontWeight = index == fontWeights.length - 1 ? fontWeights[0] : fontWeights[index + 1];
|
|
if (this.currentElement && this.currentElement.font) {
|
|
this.currentElement.font.set_weight(this.currentFontWeight);
|
|
this._redisplay();
|
|
}
|
|
this.emit('show-osd', this._extension.FILES.ICONS.FONT_WEIGHT, `<span font_weight="${this.currentFontWeight}">` +
|
|
`${DisplayStrings.FontWeight[this.currentFontWeight]}</span>`, "", -1, false);
|
|
}
|
|
|
|
switchFontStyle() {
|
|
this.currentFontStyle = this.currentFontStyle == 2 ? 0 : this.currentFontStyle + 1;
|
|
if (this.currentElement && this.currentElement.font) {
|
|
this.currentElement.font.set_style(this.currentFontStyle);
|
|
this._redisplay();
|
|
}
|
|
this.emit('show-osd', this._extension.FILES.ICONS.FONT_STYLE, `<span font_style="${DisplayStrings.FontStyleMarkup[this.currentFontStyle]}">` +
|
|
`${DisplayStrings.FontStyle[this.currentFontStyle]}</span>`, "", -1, false);
|
|
}
|
|
|
|
switchFontFamily(reverse) {
|
|
let index = Math.max(0, this.fontFamilies.indexOf(this.currentFontFamily));
|
|
if (reverse)
|
|
this.currentFontFamily = (index == 0) ? this.fontFamilies[this.fontFamilies.length - 1] : this.fontFamilies[index - 1];
|
|
else
|
|
this.currentFontFamily = (index == this.fontFamilies.length - 1) ? this.fontFamilies[0] : this.fontFamilies[index + 1];
|
|
if (this.currentElement && this.currentElement.font) {
|
|
this.currentElement.font.set_family(this.currentFontFamily);
|
|
this._redisplay();
|
|
}
|
|
this.emit('show-osd', this._extension.FILES.ICONS.FONT_FAMILY, `<span font_family="${this.currentFontFamily}">${DisplayStrings.getFontFamily(this.currentFontFamily)}</span>`, "", -1, false);
|
|
}
|
|
|
|
switchTextAlignment() {
|
|
this.currentTextAlignment = this.currentTextAlignment == 2 ? 0 : this.currentTextAlignment + 1;
|
|
if (this.currentElement && this.currentElement.textAlignment != this.currentTextAlignment) {
|
|
this.currentElement.textAlignment = this.currentTextAlignment;
|
|
this._redisplay();
|
|
}
|
|
let icon = this._extension.FILES.ICONS[this.currentTextAlignment == TextAlignment.RIGHT ? 'RIGHT_ALIGNED' : this.currentTextAlignment == TextAlignment.CENTER ? 'CENTERED' : 'LEFT_ALIGNED'];
|
|
this.emit('show-osd', icon, DisplayStrings.TextAlignment[this.currentTextAlignment], "", -1, false);
|
|
}
|
|
|
|
switchImageFile(reverse) {
|
|
this.currentImage = this._extension.FILES.IMAGES[reverse ? 'getPrevious' : 'getNext'](this.currentImage);
|
|
if (this.currentImage)
|
|
this.emit('show-osd', this.currentImage.gicon, this.currentImage.toString(), "", -1, false);
|
|
}
|
|
|
|
pasteImageFiles() {
|
|
this._extension.FILES.IMAGES.addImagesFromClipboard(lastImage => {
|
|
this.currentImage = lastImage;
|
|
this.currentTool = Shape.IMAGE;
|
|
this.updatePointerCursor();
|
|
this.emit('show-osd', this.currentImage.gicon, this.currentImage.toString(), "", -1, false);
|
|
});
|
|
}
|
|
|
|
_onColorPicked(color) {
|
|
if (color instanceof Color)
|
|
color = color.to_string().slice(0, -2);
|
|
|
|
this.currentColor = this.getColorFromString(color);
|
|
this._extension.drawingSettings.set_string("tool-color", color);
|
|
if (this.currentElement) {
|
|
this.currentElement.color = this.currentColor;
|
|
this._redisplay();
|
|
}
|
|
this.emit('show-osd', this._extension.FILES.ICONS.COLOR, String(this.currentColor), this.currentColor.to_string().slice(0, 7), -1, false);
|
|
this.initPointerCursor();
|
|
|
|
if (this._extension.settings.get_boolean("copy-picked-hex")) {
|
|
St.Clipboard.get_default().set_text(St.ClipboardType.CLIPBOARD, color);
|
|
}
|
|
}
|
|
|
|
pickColor() {
|
|
if (!Screenshot.PickPixel)
|
|
// GS 3.28-
|
|
return;
|
|
|
|
// Translators: It is displayed in an OSD notification to ask the user to start picking, so it should use the imperative mood.
|
|
this.emit('show-osd', this._extension.FILES.ICONS.COLOR_PICKER, pgettext("osd-notification", "Pick a color"), "", -1, false);
|
|
|
|
let extension = Main.extensionManager && Main.extensionManager.lookup(COLOR_PICKER_EXTENSION_UUID);
|
|
if (extension && extension.state == ExtensionUtils.ExtensionState.ENABLED && extension.stateObj && extension.stateObj.pickAsync) {
|
|
extension.stateObj.pickAsync().then(result => {
|
|
if (typeof result == 'string')
|
|
this._onColorPicked(result);
|
|
else
|
|
this.initPointerCursor();
|
|
}).catch(e => {
|
|
this.initPointerCursor();
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
try {
|
|
let screenshot = new Shell.Screenshot();
|
|
let pickPixel = new Screenshot.PickPixel(screenshot);
|
|
|
|
if (pickPixel.pickAsync) {
|
|
pickPixel.pickAsync().then(result => {
|
|
if (result instanceof Color) {
|
|
// GS 3.38+
|
|
this._onColorPicked(result);
|
|
} else {
|
|
// GS 3.36
|
|
let graphenePoint = result;
|
|
screenshot.pick_color(graphenePoint.x, graphenePoint.y, (o, res) => {
|
|
let [, color] = screenshot.pick_color_finish(res);
|
|
this._onColorPicked(color);
|
|
});
|
|
}
|
|
}).catch(() => this.initPointerCursor());
|
|
} else {
|
|
// GS 3.34-
|
|
pickPixel.show();
|
|
pickPixel.connect('finished', (pickPixel, coords) => {
|
|
if (coords)
|
|
screenshot.pick_color(...coords, (o, res) => {
|
|
let [, color] = screenshot.pick_color_finish(res);
|
|
this._onColorPicked(color);
|
|
});
|
|
else
|
|
this.initPointerCursor();
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error(`${this._extension.metadata.uuid}: color picker failed: ${e.message}`);
|
|
this.initPointerCursor();
|
|
}
|
|
}
|
|
|
|
toggleHelp() {
|
|
if (this.helper.visible) {
|
|
this.helper.hideHelp();
|
|
if (this.textEntry)
|
|
this.textEntry.grab_key_focus();
|
|
} else {
|
|
this.helper.showHelp();
|
|
this.grab_key_focus();
|
|
}
|
|
}
|
|
|
|
// The area is reactive when it is modal.
|
|
_onReactiveChanged() {
|
|
if (this.hasGrid)
|
|
this._redisplay();
|
|
if (this.helper.visible)
|
|
this.toggleHelp();
|
|
if (this.textEntry && this.reactive)
|
|
this.textEntry.grab_key_focus();
|
|
|
|
if (this.reactive) {
|
|
this.stageKeyPressedHandler = global.stage.connect('key-press-event', this._onStageKeyPressed.bind(this));
|
|
this.stageKeyReleasedHandler = global.stage.connect('key-release-event', this._onStageKeyReleased.bind(this));
|
|
} else {
|
|
if (this.stageKeyPressedHandler) {
|
|
global.stage.disconnect(this.stageKeyPressedHandler);
|
|
this.stageKeyPressedHandler = null;
|
|
}
|
|
if (this.stageKeyReleasedHandler) {
|
|
global.stage.disconnect(this.stageKeyReleasedHandler);
|
|
this.stageKeyReleasedHandler = null;
|
|
}
|
|
this.spaceKeyPressed = false;
|
|
this.laserKeyPressed = false;
|
|
}
|
|
}
|
|
|
|
// New Laser Method
|
|
_onLaserMotion(actor, event) {
|
|
// Update ruler mouse position tracking
|
|
let coords = event.get_coords();
|
|
let [success, x, y] = this._transformStagePoint(coords[0], coords[1]);
|
|
|
|
if (success && this.hasGrid && this.rulerLayer.visible) {
|
|
this.rulerMouseX = x;
|
|
this.rulerMouseY = y;
|
|
this.rulerLayer.queue_repaint();
|
|
}
|
|
|
|
if (this.currentTool == Shape.LASER) {
|
|
if (success) {
|
|
if (!this.laserPointerActive) {
|
|
this.startLaserPointer(x, y);
|
|
} else {
|
|
this.updateLaserPointer(x, y);
|
|
}
|
|
return Clutter.EVENT_STOP;
|
|
}
|
|
}
|
|
|
|
return Clutter.EVENT_PROPAGATE;
|
|
}
|
|
|
|
|
|
|
|
destroy() {
|
|
this.textCursorTimeoutId = null;
|
|
this._stopAll(true);
|
|
|
|
// Clean up laser pointer
|
|
if (this.laserAnimationTimeoutId) {
|
|
GLib.source_remove(this.laserAnimationTimeoutId);
|
|
this.laserAnimationTimeoutId = null;
|
|
}
|
|
if (this.laserTrailTimeoutId) {
|
|
GLib.source_remove(this.laserTrailTimeoutId);
|
|
this.laserTrailTimeoutId = null;
|
|
}
|
|
this.laserLayer = null;
|
|
|
|
// Clean up rulers
|
|
this.rulerLayer = null;
|
|
// End laser pointer cleanup
|
|
|
|
this._extension.drawingSettings.disconnect(this.drawingSettingsChangedHandler);
|
|
this.erase();
|
|
if (this._menu)
|
|
this._menu.disable();
|
|
delete this.areaManagerUtils;
|
|
|
|
super.destroy();
|
|
}
|
|
|
|
enterDrawingMode() {
|
|
this.keyPressedHandler = this.connect('key-press-event', this._onKeyPressed.bind(this));
|
|
this.buttonPressedHandler = this.connect('button-press-event', this._onButtonPressed.bind(this));
|
|
this.keyboardPopupMenuHandler = this.connect('popup-menu', this._onKeyboardPopupMenu.bind(this));
|
|
this.scrollHandler = this.connect('scroll-event', this._onScroll.bind(this));
|
|
|
|
// Add dedicated motion handler for laser pointer
|
|
this.laserMotionHandler = this.connect('motion-event', this._onLaserMotion.bind(this));
|
|
|
|
this.set_background_color(this.reactive && this.hasBackground ? this.areaBackgroundColor : null);
|
|
}
|
|
|
|
leaveDrawingMode(save, erase) {
|
|
if (this.keyPressedHandler) {
|
|
this.disconnect(this.keyPressedHandler);
|
|
this.keyPressedHandler = null;
|
|
}
|
|
if (this.buttonPressedHandler) {
|
|
this.disconnect(this.buttonPressedHandler);
|
|
this.buttonPressedHandler = null;
|
|
}
|
|
if (this.keyboardPopupMenuHandler) {
|
|
this.disconnect(this.keyboardPopupMenuHandler);
|
|
this.keyboardPopupMenuHandler = null;
|
|
}
|
|
if (this.scrollHandler) {
|
|
this.disconnect(this.scrollHandler);
|
|
this.scrollHandler = null;
|
|
}
|
|
// More laser motion handling
|
|
if (this.laserMotionHandler) {
|
|
this.disconnect(this.laserMotionHandler);
|
|
this.laserMotionHandler = null;
|
|
}
|
|
// Clean up laser pointer
|
|
if (this.laserPointerActive) {
|
|
this.stopLaserPointer();
|
|
}
|
|
if (this.laserAnimationTimeoutId) {
|
|
GLib.source_remove(this.laserAnimationTimeoutId);
|
|
this.laserAnimationTimeoutId = null;
|
|
}
|
|
|
|
this._stopAll(true);
|
|
|
|
if (erase)
|
|
this.erase();
|
|
|
|
this.closeMenu();
|
|
this.set_background_color(null);
|
|
this._extension.FILES.IMAGES.reset();
|
|
if (save)
|
|
this.savePersistent();
|
|
}
|
|
|
|
// Used by the menu.
|
|
getSvgContentsForJson(json) {
|
|
let elements = [];
|
|
let elementsContent = '';
|
|
|
|
elements.push(...JSON.parse(json.contents).map(object => {
|
|
if (object.color)
|
|
object.color = this.getColorFromString(object.color, 'White');
|
|
if (object.font && typeof object.font == 'string')
|
|
object.font = Pango.FontDescription.from_string(object.font);
|
|
if (object.image)
|
|
object.image = new Image(object.image);
|
|
return new Elements.DrawingElement(object);
|
|
}));
|
|
elements.forEach(element => elementsContent += element.buildSVG('transparent'));
|
|
|
|
let prefixes = 'xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"';
|
|
|
|
let getGiconSvgContent = () => {
|
|
let size = Math.min(this.monitor.width, this.monitor.height);
|
|
let [x, y] = [(this.monitor.width - size) / 2, (this.monitor.height - size) / 2];
|
|
return `<svg viewBox="${x} ${y} ${size} ${size}" ${prefixes}>${elementsContent}\n</svg>`;
|
|
};
|
|
|
|
let getImageSvgContent = () => {
|
|
return `<svg viewBox="0 0 ${this.layerContainer.width} ${this.layerContainer.height}" ${prefixes}>${elementsContent}\n</svg>`;
|
|
};
|
|
|
|
return [getGiconSvgContent, getImageSvgContent];
|
|
}
|
|
|
|
exportToSvg() {
|
|
this._stopAll();
|
|
|
|
let prefixes = 'xmlns="http://www.w3.org/2000/svg"';
|
|
if (this.elements.some(element => element.shape == Shape.IMAGE))
|
|
prefixes += ' xmlns:xlink="http://www.w3.org/1999/xlink"';
|
|
let content = `<svg viewBox="0 0 ${this.layerContainer.width} ${this.layerContainer.height}" ${prefixes}>`;
|
|
let backgroundColorString = this.hasBackground ? String(this.areaBackgroundColor) : 'transparent';
|
|
if (backgroundColorString != 'transparent')
|
|
content += `\n <rect id="background" width="100%" height="100%" fill="${backgroundColorString}"/>`;
|
|
this.elements.forEach(element => content += element.buildSVG(backgroundColorString));
|
|
content += "\n</svg>";
|
|
|
|
if (this._extension.FILES.saveSvg(content)) {
|
|
let flashspot = new Screenshot.Flashspot(this);
|
|
flashspot.fire();
|
|
if (global.play_theme_sound) {
|
|
global.play_theme_sound(0, 'screen-capture', "Save as SVG", null);
|
|
} else if (global.display && global.display.get_sound_player) {
|
|
let player = global.display.get_sound_player();
|
|
player.play_from_theme('screen-capture', "Save as SVG", null);
|
|
}
|
|
}
|
|
}
|
|
|
|
_saveAsJson(json, notify, callback) {
|
|
this._stopAll();
|
|
|
|
// do not use "content = JSON.stringify(this.elements, null, 2);", neither "content = JSON.stringify(this.elements);"
|
|
// do compromise between disk usage and human readability
|
|
let contents = this.elements.length ? `[\n ` + new Array(...this.elements.map(element => JSON.stringify(element))).join(`,\n\n `) + `\n]` : '[]';
|
|
|
|
GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE, () => {
|
|
json.contents = contents;
|
|
if (notify)
|
|
this.emit('show-osd', this._extension.FILES.ICONS.SAVE, json.name, "", -1, false);
|
|
if (!json.isPersistent)
|
|
this.currentJson = json;
|
|
if (callback)
|
|
callback();
|
|
});
|
|
}
|
|
|
|
saveAsJsonWithName(name, callback) {
|
|
this._saveAsJson(this._extension.FILES.JSONS.getNamed(name), false, callback);
|
|
}
|
|
|
|
saveAsJson(notify, callback) {
|
|
this._saveAsJson(this._extension.FILES.JSONS.getDated(), notify, callback);
|
|
}
|
|
|
|
savePersistent() {
|
|
this._saveAsJson(this._extension.FILES.JSONS.getPersistent());
|
|
}
|
|
|
|
syncPersistent() {
|
|
// do not override peristent.json with an empty drawing when changing persistency setting
|
|
if (!this.elements.length)
|
|
this._loadPersistent();
|
|
else
|
|
this.savePersistent();
|
|
|
|
}
|
|
|
|
_loadJson(json, notify) {
|
|
this._stopAll();
|
|
|
|
this.elements = [];
|
|
this.currentElement = null;
|
|
|
|
if (!json.contents)
|
|
return;
|
|
|
|
this.elements.push(...JSON.parse(json.contents).map(object => {
|
|
if (object.color)
|
|
object.color = this.getColorFromString(object.color, 'White');
|
|
if (object.font && typeof object.font == 'string')
|
|
object.font = Pango.FontDescription.from_string(object.font);
|
|
if (object.image)
|
|
object.image = new Image(object.image);
|
|
return new Elements.DrawingElement(object);
|
|
}));
|
|
|
|
if (notify)
|
|
this.emit('show-osd', this._extension.FILES.ICONS.OPEN, json.name, "", -1, false);
|
|
if (!json.isPersistent)
|
|
this.currentJson = json;
|
|
}
|
|
|
|
_loadPersistent() {
|
|
this._loadJson(this._extension.FILES.JSONS.getPersistent());
|
|
}
|
|
|
|
loadJson(json, notify) {
|
|
this._loadJson(json, notify);
|
|
this._redisplay();
|
|
}
|
|
|
|
loadPreviousJson() {
|
|
let json = this._extension.FILES.JSONS.getPrevious(this.currentJson || null);
|
|
if (json)
|
|
this.loadJson(json, true);
|
|
}
|
|
|
|
loadNextJson() {
|
|
let json = this._extension.FILES.JSONS.getNext(this.currentJson || null);
|
|
if (json)
|
|
this.loadJson(json, true);
|
|
}
|
|
|
|
get drawingContentsHasChanged() {
|
|
let contents = `[\n ` + new Array(...this.elements.map(element => JSON.stringify(element))).join(`,\n\n `) + `\n]`;
|
|
return contents != (this.currentJson && this.currentJson.contents);
|
|
}
|
|
|
|
// toJSON provides a string suitable for SVG color attribute whereas
|
|
// toString provides a string suitable for displaying the color name to the user.
|
|
getColorFromString(string, fallback) {
|
|
// GNOME 49+ fix: Handle if already a Color object
|
|
if (string && typeof string === 'object' && string.red !== undefined) {
|
|
// GNOME 48.5 SVG export fix: Ensure toJSON method exists
|
|
if (!string.toJSON || typeof string.toJSON !== 'function') {
|
|
let colorStr;
|
|
if (string.to_string && typeof string.to_string === 'function') {
|
|
// KEEP THE ALPHA - Don't slice it off!
|
|
colorStr = string.to_string(); // Preserves alpha channel
|
|
} else {
|
|
// Include alpha in RGB format
|
|
colorStr = `rgba(${string.red},${string.green},${string.blue},${string.alpha})`;
|
|
}
|
|
string.toJSON = () => colorStr;
|
|
string.toString = () => colorStr;
|
|
}
|
|
return string;
|
|
}
|
|
|
|
// Handle null/undefined/non-string
|
|
if (!string || typeof string !== 'string') {
|
|
let color = StaticColor[(fallback || 'White').toUpperCase()];
|
|
if (!color) color = StaticColor.WHITE;
|
|
color.toJSON = () => fallback || 'White';
|
|
color.toString = () => fallback || 'White';
|
|
return color;
|
|
}
|
|
|
|
// Original string handling - Now supports rgba() format with alpha
|
|
let [colorString, displayName] = string.split(':');
|
|
let [success, color] = Color.from_string(colorString);
|
|
|
|
if (success) {
|
|
color.toJSON = () => colorString;
|
|
color.toString = () => displayName || colorString;
|
|
return color;
|
|
}
|
|
|
|
console.warn(`${this._extension.metadata.uuid}: "${string}" color cannot be parsed.`);
|
|
color = StaticColor[fallback.toUpperCase()];
|
|
color.toJSON = () => fallback;
|
|
color.toString = () => fallback;
|
|
return color;
|
|
}
|
|
});
|
|
|