share_space 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +7 -3
- data/app/assets/stylesheets/share_space/application.css +28 -4
- data/app/channels/share_space/canvas_channel.rb +39 -0
- data/app/controllers/share_space/canvas_elements_controller.rb +14 -4
- data/app/models/share_space/bezier_line.rb +5 -0
- data/app/models/share_space/canvas_element.rb +1 -1
- data/app/models/share_space/cube.rb +5 -0
- data/app/models/share_space/cylinder.rb +5 -0
- data/app/models/share_space/freeform_line.rb +5 -0
- data/app/models/share_space/mask.rb +5 -0
- data/app/models/share_space/polygon.rb +5 -0
- data/app/models/share_space/sphere.rb +5 -0
- data/app/views/share_space/canvases/show.html.haml +369 -87
- data/db/migrate/20261028010000_add_options_to_share_space_canvas_elements.rb +5 -0
- data/lib/share_space/version.rb +1 -1
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7536fadda6c04dc84d07ab563818139749efd431303aa20d6e0a0b5edf14c64b
|
|
4
|
+
data.tar.gz: 9b7f8fb94ca761b2ae2fb683590e58b81826f5175410fa7df44f15eefdfc4300
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 53aabcb2e9825715e2462149a81ad44f1384255db6fd0082b05ca21d8653a933551d4f48f835496cc4fae9ec044a5e99acd008b85d4c6f1f454931b051326f71
|
|
7
|
+
data.tar.gz: abb5a654c152979dadf69f41fd68dbb77e13a3ed6423c1d42ef58a05d9de3f7a55d44bf126229b709ff7bd4527b847eca8b44090e61b0c7741a8f408a9442e20
|
data/README.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# ShareSpace
|
|
2
|
-
|
|
2
|
+
Users can create shared spaces among other users. When working together, updates propogate to other users and cursors are visible. Similar to Figma or Google Suite collaboration.
|
|
3
|
+
|
|
4
|
+
The canvas editor supports 2D shapes (including polygons and straight, Bezier, and free-form lines), simple 3D primitives, rectangular clipping masks, symmetry and transform effects, and SVG, PNG, JPEG, WebP, and print-to-PDF export. Canvas changes and cursor positions are shared live over Action Cable.
|
|
5
|
+
|
|
6
|
+
The host Rails application must mount Action Cable at `/cable` and configure a shared Action Cable adapter (Redis in multi-process deployments) for collaboration across web workers. The channel checks `current_user` and canvas ownership/shares when the host connection exposes a signed-in user; standalone engine hosts should add an equivalent authorization check.
|
|
3
7
|
|
|
4
8
|
## Usage
|
|
5
|
-
How to use my plugin.
|
|
9
|
+
TODO How to use my plugin.
|
|
6
10
|
|
|
7
11
|
## Installation
|
|
8
12
|
Add this line to your application's Gemfile:
|
|
@@ -22,7 +26,7 @@ $ gem install share_space
|
|
|
22
26
|
```
|
|
23
27
|
|
|
24
28
|
## Contributing
|
|
25
|
-
Contribution directions go here.
|
|
29
|
+
TODO Contribution directions go here.
|
|
26
30
|
|
|
27
31
|
## License
|
|
28
32
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -17,16 +17,40 @@
|
|
|
17
17
|
.editor-layout { align-items: flex-start; }
|
|
18
18
|
.toolbox { width: 230px; }
|
|
19
19
|
.toolbox .buttons { gap: .5rem; }
|
|
20
|
+
.tool-pane { width: 200px; }
|
|
21
|
+
.inspector-pane { width: 300px; }
|
|
22
|
+
.tree-item { display: flex; width: 100%; justify-content: space-between; padding: .45rem .6rem; border: 0; border-radius: 4px; background: transparent; text-align: left; cursor: pointer; }
|
|
23
|
+
.tree-item:hover, .tree-item.is-active { background: #eff1fa; color: #485fc7; }
|
|
20
24
|
.canvas-column { min-width: 0; }
|
|
21
25
|
.canvas-scroll { overflow: auto; max-height: calc(100vh - 230px); padding: 1.25rem; background: #f1f3f5; border-radius: 8px; }
|
|
22
26
|
#canvas-editor { display: block; max-width: none; box-shadow: 0 2px 12px rgb(10 10 10 / 12%); touch-action: none; }
|
|
23
|
-
|
|
24
|
-
.canvas-shape
|
|
27
|
+
#canvas-editor[data-tool="select"] { cursor: default; }
|
|
28
|
+
#canvas-editor[data-tool="select"] .canvas-shape { cursor: grab; }
|
|
29
|
+
#canvas-editor[data-tool="select"] .canvas-shape[data-locked="true"] { cursor: not-allowed; }
|
|
30
|
+
#canvas-editor[data-tool="rectangle"], #canvas-editor[data-tool="ellipse"], #canvas-editor[data-tool="line"], #canvas-editor[data-tool="polygon"], #canvas-editor[data-tool="bezier_line"], #canvas-editor[data-tool="freeform_line"], #canvas-editor[data-tool="cube"], #canvas-editor[data-tool="sphere"], #canvas-editor[data-tool="cylinder"], #canvas-editor[data-tool="mask"] { cursor: crosshair; }
|
|
31
|
+
#canvas-editor[data-tool="rectangle"] .canvas-shape, #canvas-editor[data-tool="ellipse"] .canvas-shape, #canvas-editor[data-tool="line"] .canvas-shape, #canvas-editor[data-tool="polygon"] .canvas-shape, #canvas-editor[data-tool="bezier_line"] .canvas-shape, #canvas-editor[data-tool="freeform_line"] .canvas-shape { cursor: crosshair; }
|
|
32
|
+
.tool-group { margin: .6rem 0; }
|
|
33
|
+
.tool-group summary { padding: .35rem 0; font-size: .85rem; font-weight: 700; cursor: pointer; }
|
|
34
|
+
.tool-group .buttons { margin-top: .4rem; }
|
|
35
|
+
#canvas-editor[data-cursor="point"], #canvas-editor[data-cursor="point"] .canvas-shape { cursor: move !important; }
|
|
36
|
+
#canvas-editor[data-cursor="locked"], #canvas-editor[data-cursor="locked"] .canvas-shape { cursor: not-allowed !important; }
|
|
37
|
+
#canvas-editor[data-cursor="element"], #canvas-editor[data-cursor="element"] .canvas-shape { cursor: grab !important; }
|
|
38
|
+
#canvas-editor[data-cursor="dragging"], #canvas-editor[data-cursor="dragging"] .canvas-shape { cursor: grabbing !important; }
|
|
39
|
+
.point-handle { fill: #fff; stroke: #485fc7; stroke-width: 2; cursor: move; pointer-events: all; }
|
|
40
|
+
.point-handle:hover { fill: #dfe3ff; }
|
|
25
41
|
.canvas-shape.is-selected { filter: drop-shadow(0 0 4px #485fc7); }
|
|
42
|
+
.canvas-shape.is-preview { opacity: .48 !important; pointer-events: none; }
|
|
43
|
+
.remote-cursor { pointer-events: none; overflow: visible; }
|
|
44
|
+
.remote-cursor .cursor-arrow { fill: #ff3860; stroke: #fff; stroke-width: 1.5; vector-effect: non-scaling-stroke; }
|
|
45
|
+
.remote-cursor text { font: 12px sans-serif; fill: #fff; paint-order: stroke; stroke: #d7264d; stroke-width: 4px; stroke-linejoin: round; }
|
|
46
|
+
.context-menu { position: fixed; z-index: 50; min-width: 150px; padding: .35rem; background: #fff; border: 1px solid #dbdbdb; border-radius: 6px; box-shadow: 0 4px 16px rgb(10 10 10 / 18%); }
|
|
47
|
+
.context-menu button { display: block; width: 100%; padding: .45rem .6rem; border: 0; border-radius: 4px; background: transparent; text-align: left; cursor: pointer; }
|
|
48
|
+
.context-menu button:hover:not(:disabled) { background: #eff1fa; }
|
|
49
|
+
.context-menu button:disabled { color: #b5b5b5; cursor: not-allowed; }
|
|
26
50
|
.form-box { max-width: 640px; }
|
|
27
51
|
.canvas-color { max-width: 5rem; padding: .25rem; }
|
|
28
52
|
@media (max-width: 768px) {
|
|
29
|
-
.
|
|
30
|
-
.
|
|
53
|
+
.tool-pane, .inspector-pane { width: 100%; }
|
|
54
|
+
.tool-pane .buttons { flex-direction: row !important; }
|
|
31
55
|
.canvas-scroll { max-height: 65vh; }
|
|
32
56
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module ShareSpace
|
|
2
|
+
class CanvasChannel < ActionCable::Channel::Base
|
|
3
|
+
def subscribed
|
|
4
|
+
@canvas = Canvas.find_by(id: params[:canvas_id])
|
|
5
|
+
reject unless @canvas && authorized_canvas?
|
|
6
|
+
|
|
7
|
+
stream_from "share_space_canvas_#{@canvas.id}"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def authorized_canvas?
|
|
11
|
+
return true unless connection.respond_to?(:current_user)
|
|
12
|
+
|
|
13
|
+
user = connection.current_user
|
|
14
|
+
return false unless user
|
|
15
|
+
return true unless @canvas.respond_to?(:user_id)
|
|
16
|
+
|
|
17
|
+
owns_canvas = @canvas.user_id == user.id
|
|
18
|
+
shared_canvas = user.respond_to?(:shared_canvases) && user.shared_canvases.exists?(id: @canvas.id)
|
|
19
|
+
owns_canvas || shared_canvas
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def receive(data)
|
|
23
|
+
return unless data["type"] == "cursor"
|
|
24
|
+
|
|
25
|
+
x = data["x"].to_f.clamp(0, @canvas.width.to_f)
|
|
26
|
+
y = data["y"].to_f.clamp(0, @canvas.height.to_f)
|
|
27
|
+
ActionCable.server.broadcast("share_space_canvas_#{@canvas.id}", {
|
|
28
|
+
type: "cursor", client_id: data["client_id"].to_s.first(64), x: x, y: y,
|
|
29
|
+
active: data["active"] != false
|
|
30
|
+
})
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def unsubscribed
|
|
34
|
+
ActionCable.server.broadcast("share_space_canvas_#{@canvas.id}", {
|
|
35
|
+
type: "cursor", client_id: params[:client_id].to_s.first(64), active: false
|
|
36
|
+
}) if @canvas
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -11,7 +11,9 @@ module ShareSpace
|
|
|
11
11
|
|
|
12
12
|
@element = @canvas.elements.new(element_params.except(:type).to_h.merge(type: type))
|
|
13
13
|
if @element.save
|
|
14
|
-
|
|
14
|
+
payload = element_json(@element)
|
|
15
|
+
broadcast_change("create", payload)
|
|
16
|
+
render json: payload, status: :created
|
|
15
17
|
else
|
|
16
18
|
render json: { errors: @element.errors.full_messages }, status: :unprocessable_entity
|
|
17
19
|
end
|
|
@@ -19,14 +21,18 @@ module ShareSpace
|
|
|
19
21
|
|
|
20
22
|
def update
|
|
21
23
|
if @element.update(element_params.except(:type))
|
|
22
|
-
|
|
24
|
+
payload = element_json(@element)
|
|
25
|
+
broadcast_change("update", payload)
|
|
26
|
+
render json: payload
|
|
23
27
|
else
|
|
24
28
|
render json: { errors: @element.errors.full_messages }, status: :unprocessable_entity
|
|
25
29
|
end
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
def destroy
|
|
33
|
+
payload = element_json(@element)
|
|
29
34
|
@element.destroy
|
|
35
|
+
broadcast_change("delete", payload)
|
|
30
36
|
head :no_content
|
|
31
37
|
end
|
|
32
38
|
|
|
@@ -43,7 +49,7 @@ module ShareSpace
|
|
|
43
49
|
def element_params
|
|
44
50
|
params.require(:element).permit(:type, :name, :x, :y, :width, :height, :rotation,
|
|
45
51
|
:fill_color, :stroke_color, :stroke_width, :opacity,
|
|
46
|
-
:visible, :locked, :z_index, points: [])
|
|
52
|
+
:visible, :locked, :z_index, points: [], options: {})
|
|
47
53
|
end
|
|
48
54
|
|
|
49
55
|
def element_json(element)
|
|
@@ -51,7 +57,11 @@ module ShareSpace
|
|
|
51
57
|
width: element.width&.to_f, height: element.height&.to_f, rotation: element.rotation.to_f,
|
|
52
58
|
fill_color: element.fill_color, stroke_color: element.stroke_color,
|
|
53
59
|
stroke_width: element.stroke_width&.to_f, opacity: element.opacity.to_f,
|
|
54
|
-
visible: element.visible, locked: element.locked, points: element.points }
|
|
60
|
+
visible: element.visible, locked: element.locked, points: element.points, options: element.options || {} }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def broadcast_change(action, element)
|
|
64
|
+
ActionCable.server.broadcast("share_space_canvas_#{@canvas.id}", { type: "element", action: action, element: element })
|
|
55
65
|
end
|
|
56
66
|
end
|
|
57
67
|
end
|
|
@@ -5,7 +5,7 @@ module ShareSpace
|
|
|
5
5
|
scope :visible, -> { where(visible: true) }
|
|
6
6
|
scope :ordered, -> { order(:z_index, :id) }
|
|
7
7
|
|
|
8
|
-
ELEMENT_TYPES = %w[Rectangle Ellipse Line].freeze
|
|
8
|
+
ELEMENT_TYPES = %w[Rectangle Ellipse Polygon Line BezierLine FreeformLine Cube Sphere Cylinder Mask].freeze
|
|
9
9
|
|
|
10
10
|
validates :type, inclusion: { in: ELEMENT_TYPES.map { |name| "ShareSpace::#{name}" } }
|
|
11
11
|
|
|
@@ -7,125 +7,407 @@
|
|
|
7
7
|
%p.subtitle.is-6= "#{@canvas.width} × #{@canvas.height} px"
|
|
8
8
|
.level-right
|
|
9
9
|
= link_to "All canvases", canvases_path, class: "button is-light"
|
|
10
|
-
|
|
10
|
+
.select.is-small
|
|
11
|
+
%select#export-format{ aria: { label: "Export format" } }
|
|
12
|
+
%option{ value: "svg" } SVG
|
|
13
|
+
%option{ value: "png" } PNG
|
|
14
|
+
%option{ value: "jpeg" } JPEG
|
|
15
|
+
%option{ value: "webp" } WebP
|
|
16
|
+
%option{ value: "pdf" } PDF
|
|
17
|
+
%button#export-canvas.button.is-link.is-light{ type: "button" } Export
|
|
18
|
+
%span#cursor-position.tag.is-light X 0 · Y 0
|
|
11
19
|
.columns.editor-layout
|
|
12
|
-
.column.is-narrow.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
%
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
.
|
|
30
|
-
%
|
|
31
|
-
.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
.
|
|
36
|
-
%
|
|
37
|
-
.
|
|
38
|
-
.
|
|
39
|
-
%
|
|
20
|
+
.column.is-narrow.tool-pane
|
|
21
|
+
.box
|
|
22
|
+
%h2.title.is-6 Tool
|
|
23
|
+
.buttons.are-small.is-flex-direction-column.is-align-items-stretch
|
|
24
|
+
%button.button.is-primary{ type: "button", data: { draw: "select" } } Select / move
|
|
25
|
+
%details.tool-group{ open: true }
|
|
26
|
+
%summary 2D shapes
|
|
27
|
+
.buttons.are-small.is-flex-direction-column.is-align-items-stretch
|
|
28
|
+
%button.button{ type: "button", data: { draw: "rectangle" } } Rectangle
|
|
29
|
+
%button.button{ type: "button", data: { draw: "ellipse" } } Ellipse
|
|
30
|
+
%button.button{ type: "button", data: { draw: "polygon" } } Polygon
|
|
31
|
+
.field
|
|
32
|
+
%label.label.is-size-7{ for: "polygon-sides" } Polygon sides
|
|
33
|
+
.control= number_field_tag :polygon_sides, 5, min: 3, max: 64, class: "input is-small", id: "polygon-sides"
|
|
34
|
+
%button.button{ type: "button", data: { draw: "line" } } Straight line
|
|
35
|
+
%button.button{ type: "button", data: { draw: "bezier_line" } } Bezier curve
|
|
36
|
+
%button.button{ type: "button", data: { draw: "freeform_line" } } Free form line
|
|
37
|
+
%details.tool-group
|
|
38
|
+
%summary 3D shapes
|
|
39
|
+
.buttons.are-small.is-flex-direction-column.is-align-items-stretch
|
|
40
|
+
%button.button{ type: "button", data: { draw: "cube" } } Cube
|
|
41
|
+
%button.button{ type: "button", data: { draw: "sphere" } } Sphere
|
|
42
|
+
%button.button{ type: "button", data: { draw: "cylinder" } } Cylinder
|
|
43
|
+
.box.is-shadowless.p-2
|
|
44
|
+
%h3.title.is-7 Masks
|
|
45
|
+
%button#mask-tool.button.is-small.is-fullwidth{ type: "button", data: { draw: "mask" } } Draw rectangular mask
|
|
46
|
+
.box.is-shadowless.p-2
|
|
47
|
+
%h3.title.is-7 FX
|
|
48
|
+
.field
|
|
49
|
+
%label.label.is-size-7{ for: "fx-symmetry" } Reflection
|
|
50
|
+
.control
|
|
51
|
+
.select.is-small.is-fullwidth
|
|
52
|
+
%select#fx-symmetry
|
|
53
|
+
%option{ value: "none" } None
|
|
54
|
+
%option{ value: "linear" } Linear
|
|
55
|
+
%option{ value: "radial" } Radial
|
|
56
|
+
.field
|
|
57
|
+
%label.label.is-size-7{ for: "fx-count" } Lines / copies
|
|
58
|
+
.control= number_field_tag :fx_count, 2, min: 2, max: 24, class: "input is-small", id: "fx-count"
|
|
59
|
+
.field
|
|
60
|
+
%label.label.is-size-7{ for: "fx-rotate" } Rotate (degrees)
|
|
61
|
+
.control= number_field_tag :fx_rotate, 0, class: "input is-small", id: "fx-rotate", step: 1
|
|
62
|
+
.field
|
|
63
|
+
%label.label.is-size-7{ for: "fx-skew" } Skew (degrees)
|
|
64
|
+
.control= number_field_tag :fx_skew, 0, class: "input is-small", id: "fx-skew", step: 1
|
|
65
|
+
.field
|
|
66
|
+
%label.checkbox.is-size-7
|
|
67
|
+
= check_box_tag :fx_shadow, "1", false, id: "fx-shadow"
|
|
68
|
+
Drop shadow
|
|
69
|
+
%button#apply-fx.button.is-small.is-link.is-light.is-fullwidth{ type: "button" } Apply FX to selection
|
|
70
|
+
.buttons.are-small.mt-3
|
|
71
|
+
%button#undo.button{ type: "button", disabled: true } Undo
|
|
72
|
+
%button#redo.button{ type: "button", disabled: true } Redo
|
|
73
|
+
%p.help Drag shapes to move them. Drag a line's points to reshape it.
|
|
40
74
|
.column.canvas-column
|
|
41
75
|
.canvas-scroll
|
|
42
76
|
%svg#canvas-editor{ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 #{@canvas.width} #{@canvas.height}", width: @canvas.width, height: @canvas.height,
|
|
43
77
|
style: "background: #{@canvas.background_color}", data: { canvas_width: @canvas.width, canvas_height: @canvas.height,
|
|
44
78
|
create_url: canvas_elements_path(@canvas), update_url: canvas_elements_path(@canvas, id: "ELEMENT_ID"),
|
|
45
|
-
csrf: form_authenticity_token } }
|
|
79
|
+
csrf: form_authenticity_token, tool: "select", canvas_id: @canvas.id } }
|
|
46
80
|
- @elements.each do |element|
|
|
47
81
|
- next unless element.visible
|
|
48
82
|
- attrs = { "data-id" => element.id, "data-type" => element.type.demodulize.downcase,
|
|
49
83
|
"data-x" => element.x.to_f, "data-y" => element.y.to_f, "data-width" => element.width.to_f,
|
|
50
84
|
"data-height" => element.height.to_f, "data-fill" => element.fill_color || "#48c78e",
|
|
51
85
|
"data-stroke" => element.stroke_color || "#257953", "data-stroke-width" => element.stroke_width.to_f,
|
|
52
|
-
"data-opacity" => element.opacity.to_f, "data-locked" => element.locked
|
|
53
|
-
|
|
86
|
+
"data-opacity" => element.opacity.to_f, "data-locked" => element.locked,
|
|
87
|
+
"data-points" => element.points.to_json, "data-name" => element.name,
|
|
88
|
+
"data-options" => (element.options || {}).to_json }
|
|
89
|
+
- if %w[Line BezierLine FreeformLine Polygon].include?(element.type.demodulize)
|
|
54
90
|
- points = (element.points || []).map { |point| point.is_a?(Array) ? point.join(",") : point }.join(" ")
|
|
55
|
-
%polyline{ **attrs, points: points, fill: "none", stroke: element.stroke_color || "#257953", "stroke-width" => element.stroke_width || 2 }
|
|
91
|
+
%polyline{ **attrs, points: points, fill: (element.type.demodulize == "Polygon" ? (element.fill_color || "#48c78e") : "none"), stroke: element.stroke_color || "#257953", "stroke-width" => element.stroke_width || 2 }
|
|
56
92
|
- elsif element.is_a?(Ellipse)
|
|
57
93
|
%ellipse{ **attrs, cx: element.x.to_f + element.width.to_f / 2, cy: element.y.to_f + element.height.to_f / 2,
|
|
58
94
|
rx: element.width.to_f / 2, ry: element.height.to_f / 2, fill: element.fill_color || "#48c78e",
|
|
59
95
|
stroke: element.stroke_color || "#257953", "stroke-width" => element.stroke_width || 2, opacity: element.opacity }
|
|
96
|
+
- elsif %w[Cube Sphere Cylinder].include?(element.type.demodulize)
|
|
97
|
+
%rect{ **attrs, x: element.x, y: element.y, width: element.width, height: element.height, rx: (element.type.demodulize == "Sphere" ? "50%" : 0), fill: element.fill_color || "#48c78e", stroke: element.stroke_color || "#257953", "stroke-width" => element.stroke_width || 2, opacity: element.opacity, "data-options" => (element.options || {}).to_json }
|
|
60
98
|
- else
|
|
61
99
|
%rect{ **attrs, x: element.x, y: element.y, width: element.width, height: element.height,
|
|
62
100
|
fill: element.fill_color || "#48c78e", stroke: element.stroke_color || "#257953",
|
|
63
101
|
"stroke-width" => element.stroke_width || 2, opacity: element.opacity }
|
|
102
|
+
.column.is-narrow.inspector-pane
|
|
103
|
+
.box
|
|
104
|
+
%h2.title.is-6 Element tree
|
|
105
|
+
#element-tree
|
|
106
|
+
- @elements.each do |element|
|
|
107
|
+
%button.tree-item{ type: "button", data: { select_element: element.id } }
|
|
108
|
+
%span= element.name.presence || element.type.demodulize
|
|
109
|
+
%span.has-text-grey.is-size-7= "##{element.id}"
|
|
110
|
+
- if @elements.empty?
|
|
111
|
+
%p#tree-empty.has-text-grey.is-size-7 No elements yet.
|
|
112
|
+
.box
|
|
113
|
+
%h2.title.is-6 Properties
|
|
114
|
+
%p#selection-empty.has-text-grey.is-size-7 Select an element to edit its properties.
|
|
115
|
+
#inspector.is-hidden
|
|
116
|
+
- { x: "X", y: "Y", width: "Width", height: "Height" }.each do |key, label|
|
|
117
|
+
.field
|
|
118
|
+
%label.label{ for: "element-#{key}" }= label
|
|
119
|
+
.control= number_field_tag "element_#{key}", nil, class: "input is-small", id: "element-#{key}", step: "any", min: (key == "x" || key == "y" ? nil : 1)
|
|
120
|
+
.field
|
|
121
|
+
%label.label{ for: "element-fill" } Fill
|
|
122
|
+
.control= color_field_tag :element_fill_color, "#48c78e", class: "input is-small canvas-color", id: "element-fill"
|
|
123
|
+
%p.help The selected object can be mirrored or styled with FX from the tool pane.
|
|
124
|
+
%p#point-help.help.is-hidden Drag the white handles on the canvas to move individual points.
|
|
125
|
+
.buttons
|
|
126
|
+
%button#delete-element.button.is-danger.is-light.is-small{ type: "button" } Delete
|
|
127
|
+
%button#lock-element.button.is-light.is-small{ type: "button" } Lock
|
|
128
|
+
#editor-context-menu.context-menu.is-hidden{ role: "menu" }
|
|
64
129
|
:javascript
|
|
65
130
|
(() => {
|
|
66
131
|
const svg = document.getElementById("canvas-editor");
|
|
67
132
|
if (!svg) return;
|
|
68
|
-
const token = svg.dataset.csrf;
|
|
69
|
-
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
133
|
+
const token = svg.dataset.csrf, menu = document.getElementById("editor-context-menu");
|
|
134
|
+
const inspector = document.getElementById("inspector"), empty = document.getElementById("selection-empty");
|
|
135
|
+
const pointHelp = document.getElementById("point-help");
|
|
136
|
+
const fields = { x: document.getElementById("element-x"), y: document.getElementById("element-y"), width: document.getElementById("element-width"), height: document.getElementById("element-height"), fill_color: document.getElementById("element-fill") };
|
|
137
|
+
let tool = "select", selectedIds = new Set(), drag = null, clipboard = null, history = [], historyIndex = -1;
|
|
138
|
+
const idRemap = new Map();
|
|
139
|
+
const shapes = () => [...svg.querySelectorAll(".canvas-shape")];
|
|
140
|
+
const modelType = type => ({ rectangle: "Rectangle", ellipse: "Ellipse", polygon: "Polygon", line: "Line", bezier_line: "BezierLine", bezierline: "BezierLine", freeform_line: "FreeformLine", freeformline: "FreeformLine", cube: "Cube", sphere: "Sphere", cylinder: "Cylinder", mask: "Mask" }[type] || "Rectangle");
|
|
141
|
+
const resolveId = id => idRemap.get(String(id)) || String(id);
|
|
142
|
+
const byId = id => shapes().find(shape => shape.dataset.id === resolveId(id));
|
|
143
|
+
const updateUrl = id => svg.dataset.updateUrl.replace("ELEMENT_ID", id);
|
|
144
|
+
const parsePoints = shape => {
|
|
145
|
+
try {
|
|
146
|
+
const saved = JSON.parse(shape.dataset.points || "[]");
|
|
147
|
+
return saved.map(item => Array.isArray(item) ? { x: Number(item[0]), y: Number(item[1]) } : { x: Number(String(item).split(",")[0]), y: Number(String(item).split(",")[1]) }).filter(p => Number.isFinite(p.x) && Number.isFinite(p.y));
|
|
148
|
+
} catch (_) { return []; }
|
|
149
|
+
};
|
|
150
|
+
const pointStrings = shape => parsePoints(shape).map(p => `${p.x},${p.y}`);
|
|
151
|
+
const refreshMasks = () => {
|
|
152
|
+
svg.querySelector("#canvas-masks")?.remove();
|
|
153
|
+
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); defs.id = "canvas-masks";
|
|
154
|
+
const all = shapes(); all.forEach(shape => shape.removeAttribute("clip-path"));
|
|
155
|
+
all.forEach((mask, maskIndex) => {
|
|
156
|
+
if (mask.dataset.type !== "mask") return;
|
|
157
|
+
const clip = document.createElementNS("http://www.w3.org/2000/svg", "clipPath"); clip.id = `clip-${mask.dataset.id}`; clip.setAttribute("clipPathUnits", "userSpaceOnUse");
|
|
158
|
+
const region = document.createElementNS("http://www.w3.org/2000/svg", "rect"); region.setAttribute("x", mask.dataset.x); region.setAttribute("y", mask.dataset.y); region.setAttribute("width", mask.dataset.width); region.setAttribute("height", mask.dataset.height); clip.appendChild(region); defs.appendChild(clip);
|
|
159
|
+
all.slice(0, maskIndex).filter(shape => shape.dataset.type !== "mask").forEach(shape => shape.setAttribute("clip-path", `url(#${clip.id})`));
|
|
160
|
+
});
|
|
161
|
+
svg.prepend(defs);
|
|
79
162
|
};
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
shape.setAttribute("
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
shape.setAttribute("
|
|
163
|
+
const render = source => {
|
|
164
|
+
const x = Number(source.dataset.x), y = Number(source.dataset.y), w = Number(source.dataset.width), h = Number(source.dataset.height), type = source.dataset.type;
|
|
165
|
+
const tag = type === "ellipse" || type === "sphere" ? "ellipse" : type === "polygon" || type === "cube" ? "polygon" : type === "line" || type === "freeformline" ? "polyline" : type === "bezierline" ? "path" : "rect";
|
|
166
|
+
const shape = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
|
167
|
+
Object.assign(shape.dataset, { id: source.dataset.id, type, x, y, width: w, height: h, fill: source.dataset.fill || "#48c78e", stroke: source.dataset.stroke || "#257953", strokeWidth: source.dataset.strokeWidth || 2, opacity: source.dataset.opacity || 1, locked: source.dataset.locked || "false", points: source.dataset.points || "[]", name: source.dataset.name || "", options: source.dataset.options || "{}" });
|
|
168
|
+
if (type === "ellipse" || type === "sphere") { shape.setAttribute("cx", x + w / 2); shape.setAttribute("cy", y + h / 2); shape.setAttribute("rx", w / 2); shape.setAttribute("ry", h / 2); }
|
|
169
|
+
else if (["line", "freeformline", "polygon"].includes(type)) shape.setAttribute("points", pointStrings(shape).join(" "));
|
|
170
|
+
else if (type === "cube") shape.setAttribute("points", `${x+w*.2},${y} ${x+w},${y} ${x+w},${y+h*.75} ${x+w*.8},${y+h} ${x},${y+h} ${x},${y+h*.25}`);
|
|
171
|
+
else if (type === "bezierline") { const p = parsePoints(shape); if (p.length >= 4) shape.setAttribute("d", `M ${p[0].x} ${p[0].y} C ${p[1].x} ${p[1].y}, ${p[2].x} ${p[2].y}, ${p[3].x} ${p[3].y}`); }
|
|
172
|
+
else { shape.setAttribute("x", x); shape.setAttribute("y", y); shape.setAttribute("width", w); shape.setAttribute("height", h); if (type === "cylinder") shape.setAttribute("rx", Math.min(w,h)*.14); }
|
|
173
|
+
const options = JSON.parse(shape.dataset.options || "{}");
|
|
174
|
+
shape.setAttribute("fill", ["line", "bezierline", "freeformline"].includes(type) ? "none" : shape.dataset.fill); shape.setAttribute("stroke", shape.dataset.stroke);
|
|
175
|
+
shape.setAttribute("stroke-width", shape.dataset.strokeWidth); shape.setAttribute("opacity", shape.dataset.opacity); shape.classList.add("canvas-shape");
|
|
176
|
+
shape.dataset.locked = source.dataset.locked || "false";
|
|
177
|
+
if (type === "mask") { shape.setAttribute("fill", "#7aa7ff"); shape.setAttribute("fill-opacity", ".22"); shape.setAttribute("stroke-dasharray", "7 4"); }
|
|
178
|
+
if (options.rotate || options.skew) shape.setAttribute("transform", `rotate(${Number(options.rotate || 0)} ${x + w / 2} ${y + h / 2}) skewX(${Number(options.skew || 0)})`);
|
|
179
|
+
if (options.shadow) shape.style.filter = "drop-shadow(4px 5px 4px #3338)";
|
|
95
180
|
return shape;
|
|
96
181
|
};
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
svg.
|
|
100
|
-
|
|
101
|
-
|
|
182
|
+
const serialized = shape => ({ id: shape.dataset.id, type: shape.dataset.type, x: Number(shape.dataset.x), y: Number(shape.dataset.y), width: Number(shape.dataset.width), height: Number(shape.dataset.height), fill_color: shape.dataset.fill, stroke_color: shape.dataset.stroke, stroke_width: Number(shape.dataset.strokeWidth), opacity: Number(shape.dataset.opacity), locked: shape.dataset.locked === "true", points: pointStrings(shape), options: JSON.parse(shape.dataset.options || "{}") });
|
|
183
|
+
const refreshMirrors = () => {
|
|
184
|
+
svg.querySelector("#fx-mirrors")?.remove(); refreshMasks();
|
|
185
|
+
const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); group.id = "fx-mirrors"; group.setAttribute("pointer-events", "none");
|
|
186
|
+
shapes().forEach(shape => {
|
|
187
|
+
const options = JSON.parse(shape.dataset.options || "{}"); if (!options.symmetry || options.symmetry === "none") return;
|
|
188
|
+
const count = Math.max(2, Math.min(24, Number(options.count) || 2)), cx = Number(shape.dataset.x) + Number(shape.dataset.width) / 2, cy = Number(shape.dataset.y) + Number(shape.dataset.height) / 2;
|
|
189
|
+
for (let i = 1; i < count; i++) { const clone = shape.cloneNode(true); clone.classList.remove("canvas-shape", "is-selected"); clone.removeAttribute("data-id");
|
|
190
|
+
clone.setAttribute("transform", options.symmetry === "radial" ? `rotate(${360*i/count} ${cx} ${cy})` : `rotate(${180*i/count} ${cx} ${cy}) translate(${2*cx} 0) scale(-1 1)`); group.appendChild(clone); }
|
|
191
|
+
});
|
|
192
|
+
svg.appendChild(group);
|
|
193
|
+
};
|
|
194
|
+
const snapshot = () => JSON.stringify(shapes().map(serialized));
|
|
195
|
+
const updateHistoryButtons = () => { document.getElementById("undo").disabled = historyIndex <= 0; document.getElementById("redo").disabled = historyIndex >= history.length - 1; };
|
|
196
|
+
const recordHistory = () => { const state = snapshot(); if (history[historyIndex] === state) return; history = history.slice(0, historyIndex + 1); history.push(state); if (history.length > 50) history.shift(); historyIndex = history.length - 1; updateHistoryButtons(); };
|
|
197
|
+
const drawPointHandles = () => {
|
|
198
|
+
svg.querySelector(".point-handles")?.remove();
|
|
199
|
+
const shape = selectedIds.size === 1 ? byId([...selectedIds][0]) : null;
|
|
200
|
+
const supportsPoints = ["line", "freeformline", "bezierline", "polygon"].includes(shape?.dataset.type);
|
|
201
|
+
pointHelp.classList.toggle("is-hidden", !supportsPoints);
|
|
202
|
+
if (!shape || !supportsPoints) return;
|
|
203
|
+
const group = document.createElementNS("http://www.w3.org/2000/svg", "g"); group.classList.add("point-handles");
|
|
204
|
+
parsePoints(shape).forEach((p, index) => { const handle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); handle.setAttribute("cx", p.x); handle.setAttribute("cy", p.y); handle.setAttribute("r", "7"); handle.dataset.pointIndex = index; handle.classList.add("point-handle"); group.appendChild(handle); });
|
|
205
|
+
svg.appendChild(group);
|
|
206
|
+
};
|
|
207
|
+
const tree = document.getElementById("element-tree");
|
|
208
|
+
const refreshTree = () => {
|
|
209
|
+
const currentIds = new Set(shapes().map(shape => shape.dataset.id));
|
|
210
|
+
tree.querySelectorAll("[data-select-element]").forEach(button => { if (!currentIds.has(resolveId(button.dataset.selectElement))) button.remove(); });
|
|
211
|
+
shapes().forEach(shape => {
|
|
212
|
+
if (tree.querySelector(`[data-select-element="${shape.dataset.id}"]`)) return;
|
|
213
|
+
const button = document.createElement("button"); button.type = "button"; button.dataset.selectElement = shape.dataset.id; button.className = "tree-item";
|
|
214
|
+
const label = document.createElement("span"); label.textContent = shape.dataset.name || `${shape.dataset.type[0].toUpperCase()}${shape.dataset.type.slice(1)}`;
|
|
215
|
+
const id = document.createElement("span"); id.className = "has-text-grey is-size-7"; id.textContent = `#${shape.dataset.id}`;
|
|
216
|
+
button.append(label, id); tree.appendChild(button);
|
|
217
|
+
});
|
|
218
|
+
tree.querySelectorAll("[data-select-element]").forEach(button => button.classList.toggle("is-active", selectedIds.has(resolveId(button.dataset.selectElement))));
|
|
219
|
+
const treeEmpty = document.getElementById("tree-empty"); if (treeEmpty) treeEmpty.classList.toggle("is-hidden", currentIds.size > 0);
|
|
220
|
+
};
|
|
221
|
+
const select = ids => {
|
|
222
|
+
selectedIds = new Set([...ids].map(resolveId));
|
|
223
|
+
shapes().forEach(shape => shape.classList.toggle("is-selected", selectedIds.has(shape.dataset.id)));
|
|
224
|
+
const shape = selectedIds.size === 1 ? byId([...selectedIds][0]) : null;
|
|
225
|
+
inspector.classList.toggle("is-hidden", !shape); empty.classList.toggle("is-hidden", !!shape);
|
|
226
|
+
if (shape) Object.entries(fields).forEach(([key, field]) => { field.value = key === "fill_color" ? shape.dataset.fill : shape.dataset[key]; });
|
|
227
|
+
if (shape) document.getElementById("lock-element").textContent = shape.dataset.locked === "true" ? "Unlock" : "Lock";
|
|
228
|
+
refreshTree(); drawPointHandles();
|
|
102
229
|
};
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (!response.ok) return;
|
|
107
|
-
const
|
|
230
|
+
const api = async (url, method, data) => fetch(url, { method, headers: { "Content-Type": "application/json", "X-CSRF-Token": token, Accept: "application/json" }, ...(data ? { body: JSON.stringify({ element: data }) } : {}) });
|
|
231
|
+
const save = async (shape, attrs, record = true) => {
|
|
232
|
+
Object.entries(attrs).forEach(([key, value]) => { shape.dataset[key === "fill_color" ? "fill" : key === "stroke_color" ? "stroke" : key === "stroke_width" ? "strokeWidth" : key] = Array.isArray(value) ? JSON.stringify(value) : value; });
|
|
233
|
+
const response = await api(updateUrl(shape.dataset.id), "PATCH", attrs); if (!response.ok) return;
|
|
234
|
+
const fresh = render(shape); shape.replaceWith(fresh); refreshMirrors(); select(selectedIds); if (record) recordHistory();
|
|
108
235
|
};
|
|
109
|
-
|
|
236
|
+
const createElement = async (data, historyChange = true) => {
|
|
237
|
+
const response = await api(svg.dataset.createUrl, "POST", data); if (!response.ok) return null;
|
|
238
|
+
const saved = await response.json(), source = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
239
|
+
Object.assign(source.dataset, { id: saved.id, type: saved.type.split("::").pop().toLowerCase(), x: saved.x, y: saved.y, width: saved.width, height: saved.height, fill: saved.fill_color, stroke: saved.stroke_color, strokeWidth: saved.stroke_width, opacity: saved.opacity, locked: saved.locked, points: JSON.stringify(saved.points || data.points || []), name: saved.name || data.name || "", options: JSON.stringify(saved.options || data.options || {}) });
|
|
240
|
+
const shape = render(source); svg.querySelector("#fx-mirrors")?.remove(); svg.appendChild(shape); refreshMirrors(); select([shape.dataset.id]); if (historyChange) recordHistory(); return shape;
|
|
241
|
+
};
|
|
242
|
+
const deleteShape = async shape => { await api(updateUrl(shape.dataset.id), "DELETE"); shape.remove(); selectedIds.delete(shape.dataset.id); refreshMirrors(); };
|
|
243
|
+
const restoreSnapshot = async state => {
|
|
244
|
+
const target = JSON.parse(state), targetIds = new Set(target.map(item => resolveId(item.id)));
|
|
245
|
+
for (const shape of shapes()) if (!targetIds.has(shape.dataset.id)) await deleteShape(shape);
|
|
246
|
+
for (const item of target) {
|
|
247
|
+
let shape = byId(item.id);
|
|
248
|
+
const attrs = { x: item.x, y: item.y, width: item.width, height: item.height, fill_color: item.fill_color, stroke_color: item.stroke_color, stroke_width: item.stroke_width, opacity: item.opacity, locked: item.locked, points: item.points, options: item.options };
|
|
249
|
+
if (!shape) {
|
|
250
|
+
const created = await createElement({ ...attrs, type: `ShareSpace::${item.type[0].toUpperCase()}${item.type.slice(1)}`, visible: true }, false);
|
|
251
|
+
if (created && String(item.id) !== created.dataset.id) idRemap.set(String(item.id), created.dataset.id);
|
|
252
|
+
} else await save(shape, attrs, false);
|
|
253
|
+
}
|
|
254
|
+
select([]); drawPointHandles();
|
|
255
|
+
};
|
|
256
|
+
const undo = async () => { if (historyIndex <= 0) return; historyIndex -= 1; await restoreSnapshot(history[historyIndex]); updateHistoryButtons(); };
|
|
257
|
+
const redo = async () => { if (historyIndex >= history.length - 1) return; historyIndex += 1; await restoreSnapshot(history[historyIndex]); updateHistoryButtons(); };
|
|
258
|
+
const showMenu = (event, element) => {
|
|
259
|
+
event.preventDefault();
|
|
260
|
+
if (element) select([element.dataset.id]);
|
|
261
|
+
const actions = element ? [["copy", "Copy"], ["paste", "Paste"], ["delete", "Delete"], ["lock", element.dataset.locked === "true" ? "Unlock" : "Lock"]] : [["select-all", "Select all"], ["undo", "Undo"], ["redo", "Redo"], ["paste", "Paste"]];
|
|
262
|
+
menu.replaceChildren(...actions.map(([action, label]) => { const button = document.createElement("button"); button.type = "button"; button.dataset.action = action; button.textContent = label; button.disabled = (action === "undo" && historyIndex <= 0) || (action === "redo" && historyIndex >= history.length - 1) || (action === "paste" && !clipboard); return button; }));
|
|
263
|
+
menu.style.left = `${event.clientX}px`; menu.style.top = `${event.clientY}px`; menu.classList.remove("is-hidden");
|
|
264
|
+
};
|
|
265
|
+
const paste = async () => { if (!clipboard) return; const data = { ...clipboard, type: `ShareSpace::${modelType(clipboard.type)}`, x: Number(clipboard.x) + 20, y: Number(clipboard.y) + 20, points: clipboard.points.map(value => { const [x,y] = value.split(",").map(Number); return `${x+20},${y+20}`; }) }; delete data.id; delete data.locked; await createElement(data); };
|
|
266
|
+
shapes().forEach(shape => shape.replaceWith(render(shape))); refreshMirrors();
|
|
267
|
+
history = [snapshot()]; historyIndex = 0; updateHistoryButtons();
|
|
110
268
|
svg.addEventListener("pointerdown", event => {
|
|
111
|
-
const
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
269
|
+
const handle = event.target.closest(".point-handle");
|
|
270
|
+
if (handle) { const shape = byId([...selectedIds][0]), points = parsePoints(shape), p = localPoint(event); drag = { kind: "point", shape, index: Number(handle.dataset.pointIndex), points }; svg.setPointerCapture(event.pointerId); event.preventDefault(); return; }
|
|
271
|
+
const el = event.target.closest(".canvas-shape"); if (tool !== "select") return;
|
|
272
|
+
if (!el) { select([]); return; }
|
|
273
|
+
select([el.dataset.id]); if (el.dataset.locked === "true") return;
|
|
274
|
+
const p = localPoint(event); drag = { kind: "element", el, x: p.x, y: p.y, startX: Number(el.dataset.x), startY: Number(el.dataset.y), points: parsePoints(el) }; svg.setPointerCapture(event.pointerId);
|
|
275
|
+
});
|
|
276
|
+
svg.addEventListener("pointermove", event => {
|
|
277
|
+
if (!drag) return; const p = localPoint(event);
|
|
278
|
+
if (drag.kind === "draw") { const last = drag.points[drag.points.length - 1]; if (Math.hypot(p.x-last.x, p.y-last.y) > 3) drag.points.push(p); drag.preview.dataset.points = JSON.stringify(drag.points.map(point => `${point.x},${point.y}`)); const fresh = render(drag.preview); drag.preview.replaceWith(fresh); drag.preview = fresh; }
|
|
279
|
+
else if (drag.kind === "draw-shape") { drag.end = p; updatePreview(drag); }
|
|
280
|
+
else if (drag.kind === "point") { drag.points[drag.index] = { x: p.x, y: p.y }; drag.shape.dataset.points = JSON.stringify(drag.points.map(point => `${point.x},${point.y}`)); const fresh = render(drag.shape); drag.shape.replaceWith(fresh); drag.shape = fresh; select([fresh.dataset.id]); }
|
|
281
|
+
else { const dx = p.x - drag.x, dy = p.y - drag.y; drag.el.dataset.x = drag.startX + dx; drag.el.dataset.y = drag.startY + dy; if (drag.el.dataset.type === "line") drag.el.dataset.points = JSON.stringify(drag.points.map(point => `${point.x+dx},${point.y+dy}`)); const fresh = render(drag.el); drag.el.replaceWith(fresh); drag.el = fresh; select([fresh.dataset.id]); }
|
|
115
282
|
});
|
|
116
|
-
|
|
117
|
-
svg.addEventListener("pointerup", () => { if (drag)
|
|
118
|
-
|
|
283
|
+
let suppressClick = false;
|
|
284
|
+
svg.addEventListener("pointerup", () => { if (!drag) return; let action;
|
|
285
|
+
if (drag.kind === "draw") { suppressClick = true; drag.preview.remove(); action = createElement({ type: "ShareSpace::FreeformLine", x: Math.min(...drag.points.map(p => p.x)), y: Math.min(...drag.points.map(p => p.y)), width: 1, height: 1, fill_color: "#48c78e", stroke_color: "#257953", stroke_width: 2, opacity: 1, visible: true, locked: false, points: drag.points.map(p => `${p.x},${p.y}`), options: {} }); }
|
|
286
|
+
else if (drag.kind === "draw-shape") { suppressClick = true; updatePreview(drag, true); drag.preview.remove(); action = createElement(drag.data); }
|
|
287
|
+
else if (drag.kind === "point") action = save(drag.shape, { points: pointStrings(drag.shape) });
|
|
288
|
+
else action = save(drag.el, { x: Number(drag.el.dataset.x), y: Number(drag.el.dataset.y), ...(drag.el.dataset.type === "line" ? { points: pointStrings(drag.el) } : {}) });
|
|
289
|
+
drag = null; action;
|
|
290
|
+
});
|
|
291
|
+
const setTool = value => { tool = value; svg.dataset.tool = value; svg.dataset.cursor = value === "select" ? "canvas" : "draw"; document.querySelectorAll("[data-draw]").forEach(item => item.classList.toggle("is-primary", item.dataset.draw === value)); };
|
|
292
|
+
document.querySelectorAll("[data-draw]").forEach(button => button.addEventListener("click", () => setTool(button.dataset.draw)));
|
|
293
|
+
svg.addEventListener("pointermove", event => {
|
|
294
|
+
if (drag) return;
|
|
295
|
+
if (tool !== "select") { svg.dataset.cursor = "draw"; return; }
|
|
296
|
+
const handle = event.target.closest(".point-handle"), shape = event.target.closest(".canvas-shape");
|
|
297
|
+
svg.dataset.cursor = handle ? "point" : shape?.dataset.locked === "true" ? "locked" : shape ? "element" : "canvas";
|
|
298
|
+
});
|
|
299
|
+
svg.addEventListener("pointerleave", () => { if (!drag) svg.dataset.cursor = "canvas"; });
|
|
300
|
+
svg.addEventListener("pointerdown", () => { if (drag) svg.dataset.cursor = "dragging"; });
|
|
119
301
|
svg.addEventListener("click", async event => {
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
302
|
+
if (suppressClick) { suppressClick = false; return; }
|
|
303
|
+
if (tool === "select" || event.target.closest(".canvas-shape,.point-handle")) return;
|
|
304
|
+
const p = localPoint(event), type = tool, width = type.includes("line") ? 120 : 140, height = type.includes("line") ? 80 : 90;
|
|
305
|
+
const center = { x: p.x + width / 2, y: p.y + height / 2 }, sides = Math.max(3, Math.min(64, Number(document.getElementById("polygon-sides").value) || 5));
|
|
306
|
+
let points = [];
|
|
307
|
+
if (type === "line" || type === "freeform_line") points = [`${p.x},${p.y}`, `${p.x+width},${p.y+height}`];
|
|
308
|
+
if (type === "bezier_line") points = [`${p.x},${p.y+height}`, `${p.x+width/3},${p.y}`, `${p.x+2*width/3},${p.y+height}`, `${p.x+width},${p.y}`];
|
|
309
|
+
if (type === "polygon") points = Array.from({ length: sides }, (_, i) => `${center.x + Math.cos(2*Math.PI*i/sides)*width/2},${center.y + Math.sin(2*Math.PI*i/sides)*height/2}`);
|
|
310
|
+
if (type === "mask") points = [];
|
|
311
|
+
await createElement({ type: `ShareSpace::${modelType(type)}`, x: p.x, y: p.y, width, height, fill_color: "#48c78e", stroke_color: "#257953", stroke_width: 2, opacity: 1, visible: true, locked: false, points, options: {} });
|
|
312
|
+
setTool("select");
|
|
313
|
+
});
|
|
314
|
+
svg.addEventListener("pointerdown", event => {
|
|
315
|
+
if (tool === "select" || event.target.closest(".canvas-shape,.point-handle")) return;
|
|
316
|
+
const p = localPoint(event), preview = document.createElementNS("http://www.w3.org/2000/svg", "g"), type = tool;
|
|
317
|
+
Object.assign(preview.dataset, { type: modelType(type).toLowerCase(), id: "preview", x: p.x, y: p.y, width: 1, height: 1, points: "[]", fill: "#48c78e", stroke: "#257953", strokeWidth: 2, opacity: .48, locked: "false", options: "{}" });
|
|
318
|
+
const shape = render(preview); shape.classList.add("is-preview"); svg.appendChild(shape);
|
|
319
|
+
const state = { kind: type === "freeform_line" ? "draw" : "draw-shape", type, start: p, end: p, points: [p], preview: shape };
|
|
320
|
+
drag = state; if (state.kind === "draw-shape") updatePreview(state); svg.setPointerCapture(event.pointerId); event.preventDefault();
|
|
321
|
+
});
|
|
322
|
+
function updatePreview(state, finalize = false) {
|
|
323
|
+
let { x, y, width, height } = bounds(state.start, state.end), points = [], type = state.type;
|
|
324
|
+
if (Math.abs(width) < 1 && Math.abs(height) < 1) { width = 140; height = 90; }
|
|
325
|
+
const sides = Math.max(3, Math.min(64, Number(document.getElementById("polygon-sides").value) || 5)), cx = x + width / 2, cy = y + height / 2;
|
|
326
|
+
if (["line", "freeform_line"].includes(type)) points = [`${state.start.x},${state.start.y}`, `${state.end.x},${state.end.y}`];
|
|
327
|
+
if (type === "bezier_line") points = [`${x},${y+height}`, `${x+width/3},${y}`, `${x+2*width/3},${y+height}`, `${x+width},${y}`];
|
|
328
|
+
if (type === "polygon") points = Array.from({ length: sides }, (_, i) => `${cx + Math.cos(2*Math.PI*i/sides)*width/2},${cy + Math.sin(2*Math.PI*i/sides)*height/2}`);
|
|
329
|
+
const data = { type: `ShareSpace::${modelType(type)}`, x, y, width, height, fill_color: "#48c78e", stroke_color: "#257953", stroke_width: 2, opacity: 1, visible: true, locked: false, points, options: {} };
|
|
330
|
+
state.data = data; Object.assign(state.preview.dataset, { type: modelType(type).toLowerCase(), x, y, width, height, points: JSON.stringify(points) });
|
|
331
|
+
if (finalize) return;
|
|
332
|
+
const fresh = render(state.preview); fresh.classList.add("is-preview"); state.preview.replaceWith(fresh); state.preview = fresh;
|
|
333
|
+
}
|
|
334
|
+
function bounds(a, b) { return { x: Math.min(a.x,b.x), y: Math.min(a.y,b.y), width: Math.abs(a.x-b.x), height: Math.abs(a.y-b.y) }; }
|
|
335
|
+
tree.addEventListener("click", event => { const button = event.target.closest("[data-select-element]"); if (button) select([button.dataset.selectElement]); });
|
|
336
|
+
Object.entries(fields).forEach(([key, field]) => field.addEventListener("change", () => { const shape = selectedIds.size === 1 ? byId([...selectedIds][0]) : null; if (shape) save(shape, { [key]: key === "fill_color" ? field.value : Number(field.value) }); }));
|
|
337
|
+
document.getElementById("delete-element").addEventListener("click", async () => { for (const id of [...selectedIds]) { const shape = byId(id); if (shape) await deleteShape(shape); } select([]); recordHistory(); });
|
|
338
|
+
document.getElementById("lock-element").addEventListener("click", async () => { const shape = selectedIds.size === 1 ? byId([...selectedIds][0]) : null; if (shape) save(shape, { locked: shape.dataset.locked !== "true" }); });
|
|
339
|
+
document.getElementById("apply-fx").addEventListener("click", async () => {
|
|
340
|
+
const symmetry = document.getElementById("fx-symmetry").value, count = Number(document.getElementById("fx-count").value) || 2;
|
|
341
|
+
const rotate = Number(document.getElementById("fx-rotate").value) || 0, skew = Number(document.getElementById("fx-skew").value) || 0, shadow = document.getElementById("fx-shadow").checked;
|
|
342
|
+
for (const id of selectedIds) { const shape = byId(id); if (!shape) continue; const options = { ...JSON.parse(shape.dataset.options || "{}"), symmetry, count, rotate, skew, shadow }; await save(shape, { options }); }
|
|
343
|
+
});
|
|
344
|
+
document.getElementById("undo").addEventListener("click", undo); document.getElementById("redo").addEventListener("click", redo);
|
|
345
|
+
svg.addEventListener("contextmenu", event => showMenu(event, event.target.closest(".canvas-shape")));
|
|
346
|
+
menu.addEventListener("click", async event => { const action = event.target.dataset.action; if (!action) return; const shape = selectedIds.size === 1 ? byId([...selectedIds][0]) : null; menu.classList.add("is-hidden");
|
|
347
|
+
if (action === "select-all") select(shapes().map(item => item.dataset.id));
|
|
348
|
+
if (action === "undo") await undo(); if (action === "redo") await redo();
|
|
349
|
+
if (action === "copy" && shape) clipboard = serialized(shape);
|
|
350
|
+
if (action === "paste") await paste();
|
|
351
|
+
if (action === "delete" && shape) { await deleteShape(shape); select([]); recordHistory(); }
|
|
352
|
+
if (action === "lock" && shape) await save(shape, { locked: shape.dataset.locked !== "true" });
|
|
353
|
+
});
|
|
354
|
+
document.addEventListener("click", event => { if (!menu.contains(event.target)) menu.classList.add("is-hidden"); });
|
|
355
|
+
document.addEventListener("keydown", event => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") { event.preventDefault(); event.shiftKey ? redo() : undo(); } else if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "c" && selectedIds.size === 1) { const shape = byId([...selectedIds][0]); if (shape) clipboard = serialized(shape); } else if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "v") paste(); else if (event.key === "Escape") menu.classList.add("is-hidden"); });
|
|
356
|
+
document.getElementById("export-canvas").addEventListener("click", async event => {
|
|
357
|
+
const button = event.currentTarget, format = document.getElementById("export-format").value; button.classList.add("is-loading");
|
|
358
|
+
try {
|
|
359
|
+
const clone = svg.cloneNode(true); clone.querySelectorAll(".point-handles,#fx-mirrors").forEach(node => node.remove()); clone.querySelectorAll(".canvas-shape").forEach(node => node.classList.remove("is-selected"));
|
|
360
|
+
const source = new XMLSerializer().serializeToString(clone), svgBlob = new Blob([source], { type: "image/svg+xml;charset=utf-8" });
|
|
361
|
+
if (format === "pdf") { const url = URL.createObjectURL(svgBlob), win = window.open(url, "_blank"); if (win) { win.addEventListener("load", () => win.print()); } else window.print(); setTimeout(() => URL.revokeObjectURL(url), 60_000); return; }
|
|
362
|
+
let blob = svgBlob;
|
|
363
|
+
if (format !== "svg") { const image = new Image(), url = URL.createObjectURL(svgBlob); image.src = url; await image.decode(); const canvas = document.createElement("canvas"); canvas.width = Number(svg.dataset.canvasWidth); canvas.height = Number(svg.dataset.canvasHeight); const ctx = canvas.getContext("2d"); if (format === "jpeg") { ctx.fillStyle = "#fff"; ctx.fillRect(0, 0, canvas.width, canvas.height); } ctx.drawImage(image, 0, 0); URL.revokeObjectURL(url); blob = await new Promise(resolve => canvas.toBlob(resolve, `image/${format}`, .95)); }
|
|
364
|
+
const url = URL.createObjectURL(blob), link = document.createElement("a"); link.href = url; link.download = `${document.title.replace(/[^a-z0-9_-]+/gi, "-")}.${format}`; link.click(); setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
365
|
+
} catch (error) { console.error("Canvas export failed", error); alert("The canvas could not be exported in that format."); }
|
|
366
|
+
finally { button.classList.remove("is-loading"); }
|
|
367
|
+
});
|
|
368
|
+
function localPoint(event) { const p = svg.createSVGPoint(); p.x = event.clientX; p.y = event.clientY; return p.matrixTransform(svg.getScreenCTM().inverse()); }
|
|
369
|
+
const clientId = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
370
|
+
const cableUrl = `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/cable`;
|
|
371
|
+
let socket, lastCursorAt = 0;
|
|
372
|
+
const connectCollaboration = () => {
|
|
373
|
+
socket = new WebSocket(cableUrl);
|
|
374
|
+
socket.addEventListener("open", () => socket.send(JSON.stringify({ command: "subscribe", identifier: JSON.stringify({ channel: "ShareSpace::CanvasChannel", canvas_id: svg.dataset.canvasId, client_id: clientId }) })));
|
|
375
|
+
socket.addEventListener("message", event => {
|
|
376
|
+
let frame; try { frame = JSON.parse(event.data); } catch (_) { return; }
|
|
377
|
+
if (frame.type === "ping" || frame.type === "welcome" || frame.type === "confirm_subscription") return;
|
|
378
|
+
const message = frame.message; if (!message) return;
|
|
379
|
+
if (message.type === "cursor") return updateRemoteCursor(message);
|
|
380
|
+
if (message.type !== "element" || String(message.element?.id) === "undefined") return;
|
|
381
|
+
const element = message.element, id = String(element.id);
|
|
382
|
+
if (message.action === "delete") { byId(id)?.remove(); selectedIds.delete(id); select(selectedIds); }
|
|
383
|
+
else {
|
|
384
|
+
let shape = byId(id); const source = shape ? shape.cloneNode(false) : document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
385
|
+
Object.assign(source.dataset, { id, type: element.type.split("::").pop().toLowerCase(), x: element.x, y: element.y, width: element.width, height: element.height, fill: element.fill_color || "#48c78e", stroke: element.stroke_color || "#257953", strokeWidth: element.stroke_width || 2, opacity: element.opacity, locked: element.locked, points: JSON.stringify(element.points || []), options: JSON.stringify(element.options || {}), name: element.name || "" });
|
|
386
|
+
const fresh = render(source); if (shape) shape.replaceWith(fresh); else svg.appendChild(fresh); refreshMirrors();
|
|
387
|
+
}
|
|
388
|
+
refreshTree(); recordHistory();
|
|
389
|
+
});
|
|
390
|
+
};
|
|
391
|
+
const cursors = new Map();
|
|
392
|
+
function updateRemoteCursor(message) {
|
|
393
|
+
const id = String(message.client_id || ""); if (!id || id === clientId) return;
|
|
394
|
+
let marker = cursors.get(id);
|
|
395
|
+
if (message.active === false) { marker?.remove(); cursors.delete(id); return; }
|
|
396
|
+
if (!marker) {
|
|
397
|
+
marker = document.createElementNS("http://www.w3.org/2000/svg", "g"); marker.classList.add("remote-cursor");
|
|
398
|
+
const arrow = document.createElementNS("http://www.w3.org/2000/svg", "path"); arrow.setAttribute("d", "M 0 0 L 0 18 L 5 13 L 10 22 L 14 20 L 9 11 L 16 10 Z"); arrow.classList.add("cursor-arrow");
|
|
399
|
+
const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); label.setAttribute("x", "18"); label.setAttribute("y", "17"); label.textContent = `Collaborator ${cursors.size + 1}`;
|
|
400
|
+
marker.append(arrow, label); svg.appendChild(marker); cursors.set(id, marker);
|
|
401
|
+
}
|
|
402
|
+
marker.setAttribute("transform", `translate(${message.x} ${message.y})`);
|
|
403
|
+
marker.children[1].textContent = `Collaborator ${[...cursors.keys()].indexOf(id) + 1} · ${Math.round(message.x)}, ${Math.round(message.y)}`;
|
|
404
|
+
}
|
|
405
|
+
connectCollaboration();
|
|
406
|
+
svg.addEventListener("pointermove", event => {
|
|
407
|
+
const p = localPoint(event); document.getElementById("cursor-position").textContent = `X ${Math.round(p.x)} · Y ${Math.round(p.y)}`;
|
|
408
|
+
if (!socket || socket.readyState !== WebSocket.OPEN || event.timeStamp - lastCursorAt < 50) return;
|
|
409
|
+
lastCursorAt = event.timeStamp; const point = p;
|
|
410
|
+
socket.send(JSON.stringify({ command: "message", identifier: JSON.stringify({ channel: "ShareSpace::CanvasChannel", canvas_id: svg.dataset.canvasId, client_id: clientId }), data: JSON.stringify({ type: "cursor", client_id: clientId, x: point.x, y: point.y }) }));
|
|
128
411
|
});
|
|
129
|
-
|
|
130
|
-
document.getElementById("delete-element").addEventListener("click", async () => { if (!selected) return; await fetch(urlFor(selected.dataset.id), { method: "DELETE", headers: { "X-CSRF-Token": token } }); selected.remove(); select(null); });
|
|
412
|
+
window.addEventListener("beforeunload", () => { if (socket?.readyState === WebSocket.OPEN) socket.close(); });
|
|
131
413
|
})();
|
data/lib/share_space/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: share_space
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- paydaypay1
|
|
@@ -50,6 +50,7 @@ files:
|
|
|
50
50
|
- README.md
|
|
51
51
|
- Rakefile
|
|
52
52
|
- app/assets/stylesheets/share_space/application.css
|
|
53
|
+
- app/channels/share_space/canvas_channel.rb
|
|
53
54
|
- app/controllers/share_space/application_controller.rb
|
|
54
55
|
- app/controllers/share_space/canvas_elements_controller.rb
|
|
55
56
|
- app/controllers/share_space/canvases_controller.rb
|
|
@@ -57,11 +58,18 @@ files:
|
|
|
57
58
|
- app/jobs/share_space/application_job.rb
|
|
58
59
|
- app/mailers/share_space/application_mailer.rb
|
|
59
60
|
- app/models/share_space/application_record.rb
|
|
61
|
+
- app/models/share_space/bezier_line.rb
|
|
60
62
|
- app/models/share_space/canvas.rb
|
|
61
63
|
- app/models/share_space/canvas_element.rb
|
|
64
|
+
- app/models/share_space/cube.rb
|
|
65
|
+
- app/models/share_space/cylinder.rb
|
|
62
66
|
- app/models/share_space/ellipse.rb
|
|
67
|
+
- app/models/share_space/freeform_line.rb
|
|
63
68
|
- app/models/share_space/line.rb
|
|
69
|
+
- app/models/share_space/mask.rb
|
|
70
|
+
- app/models/share_space/polygon.rb
|
|
64
71
|
- app/models/share_space/rectangle.rb
|
|
72
|
+
- app/models/share_space/sphere.rb
|
|
65
73
|
- app/views/layouts/share_space/application.html.haml
|
|
66
74
|
- app/views/share_space/canvases/_form.html.haml
|
|
67
75
|
- app/views/share_space/canvases/edit.html.haml
|
|
@@ -71,6 +79,7 @@ files:
|
|
|
71
79
|
- config/routes.rb
|
|
72
80
|
- db/migrate/20261027225958_create_share_space_canvas.rb
|
|
73
81
|
- db/migrate/20261027231337_create_canvas_element.rb
|
|
82
|
+
- db/migrate/20261028010000_add_options_to_share_space_canvas_elements.rb
|
|
74
83
|
- lib/share_space.rb
|
|
75
84
|
- lib/share_space/engine.rb
|
|
76
85
|
- lib/share_space/version.rb
|