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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 32915e8b4741eecd245a29eee960ab1484b707f79a3c445915094f61194b4c4e
4
- data.tar.gz: 867829aafa5531cd2bbedf52f6899076ba8edd870633673357ac3cdcde76c989
3
+ metadata.gz: 7536fadda6c04dc84d07ab563818139749efd431303aa20d6e0a0b5edf14c64b
4
+ data.tar.gz: 9b7f8fb94ca761b2ae2fb683590e58b81826f5175410fa7df44f15eefdfc4300
5
5
  SHA512:
6
- metadata.gz: a066416f1c20fe267023465dce138447f283257007fd56da4ebf639016d15a6f2002d0e5917e690a9cb81c5b207886049d17dd249541e80935d37c4c293b6c3b
7
- data.tar.gz: b4b1b0262dc2fd138e7fe32726ad6f7c7f042644791fbcf360ecc344cf4762ae0c3f1700c3e64af220c3847fe8e7b5a4110e1ab47b76a6f19048df339bc7d03f
6
+ metadata.gz: 53aabcb2e9825715e2462149a81ad44f1384255db6fd0082b05ca21d8653a933551d4f48f835496cc4fae9ec044a5e99acd008b85d4c6f1f454931b051326f71
7
+ data.tar.gz: abb5a654c152979dadf69f41fd68dbb77e13a3ed6423c1d42ef58a05d9de3f7a55d44bf126229b709ff7bd4527b847eca8b44090e61b0c7741a8f408a9442e20
data/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # ShareSpace
2
- Short description and motivation.
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
- .canvas-shape { cursor: grab; }
24
- .canvas-shape:active { cursor: grabbing; }
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
- .toolbox { width: 100%; }
30
- .toolbox .buttons { flex-direction: row !important; }
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
- render json: element_json(@element), status: :created
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
- render json: element_json(@element)
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
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class BezierLine < CanvasElement
3
+ validates :points, length: { is: 4 }
4
+ end
5
+ 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
 
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class Cube < CanvasElement
3
+ validates :width, :height, numericality: { greater_than: 0 }
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class Cylinder < CanvasElement
3
+ validates :width, :height, numericality: { greater_than: 0 }
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class FreeformLine < CanvasElement
3
+ validates :points, presence: true
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class Mask < CanvasElement
3
+ validates :width, :height, numericality: { greater_than: 0 }
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class Polygon < CanvasElement
3
+ validates :points, presence: true
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class Sphere < CanvasElement
3
+ validates :width, :height, numericality: { greater_than: 0 }
4
+ end
5
+ end
@@ -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
- = link_to "Settings", edit_canvas_path(@canvas), class: "button is-light"
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.toolbox
13
- %p.menu-label Add element
14
- .buttons.are-small.is-flex-direction-column.is-align-items-stretch
15
- %button.button.is-primary{ type: "button", data: { draw: "rectangle" } } Rectangle
16
- %button.button{ type: "button", data: { draw: "ellipse" } } Ellipse
17
- %button.button{ type: "button", data: { draw: "line" } } Line
18
- %button.button{ type: "button", data: { draw: "select" } } Select / move
19
- %hr
20
- %p.menu-label Selected element
21
- %p#selection-empty.has-text-grey.is-size-7 Click a shape to edit its properties.
22
- #inspector.is-hidden
23
- .field
24
- %label.label X
25
- .control= number_field_tag :element_x, nil, class: "input is-small", id: "element-x", step: "any"
26
- .field
27
- %label.label Y
28
- .control= number_field_tag :element_y, nil, class: "input is-small", id: "element-y", step: "any"
29
- .field
30
- %label.label Width
31
- .control= number_field_tag :element_width, nil, class: "input is-small", id: "element-width", min: 1, step: "any"
32
- .field
33
- %label.label Height
34
- .control= number_field_tag :element_height, nil, class: "input is-small", id: "element-height", min: 1, step: "any"
35
- .field
36
- %label.label Fill
37
- .control= color_field_tag :element_fill_color, "#48c78e", class: "input is-small canvas-color", id: "element-fill"
38
- .buttons
39
- %button#delete-element.button.is-danger.is-light.is-small{ type: "button" } Delete
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
- - if element.is_a?(Line)
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
- let tool = "select", selected = null, drag = null;
70
- const inspector = document.getElementById("inspector");
71
- const empty = document.getElementById("selection-empty");
72
- const fields = { x: document.getElementById("element-x"), y: document.getElementById("element-y"),
73
- width: document.getElementById("element-width"), height: document.getElementById("element-height"),
74
- fill_color: document.getElementById("element-fill") };
75
- const urlFor = id => svg.dataset.updateUrl.replace("ELEMENT_ID", id);
76
- const localPoint = event => {
77
- const p = svg.createSVGPoint(); p.x = event.clientX; p.y = event.clientY;
78
- return p.matrixTransform(svg.getScreenCTM().inverse());
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 payload = el => ({ x: Number(el.dataset.x), y: Number(el.dataset.y), width: Number(el.dataset.width),
81
- height: Number(el.dataset.height), fill_color: el.dataset.fill });
82
- const render = el => {
83
- const x = Number(el.dataset.x), y = Number(el.dataset.y), w = Number(el.dataset.width), h = Number(el.dataset.height);
84
- const shape = el.dataset.type === "ellipse" ? document.createElementNS("http://www.w3.org/2000/svg", "ellipse") :
85
- el.dataset.type === "line" ? document.createElementNS("http://www.w3.org/2000/svg", "line") :
86
- document.createElementNS("http://www.w3.org/2000/svg", "rect");
87
- shape.dataset.id = el.dataset.id; shape.dataset.type = el.dataset.type;
88
- Object.assign(shape.dataset, { x, y, width: w, height: h, fill: el.dataset.fill, stroke: el.dataset.stroke || "#257953", locked: el.dataset.locked || "false" });
89
- shape.setAttribute("fill", el.dataset.type === "line" ? "none" : el.dataset.fill);
90
- shape.setAttribute("stroke", el.dataset.stroke || "#257953"); shape.setAttribute("stroke-width", el.dataset.strokeWidth || 2);
91
- if (el.dataset.type === "ellipse") { shape.setAttribute("cx", x + w / 2); shape.setAttribute("cy", y + h / 2); shape.setAttribute("rx", w / 2); shape.setAttribute("ry", h / 2); }
92
- else if (el.dataset.type === "line") { shape.setAttribute("x1", x); shape.setAttribute("y1", y); shape.setAttribute("x2", x + w); shape.setAttribute("y2", y + h); }
93
- else { shape.setAttribute("x", x); shape.setAttribute("y", y); shape.setAttribute("width", w); shape.setAttribute("height", h); }
94
- shape.setAttribute("opacity", el.dataset.opacity || 1); shape.classList.add("canvas-shape");
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 select = el => {
98
- selected = el;
99
- svg.querySelectorAll(".canvas-shape").forEach(shape => shape.classList.toggle("is-selected", shape === el));
100
- inspector.classList.toggle("is-hidden", !el); empty.classList.toggle("is-hidden", !!el);
101
- if (el) Object.entries(fields).forEach(([key, input]) => input.value = key === "fill_color" ? el.dataset.fill : el.dataset[key]);
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 save = async (el, attrs) => {
104
- Object.entries(attrs).forEach(([key, value]) => { el.dataset[key === "fill_color" ? "fill" : key] = value; });
105
- const response = await fetch(urlFor(el.dataset.id), { method: "PATCH", headers: { "Content-Type": "application/json", "X-CSRF-Token": token, Accept: "application/json" }, body: JSON.stringify({ element: attrs }) });
106
- if (!response.ok) return;
107
- const replacement = render(el); el.replaceWith(replacement); if (selected === el) select(replacement);
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
- svg.querySelectorAll("[data-id]").forEach(el => { const shape = render(el); el.replaceWith(shape); });
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 el = event.target.closest(".canvas-shape");
112
- if (tool !== "select") return;
113
- if (el) { select(el); if (el.dataset.locked !== "true") { const p = localPoint(event); drag = { el, x: p.x, y: p.y, startX: Number(el.dataset.x), startY: Number(el.dataset.y) }; svg.setPointerCapture(event.pointerId); } }
114
- else select(null);
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
- svg.addEventListener("pointermove", event => { if (!drag) return; const p = localPoint(event); drag.el.dataset.x = drag.startX + p.x - drag.x; drag.el.dataset.y = drag.startY + p.y - drag.y; const updated = render(drag.el); drag.el.replaceWith(updated); drag.el = updated; select(updated); });
117
- svg.addEventListener("pointerup", () => { if (drag) { save(drag.el, { x: Number(drag.el.dataset.x), y: Number(drag.el.dataset.y) }); drag = null; } });
118
- document.querySelectorAll("[data-draw]").forEach(button => button.addEventListener("click", () => { tool = button.dataset.draw; document.querySelectorAll("[data-draw]").forEach(b => b.classList.toggle("is-primary", b === button)); }));
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 (tool === "select" || event.target.closest(".canvas-shape")) return;
121
- const p = localPoint(event), kind = tool;
122
- const attrs = { type: `ShareSpace::${kind[0].toUpperCase()}${kind.slice(1)}`, x: p.x, y: p.y, width: kind === "line" ? 120 : 140, height: kind === "line" ? 80 : 90, fill_color: "#48c78e", stroke_color: "#257953", stroke_width: 2, opacity: 1, visible: true, locked: false, points: kind === "line" ? [`${p.x},${p.y}`, `${p.x + 120},${p.y + 80}`] : [] };
123
- const response = await fetch(svg.dataset.createUrl, { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": token, Accept: "application/json" }, body: JSON.stringify({ element: attrs }) });
124
- if (!response.ok) return;
125
- const data = await response.json(), el = document.createElementNS("http://www.w3.org/2000/svg", "g");
126
- Object.assign(el.dataset, { id: data.id, type: kind, x: data.x, y: data.y, width: data.width, height: data.height, fill: data.fill_color, stroke: data.stroke_color, strokeWidth: data.stroke_width, opacity: data.opacity });
127
- const shape = render(el); svg.appendChild(shape); select(shape); tool = "select";
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
- Object.entries(fields).forEach(([key, input]) => input.addEventListener("change", () => { if (selected) save(selected, { [key]: key === "fill_color" ? input.value : Number(input.value) }); }));
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
  })();
@@ -0,0 +1,5 @@
1
+ class AddOptionsToShareSpaceCanvasElements < ActiveRecord::Migration[8.1]
2
+ def change
3
+ add_column :share_space_canvas_elements, :options, :json, null: false, default: {}
4
+ end
5
+ end
@@ -1,3 +1,3 @@
1
1
  module ShareSpace
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
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.1.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