1---------------------------------------------------------------------- 2-- tools.lua 3---------------------------------------------------------------------- 4--[[ 5 6 This file is part of the extensible drawing editor Ipe. 7 Copyright (c) 1993-2020 Otfried Cheong 8 9 Ipe is free software; you can redistribute it and/or modify it 10 under the terms of the GNU General Public License as published by 11 the Free Software Foundation; either version 3 of the License, or 12 (at your option) any later version. 13 14 As a special exception, you have permission to link Ipe with the 15 CGAL library and distribute executables, as long as you follow the 16 requirements of the Gnu General Public License in regard to all of 17 the software in the executable aside from CGAL. 18 19 Ipe is distributed in the hope that it will be useful, but WITHOUT 20 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY 21 or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public 22 License for more details. 23 24 You should have received a copy of the GNU General Public License 25 along with Ipe; if not, you can find it at 26 "http://www.gnu.org/copyleft/gpl.html", or write to the Free 27 Software Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. 28 29--]] 30 31function externalEditor(d, field) 32 local text = d:get(field) 33 local fname = os.tmpname() 34 if prefs.editable_textfile then 35 fname = prefs.editable_textfile 36 end 37 local f = io.open(fname, "w") 38 f:write(text) 39 f:close() 40 ipeui.waitDialog(d, string.format(prefs.external_editor, fname)) 41 f = io.open(fname, "r") 42 text = f:read("*all") 43 f:close() 44 os.remove(fname) 45 d:set(field, text) 46 if prefs.editor_closes_dialog and not prefs.auto_external_editor then 47 d:accept(true) 48 end 49end 50 51function addEditorField(d, field) 52 if prefs.external_editor then 53 d:addButton("editor", "&Editor", function (d) externalEditor(d, field) end) 54 end 55end 56 57---------------------------------------------------------------------- 58 59local function circleshape(center, radius) 60 return { type="ellipse"; 61 ipe.Matrix(radius, 0, 0, radius, center.x, center.y) } 62end 63 64local function segmentshape(v1, v2) 65 return { type="curve", closed=false; { type="segment"; v1, v2 } } 66end 67 68local function boxshape(v1, v2) 69 return { type="curve", closed=true; 70 { type="segment"; v1, V(v1.x, v2.y) }, 71 { type="segment"; V(v1.x, v2.y), v2 }, 72 { type="segment"; v2, V(v2.x, v1.y) } } 73end 74 75local function arcshape(center, radius, alpha, beta) 76 local a = ipe.Arc(ipe.Matrix(radius, 0, 0, radius, center.x, center.y), 77 alpha, beta) 78 local v1 = center + radius * ipe.Direction(alpha) 79 local v2 = center + radius * ipe.Direction(beta) 80 return { type="curve", closed=false; { type="arc", arc=a; v1, v2 } } 81end 82 83local function rarcshape(center, radius, alpha, beta) 84 local a = ipe.Arc(ipe.Matrix(radius, 0, 0, -radius, center.x, center.y), 85 alpha, beta) 86 local v1 = center + radius * ipe.Direction(alpha) 87 local v2 = center + radius * ipe.Direction(beta) 88 return { type="curve", closed=false; { type="arc", arc=a; v1, v2 } } 89end 90 91---------------------------------------------------------------------- 92 93local VERTEX = 1 94local SPLINE = 2 95local ARC = 3 96 97LINESTOOL = {} 98LINESTOOL.__index = LINESTOOL 99 100-- mode is one of "lines", "polygons", "splines" 101function LINESTOOL:new(model, mode) 102 local tool = {} 103 setmetatable(tool, LINESTOOL) 104 tool.model = model 105 tool.mode = mode 106 tool.splinetype = "spline" 107 if model.attributes.splinetype == "cardinal" then 108 tool.splinetype = "cardinal" 109 elseif model.attributes.splinetype == "spiro" then 110 tool.splinetype = "spiro" 111 end 112 tool.tension = model.attributes.tension 113 local v = model.ui:pos() 114 tool.v = { v, v } 115 model.ui:setAutoOrigin(v) 116 if mode == "splines" then 117 tool.t = { VERTEX, SPLINE } 118 else 119 tool.t = { VERTEX, VERTEX } 120 end 121 model.ui:shapeTool(tool) 122 tool.setColor(1.0, 0, 0) 123 tool.setSnapping(true, true) 124 return tool 125end 126 127function LINESTOOL:last() 128 return self.t[#self.t] 129end 130 131function LINESTOOL:has_segs(count) 132 if #self.t < count then return false end 133 local result = true 134 for i = #self.t - count + 1,#self.t do 135 if self.t[i] ~= VERTEX then return false end 136 end 137 return true 138end 139 140local function compute_arc(p0, pmid, p1) 141 if p0 == p1 then return { type="segment", p0, p1 } end 142 local l1 = ipe.Line(p0, (pmid- p0):normalized()) 143 local l2 = ipe.Line(p0, l1:normal()) 144 local bi1 = ipe.Bisector(p0, p1) 145 local center = l2:intersects(bi1) 146 local side = l1:side(p1) 147 if side == 0.0 or not center then return { type="segment", p0, p1 } end 148 local u = p0 - center 149 local alpha = side * u:angle() 150 local beta = side * ipe.normalizeAngle((p1 - center):angle(), alpha) 151 local radius = u:len() 152 local m = { radius, 0, 0, side * radius, center.x, center.y } 153 return { type = "arc", p0, p1, arc = ipe.Arc(ipe.Matrix(m), alpha, beta) } 154end 155 156-- compute orientation of tangent to previous segment at its final point 157function LINESTOOL:compute_orientation() 158 if self.t[#self.t - 2] ~= ARC then 159 return (self.v[#self.v - 1] - self.v[#self.v - 2]):angle() 160 else 161 -- only arc needs special handling 162 local arc = compute_arc(self.v[#self.v - 3], self.v[#self.v - 2], 163 self.v[#self.v - 1]) 164 if arc.type == "arc" then 165 local alpha, beta = arc.arc:angles() 166 local dir = ipe.Direction(beta + math.pi / 2) 167 return (arc.arc:matrix():linear() * dir):angle() 168 else 169 return (self.v[#self.v - 1] - self.v[#self.v - 3]):angle() 170 end 171 end 172end 173 174function LINESTOOL:compute() 175 self.shape = { type="curve", closed=(self.mode == "polygons") } 176 local i = 2 177 while i <= #self.v do 178 -- invariant: t[i-1] == VERTEX 179 local seg 180 if self.t[i] == VERTEX then 181 seg = { type="segment", self.v[i-1], self.v[i] } 182 i = i + 1 183 elseif self.t[i] == SPLINE then 184 local j = i 185 while j <= #self.v and self.t[j] == SPLINE do j = j + 1 end 186 if j > #self.v then j = #self.v end 187 seg = { type = self.splinetype } 188 if self.splinetype == "cardinal" then seg.tension = prefs.spline_tension end 189 for k = i-1,j do seg[#seg+1] = self.v[k] end 190 i = j + 1 191 elseif self.t[i] == ARC then 192 seg = compute_arc(self.v[i-1], self.v[i], self.v[i+1]) 193 i = i + 2 194 end 195 self.shape[#self.shape + 1] = seg 196 end 197 self.setShape( { self.shape } ) 198end 199 200function LINESTOOL:mouseButton(button, modifiers, press) 201 if not press then return end 202 local v = self.model.ui:pos() 203 self.v[#self.v] = v 204 if button == 0x81 then 205 -- double click 206 -- the first click already added a vertex, do not use second one 207 if #self.v == 2 then return end 208 table.remove(self.v) 209 table.remove(self.t) 210 button = 2 211 end 212 if modifiers.control and button == 1 then button = 2 end 213 if button == 2 then 214 if self:last() == SPLINE then self.t[#self.t] = VERTEX end 215 self:compute() 216 self.model.ui:finishTool() 217 local obj = ipe.Path(self.model.attributes, { self.shape }, true) 218 -- if it is just a point, force line cap to round 219 if #self.shape == 1 and self.shape[1].type == "segment" and 220 self.shape[1][1] == self.shape[1][2] then 221 obj:set("linecap", "round") 222 end 223 self.model:creation("create path", obj) 224 return 225 end 226 self.v[#self.v + 1] = v 227 self.model.ui:setAutoOrigin(v) 228 if self:last() == SPLINE then 229 local typ = modifiers.shift and VERTEX or SPLINE 230 self.t[#self.t] = typ 231 self.t[#self.t + 1] = typ 232 else 233 self.t[#self.t + 1] = VERTEX 234 end 235 self:compute() 236 self.model.ui:update(false) -- update tool 237 self:explain() 238end 239 240function LINESTOOL:mouseMove() 241 self.v[#self.v] = self.model.ui:pos() 242 self:compute() 243 self.model.ui:update(false) -- update tool 244 self:explain() 245end 246 247function LINESTOOL:explain() 248 local s 249 if self:last() == SPLINE then 250 s = ("Left: add ctrl point | Shift+Left: switch to line mode" .. 251 " | Del: delete ctrl point") 252 else 253 s = "Left: add vtx | Del: delete vtx" 254 end 255 s = s .. " | Right, Ctrl-Left, or Double-Left: final vtx" 256 if self:has_segs(2) then 257 s = s .. " | " .. shortcuts_linestool.spline .. ": spline mode" 258 end 259 if self:has_segs(3) then 260 s = s .. " | " .. shortcuts_linestool.arc .. ": circle arc" 261 end 262 if #self.v > 2 and self.t[#self.t - 1] == VERTEX then 263 s = s .. " | " .. shortcuts_linestool.set_axis ..": set axis" 264 end 265 self.model.ui:explain(s, 0) 266end 267 268function LINESTOOL:key(text, modifiers) 269 if text == prefs.delete_key then -- Delete 270 if #self.v > 2 then 271 table.remove(self.v) 272 table.remove(self.t) 273 if self:last() == ARC then 274 self.t[#self.t] = VERTEX 275 end 276 self:compute() 277 self.model.ui:update(false) 278 self:explain() 279 else 280 self.model.ui:finishTool() 281 end 282 return true 283 elseif text == "\027" then 284 self.model.ui:finishTool() 285 return true 286 elseif self:has_segs(2) and text == shortcuts_linestool.spline then 287 self.t[#self.t] = SPLINE 288 self:compute() 289 self.model.ui:update(false) 290 self:explain() 291 return true 292 elseif self:has_segs(3) and text == shortcuts_linestool.arc then 293 self.t[#self.t - 1] = ARC 294 self:compute() 295 self.model.ui:update(false) 296 self:explain() 297 return true 298 elseif #self.v > 2 and self.t[#self.t - 1] == VERTEX and 299 text == shortcuts_linestool.set_axis then 300 -- set axis 301 self.model.snap.with_axes = true 302 self.model.ui:setActionState("show_axes", self.model.snap.with_axes) 303 self.model.snap.snapangle = true 304 self.model.snap.origin = self.v[#self.v - 1] 305 self.model.snap.orientation = self:compute_orientation() 306 self.model:setSnap() 307 self.model.ui:setActionState("snapangle", true) 308 self.model.ui:update(true) -- redraw coordinate system 309 self:explain() 310 return true 311 else 312 return false 313 end 314end 315 316---------------------------------------------------------------------- 317 318BOXTOOL = {} 319BOXTOOL.__index = BOXTOOL 320 321function BOXTOOL:new(model, square, mode) 322 local tool = {} 323 setmetatable(tool, BOXTOOL) 324 tool.model = model 325 tool.mode = mode 326 tool.square = square 327 local v = model.ui:pos() 328 tool.v = { v, v } 329 model.ui:shapeTool(tool) 330 tool.cur = 2 331 tool.setColor(1.0, 0, 0) 332 return tool 333end 334 335function BOXTOOL:compute() 336 self.v[self.cur] = self.model.ui:pos() 337 local d = self.v[2] - self.v[1] 338 local sign = V(d.x > 0 and 1 or -1, d.y > 0 and 1 or -1) 339 local dd = math.max(math.abs(d.x), math.abs(d.y)) 340 if self.mode == "rectangles1" or self.mode == "rectangles2" then 341 if self.square then 342 self.v[2] = self.v[1] + dd * sign 343 end 344 if self.mode == "rectangles1" then 345 self.shape = boxshape(self.v[1], self.v[2]) 346 else 347 local u = self.v[2] - self.v[1] 348 self.shape = boxshape(self.v[1] - u, self.v[2]) 349 end 350 elseif self.mode == "rectangles3" and self.square then 351 local u = self.v[2] - self.v[1] 352 local lu = V(-u.y, u.x) 353 self.shape = { type="curve", closed=true; 354 { type="segment"; self.v[1], self.v[2] }, 355 { type="segment"; self.v[2], self.v[2] + lu }, 356 { type="segment"; self.v[2] + lu, self.v[1] + lu } } 357 else 358 if self.cur == 2 and self.square then -- make first-segment axis-parallel 359 if math.abs(d.x) > math.abs(d.y) then 360 self.v[2] = self.v[1] + V(sign.x * dd, 0) 361 else 362 self.v[2] = self.v[1] + V(0, sign.y * dd) 363 end 364 end 365 if self.cur == 2 then 366 self.shape = segmentshape(self.v[1], self.v[2]) 367 else 368 if self.v[2] ~= self.v[1] and self.v[3] ~= self.v[2] and 369 self.mode == "rectangles3" then 370 local l1 = ipe.LineThrough(self.v[1], self.v[2]) 371 local l2 = ipe.Line(self.v[2], l1:normal()) 372 self.v[3] = l2:project(self.v[3]) 373 end 374 local u = self.v[3] - self.v[2] 375 self.shape = { type="curve", closed=true; 376 { type="segment"; self.v[1], self.v[2] }, 377 { type="segment"; self.v[2], self.v[3] }, 378 { type="segment"; self.v[3], self.v[1] + u } } 379 end 380 end 381end 382 383function BOXTOOL:mouseButton(button, modifiers, press) 384 if not press then return end 385 self:compute() 386 if self.cur == 2 and (self.mode == "parallelogram" or 387 (self.mode == "rectangles3" and not self.square)) then 388 self.cur = 3 389 self.v[3] = self.v[2] 390 return 391 end 392 self.model.ui:finishTool() 393 local obj = ipe.Path(self.model.attributes, { self.shape }) 394 self.model:creation("create box", obj) 395end 396 397function BOXTOOL:mouseMove() 398 self:compute() 399 self.setShape( { self.shape } ) 400 self.model.ui:update(false) -- update tool 401end 402 403function BOXTOOL:key(text, modifiers) 404 if text == "\027" then 405 self.model.ui:finishTool() 406 return true 407 else 408 return false 409 end 410end 411 412---------------------------------------------------------------------- 413 414SPLINEGONTOOL = {} 415SPLINEGONTOOL.__index = SPLINEGONTOOL 416 417function SPLINEGONTOOL:new(model) 418 local tool = {} 419 setmetatable(tool, SPLINEGONTOOL) 420 tool.model = model 421 local v = model.ui:pos() 422 tool.v = { v, v } 423 model.ui:shapeTool(tool) 424 tool.setColor(1.0, 0, 0) 425 return tool 426end 427 428function SPLINEGONTOOL:compute(update) 429 if #self.v == 2 then 430 self.shape = segmentshape(self.v[1], self.v[2]) 431 else 432 self.shape = { type="closedspline" } 433 for _,v in ipairs(self.v) do self.shape[#self.shape + 1] = v end 434 end 435 if update then 436 self.setShape( { self.shape } ) 437 self.model.ui:update(false) -- update tool 438 end 439end 440 441function SPLINEGONTOOL:explain() 442 local s = "Left: Add vertex " 443 .. "| Right, ctrl-left, or double-left: Add final vertex" 444 .. "| Del: Delete vertex" 445 self.model.ui:explain(s, 0) 446end 447 448function SPLINEGONTOOL:mouseButton(button, modifiers, press) 449 if not press then return end 450 local v = self.model.ui:pos() 451 self.v[#self.v] = v 452 if button == 0x81 then 453 -- double click 454 -- the first click already added a vertex, do not use second one 455 if #self.v == 2 then return end 456 table.remove(self.v) 457 button = 2 458 end 459 if modifiers.control and button == 1 then button = 2 end 460 if button == 2 then 461 self:compute(false) 462 self.model.ui:finishTool() 463 local obj = ipe.Path(self.model.attributes, { self.shape }) 464 self.model:creation("create splinegon", obj) 465 return 466 end 467 self.v[#self.v + 1] = v 468 self:compute(true) 469 self:explain() 470end 471 472function SPLINEGONTOOL:mouseMove() 473 self.v[#self.v] = self.model.ui:pos() 474 self:compute(true) 475end 476 477function SPLINEGONTOOL:key(text, modifiers) 478 if text == prefs.delete_key then -- Delete 479 if #self.v > 2 then 480 table.remove(self.v) 481 self:compute(true) 482 self:explain() 483 else 484 self.model.ui:finishTool() 485 end 486 return true 487 elseif text == "\027" then 488 self.model.ui:finishTool() 489 return true 490 else 491 self:explain() 492 return false 493 end 494end 495 496---------------------------------------------------------------------- 497 498CIRCLETOOL = {} 499CIRCLETOOL.__index = CIRCLETOOL 500 501function CIRCLETOOL:new(model, mode) 502 local tool = {} 503 setmetatable(tool, CIRCLETOOL) 504 tool.model = model 505 tool.mode = mode 506 local v = model.ui:pos() 507 tool.v = { v, v, v } 508 tool.cur = 2 509 model.ui:shapeTool(tool) 510 tool.setColor(1.0, 0, 0) 511 return tool 512end 513 514function CIRCLETOOL:compute() 515 if self.mode == "circle1" then 516 self.shape = circleshape(self.v[1], (self.v[2] - self.v[1]):len()) 517 elseif self.mode == "circle2" then 518 self.shape = circleshape(0.5 * (self.v[1] + self.v[2]), 519 (self.v[2] - self.v[1]):len() / 2.0) 520 elseif self.mode == "circle3" then 521 if self.cur == 2 or self.v[3] == self.v[2] then 522 self.shape = circleshape(0.5 * (self.v[1] + self.v[2]), 523 (self.v[2] - self.v[1]):len() / 2.0) 524 else 525 local l1 = ipe.LineThrough(self.v[1], self.v[2]) 526 if math.abs(l1:side(self.v[3])) < 1e-9 then 527 self.shape = segmentshape(self.v[1], self.v[2]) 528 else 529 local l2 = ipe.LineThrough(self.v[2], self.v[3]) 530 local bi1 = ipe.Bisector(self.v[1], self.v[2]) 531 local bi2 = ipe.Bisector(self.v[2], self.v[3]) 532 local center = bi1:intersects(bi2) 533 self.shape = circleshape(center, (self.v[1] - center):len()) 534 end 535 end 536 end 537end 538 539function CIRCLETOOL:mouseButton(button, modifiers, press) 540 if not press then return end 541 local v = self.model.ui:pos() 542 -- refuse point identical to previous 543 if v == self.v[self.cur - 1] then return end 544 self.v[self.cur] = v 545 self:compute() 546 if self.cur == 3 or (self.mode ~= "circle3" and self.cur == 2) then 547 self.model.ui:finishTool() 548 local obj = ipe.Path(self.model.attributes, { self.shape }) 549 self.model:creation("create circle", obj) 550 else 551 self.cur = self.cur + 1 552 self.model.ui:update(false) 553 end 554end 555 556function CIRCLETOOL:mouseMove() 557 self.v[self.cur] = self.model.ui:pos() 558 self:compute() 559 self.setShape({ self.shape }) 560 self.model.ui:update(false) -- update tool 561end 562 563function CIRCLETOOL:key(text, modifiers) 564 if text == "\027" then 565 self.model.ui:finishTool() 566 return true 567 else 568 return false 569 end 570end 571 572---------------------------------------------------------------------- 573 574ARCTOOL = {} 575ARCTOOL.__index = ARCTOOL 576 577function ARCTOOL:new(model, mode) 578 local tool = {} 579 setmetatable(tool, ARCTOOL) 580 tool.model = model 581 tool.mode = mode 582 local v = model.ui:pos() 583 tool.v = { v, v, v } 584 tool.cur = 2 585 model.ui:shapeTool(tool) 586 tool.setColor(1.0, 0, 0) 587 return tool 588end 589 590function ARCTOOL:compute() 591 local u = self.v[2] - self.v[1] 592 if self.cur == 2 then 593 if self.mode == "arc3" then 594 self.shape = circleshape(0.5 * (self.v[1] + self.v[2]), u:len() / 2.0) 595 else 596 self.shape = circleshape(self.v[1], u:len()) 597 end 598 return 599 end 600 local alpha = u:angle() 601 local beta = (self.v[3] - self.v[1]):angle() 602 if self.mode == "arc1" then 603 self.shape = arcshape(self.v[1], u:len(), alpha, beta) 604 elseif self.mode == "arc2" then 605 self.shape = rarcshape(self.v[1], u:len(), alpha, beta) 606 else 607 local l1 = ipe.LineThrough(self.v[1], self.v[2]) 608 if math.abs(l1:distance(self.v[3])) < 1e-9 or self.v[3] == self.v[2] then 609 self.shape = segmentshape(self.v[1], self.v[3]) 610 else 611 local l2 = ipe.LineThrough(self.v[2], self.v[3]) 612 local bi1 = ipe.Line(0.5 * (self.v[1] + self.v[2]), l1:normal()) 613 local bi2 = ipe.Line(0.5 * (self.v[2] + self.v[3]), l2:normal()) 614 local center = bi1:intersects(bi2) 615 u = self.v[1] - center 616 alpha = u:angle() 617 beta = ipe.normalizeAngle((self.v[2] - center):angle(), alpha) 618 local gamma = ipe.normalizeAngle((self.v[3] - center):angle(), alpha) 619 if gamma > beta then 620 self.shape = arcshape(center, u:len(), alpha, gamma) 621 else 622 self.shape = rarcshape(center, u:len(), alpha, gamma) 623 end 624 end 625 end 626end 627 628function ARCTOOL:mouseButton(button, modifiers, press) 629 if not press then return end 630 local v = self.model.ui:pos() 631 -- refuse point identical to previous 632 if v == self.v[self.cur - 1] then return end 633 self.v[self.cur] = v 634 self:compute() 635 if self.cur == 3 then 636 self.model.ui:finishTool() 637 local obj = ipe.Path(self.model.attributes, { self.shape }, true) 638 self.model:creation("create arc", obj) 639 else 640 self.cur = self.cur + 1 641 self.model.ui:update(false) 642 end 643end 644 645function ARCTOOL:mouseMove() 646 self.v[self.cur] = self.model.ui:pos() 647 self:compute() 648 self.setShape({ self.shape }) 649 self.model.ui:update(false) -- update tool 650end 651 652function ARCTOOL:key(text, modifiers) 653 if text == "\027" then 654 self.model.ui:finishTool() 655 return true 656 else 657 return false 658 end 659end 660 661---------------------------------------------------------------------- 662 663local function path_len(points) 664 local t = 0 665 for i=2,#points do 666 t = t + (points[i] - points[i-1]):len() 667 end 668 return t 669end 670 671-- perform moving average 672local function smooth_path(v) 673 local M = prefs.ink_smoothing 674 local w = { v[1] } 675 for i = 2,#v-1 do 676 local p = V(0,0) 677 for j = i-M, i+M do 678 local q 679 if j < 1 then q = v[1] elseif j > #v then q = v[#v] else q = v[i] end 680 p = p + q 681 end 682 w[#w+1] = (1/(2*M+1)) * p 683 end 684 w[#w+1] = v[#v] 685 return w 686end 687 688local function simplify_path(points, tolerance) 689 local marked = {} 690 marked[1] = true 691 marked[#points] = true 692 local stack = { { 1, #points } } 693 694 -- mark points to keep 695 while #stack > 0 do 696 local pair = table.remove(stack) 697 local first = pair[1] 698 local last = pair[2] 699 local max_dist = 0 700 local index = nil 701 local seg = ipe.Segment(points[first], points[last]) 702 for i = first + 1,last do 703 local d = seg:distance(points[i]) 704 if d > max_dist then 705 index = i 706 max_dist = d 707 end 708 end 709 if max_dist > tolerance then 710 marked[index] = true 711 stack[#stack + 1] = { first, index } 712 stack[#stack + 1] = { index, last } 713 end 714 end 715 -- only use marked vertices 716 local out = {} 717 for i, point in ipairs(points) do 718 if marked[i] then 719 out[#out+1] = point 720 end 721 end 722 return out 723end 724 725-- vector for control points around p2 726local function tang(p1, p2, p3) 727 return (p2-p1):normalized() + (p3 - p2):normalized() 728end 729 730-- first control point between p2 and p3 731local function cp1(p1, p2, p3) 732 local tangent = tang(p1, p2, p3):normalized() 733 local vlen = (p3 - p2):len() / 3.0 734 return p2 + vlen * tangent 735end 736 737-- second control point between p2 and p3 738local function cp2(p2, p3, p4) 739 local tangent = tang(p2, p3, p4):normalized() 740 local vlen = (p3 - p2):len() / 3.0 741 return p3 - vlen * tangent 742end 743 744local function compute_spline(w) 745 local shape = { } 746 if #w > 2 then 747 local cp = cp2(w[1], w[2], w[3]) 748 local seg = { w[1], cp, w[2] } 749 seg.type = "spline" 750 shape[1] = seg 751 else 752 local seg = { w[1], w[2] } 753 seg.type = "segment" 754 shape[1] = seg 755 end 756 for i = 2,#w-2 do 757 local q1 = w[i-1] 758 local q2 = w[i] 759 local q3 = w[i+1] 760 local q4 = w[i+2] 761 local cpa = cp1(q1, q2, q3) 762 local cpb = cp2(q2, q3, q4) 763 local seg = { q2, cpa, cpb, q3} 764 seg.type = "spline" 765 shape[#shape + 1] = seg 766 end 767 if #w > 2 then 768 local cp = cp1(w[#w-2], w[#w-1], w[#w]) 769 local seg = { w[#w-1], cp, w[#w] } 770 seg.type = "spline" 771 shape[#shape + 1] = seg 772 end 773 return shape 774end 775 776---------------------------------------------------------------------- 777 778INKTOOL = {} 779INKTOOL.__index = INKTOOL 780 781function INKTOOL:new(model) 782 local tool = {} 783 setmetatable(tool, INKTOOL) 784 tool.model = model 785 local v = model.ui:pos() 786 tool.v = { v } 787 -- print("make ink tool") 788 model.ui:shapeTool(tool) 789 local s = model.doc:sheets():find("color", model.attributes.stroke) 790 tool.setColor(s.r, s.g, s.b) 791 tool.w = model.doc:sheets():find("pen", model.attributes.pen) 792 model.ui:setCursor(tool.w, s.r, s.g, s.b) 793 return tool 794end 795 796function INKTOOL:compute(incremental) 797 local v = self.v 798 if incremental and #v > 2 then 799 self.shape[#self.shape+1]={ type="segment", v[#v-1], v[#v]} 800 else 801 self.shape = { type="curve", closed=false } 802 for i = 2, #v do 803 self.shape[#self.shape + 1] = { type="segment", v[i-1], v[i] } 804 end 805 end 806 self.setShape( { self.shape }, 0, self.w * self.model.ui:zoom()) 807end 808 809function INKTOOL:mouseButton(button, modifiers, press) 810 if self.shape then 811 self.v = smooth_path(self.v) 812 self.v = simplify_path(self.v, prefs.ink_tolerance / self.model.ui:zoom()) 813 else 814 self.v = { self.v[1], self.v[1] } 815 end 816 if prefs.ink_spline then 817 self.shape = compute_spline(self.v) 818 self.shape.type = "curve" 819 self.shape.closed = false 820 else 821 self:compute(false) 822 end 823 local obj = ipe.Path(self.model.attributes, { self.shape }) 824 -- round linecaps are prettier for handwriting 825 obj:set("linecap", "round") 826 obj:set("linejoin", "round") 827 if path_len(self.v) * self.model.ui:zoom() < prefs.ink_dot_length then 828 -- pen was pressed and released with no or very little movement 829 if prefs.ink_dot_wider then 830 obj:set("pen", self.w * prefs.ink_dot_wider) 831 end 832 self.model:creation("create ink dot", obj) 833 else 834 self.model:creation("create ink path", obj) 835 end 836 self.model:page():deselectAll() -- ink isn't selected after creation 837 self.model.ui:finishTool() 838end 839 840function INKTOOL:mouseMove() 841 local v1 = self.v[#self.v] 842 local v2 = self.model.ui:pos() 843 if (v1-v2):len() * self.model.ui:zoom() > prefs.ink_min_movement then 844 -- do not update if motion is too small 845 self.v[#self.v + 1] = v2 846 self:compute(true) 847 local r = ipe.Rect() 848 local offset = V(self.w, self.w) 849 r:add(v1) 850 r:add(v2) 851 r:add(r:bottomLeft() - offset) 852 r:add(r:topRight() + offset) 853 self.model.ui:update(r) -- update rectangle containing new segment 854 end 855end 856 857function INKTOOL:key(text, modifiers) 858 if text == "\027" then 859 self.model.ui:finishTool() 860 return true 861 else 862 return false 863 end 864end 865 866---------------------------------------------------------------------- 867 868function MODEL:shredObject() 869 local bound = prefs.close_distance 870 local pos = self.ui:unsnappedPos() 871 local p = self:page() 872 873 local closest 874 for i,obj,sel,layer in p:objects() do 875 if p:visible(self.vno, i) and not p:isLocked(layer) then 876 local d = p:distance(i, pos, bound) 877 if d < bound then closest = i; bound = d end 878 end 879 end 880 881 if closest then 882 local t = { label="shred object", 883 pno = self.pno, 884 vno = self.vno, 885 num = closest, 886 original=self:page():clone(), 887 undo=revertOriginal, 888 } 889 t.redo = function (t, doc) 890 local p = doc[t.pno] 891 p:remove(t.num) 892 p:ensurePrimarySelection() 893 end 894 self:register(t) 895 else 896 self.ui:explain("No object found to shred") 897 end 898end 899 900---------------------------------------------------------------------- 901 902function MODEL:createMark() 903 local obj = ipe.Reference(self.attributes, self.attributes.markshape, 904 self.ui:pos()) 905 self:creation("create mark", obj) 906end 907 908---------------------------------------------------------------------- 909 910function MODEL:createText(mode, pos, width, pinned) 911 local prompt = "Enter Latex source" 912 local stylekind = "labelstyle" 913 local styleatt = self.attributes.labelstyle 914 local explainer = "create text" 915 if mode == "math" then 916 prompt = "Enter Latex source for math formula" 917 end 918 if mode == "paragraph" then 919 styleatt = self.attributes.textstyle 920 stylekind = "textstyle" 921 explainer = "create text paragraph" 922 end 923 local styles = self.doc:sheets():allNames(stylekind) 924 local sizes = self.doc:sheets():allNames("textsize") 925 local d = ipeui.Dialog(self.ui:win(), "Create text object") 926 d:add("label", "label", { label=prompt }, 1, 1, 1, 2) 927 d:add("style", "combo", styles, -1, 3) 928 d:add("size", "combo", sizes, -1, 4) 929 d:add("text", "text", { syntax="latex", focus=true, 930 spell_check=prefs.spell_check }, 0, 1, 1, 4) 931 addEditorField(d, "text") 932 d:addButton("ok", "&Ok", "accept") 933 d:addButton("cancel", "&Cancel", "reject") 934 d:setStretch("row", 2, 1) 935 d:setStretch("column", 2, 1) 936 d:set("ignore-escape", "text", "") 937 local style = indexOf(styleatt, styles) 938 if not style then style = indexOf("normal", styles) end 939 local size = indexOf(self.attributes.textsize, sizes) 940 if not size then size = indexOf("normal", sizes) end 941 d:set("style", style) 942 d:set("size", size) 943 if mode == "math" then 944 d:set("style", "math") 945 d:setEnabled("style", false) 946 end 947 if prefs.auto_external_editor then 948 externalEditor(d, "text") 949 end 950 if ((prefs.auto_external_editor and prefs.editor_closes_dialog) 951 or d:execute(prefs.editor_size)) then 952 local t = d:get("text") 953 local style = styles[d:get("style")] 954 local size = sizes[d:get("size")] 955 local obj = ipe.Text(self.attributes, t, pos, width) 956 obj:set("textsize", size) 957 obj:set(stylekind, style) 958 if pinned then 959 obj:set("pinned", "horizontal") 960 end 961 self:creation(explainer, obj) 962 self:autoRunLatex() 963 end 964end 965 966---------------------------------------------------------------------- 967 968function MODEL:action_insert_text_box() 969 local layout = self.doc:sheets():find("layout") 970 local p = self:page() 971 local r = ipe.Rect() 972 local m = ipe.Matrix() 973 for i, obj, sel, layer in p:objects() do 974 if p:visible(self.vno, i) and obj:type() == "text" then 975 obj:addToBBox(r, m) 976 end 977 end 978 local y = layout.framesize.y 979 if not r:isEmpty() and r:bottom() < layout.framesize.y then 980 y = r:bottom() - layout.paragraph_skip 981 end 982 self:createText("paragraph", ipe.Vector(0, y), layout.framesize.x, true) 983end 984 985---------------------------------------------------------------------- 986 987PARAGRAPHTOOL = {} 988PARAGRAPHTOOL.__index = PARAGRAPHTOOL 989 990function PARAGRAPHTOOL:new(model) 991 local tool = {} 992 setmetatable(tool, PARAGRAPHTOOL) 993 tool.model = model 994 local v = model.ui:pos() 995 tool.v = { v, v } 996 model.ui:shapeTool(tool) 997 tool.setColor(1.0, 0, 1.0) 998 return tool 999end 1000 1001function PARAGRAPHTOOL:compute() 1002 self.v[2] = V(self.model.ui:pos().x, self.v[1].y - 20) 1003 self.shape = boxshape(self.v[1], self.v[2]) 1004end 1005 1006function PARAGRAPHTOOL:mouseButton(button, modifiers, press) 1007 if not press then return end 1008 self:compute() 1009 self.model.ui:finishTool() 1010 local pos = V(math.min(self.v[1].x, self.v[2].x), self.v[1].y) 1011 local wid = math.abs(self.v[2].x - self.v[1].x) 1012 self.model:createText("paragraph", pos, wid) 1013end 1014 1015function PARAGRAPHTOOL:mouseMove() 1016 self:compute() 1017 self.setShape( { self.shape } ) 1018 self.model.ui:update(false) -- update tool 1019end 1020 1021function PARAGRAPHTOOL:key(text, modifiers) 1022 if text == "\027" then 1023 self.model.ui:finishTool() 1024 return true 1025 else 1026 return false 1027 end 1028end 1029 1030---------------------------------------------------------------------- 1031 1032local function collect_edges(p, box, view) 1033 local edges = {} 1034 for i, obj, sel, layer in p:objects() do 1035 if obj:type() == "path" and (view == nil or p:visible(view, i)) then 1036 local shape = obj:shape() 1037 local m = obj:matrix() 1038 if #shape == 1 and shape[1].type == "curve" 1039 and shape[1].closed == false then 1040 local curve = shape[1] 1041 local head = curve[1][1] 1042 local seg = curve[#curve] 1043 local tail = seg[#seg] 1044 if box:contains(m * head) then 1045 edges[#edges + 1] = { obj=obj, head=true, objno=i } 1046 elseif box:contains(m * tail) then 1047 edges[#edges + 1] = { obj=obj, head=false, objno=i } 1048 end 1049 end 1050 end 1051 end 1052 return edges 1053end 1054 1055---------------------------------------------------------------------- 1056 1057GRAPHTOOL = {} 1058GRAPHTOOL.__index = GRAPHTOOL 1059 1060local function findClosestVertex(model) 1061 local bound = prefs.close_distance 1062 local pos = model.ui:unsnappedPos() 1063 local p = model:page() 1064 1065 local closest 1066 for i,obj,sel,layer in p:objects() do 1067 if p:visible(model.vno, i) and not p:isLocked(layer) then 1068 if obj:type() == "group" or obj:type() == "reference" or 1069 obj:type() == "text" then 1070 local d = p:distance(i, pos, bound) 1071 if d < bound then closest = i; bound = d end 1072 end 1073 end 1074 end 1075 1076 if closest then 1077 -- deselect all, and select only closest object 1078 p:deselectAll() 1079 p:setSelect(closest, 1) 1080 return true 1081 else 1082 return p:hasSelection() 1083 end 1084end 1085 1086function GRAPHTOOL:new(model, moveInvisibleEdges) 1087 if not findClosestVertex(model) then return end 1088 local p = model:page() 1089 self.prim = p:primarySelection() 1090 local view = model.vno 1091 if moveInvisibleEdges then view = nil end 1092 local tool = {} 1093 setmetatable(tool, GRAPHTOOL) 1094 tool.model = model 1095 tool.orig = model.ui:pos() 1096 tool.box = p:bbox(self.prim) 1097 tool.edges = collect_edges(p, tool.box, view) 1098 model.ui:shapeTool(tool) 1099 tool.setColor(1.0, 0.0, 0.0) 1100 return tool 1101end 1102 1103function GRAPHTOOL:compute() 1104 self.t = self.model.ui:pos() - self.orig 1105 self.shape = { boxshape(self.box:bottomLeft() + self.t, 1106 self.box:topRight() + self.t) } 1107 for i = 1,#self.edges do 1108 local obj = self.edges[i].obj 1109 local shape = obj:shape() 1110 transformShape(obj:matrix(), shape) 1111 local curve = shape[1] 1112 if self.edges[i].head then 1113 curve[1][1] = curve[1][1] + self.t 1114 else 1115 local seg = curve[#curve] 1116 seg[#seg] = seg[#seg] + self.t 1117 end 1118 self.shape[i+1] = curve 1119 end 1120end 1121 1122function GRAPHTOOL:mouseMove() 1123 self:compute() 1124 self.setShape(self.shape) 1125 self.model.ui:update(false) -- update tool 1126end 1127 1128local function apply_node_mode(t, doc) 1129 local p = doc[t.pno] 1130 p:transform(t.primary, ipe.Translation(t.translation)) 1131 for _,e in ipairs(t.edges) do 1132 local obj = p[e.objno] 1133 local shape = obj:shape() 1134 transformShape(obj:matrix(), shape) 1135 local curve = shape[1] 1136 if e.head then 1137 curve[1][1] = curve[1][1] + t.translation 1138 else 1139 local seg = curve[#curve] 1140 seg[#seg] = seg[#seg] + t.translation 1141 end 1142 p[e.objno]:setShape(shape) 1143 p[e.objno]:setMatrix(ipe.Matrix()) 1144 end 1145end 1146 1147function GRAPHTOOL:mouseButton(button, modifiers, press) 1148 if press then return end 1149 self:compute() 1150 self.model.ui:finishTool() 1151 local edges = {} 1152 for i = 1,#self.edges do 1153 self.edges[i].obj = nil 1154 end 1155 1156 local t = { label = "move graph vertex", 1157 pno = self.model.pno, 1158 vno = self.model.vno, 1159 primary = self.prim, 1160 original = self.model:page():clone(), 1161 translation = self.t, 1162 edges = self.edges, 1163 undo = revertOriginal, 1164 redo = apply_node_mode, 1165 } 1166 self.model:register(t) 1167end 1168 1169function GRAPHTOOL:key(text, modifiers) 1170 if text == "\027" then 1171 self.model.ui:finishTool() 1172 return true 1173 else 1174 return false 1175 end 1176end 1177 1178---------------------------------------------------------------------- 1179 1180CHANGEWIDTHTOOL = {} 1181CHANGEWIDTHTOOL.__index = CHANGEWIDTHTOOL 1182 1183function CHANGEWIDTHTOOL:new(model, prim, obj) 1184 local tool = {} 1185 setmetatable(tool, CHANGEWIDTHTOOL) 1186 tool.model = model 1187 tool.prim = prim 1188 tool.obj = obj 1189 tool.pos = obj:matrix() * obj:position() 1190 tool.wid = obj:get("width") 1191 tool.align = obj:get("horizontalalignment") 1192 if tool.align == "right" then 1193 tool.posfactor = -1 1194 tool.dir = -1 1195 elseif tool.align == "hcenter" then 1196 tool.posfactor = -0.5 1197 tool.dir = 1 1198 else 1199 tool.posfactor = 0 1200 tool.dir = 1 1201 end 1202 model.ui:shapeTool(tool) 1203 tool.setColor(1.0, 0, 1.0) 1204 local pos = tool.pos + V(tool.posfactor * tool.wid, 0) 1205 tool.setShape( { boxshape(pos, pos + V(tool.wid, -20)) } ) 1206 model.ui:update(false) -- update tool 1207 return tool 1208end 1209 1210function CHANGEWIDTHTOOL:compute() 1211 local w = self.model.ui:pos() 1212 self.nwid = self.wid + self.dir * (w.x - self.v.x) 1213 local pos = self.pos + V(self.posfactor * self.nwid, 0) 1214 self.shape = boxshape(pos, pos + V(self.nwid, -20)) 1215end 1216 1217function CHANGEWIDTHTOOL:mouseButton(button, modifiers, press) 1218 if press then 1219 if not self.v then self.v = self.model.ui:pos() end 1220 if self.align == "hcenter" and self.v.x < self.pos.x then self.dir = -1 end 1221 else 1222 self:compute() 1223 self.model.ui:finishTool() 1224 if self.nwid <= 0 then 1225 self.model:warning("The width of a text object should be positive.") 1226 return 1227 end 1228 self.model:setAttributeOfPrimary(self.prim, "width", self.nwid) 1229 self.model:autoRunLatex() 1230 end 1231end 1232 1233function CHANGEWIDTHTOOL:mouseMove() 1234 if self.v then 1235 self:compute() 1236 self.setShape( { self.shape } ) 1237 self.model.ui:update(false) -- update tool 1238 end 1239end 1240 1241function CHANGEWIDTHTOOL:key(text, modifiers) 1242 if text == "\027" then 1243 self.model.ui:finishTool() 1244 return true 1245 else 1246 return false 1247 end 1248end 1249 1250function MODEL:action_change_width() 1251 local p = self:page() 1252 local prim = p:primarySelection() 1253 if not prim or p[prim]:type() ~= "text" or not p[prim]:get("minipage") then 1254 self.ui:explain("no selection or not a minipage object") 1255 return 1256 end 1257 CHANGEWIDTHTOOL:new(self, prim, p[prim]) 1258end 1259 1260---------------------------------------------------------------------- 1261 1262function MODEL:startTransform(mode, withShift) 1263 self:updateCloseSelection(false) 1264 if mode == "stretch" and withShift then mode = "scale" end 1265 self.ui:transformTool(self:page(), self.vno, mode, withShift, 1266 function (m) self:transformation(mode, m) end) 1267end 1268 1269function MODEL:startModeTool(modifiers) 1270 if self.mode == "select" then 1271 self.ui:selectTool(self:page(), self.vno, 1272 prefs.select_distance, modifiers.shift) 1273 elseif (self.mode == "translate" or self.mode == "stretch" 1274 or self.mode == "rotate" or self.mode == "shear") then 1275 self:startTransform(self.mode, modifiers.shift) 1276 elseif self.mode == "pan" then 1277 self.ui:panTool(self:page(), self.vno) 1278 elseif self.mode == "shredder" then 1279 self:shredObject() 1280 elseif self.mode == "graph" then 1281 GRAPHTOOL:new(self, modifiers.shift) 1282 elseif self.mode:sub(1,10) == "rectangles" or self.mode == "parallelogram" then 1283 BOXTOOL:new(self, modifiers.shift, self.mode) 1284 elseif self.mode == "splinegons" then 1285 SPLINEGONTOOL:new(self) 1286 elseif (self.mode == "lines" or self.mode == "polygons" or 1287 self.mode == "splines") then 1288 LINESTOOL:new(self, self.mode) 1289 elseif self.mode:sub(1,6) == "circle" then 1290 CIRCLETOOL:new(self, self.mode) 1291 elseif self.mode:sub(1,3) == "arc" then 1292 ARCTOOL:new(self, self.mode) 1293 elseif self.mode == "ink" then 1294 INKTOOL:new(self) 1295 elseif self.mode == "marks" then 1296 self:createMark() 1297 elseif self.mode == "label" or self.mode == "math" then 1298 self:createText(self.mode, self.ui:pos()) 1299 elseif self.mode == "paragraph" then 1300 PARAGRAPHTOOL:new(self) 1301 elseif self.mode == "laser" then 1302 LASERTOOL:new(self) 1303 else 1304 print("start mode tool:", self.mode) 1305 end 1306end 1307 1308local mouse_mappings = { 1309 select = function (m, mo) 1310 m.ui:selectTool(m:page(), m.vno, prefs.select_distance, mo.shift) 1311 end, 1312 translate = function (m, mo) m:startTransform("translate", mo.shift) end, 1313 rotate = function (m, mo) m:startTransform("rotate", mo.shift) end, 1314 stretch = function (m, mo) m:startTransform("stretch", mo.shift) end, 1315 scale = function (m, mo) m:startTransform("stretch", true) end, 1316 pan = function (m, mo) m.ui:panTool(m:page(), m.vno) end, 1317 menu = function (m, mo) m:propertiesPopup() end, 1318 shredder = function (m, mo) m:shredObject() end, 1319} 1320 1321function MODEL:mouseButtonAction(button, modifiers) 1322 -- print("Mouse button", button, modifiers.alt, modifiers.control) 1323 if button == 0x81 then button = 1 end -- left double-click 1324 if button == 1 and not modifiers.alt and 1325 not modifiers.control and not modifiers.meta then 1326 self:startModeTool(modifiers) 1327 else 1328 local s = "" 1329 if button == 1 then s = "left" 1330 elseif button == 2 then s = "right" 1331 elseif button == 4 then s = "middle" 1332 elseif button == 8 then s = "button8" 1333 elseif button == 16 then s = "button9" 1334 elseif button == 0 then 1335 -- This is a hack because of the Qt limitation. 1336 -- It really means any other button. 1337 s = "button10" 1338 end 1339 if modifiers.shift then s = s .. "_shift" end 1340 if modifiers.control then s = s .. "_control" end 1341 if modifiers.alt then s = s .. "_alt" end 1342 if modifiers.meta then s = s .. "_meta" end 1343 if modifiers.command then s = s .. "_command" end 1344 local r = mouse[s] 1345 if type(r) == "string" then r = mouse_mappings[r] end 1346 if r then 1347 r(self, modifiers) 1348 else 1349 print("No mouse action defined for " .. s) 1350 end 1351 end 1352end 1353 1354---------------------------------------------------------------------- 1355 1356function apply_text_edit(d, data, run_latex) 1357 -- refuse to do anything with empty text 1358 if string.match(d:get("text"), "^%s*$") then return end 1359 if data.obj:text() == d:get("text") and 1360 (not data.size or data.obj:get("textsize") == data.sizes[d:get("size")]) and 1361 (not data.style or data.obj:get(data.stylekind) == data.styles[d:get("style")]) then 1362 return -- hasn't changed since last time 1363 end 1364 local model = data.model 1365 local final = data.obj:clone() 1366 final:setText(d:get("text")) 1367 if data.style then final:set(data.stylekind, data.styles[d:get("style")]) end 1368 if data.size then final:set("textsize", data.sizes[d:get("size")]) end 1369 local t = { label="edit text", 1370 pno=model.pno, 1371 vno=model.vno, 1372 original=data.obj:clone(), 1373 primary=data.prim, 1374 final=final, 1375 } 1376 t.undo = function (t, doc) 1377 doc[t.pno]:replace(t.primary, t.original) 1378 end 1379 t.redo = function (t, doc) 1380 doc[t.pno]:replace(t.primary, t.final) 1381 end 1382 model:register(t) 1383 -- need to update data.obj for the next run! 1384 data.obj = final 1385 if run_latex then model:runLatex() end 1386end 1387 1388function MODEL:action_edit_text(prim, obj) 1389 local mp = obj:get("minipage") 1390 local stylekind = "labelstyle" 1391 if mp then stylekind = "textstyle" end 1392 local d = ipeui.Dialog(self.ui:win(), "Edit text object") 1393 local data = { model=self, 1394 stylekind = stylekind, 1395 styles = self.doc:sheets():allNames(stylekind), 1396 sizes = self.doc:sheets():allNames("textsize"), 1397 prim=prim, 1398 obj=obj, 1399 } 1400 d:add("label", "label", { label="Edit latex source" }, 1, 1, 1, 2) 1401 d:add("text", "text", { syntax="latex", focus=true, 1402 spell_check=prefs.spell_check}, 0, 1, 1, 4) 1403 d:addButton("apply", "&Apply", 1404 function (d) apply_text_edit(d, data, true) end) 1405 addEditorField(d, "text") 1406 d:addButton("ok", "&Ok", "accept") 1407 d:addButton("cancel", "&Cancel", "reject") 1408 d:setStretch("row", 2, 1) 1409 d:setStretch("column", 2, 1) 1410 d:set("text", obj:text()) 1411 d:set("ignore-escape", "text", obj:text()) 1412 data.style = indexOf(obj:get(stylekind), data.styles) 1413 if data.style then 1414 d:add("style", "combo", data.styles, 1, 3) 1415 d:set("style", data.style) 1416 end 1417 data.size = indexOf(obj:get("textsize"), data.sizes) 1418 if data.size then 1419 d:add("size", "combo", data.sizes, 1, 4) 1420 d:set("size", data.size) 1421 end 1422 if prefs.auto_external_editor then 1423 externalEditor(d, "text") 1424 end 1425 if ((prefs.auto_external_editor and prefs.editor_closes_dialog) 1426 or d:execute(prefs.editor_size)) then 1427 if string.match(d:get("text"), "^%s*$") then return end 1428 apply_text_edit(d, data, self.auto_latex) 1429 end 1430end 1431 1432function MODEL:accept_group_text_edit(prim, group, t, tobj) 1433 local els = group:elements() 1434 els[t] = tobj 1435 local final = ipe.Group(els) 1436 -- copy properties 1437 final:set("pinned", group:get("pinned")) 1438 final:set("transformations", group:get("transformations")) 1439 final:setMatrix(group:matrix()) 1440 final:setText(group:text()) 1441 final:setClip(group:clip()) 1442 final:set("decoration", group:get("decoration")) 1443 local t = { label="edit text in group", 1444 pno=self.pno, 1445 vno=self.vno, 1446 primary=prim, 1447 original=group:clone(), 1448 final=final, 1449 } 1450 t.undo = function (t, doc) 1451 doc[t.pno]:replace(t.primary, t.original) 1452 end 1453 t.redo = function (t, doc) 1454 doc[t.pno]:replace(t.primary, t.final) 1455 end 1456 self:register(t) 1457 self:autoRunLatex() 1458end 1459 1460function MODEL:action_edit_group_text(prim, obj) 1461 local t = nil 1462 for i = 1,obj:count() do 1463 if obj:elementType(i) == "text" then t = i end 1464 end 1465 if not t then 1466 self:warning("Cannot edit object", 1467 "Only groups containing text can be edited") 1468 return 1469 end 1470 local tobj = obj:element(t) 1471 local d = ipeui.Dialog(self.ui:win(), "Edit text in group object") 1472 d:add("label", "label", { label="Edit latex source" }, 1, 1, 1, 2) 1473 d:add("text", "text", { syntax="latex", focus=true, 1474 spell_check=prefs.spell_check}, 0, 1, 1, 4) 1475 addEditorField(d, "text") 1476 d:addButton("ok", "&Ok", "accept") 1477 d:addButton("cancel", "&Cancel", "reject") 1478 d:setStretch("row", 2, 1) 1479 d:setStretch("column", 2, 1) 1480 d:set("text", tobj:text()) 1481 d:set("ignore-escape", "text", tobj:text()) 1482 if prefs.auto_external_editor then 1483 externalEditor(d, "text") 1484 end 1485 if ((prefs.auto_external_editor and prefs.editor_closes_dialog) 1486 or d:execute(prefs.editor_size)) then 1487 if string.match(d:get("text"), "^%s*$") then return end 1488 local final = tobj:clone() 1489 final:setText(d:get("text")) 1490 self:accept_group_text_edit(prim, obj, t, final) 1491 end 1492end 1493 1494function MODEL:action_edit() 1495 local p = self:page() 1496 local prim = p:primarySelection() 1497 if not prim then self.ui:explain("no selection") return end 1498 local obj = p[prim] 1499 if obj:type() == "text" then 1500 self:action_edit_text(prim, obj) 1501 elseif obj:type() == "path" then 1502 self:action_edit_path(prim, obj) 1503 elseif obj:type() == "group" then 1504 self:action_edit_group_text(prim, obj) 1505 else 1506 self:warning("Cannot edit " .. obj:type() .. " object", 1507 "Only text objects, path objects, and groups with text can be edited") 1508 end 1509end 1510 1511---------------------------------------------------------------------- 1512 1513local function start_group_edit(t, doc) 1514 local p = doc[t.pno] 1515 local layers = p:layers() 1516 local layerOfPrim = p:layerOf(t.primary) 1517 local activeLayer = p:active(t.vno) 1518 local activeIndex = indexOf(activeLayer, layers) 1519 local g = p[t.primary] 1520 local elements = g:elements() 1521 local matrix = g:matrix() 1522 local layer = "EDIT-GROUP" 1523 -- make sure new layer name is unique 1524 while indexOf(layer, layers) do 1525 layer = layer .. "*" 1526 end 1527 local data = "active=" .. activeLayer .. ";primary=" .. layerOfPrim .. ";" 1528 if g:get("decoration") ~= "normal" then 1529 data = data .. "decoration=" .. g:get("decoration"):sub(12) .. ";" 1530 end 1531 data = data .. "locked=" 1532 for _,l in ipairs(layers) do 1533 if p:isLocked(l) then data = data .. l .. "," end 1534 end 1535 p:remove(t.primary) 1536 p:addLayer(layer) 1537 p:setLayerData(layer, data) 1538 p:moveLayer(layer, activeIndex + 1) 1539 for _,obj in ipairs(elements) do 1540 p:insert(nil, obj, nil, layer) 1541 p:transform(#p, matrix) 1542 end 1543 p:deselectAll() 1544 p:setActive(t.vno, layer) 1545 p:setVisible(t.vno, layer, true) 1546 for _,l in ipairs(layers) do p:setLocked(l, true) end 1547end 1548 1549local function end_group_edit(t, doc) 1550 local p = doc[t.pno] 1551 local activeLayer = p:active(t.vno) 1552 local data = p:layerData(activeLayer) 1553 local elements = {} 1554 for i, obj, sel, layer in p:objects() do 1555 if layer == activeLayer then 1556 elements[#elements + 1] = obj:clone() 1557 end 1558 end 1559 for i = #p,1,-1 do 1560 if p:layerOf(i) == activeLayer then 1561 p:remove(i) 1562 end 1563 end 1564 p:removeLayer(activeLayer) 1565 local group = ipe.Group(elements) 1566 for _,l in ipairs(p:layers()) do p:setLocked(l, false) end 1567 print("End group edit: ", data) 1568 local newActive = nil 1569 local layerOfGroup = nil 1570 for w in string.gmatch(data, "%w+=[^;]*") do 1571 if w:sub(1, 11) == "decoration=" then 1572 group:set("decoration", "decoration/" .. w:sub(12)) 1573 end 1574 if w:sub(1, 7) == "active=" then 1575 newActive = w:sub(8) 1576 end 1577 if w:sub(1, 8) == "primary=" then 1578 layerOfGroup = w:sub(9) 1579 end 1580 if w:sub(1, 7) == "locked=" then 1581 locked = w:sub(8) 1582 for lock in string.gmatch(locked, "[^,]+") do 1583 p:setLocked(lock, true) 1584 end 1585 end 1586 end 1587 -- to be safe, if the page was tampered with 1588 if not newActive then 1589 newActive = p:layers()[1] -- just use first layer of the page 1590 end 1591 if p:isLocked(newActive) then p:setLocked(newActive, false) end 1592 if not layerOfGroup then layerOfGroup = newActive end 1593 p:setActive(t.vno, newActive) 1594 p:insert(nil, group, 1, layerOfGroup) 1595end 1596 1597function MODEL:saction_edit_group() 1598 local p = self:page() 1599 local prim = p:primarySelection() 1600 if p[prim]:type() ~= "group" then 1601 self.ui:explain("primary selection is not a group") 1602 return 1603 end 1604 local t = { label="start group edit", 1605 pno = self.pno, 1606 vno = self.vno, 1607 primary = prim, 1608 original = p:clone(), 1609 undo = revertOriginal, 1610 redo = start_group_edit, 1611 } 1612 self:register(t) 1613end 1614 1615function MODEL:action_end_group_edit() 1616 local p = self:page() 1617 if (not string.match(p:active(self.vno), "^EDIT%-GROUP")) or 1618 p:countLayers() < 2 then 1619 self:warning("Cannot end group edit", 1620 "Active layer is not a group edit layer") 1621 return 1622 end 1623 local t = { label="end group edit", 1624 pno = self.pno, 1625 vno = self.vno, 1626 original = p:clone(), 1627 undo = revertOriginal, 1628 redo = end_group_edit, 1629 } 1630 self:register(t) 1631end 1632 1633---------------------------------------------------------------------- 1634 1635PASTETOOL = {} 1636PASTETOOL.__index = PASTETOOL 1637 1638function PASTETOOL:new(model, elements, pos) 1639 local tool = {} 1640 _G.setmetatable(tool, PASTETOOL) 1641 tool.model = model 1642 tool.elements = elements 1643 tool.start = model.ui:pos() 1644 if pos then tool.start = pos end 1645 local obj = ipe.Group(elements) 1646 tool.pinned = obj:get("pinned") 1647 model.ui:pasteTool(obj, tool) 1648 tool.setColor(1.0, 0, 0) 1649 tool:computeTranslation() 1650 tool.setMatrix(ipe.Translation(tool.translation)) 1651 return tool 1652end 1653 1654function PASTETOOL:computeTranslation() 1655 self.translation = self.model.ui:pos() - self.start 1656 if self.pinned == "horizontal" or self.pinned == "fixed" then 1657 self.translation = V(0, self.translation.y) 1658 end 1659 if self.pinned == "vertical" or self.pinned == "fixed" then 1660 self.translation = V(self.translation.x, 0) 1661 end 1662end 1663 1664function PASTETOOL:mouseButton(button, modifiers, press) 1665 self:computeTranslation() 1666 self.model.ui:finishTool() 1667 local t = { label="paste objects at cursor", 1668 pno = self.model.pno, 1669 vno = self.model.vno, 1670 elements = self.elements, 1671 layer = self.model:page():active(self.model.vno), 1672 translation = ipe.Translation(self.translation), 1673 } 1674 t.undo = function (t, doc) 1675 local p = doc[t.pno] 1676 for i = 1,#t.elements do p:remove(#p) end 1677 end 1678 t.redo = function (t, doc) 1679 local p = doc[t.pno] 1680 for i,obj in ipairs(t.elements) do 1681 p:insert(nil, obj, 2, t.layer) 1682 p:transform(#p, t.translation) 1683 end 1684 p:ensurePrimarySelection() 1685 end 1686 self.model:page():deselectAll() 1687 self.model:register(t) 1688end 1689 1690function PASTETOOL:mouseMove() 1691 self:computeTranslation() 1692 self.setMatrix(ipe.Translation(self.translation)) 1693 self.model.ui:update(false) -- update tool 1694end 1695 1696function PASTETOOL:key(text, modifiers) 1697 if text == "\027" then 1698 self.model.ui:finishTool() 1699 return true 1700 else 1701 return false 1702 end 1703end 1704 1705function MODEL:action_paste_at_cursor() 1706 local data = self.ui:clipboard(true) -- allow bitmap 1707 if not data then 1708 self:warning("Nothing to paste") 1709 return 1710 end 1711 if type(data) == "string" then 1712 if data:sub(1,13) ~= "<ipeselection" then 1713 self:warning("No Ipe selection to paste") 1714 return 1715 end 1716 local pos 1717 local px, py = data:match('^<ipeselection pos="([%d%.]+) ([%d%.]+)"') 1718 if px then pos = V(tonumber(px), tonumber(py)) end 1719 local elements = ipe.Object(data) 1720 if not elements then 1721 self:warning("Could not parse Ipe selection on clipboard") 1722 return 1723 end 1724 PASTETOOL:new(self, elements, pos) 1725 else 1726 -- pasting bitmap 1727 PASTETOOL:new(self, { data }, V(0, 0)) 1728 end 1729end 1730 1731---------------------------------------------------------------------- 1732 1733LASERTOOL = {} 1734LASERTOOL.__index = LASERTOOL 1735 1736function LASERTOOL:new(model) 1737 local tool = {} 1738 _G.setmetatable(tool, LASERTOOL) 1739 tool.model = model 1740 tool.pos = model.ui:pos() 1741 model.ui:shapeTool(tool) 1742 local p = prefs.laser_pointer.color 1743 tool.setColor(p.r, p.g, p.b) 1744 tool:setPosShape() 1745 return tool 1746end 1747 1748function LASERTOOL:setPosShape() 1749 local radius = prefs.laser_pointer.radius 1750 self.pos = self.model.ui:pos() 1751 local shape = { type="ellipse"; ipe.Matrix(radius, 0, 0, radius, self.pos.x, self.pos.y) } 1752 self.setShape( { shape }, 0, prefs.laser_pointer.pen) 1753 self.model.ui:update(false) -- update tool 1754end 1755 1756function LASERTOOL:mouseButton(button, modifiers, press) 1757 if not press then 1758 self.model.ui:finishTool() 1759 end 1760end 1761 1762function LASERTOOL:mouseMove() 1763 self:setPosShape() 1764end 1765 1766function LASERTOOL:key(text, modifiers) 1767 return false 1768end 1769 1770---------------------------------------------------------------------- 1771