share_space 0.1.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.
Files changed (31) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +28 -0
  4. data/Rakefile +3 -0
  5. data/app/assets/stylesheets/share_space/application.css +32 -0
  6. data/app/controllers/share_space/application_controller.rb +4 -0
  7. data/app/controllers/share_space/canvas_elements_controller.rb +57 -0
  8. data/app/controllers/share_space/canvases_controller.rb +53 -0
  9. data/app/helpers/share_space/application_helper.rb +4 -0
  10. data/app/jobs/share_space/application_job.rb +4 -0
  11. data/app/mailers/share_space/application_mailer.rb +6 -0
  12. data/app/models/share_space/application_record.rb +5 -0
  13. data/app/models/share_space/canvas.rb +12 -0
  14. data/app/models/share_space/canvas_element.rb +23 -0
  15. data/app/models/share_space/ellipse.rb +5 -0
  16. data/app/models/share_space/line.rb +5 -0
  17. data/app/models/share_space/rectangle.rb +5 -0
  18. data/app/views/layouts/share_space/application.html.haml +21 -0
  19. data/app/views/share_space/canvases/_form.html.haml +21 -0
  20. data/app/views/share_space/canvases/edit.html.haml +6 -0
  21. data/app/views/share_space/canvases/index.html.haml +20 -0
  22. data/app/views/share_space/canvases/new.html.haml +2 -0
  23. data/app/views/share_space/canvases/show.html.haml +131 -0
  24. data/config/routes.rb +6 -0
  25. data/db/migrate/20261027225958_create_share_space_canvas.rb +12 -0
  26. data/db/migrate/20261027231337_create_canvas_element.rb +34 -0
  27. data/lib/share_space/engine.rb +5 -0
  28. data/lib/share_space/version.rb +3 -0
  29. data/lib/share_space.rb +6 -0
  30. data/lib/tasks/share_space_tasks.rake +4 -0
  31. metadata +103 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 32915e8b4741eecd245a29eee960ab1484b707f79a3c445915094f61194b4c4e
