tina4ruby 3.13.149 → 3.13.151
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/CHANGELOG.md +26 -0
- data/lib/tina4/auth.rb +3 -3
- data/lib/tina4/cli.rb +1 -1
- data/lib/tina4/crud.rb +7 -7
- data/lib/tina4/dev_admin.rb +14 -0
- data/lib/tina4/drivers/mongodb_driver.rb +48 -13
- data/lib/tina4/drivers/mssql_driver.rb +6 -1
- data/lib/tina4/form_parser.rb +38 -4
- data/lib/tina4/frond.rb +151 -20
- data/lib/tina4/mcp.rb +36 -12
- data/lib/tina4/metrics.rb +5 -1
- data/lib/tina4/middleware.rb +10 -1
- data/lib/tina4/plan.rb +3 -3
- data/lib/tina4/point.rb +1 -1
- data/lib/tina4/project_index.rb +1 -1
- data/lib/tina4/public/bundled-component-provenance.json +2 -2
- data/lib/tina4/public/js/tina4-dev-admin-split.html +202 -0
- data/lib/tina4/public/js/tina4-dev-admin.min.js +147 -138
- data/lib/tina4/rack_app.rb +4 -1
- data/lib/tina4/realtime/s3_client.rb +240 -0
- data/lib/tina4/realtime/s3_storage.rb +15 -28
- data/lib/tina4/realtime/storage.rb +4 -5
- data/lib/tina4/realtime.rb +2 -1
- data/lib/tina4/request.rb +41 -3
- data/lib/tina4/response_cache.rb +9 -1
- data/lib/tina4/router.rb +1 -1
- data/lib/tina4/sql_translator.rb +5 -3
- data/lib/tina4/template.rb +32 -17
- data/lib/tina4/templates/crud/form.twig +5 -5
- data/lib/tina4/templates/crud/table.twig +2 -2
- data/lib/tina4/templates/errors/302.twig +4 -3
- data/lib/tina4/version.rb +1 -1
- data/lib/tina4/webserver.rb +1 -1
- metadata +4 -2
data/lib/tina4/mcp.rb
CHANGED
|
@@ -675,6 +675,37 @@ module Tina4
|
|
|
675
675
|
|
|
676
676
|
# ── Built-in dev tools ────────────────────────────────────────────
|
|
677
677
|
module McpDevTools
|
|
678
|
+
# Where +path+ will land once every symlink in it is followed, including the
|
|
679
|
+
# parts that do not exist yet: a NEW file is created through its deepest
|
|
680
|
+
# existing parent, and a dangling symlink through the place it names. nil
|
|
681
|
+
# when that cannot be worked out (a link loop, an unreadable link).
|
|
682
|
+
# Does +path+, once its symlinks are followed, land outside +project_root+?
|
|
683
|
+
# A root that no longer exists holds nothing to link through, so nothing
|
|
684
|
+
# under it is judged.
|
|
685
|
+
def self.lands_outside?(path, project_root)
|
|
686
|
+
real_root = File.realpath(project_root)
|
|
687
|
+
real = real_path_of(path)
|
|
688
|
+
real_prefix = "#{real_root.chomp(File::SEPARATOR)}#{File::SEPARATOR}"
|
|
689
|
+
!(real && (real == real_root || real.start_with?(real_prefix)))
|
|
690
|
+
rescue SystemCallError
|
|
691
|
+
false
|
|
692
|
+
end
|
|
693
|
+
|
|
694
|
+
def self.real_path_of(path, hops = 0)
|
|
695
|
+
probe = path
|
|
696
|
+
probe = File.dirname(probe) until File.exist?(probe) || File.symlink?(probe) || File.dirname(probe) == probe
|
|
697
|
+
return File.realpath(probe) if File.exist?(probe)
|
|
698
|
+
return nil if hops >= 40
|
|
699
|
+
|
|
700
|
+
# A dangling symlink. Its target is joined as text, not normalised, so a
|
|
701
|
+
# ".." after a linked component is resolved the way the OS will follow it.
|
|
702
|
+
target = File.readlink(probe)
|
|
703
|
+
base = target.start_with?(File::SEPARATOR) ? target : "#{File.dirname(probe)}#{File::SEPARATOR}#{target}"
|
|
704
|
+
real_path_of("#{base}#{path[probe.length..]}", hops + 1)
|
|
705
|
+
rescue SystemCallError
|
|
706
|
+
nil
|
|
707
|
+
end
|
|
708
|
+
|
|
678
709
|
# Register all 24 built-in dev tools on the given McpServer.
|
|
679
710
|
def self.register(server)
|
|
680
711
|
project_root = File.expand_path(Dir.pwd)
|
|
@@ -794,18 +825,11 @@ module Tina4
|
|
|
794
825
|
unless resolved == project_root || resolved.start_with?(root_prefix)
|
|
795
826
|
raise ArgumentError, "Path escapes project directory: #{rel_path}"
|
|
796
827
|
end
|
|
797
|
-
# Belt-and-braces against symlink escapes:
|
|
798
|
-
#
|
|
799
|
-
#
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
if File.exist?(resolved)
|
|
803
|
-
real = File.realpath(resolved)
|
|
804
|
-
real_root = File.realpath(project_root)
|
|
805
|
-
real_prefix = "#{real_root.chomp(File::SEPARATOR)}#{File::SEPARATOR}"
|
|
806
|
-
unless real == real_root || real.start_with?(real_prefix)
|
|
807
|
-
raise ArgumentError, "Path escapes project directory: #{rel_path}"
|
|
808
|
-
end
|
|
828
|
+
# Belt-and-braces against symlink escapes: canonicalise the path and
|
|
829
|
+
# re-check containment. A symlink inside the tree pointing outside
|
|
830
|
+
# would otherwise slip past the textual check.
|
|
831
|
+
if McpDevTools.lands_outside?(resolved, project_root)
|
|
832
|
+
raise ArgumentError, "Path escapes project directory: #{rel_path}"
|
|
809
833
|
end
|
|
810
834
|
resolved
|
|
811
835
|
end
|
data/lib/tina4/metrics.rb
CHANGED
|
@@ -50,7 +50,11 @@ module Tina4
|
|
|
50
50
|
binary = engine_path
|
|
51
51
|
raise MetricsEngineError, "tina4 not found on PATH - #{INSTALL_HINT}" unless binary
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
# --no-history: a dashboard READ must never write .tina4-metrics.json.
|
|
54
|
+
# Without it every scan records history, the file watcher sees the write
|
|
55
|
+
# and reloads the page, which re-scans - a self-inflicted ~2-second reload
|
|
56
|
+
# loop that makes the dashboard unusable.
|
|
57
|
+
stdout, stderr, status = Open3.capture3(binary, 'metrics', '--path', path.to_s, '--json', '--no-history')
|
|
54
58
|
unless status.success?
|
|
55
59
|
detail = (stderr.empty? ? stdout : stderr).strip.lines.first || "exit code #{status.exitstatus}"
|
|
56
60
|
raise MetricsEngineError, "tina4 metrics failed on #{path}: #{detail.strip}"
|
data/lib/tina4/middleware.rb
CHANGED
|
@@ -783,6 +783,7 @@ module Tina4
|
|
|
783
783
|
# TINA4_CSP — Content-Security-Policy (default: "default-src 'self'")
|
|
784
784
|
# TINA4_REFERRER_POLICY — Referrer-Policy (default: strict-origin-when-cross-origin)
|
|
785
785
|
# TINA4_PERMISSIONS_POLICY — Permissions-Policy (default: camera=(), microphone=(), geolocation=())
|
|
786
|
+
# TINA4_COOP — Cross-Origin-Opener-Policy (default: same-origin-allow-popups)
|
|
786
787
|
class SecurityHeadersMiddleware
|
|
787
788
|
class << self
|
|
788
789
|
# Register this middleware in the default chain (secure-by-default).
|
|
@@ -815,7 +816,15 @@ module Tina4
|
|
|
815
816
|
["Content-Security-Policy", Tina4::Csp.resolve_csp_header(Tina4::Csp.current_nonce)],
|
|
816
817
|
["Referrer-Policy", ENV["TINA4_REFERRER_POLICY"] || "strict-origin-when-cross-origin"],
|
|
817
818
|
["X-XSS-Protection", "0"],
|
|
818
|
-
["Permissions-Policy", ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"]
|
|
819
|
+
["Permissions-Policy", ENV["TINA4_PERMISSIONS_POLICY"] || "camera=(), microphone=(), geolocation=()"],
|
|
820
|
+
# Cross-Origin-Opener-Policy severs the window-reference link to a
|
|
821
|
+
# cross-origin opener (closing reverse-tab-nabbing + cross-window
|
|
822
|
+
# XS-Leaks that X-Frame-Options does not). Default same-origin-allow-popups:
|
|
823
|
+
# isolates the page from its opener while leaving popups IT opens able to
|
|
824
|
+
# post back, so OAuth/payment popups keep working. TINA4_COOP=same-origin
|
|
825
|
+
# for full isolation, unsafe-none to opt out. Secure-context only (https
|
|
826
|
+
# and http://localhost), inert elsewhere.
|
|
827
|
+
["Cross-Origin-Opener-Policy", ENV["TINA4_COOP"] || "same-origin-allow-popups"]
|
|
819
828
|
]
|
|
820
829
|
end
|
|
821
830
|
|
data/lib/tina4/plan.rb
CHANGED
|
@@ -53,7 +53,7 @@ module Tina4
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def slugify(title)
|
|
56
|
-
slug = title.to_s.strip.downcase.gsub(/[^a-z0-9_\-]+/, "-").gsub(/\A
|
|
56
|
+
slug = title.to_s.strip.downcase.gsub(/[^a-z0-9_\-]+/, "-").gsub(/\A-++|-++\z/, "")
|
|
57
57
|
slug = slug[0, 80]
|
|
58
58
|
slug.empty? ? "plan-#{Time.now.to_i}" : slug
|
|
59
59
|
end
|
|
@@ -69,7 +69,7 @@ module Tina4
|
|
|
69
69
|
section = nil # :steps | :notes | :other | nil
|
|
70
70
|
|
|
71
71
|
lines.each do |raw|
|
|
72
|
-
line = raw.sub(/[[:space:]]
|
|
72
|
+
line = raw.sub(/[[:space:]]++\z/, "")
|
|
73
73
|
if title.empty? && line.start_with?("# ")
|
|
74
74
|
title = line[2..].to_s.strip
|
|
75
75
|
next
|
|
@@ -407,7 +407,7 @@ module Tina4
|
|
|
407
407
|
user_parts << "Extra context from caller: #{prompt}" unless prompt.to_s.empty?
|
|
408
408
|
user_parts << "Reply with ONLY the JSON array — no explanation, no markdown fences."
|
|
409
409
|
|
|
410
|
-
ai_url = ENV.fetch("TINA4_AI_URL", "http://localhost:
|
|
410
|
+
ai_url = ENV.fetch("TINA4_AI_URL", "http://localhost:11435/api/chat")
|
|
411
411
|
ai_model = ENV.fetch("TINA4_AI_MODEL", "qwen2.5-coder:14b")
|
|
412
412
|
|
|
413
413
|
reply = begin
|
data/lib/tina4/point.rb
CHANGED
|
@@ -52,7 +52,7 @@ module Tina4
|
|
|
52
52
|
return from_geojson(value, srid) if value.is_a?(Hash)
|
|
53
53
|
if value.is_a?(String)
|
|
54
54
|
text = value.strip
|
|
55
|
-
match = text.match(/\A(?:SRID\s*=\s*(\d+)\s*;\s*)?POINT\s*(?:Z|M|ZM)?\s*\(\s*([-+0-9.eE]+)\s
|
|
55
|
+
match = text.match(/\A(?:SRID\s*=\s*(\d+)\s*;\s*)?POINT\s*(?:Z|M|ZM)?\s*\(\s*([-+0-9.eE]+)\s++([-+0-9.eE]+)(?:\s++[-+0-9.eE]+)*+\s*+\)\z/i)
|
|
56
56
|
return new(match[2], match[3], srid: match[1] ? match[1].to_i : srid) if match
|
|
57
57
|
raw = [text].pack("H*") if text.length >= 42 && text.length.even? && text.match?(/\A[0-9a-f]+\z/i)
|
|
58
58
|
raw ||= value.b if [0, 1].include?(value.getbyte(0))
|
data/lib/tina4/project_index.rb
CHANGED
|
@@ -71,7 +71,7 @@ module Tina4
|
|
|
71
71
|
text.scan(/^\s*def\s+(self\.)?([A-Za-z_][\w!?=]*)/) { |m| out["symbols"] << m[1] }
|
|
72
72
|
text.scan(/^\s*require(?:_relative)?\s+['"]([^'"]+)['"]/) { |m| out["imports"] << m[0] }
|
|
73
73
|
# Tina4.get "/path" OR Tina4::Router.get("/path") OR get "/path" do
|
|
74
|
-
route_re = /(?:Tina4(?:::Router)?\.|^\s*)(get|post|put|patch|delete|any|any_method|secure_get|secure_post)\s
|
|
74
|
+
route_re = /(?:Tina4(?:::Router)?\.|^\s*)(get|post|put|patch|delete|any|any_method|secure_get|secure_post)\s*+\(?\s*+['"]([^'"]+)['"]/
|
|
75
75
|
text.scan(route_re) do |meth, path|
|
|
76
76
|
next unless ROUTE_METHODS.include?(meth)
|
|
77
77
|
out["routes"] << { "method" => meth.upcase, "path" => path, "handler" => "" }
|
|
@@ -41,8 +41,8 @@
|
|
|
41
41
|
},
|
|
42
42
|
{
|
|
43
43
|
"path": "js/tina4-dev-admin.min.js",
|
|
44
|
-
"component": "tina4-dev-admin-
|
|
45
|
-
"sha256": "
|
|
44
|
+
"component": "tina4-dev-admin-8c4617d",
|
|
45
|
+
"sha256": "6b05be06b5d76ee4126a11f019d104d3920c1e3234250cfe1966a33f34c1a234"
|
|
46
46
|
}
|
|
47
47
|
]
|
|
48
48
|
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"><title>Tina4 Dev - Split</title>
|
|
3
|
+
<style>
|
|
4
|
+
html,body{margin:0;height:100%;background:#0f172a;font-family:system-ui,-apple-system,sans-serif;overflow:hidden}
|
|
5
|
+
/* One top toolbar controls both panels and the preview device. */
|
|
6
|
+
.t4-topbar{height:40px;box-sizing:border-box;display:flex;align-items:center;gap:6px;padding:0 10px;background:#0b1220;border-bottom:1px solid #1f2937}
|
|
7
|
+
.t4-brand{color:#e2e8f0;font:600 12px system-ui;margin-right:6px}
|
|
8
|
+
.t4-spacer{flex:1 1 auto}
|
|
9
|
+
.t4-devbtn{background:#1e293b;color:#cbd5e1;border:1px solid #334155;border-radius:6px;padding:3px 9px;font:12px system-ui;cursor:pointer}
|
|
10
|
+
.t4-devbtn.active{background:#2563eb;border-color:#2563eb;color:#fff}
|
|
11
|
+
/* Icon toggle buttons: full opacity + highlight when the panel is shown,
|
|
12
|
+
dimmed when hidden. */
|
|
13
|
+
.t4-tg{display:inline-flex;align-items:center;justify-content:center;width:28px;height:26px;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;opacity:.5}
|
|
14
|
+
.t4-tg svg{width:15px;height:15px}
|
|
15
|
+
.t4-tg.active{background:#334155;color:#fff;opacity:1}
|
|
16
|
+
.t4-dim{color:#64748b;font:11px/1 ui-monospace,monospace;margin:0 6px}
|
|
17
|
+
.t4-popout{background:#0f766e;color:#fff;border:1px solid #0f766e;border-radius:6px;padding:3px 10px;font:12px system-ui;cursor:pointer;margin-left:6px}
|
|
18
|
+
.t4-split{display:flex;height:calc(100vh - 41px);width:100vw}
|
|
19
|
+
.t4-left{flex:0 0 auto;width:58%;min-width:0;height:100%;overflow:hidden}
|
|
20
|
+
.t4-left iframe{width:100%;height:100%;border:0;display:block}
|
|
21
|
+
.t4-gutter{flex:0 0 6px;cursor:col-resize;background:#1f2937}
|
|
22
|
+
.t4-gutter:hover,.t4-gutter.drag{background:#2563eb}
|
|
23
|
+
.t4-right{flex:1 1 auto;min-width:0;height:100%;overflow:hidden;display:flex;flex-direction:column}
|
|
24
|
+
/* A LIGHT backdrop so the dark device frame stands out. */
|
|
25
|
+
.t4-stage{flex:1 1 auto;min-width:0;min-height:0;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center;background:radial-gradient(circle at 50% 32%,#f8fafc,#c3ccd9)}
|
|
26
|
+
/* The device frame - a phone/tablet bezel around the live app. overflow:hidden
|
|
27
|
+
+ border-radius CLIP the app to the rounded frame, so content is contained.
|
|
28
|
+
No width/height transition: fit() measures right after a toggle, and an
|
|
29
|
+
animated size would be read mid-transition (the "clicking does nothing" bug). */
|
|
30
|
+
.t4-device{position:relative;background:#111;box-shadow:0 16px 50px rgba(15,23,42,.45);transform-origin:center center;overflow:hidden}
|
|
31
|
+
.t4-device iframe{width:100%;height:100%;border:0;display:block;background:#fff}
|
|
32
|
+
.t4-device.mobile{border:14px solid #111;border-radius:46px}
|
|
33
|
+
.t4-device.tablet{border:16px solid #111;border-radius:26px}
|
|
34
|
+
/* Desktop fills the whole preview block - no bezel, no scaling. */
|
|
35
|
+
.t4-device.desktop{border:0;border-radius:0;box-shadow:none;width:100%;height:100%}
|
|
36
|
+
.t4-hidden{display:none !important}
|
|
37
|
+
/* While dragging the gutter, let mouse events pass over the iframes. */
|
|
38
|
+
body.t4-dragging iframe{pointer-events:none}
|
|
39
|
+
</style></head>
|
|
40
|
+
<body>
|
|
41
|
+
<div class="t4-topbar">
|
|
42
|
+
<span class="t4-brand">Tina4 Dev</span>
|
|
43
|
+
<button class="t4-tg active" id="t4-tg-files" title="Show/hide the file navigator"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4"><rect x="1.5" y="2.5" width="13" height="11" rx="1.5"/><line x1="6" y1="2.5" x2="6" y2="13.5"/></svg></button>
|
|
44
|
+
<button class="t4-tg active" id="t4-tg-editor" title="Show/hide the code editor"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="5.5,4.5 2,8 5.5,11.5"/><polyline points="10.5,4.5 14,8 10.5,11.5"/></svg></button>
|
|
45
|
+
<button class="t4-tg active" id="t4-tg-preview" title="Show/hide the preview"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"><rect x="1.5" y="2.5" width="13" height="9" rx="1.5"/><line x1="5.5" y1="14" x2="10.5" y2="14"/><line x1="8" y1="11.5" x2="8" y2="14"/></svg></button>
|
|
46
|
+
<span class="t4-spacer"></span>
|
|
47
|
+
<span class="t4-dim" id="t4-dim">1440 x 900</span>
|
|
48
|
+
<button class="t4-devbtn" data-mode="mobile">Mobile</button>
|
|
49
|
+
<button class="t4-devbtn" data-mode="tablet">Tablet</button>
|
|
50
|
+
<button class="t4-devbtn active" data-mode="desktop">Desktop</button>
|
|
51
|
+
<button class="t4-popout" id="t4-popout" title="Open this view in its own window - drag it to another screen">⇱ Pop out</button>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="t4-split" id="t4-split">
|
|
54
|
+
<div class="t4-left" id="t4-left"><iframe src="/__dev" title="Tina4 Dev Admin"></iframe></div>
|
|
55
|
+
<div class="t4-gutter" id="t4-gutter"></div>
|
|
56
|
+
<div class="t4-right" id="t4-right">
|
|
57
|
+
<div class="t4-stage" id="t4-stage">
|
|
58
|
+
<div class="t4-device desktop" id="t4-device">
|
|
59
|
+
<iframe id="t4-preview" src="/" title="Live preview"></iframe>
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<script>
|
|
65
|
+
(function () {
|
|
66
|
+
var SIZES = { mobile: {w:390,h:844}, tablet: {w:820,h:1180}, desktop: {w:1440,h:900} };
|
|
67
|
+
var KEY = 'tina4_split_device';
|
|
68
|
+
var device = document.getElementById('t4-device');
|
|
69
|
+
var stage = document.getElementById('t4-stage');
|
|
70
|
+
var dim = document.getElementById('t4-dim');
|
|
71
|
+
var left = document.getElementById('t4-left');
|
|
72
|
+
var right = document.getElementById('t4-right');
|
|
73
|
+
var gutter = document.getElementById('t4-gutter');
|
|
74
|
+
var split = document.getElementById('t4-split');
|
|
75
|
+
var mode = 'desktop';
|
|
76
|
+
try { var saved = localStorage.getItem(KEY); if (saved && SIZES[saved]) mode = saved; } catch (_) {}
|
|
77
|
+
var leftWidth = '58%';
|
|
78
|
+
|
|
79
|
+
// ── device preview ──
|
|
80
|
+
function fit() {
|
|
81
|
+
if (right.classList.contains('t4-hidden')) return;
|
|
82
|
+
if (mode === 'desktop') {
|
|
83
|
+
// Desktop just fills the preview block - responsive, no frame, no scale.
|
|
84
|
+
device.style.transform = 'none';
|
|
85
|
+
device.style.width = '100%';
|
|
86
|
+
device.style.height = '100%';
|
|
87
|
+
var r0 = stage.getBoundingClientRect();
|
|
88
|
+
if (dim) dim.textContent = Math.round(r0.width) + ' x ' + Math.round(r0.height);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
var s = SIZES[mode];
|
|
92
|
+
device.style.transform = 'none';
|
|
93
|
+
device.style.width = s.w + 'px';
|
|
94
|
+
device.style.height = s.h + 'px';
|
|
95
|
+
var rect = stage.getBoundingClientRect();
|
|
96
|
+
var pad = 28;
|
|
97
|
+
var ow = device.offsetWidth, oh = device.offsetHeight;
|
|
98
|
+
var scale = Math.min((rect.width - pad) / ow, (rect.height - pad) / oh, 1);
|
|
99
|
+
if (!isFinite(scale) || scale <= 0) scale = 1;
|
|
100
|
+
device.style.transform = 'scale(' + scale + ')';
|
|
101
|
+
}
|
|
102
|
+
function setMode(m) {
|
|
103
|
+
if (!SIZES[m]) return;
|
|
104
|
+
mode = m;
|
|
105
|
+
device.className = 't4-device ' + m;
|
|
106
|
+
if (dim) dim.textContent = SIZES[m].w + ' x ' + SIZES[m].h;
|
|
107
|
+
document.querySelectorAll('.t4-devbtn').forEach(function (b) {
|
|
108
|
+
b.classList.toggle('active', b.getAttribute('data-mode') === m);
|
|
109
|
+
});
|
|
110
|
+
try { localStorage.setItem(KEY, m); } catch (_) {}
|
|
111
|
+
fit();
|
|
112
|
+
}
|
|
113
|
+
document.querySelectorAll('.t4-devbtn').forEach(function (b) {
|
|
114
|
+
b.addEventListener('click', function () { setMode(b.getAttribute('data-mode')); });
|
|
115
|
+
});
|
|
116
|
+
var pop = document.getElementById('t4-popout');
|
|
117
|
+
if (pop) pop.addEventListener('click', function () {
|
|
118
|
+
var s = SIZES[mode];
|
|
119
|
+
var w = Math.min(s.w, (screen.availWidth || 1920) - 40);
|
|
120
|
+
var h = Math.min(s.h, (screen.availHeight || 1080) - 120);
|
|
121
|
+
window.open('/', 'tina4preview_' + mode, 'width=' + w + ',height=' + h + ',menubar=no,toolbar=no,location=no');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// ── show / hide each panel (Files, Editor, Preview) ──
|
|
125
|
+
// Files (the nav) and Editor (the code area) are SEPARATE sections living
|
|
126
|
+
// inside the left /__dev SPA iframe, so hiding the editor must not take the
|
|
127
|
+
// file nav with it. They are same-origin, so we reach into the iframe and
|
|
128
|
+
// toggle its own `#editor-sidebar` (nav) and `.editor-main` (editor). The
|
|
129
|
+
// Preview is the right pane of this page.
|
|
130
|
+
var vis = { files: true, editor: true, preview: true };
|
|
131
|
+
function frameDoc() {
|
|
132
|
+
try { return document.querySelector('#t4-left iframe').contentDocument; } catch (_) { return null; }
|
|
133
|
+
}
|
|
134
|
+
function setFrameEl(sel, show) {
|
|
135
|
+
var d = frameDoc(); if (!d) return;
|
|
136
|
+
var el = d.querySelector(sel); if (el) el.style.display = show ? '' : 'none';
|
|
137
|
+
}
|
|
138
|
+
function applyPanels() {
|
|
139
|
+
setFrameEl('#editor-sidebar', vis.files);
|
|
140
|
+
setFrameEl('#editor-splitter-left', vis.files && vis.editor);
|
|
141
|
+
setFrameEl('.editor-main', vis.editor);
|
|
142
|
+
var leftEmpty = !vis.files && !vis.editor;
|
|
143
|
+
left.classList.toggle('t4-hidden', leftEmpty);
|
|
144
|
+
right.classList.toggle('t4-hidden', !vis.preview);
|
|
145
|
+
gutter.classList.toggle('t4-hidden', leftEmpty || !vis.preview);
|
|
146
|
+
if (!vis.preview) {
|
|
147
|
+
// Preview hidden: the editor/nav fills the whole width.
|
|
148
|
+
left.style.flex = '1 1 auto'; left.style.width = 'auto';
|
|
149
|
+
} else if (vis.files && !vis.editor) {
|
|
150
|
+
// Only the file nav is shown: shrink the left pane to the nav width so
|
|
151
|
+
// there is no empty gap where the editor used to be.
|
|
152
|
+
left.style.flex = '0 0 auto'; left.style.width = '250px';
|
|
153
|
+
} else {
|
|
154
|
+
left.style.flex = '0 0 auto'; left.style.width = leftWidth;
|
|
155
|
+
}
|
|
156
|
+
document.getElementById('t4-tg-files').classList.toggle('active', vis.files);
|
|
157
|
+
document.getElementById('t4-tg-editor').classList.toggle('active', vis.editor);
|
|
158
|
+
document.getElementById('t4-tg-preview').classList.toggle('active', vis.preview);
|
|
159
|
+
fit();
|
|
160
|
+
}
|
|
161
|
+
function toggle(name) {
|
|
162
|
+
// Never hide all three - at least one stays visible.
|
|
163
|
+
var n = { files: vis.files, editor: vis.editor, preview: vis.preview };
|
|
164
|
+
n[name] = !n[name];
|
|
165
|
+
if (!n.files && !n.editor && !n.preview) return;
|
|
166
|
+
vis[name] = !vis[name];
|
|
167
|
+
applyPanels();
|
|
168
|
+
}
|
|
169
|
+
document.getElementById('t4-tg-files').addEventListener('click', function () { toggle('files'); });
|
|
170
|
+
document.getElementById('t4-tg-editor').addEventListener('click', function () { toggle('editor'); });
|
|
171
|
+
document.getElementById('t4-tg-preview').addEventListener('click', function () { toggle('preview'); });
|
|
172
|
+
|
|
173
|
+
// ── drag the gutter to resize ──
|
|
174
|
+
var dragging = false;
|
|
175
|
+
gutter.addEventListener('mousedown', function (e) {
|
|
176
|
+
dragging = true;
|
|
177
|
+
document.body.classList.add('t4-dragging');
|
|
178
|
+
gutter.classList.add('drag');
|
|
179
|
+
e.preventDefault();
|
|
180
|
+
});
|
|
181
|
+
window.addEventListener('mousemove', function (e) {
|
|
182
|
+
if (!dragging) return;
|
|
183
|
+
var r = split.getBoundingClientRect();
|
|
184
|
+
var w = e.clientX - r.left;
|
|
185
|
+
w = Math.max(220, Math.min(w, r.width - 260));
|
|
186
|
+
leftWidth = w + 'px';
|
|
187
|
+
left.style.width = leftWidth;
|
|
188
|
+
fit();
|
|
189
|
+
});
|
|
190
|
+
window.addEventListener('mouseup', function () {
|
|
191
|
+
if (!dragging) return;
|
|
192
|
+
dragging = false;
|
|
193
|
+
document.body.classList.remove('t4-dragging');
|
|
194
|
+
gutter.classList.remove('drag');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
window.addEventListener('resize', fit);
|
|
198
|
+
setMode(mode);
|
|
199
|
+
applyPanels();
|
|
200
|
+
})();
|
|
201
|
+
</script>
|
|
202
|
+
</body></html>
|