4
+ data.tar.gz: 867829aafa5531cd2bbedf52f6899076ba8edd870633673357ac3cdcde76c989
5
+ SHA512:
6
+ metadata.gz: a066416f1c20fe267023465dce138447f283257007fd56da4ebf639016d15a6f2002d0e5917e690a9cb81c5b207886049d17dd249541e80935d37c4c293b6c3b
7
+ data.tar.gz: b4b1b0262dc2fd138e7fe32726ad6f7c7f042644791fbcf360ecc344cf4762ae0c3f1700c3e64af220c3847fe8e7b5a4110e1ab47b76a6f19048df339bc7d03f
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright paydaypay1
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # ShareSpace
2
+ Short description and motivation.
3
+
4
+ ## Usage
5
+ How to use my plugin.
6
+
7
+ ## Installation
8
+ Add this line to your application's Gemfile:
9
+
10
+ ```ruby
11
+ gem "share_space"
12
+ ```
13
+
14
+ And then execute:
15
+ ```bash
16
+ $ bundle
17
+ ```
18
+
19
+ Or install it yourself as:
20
+ ```bash
21
+ $ gem install share_space
22
+ ```
23
+
24
+ ## Contributing
25
+ Contribution directions go here.
26
+
27
+ ## License
28
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,3 @@
1
+ require "bundler/setup"
2
+
3
+ require "bundler/gem_tasks"
@@ -0,0 +1,32 @@
1
+ /*
2
+ * This is a manifest file that'll be compiled into application.css, which will include all the files
3
+ * listed below.
4
+ *
5
+ * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
6
+ * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
7
+ *
8
+ * You're free to add application-wide styles to this file and they'll appear at the bottom of the
9
+ * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
10
+ * files in this directory. Styles in this file should be added after the last require_* statement.
11
+ * It is generally better to create a new file per style scope.
12
+ *
13
+ *= require_tree .
14
+ *= require_self
15
+ */
16
+ .editor-heading { margin-bottom: 1rem !important; }
17
+ .editor-layout { align-items: flex-start; }
18
+ .toolbox { width: 230px; }
19
+ .toolbox .buttons { gap: .5rem; }
20
+ .canvas-column { min-width: 0; }
21
+ .canvas-scroll { overflow: auto; max-height: calc(100vh - 230px); padding: 1.25rem; background: #f1f3f5; border-radius: 8px; }
22
+ #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; }
25
+ .canvas-shape.is-selected { filter: drop-shadow(0 0 4px #485fc7); }
26
+ .form-box { max-width: 640px; }
27
+ .canvas-color { max-width: 5rem; padding: .25rem; }
28
+ @media (max-width: 768px) {
29
+ .toolbox { width: 100%; }
30
+ .toolbox .buttons { flex-direction: row !important; }
31
+ .canvas-scroll { max-height: 65vh; }
32
+ }
@@ -0,0 +1,4 @@
1
+ module ShareSpace
2
+ class ApplicationController < ActionController::Base
3
+ end
4
+ end
@@ -0,0 +1,57 @@
1
+ module ShareSpace
2
+ class CanvasElementsController < ApplicationController
3
+ before_action :set_canvas
4
+ before_action :set_element, only: %i[update destroy]
5
+
6
+ def create
7
+ type = element_params[:type].presence || "ShareSpace::Rectangle"
8
+ unless CanvasElement::ELEMENT_TYPES.map { |name| "ShareSpace::#{name}" }.include?(type)
9
+ return head :unprocessable_entity
10
+ end
11
+
12
+ @element = @canvas.elements.new(element_params.except(:type).to_h.merge(type: type))
13
+ if @element.save
14
+ render json: element_json(@element), status: :created
15
+ else
16
+ render json: { errors: @element.errors.full_messages }, status: :unprocessable_entity
17
+ end
18
+ end
19
+
20
+ def update
21
+ if @element.update(element_params.except(:type))
22
+ render json: element_json(@element)
23
+ else
24
+ render json: { errors: @element.errors.full_messages }, status: :unprocessable_entity
25
+ end
26
+ end
27
+
28
+ def destroy
29
+ @element.destroy
30
+ head :no_content
31
+ end
32
+
33
+ private
34
+
35
+ def set_canvas
36
+ @canvas = Canvas.find(params[:canvas_id])
37
+ end
38
+
39
+ def set_element
40
+ @element = @canvas.elements.find(params[:id])
41
+ end
42
+
43
+ def element_params
44
+ params.require(:element).permit(:type, :name, :x, :y, :width, :height, :rotation,
45
+ :fill_color, :stroke_color, :stroke_width, :opacity,
46
+ :visible, :locked, :z_index, points: [])
47
+ end
48
+
49
+ def element_json(element)
50
+ { id: element.id, type: element.type, name: element.name, x: element.x.to_f, y: element.y.to_f,
51
+ width: element.width&.to_f, height: element.height&.to_f, rotation: element.rotation.to_f,
52
+ fill_color: element.fill_color, stroke_color: element.stroke_color,
53
+ stroke_width: element.stroke_width&.to_f, opacity: element.opacity.to_f,
54
+ visible: element.visible, locked: element.locked, points: element.points }
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,53 @@
1
+ module ShareSpace
2
+ class CanvasesController < ApplicationController
3
+ before_action :set_canvas, only: %i[show edit update destroy]
4
+
5
+ def index
6
+ @canvases = Canvas.order(updated_at: :desc)
7
+ end
8
+
9
+ def show
10
+ @elements = @canvas.elements.ordered
11
+ @element = @canvas.elements.build(type: "ShareSpace::Rectangle", width: 160, height: 100,
12
+ fill_color: "#48c78e", stroke_color: "#257953", stroke_width: 2)
13
+ end
14
+
15
+ def new
16
+ @canvas = Canvas.new(width: 1200, height: 800, background_color: "#ffffff")
17
+ end
18
+
19
+ def create
20
+ @canvas = Canvas.new(canvas_params)
21
+ if @canvas.save
22
+ redirect_to canvas_path(@canvas), notice: "Canvas created."
23
+ else
24
+ render :new, status: :unprocessable_entity
25
+ end
26
+ end
27
+
28
+ def edit; end
29
+
30
+ def update
31
+ if @canvas.update(canvas_params)
32
+ redirect_to canvas_path(@canvas), notice: "Canvas updated."
33
+ else
34
+ render :edit, status: :unprocessable_entity
35
+ end
36
+ end
37
+
38
+ def destroy
39
+ @canvas.destroy
40
+ redirect_to canvases_path, notice: "Canvas deleted."
41
+ end
42
+
43
+ private
44
+
45
+ def set_canvas
46
+ @canvas = Canvas.find(params[:id])
47
+ end
48
+
49
+ def canvas_params
50
+ params.require(:canvas).permit(:name, :width, :height, :background_color)
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,4 @@
1
+ module ShareSpace
2
+ module ApplicationHelper
3
+ end
4
+ end
@@ -0,0 +1,4 @@
1
+ module ShareSpace
2
+ class ApplicationJob < ActiveJob::Base
3
+ end
4
+ end
@@ -0,0 +1,6 @@
1
+ module ShareSpace
2
+ class ApplicationMailer < ActionMailer::Base
3
+ default from: "from@example.com"
4
+ layout "mailer"
5
+ end
6
+ end
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ end
@@ -0,0 +1,12 @@
1
+ module ShareSpace
2
+ class Canvas < ApplicationRecord
3
+ has_many :elements,
4
+ class_name: "CanvasElement",
5
+ dependent: :destroy,
6
+ inverse_of: :canvas
7
+
8
+ validates :name, presence: true
9
+ validates :width, numericality: { greater_than: 0 }
10
+ validates :height, numericality: { greater_than: 0 }
11
+ end
12
+ end
@@ -0,0 +1,23 @@
1
+ module ShareSpace
2
+ class CanvasElement < ApplicationRecord
3
+ belongs_to :canvas, inverse_of: :elements
4
+
5
+ scope :visible, -> { where(visible: true) }
6
+ scope :ordered, -> { order(:z_index, :id) }
7
+
8
+ ELEMENT_TYPES = %w[Rectangle Ellipse Line].freeze
9
+
10
+ validates :type, inclusion: { in: ELEMENT_TYPES.map { |name| "ShareSpace::#{name}" } }
11
+
12
+ validates :x, :y, numericality: true
13
+ validates :opacity, numericality: { in: 0.0..1.0 }
14
+
15
+ def hide!
16
+ update!(visible: false)
17
+ end
18
+
19
+ def show!
20
+ update!(visible: true)
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class Ellipse < CanvasElement
3
+ validates :width, :height, numericality: { greater_than: 0 }
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class Line < CanvasElement
3
+ validates :points, presence: true
4
+ end
5
+ end
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class Rectangle < CanvasElement
3
+ validates :width, :height, numericality: { greater_than: 0 }
4
+ end
5
+ end
@@ -0,0 +1,21 @@
1
+ !!!
2
+ %html
3
+ %head
4
+ %title ShareSpace canvas
5
+ %meta{ name: "viewport", content: "width=device-width, initial-scale=1" }
6
+ = csrf_meta_tags
7
+ = csp_meta_tag
8
+ %link{ rel: "stylesheet", href: "https://cdn.jsdelivr.net/npm/bulma@1.0.4/css/bulma.min.css" }
9
+ = stylesheet_link_tag "share_space/application", media: "all"
10
+ = yield :head
11
+ %body
12
+ %nav.navbar.is-dark{ role: "navigation" }
13
+ .navbar-brand
14
+ = link_to "ShareSpace", canvases_path, class: "navbar-item has-text-weight-bold"
15
+ %main.section
16
+ .container.is-fluid
17
+ - if notice
18
+ .notification.is-success.is-light= notice
19
+ - if alert
20
+ .notification.is-danger.is-light= alert
21
+ = yield
@@ -0,0 +1,21 @@
1
+ = form_with model: @canvas, url: (@canvas.persisted? ? canvas_path(@canvas) : canvases_path) do |form|
2
+ - if @canvas.errors.any?
3
+ .notification.is-danger= @canvas.errors.full_messages.to_sentence
4
+ .field
5
+ = form.label :name, class: "label"
6
+ .control= form.text_field :name, class: "input", required: true
7
+ .columns
8
+ .column
9
+ .field
10
+ = form.label :width, class: "label"
11
+ .control= form.number_field :width, class: "input", min: 1, required: true
12
+ .column
13
+ .field
14
+ = form.label :height, class: "label"
15
+ .control= form.number_field :height, class: "input", min: 1, required: true
16
+ .field
17
+ = form.label :background_color, class: "label"
18
+ .control= form.color_field :background_color, class: "input canvas-color"
19
+ .field.is-grouped.mt-5
20
+ .control= form.submit class: "button is-primary"
21
+ .control= link_to "Cancel", canvases_path, class: "button is-light"
@@ -0,0 +1,6 @@
1
+ %h1.title Canvas settings
2
+ .box.form-box
3
+ = render "form"
4
+ - if @canvas.persisted?
5
+ %hr
6
+ = button_to "Delete canvas", canvas_path(@canvas), method: :delete, class: "button is-danger is-light", form: { data: { turbo_confirm: "Delete this canvas and all its elements?" } }
@@ -0,0 +1,20 @@
1
+ .level
2
+ .level-left
3
+ %div
4
+ %h1.title Canvases
5
+ %p.subtitle Create and edit your browser based canvases.
6
+ .level-right
7
+ = link_to "New canvas", new_canvas_path, class: "button is-primary"
8
+ - if @canvases.any?
9
+ .columns.is-multiline
10
+ - @canvases.each do |canvas|
11
+ .column.is-one-quarter
12
+ .card
13
+ .card-content
14
+ %p.title.is-5= canvas.name
15
+ %p.subtitle.is-6= "#{canvas.width} × #{canvas.height}"
16
+ .card-footer
17
+ = link_to "Open editor", canvas_path(canvas), class: "card-footer-item"
18
+ = link_to "Settings", edit_canvas_path(canvas), class: "card-footer-item"
19
+ - else
20
+ .notification.is-light No canvases yet. Create one to start drawing.
@@ -0,0 +1,2 @@
1
+ %h1.title New canvas
2
+ .box.form-box= render "form"
@@ -0,0 +1,131 @@
1
+ - content_for :head do
2
+ %meta{ name: "csrf-token", content: form_authenticity_token }
3
+ .level.editor-heading
4
+ .level-left
5
+ %div
6
+ %h1.title.is-4= @canvas.name
7
+ %p.subtitle.is-6= "#{@canvas.width} × #{@canvas.height} px"
8
+ .level-right
9
+ = link_to "All canvases", canvases_path, class: "button is-light"
10
+ = link_to "Settings", edit_canvas_path(@canvas), class: "button is-light"
11
+ .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
40
+ .column.canvas-column
41
+ .canvas-scroll
42
+ %svg#canvas-editor{ xmlns: "http://www.w3.org/2000/svg", viewBox: "0 0 #{@canvas.width} #{@canvas.height}", width: @canvas.width, height: @canvas.height,
43
+ style: "background: #{@canvas.background_color}", data: { canvas_width: @canvas.width, canvas_height: @canvas.height,
44
+ create_url: canvas_elements_path(@canvas), update_url: canvas_elements_path(@canvas, id: "ELEMENT_ID"),
45
+ csrf: form_authenticity_token } }
46
+ - @elements.each do |element|
47
+ - next unless element.visible
48
+ - attrs = { "data-id" => element.id, "data-type" => element.type.demodulize.downcase,
49
+ "data-x" => element.x.to_f, "data-y" => element.y.to_f, "data-width" => element.width.to_f,
50
+ "data-height" => element.height.to_f, "data-fill" => element.fill_color || "#48c78e",
51
+ "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)
54
+ - 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 }
56
+ - elsif element.is_a?(Ellipse)
57
+ %ellipse{ **attrs, cx: element.x.to_f + element.width.to_f / 2, cy: element.y.to_f + element.height.to_f / 2,
58
+ rx: element.width.to_f / 2, ry: element.height.to_f / 2, fill: element.fill_color || "#48c78e",
59
+ stroke: element.stroke_color || "#257953", "stroke-width" => element.stroke_width || 2, opacity: element.opacity }
60
+ - else
61
+ %rect{ **attrs, x: element.x, y: element.y, width: element.width, height: element.height,
62
+ fill: element.fill_color || "#48c78e", stroke: element.stroke_color || "#257953",
63
+ "stroke-width" => element.stroke_width || 2, opacity: element.opacity }
64
+ :javascript
65
+ (() => {
66
+ const svg = document.getElementById("canvas-editor");
67
+ 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());
79
+ };
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");
95
+ return shape;
96
+ };
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]);
102
+ };
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);
108
+ };
109
+ svg.querySelectorAll("[data-id]").forEach(el => { const shape = render(el); el.replaceWith(shape); });
110
+ 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);
115
+ });
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)); }));
119
+ 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";
128
+ });
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); });
131
+ })();
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ ShareSpace::Engine.routes.draw do
2
+ root "canvases#index"
3
+ resources :canvases do
4
+ resources :elements, controller: "canvas_elements", only: %i[create update destroy]
5
+ end
6
+ end
@@ -0,0 +1,12 @@
1
+ class CreateShareSpaceCanvas < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :share_space_canvases do |t|
4
+ t.string :name, null: false, default: "Untitled canvas"
5
+ t.integer :width, null: false, default: 1200
6
+ t.integer :height, null: false, default: 800
7
+ t.string :background_color, null: false, default: "#ffffff"
8
+
9
+ t.timestamps
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,34 @@
1
+ class CreateCanvasElement < ActiveRecord::Migration[8.1]
2
+ def change
3
+ create_table :share_space_canvas_elements do |t|
4
+ t.references :canvas,
5
+ null: false,
6
+ foreign_key: { to_table: :share_space_canvases }
7
+
8
+ t.string :type
9
+ t.string :name
10
+
11
+ t.decimal :x, precision: 12, scale: 4, null: false, default: 0
12
+ t.decimal :y, precision: 12, scale: 4, null: false, default: 0
13
+ t.decimal :width, precision: 12, scale: 4
14
+ t.decimal :height, precision: 12, scale: 4
15
+ t.decimal :rotation, precision: 8, scale: 4, default: 0
16
+
17
+ t.boolean :visible, null: false, default: true
18
+ t.boolean :locked, null: false, default: false
19
+ t.integer :z_index, null: false, default: 0
20
+
21
+ t.string :fill_color
22
+ t.string :stroke_color
23
+ t.decimal :stroke_width, precision: 8, scale: 4
24
+ t.decimal :opacity, precision: 4, scale: 3,
25
+ null: false, default: 1.0
26
+
27
+ t.json :points, null: false, default: []
28
+
29
+ t.timestamps
30
+ end
31
+
32
+ add_index :share_space_canvas_elements, [:canvas_id, :z_index]
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ module ShareSpace
2
+ class Engine < ::Rails::Engine
3
+ isolate_namespace ShareSpace
4
+ end
5
+ end
@@ -0,0 +1,3 @@
1
+ module ShareSpace
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,6 @@
1
+ require "share_space/version"
2
+ require "share_space/engine"
3
+
4
+ module ShareSpace
5
+ # Your code goes here...
6
+ end
@@ -0,0 +1,4 @@
1
+ # desc "Explaining what the task does"
2
+ # task :share_space do
3
+ # # Task goes here
4
+ # end
metadata ADDED
@@ -0,0 +1,103 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: share_space
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - paydaypay1
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rails
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: 8.1.4
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: 8.1.4
26
+ - !ruby/object:Gem::Dependency
27
+ name: haml-rails
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ description: Users can create shared spaces among other users. When working together,
41
+ updates propogate to other users and cursors are visible. Similar to Figma or Google
42
+ Suite collaboration.
43
+ email:
44
+ - paydaypay1@gmail.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - MIT-LICENSE
50
+ - README.md
51
+ - Rakefile
52
+ - app/assets/stylesheets/share_space/application.css
53
+ - app/controllers/share_space/application_controller.rb
54
+ - app/controllers/share_space/canvas_elements_controller.rb
55
+ - app/controllers/share_space/canvases_controller.rb
56
+ - app/helpers/share_space/application_helper.rb
57
+ - app/jobs/share_space/application_job.rb
58
+ - app/mailers/share_space/application_mailer.rb
59
+ - app/models/share_space/application_record.rb
60
+ - app/models/share_space/canvas.rb
61
+ - app/models/share_space/canvas_element.rb
62
+ - app/models/share_space/ellipse.rb
63
+ - app/models/share_space/line.rb
64
+ - app/models/share_space/rectangle.rb
65
+ - app/views/layouts/share_space/application.html.haml
66
+ - app/views/share_space/canvases/_form.html.haml
67
+ - app/views/share_space/canvases/edit.html.haml
68
+ - app/views/share_space/canvases/index.html.haml
69
+ - app/views/share_space/canvases/new.html.haml
70
+ - app/views/share_space/canvases/show.html.haml
71
+ - config/routes.rb
72
+ - db/migrate/20261027225958_create_share_space_canvas.rb
73
+ - db/migrate/20261027231337_create_canvas_element.rb
74
+ - lib/share_space.rb
75
+ - lib/share_space/engine.rb
76
+ - lib/share_space/version.rb
77
+ - lib/tasks/share_space_tasks.rake
78
+ homepage: https://github.com/paydaypay1/share_space
79
+ licenses:
80
+ - MIT
81
+ metadata:
82
+ allowed_push_host: https://rubygems.org/
83
+ homepage_uri: https://github.com/paydaypay1/share_space
84
+ source_code_uri: https://github.com/paydaypay1/share_space
85
+ changelog_uri: https://github.com/paydaypay1/share_space/blob/main/CHANGELOG.md
86
+ rdoc_options: []
87
+ require_paths:
88
+ - lib
89
+ required_ruby_version: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - ">="
92
+ - !ruby/object:Gem::Version
93
+ version: 3.2.0
94
+ required_rubygems_version: !ruby/object:Gem::Requirement
95
+ requirements:
96
+ - - ">="
97
+ - !ruby/object:Gem::Version
98
+ version: '0'
99
+ requirements: []
100
+ rubygems_version: 4.0.6
101
+ specification_version: 4
102
+ summary: Provides a shared space for realtime collaboration.
103
+ test_files: []