protege 0.1.0.alpha.1
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 +7 -0
- data/README.md +24 -0
- data/Rakefile +12 -0
- data/app/assets/builds/protege.css +1128 -0
- data/app/assets/javascripts/protege/attachment_picker.js +163 -0
- data/app/assets/javascripts/protege/copy.js +80 -0
- data/app/assets/javascripts/protege/forms.js +28 -0
- data/app/assets/javascripts/protege/keys.js +212 -0
- data/app/assets/javascripts/protege/resize.js +149 -0
- data/app/assets/javascripts/protege/theme.js +25 -0
- data/app/assets/stylesheets/protege/application.css +15 -0
- data/app/assets/tailwind/application.css +137 -0
- data/app/controllers/concerns/protege/persona_scoped.rb +22 -0
- data/app/controllers/concerns/protege/thread_scoped.rb +22 -0
- data/app/controllers/protege/access_rules_controller.rb +72 -0
- data/app/controllers/protege/application_controller.rb +18 -0
- data/app/controllers/protege/archives_controller.rb +52 -0
- data/app/controllers/protege/email_domains_controller.rb +72 -0
- data/app/controllers/protege/home_controller.rb +15 -0
- data/app/controllers/protege/messages_controller.rb +46 -0
- data/app/controllers/protege/mta_domains_syncs_controller.rb +22 -0
- data/app/controllers/protege/personas_controller.rb +118 -0
- data/app/controllers/protege/replies_controller.rb +64 -0
- data/app/controllers/protege/responsibilities_controller.rb +82 -0
- data/app/controllers/protege/responsibility_runs_controller.rb +46 -0
- data/app/controllers/protege/threads_controller.rb +75 -0
- data/app/controllers/protege/traces/searches_controller.rb +19 -0
- data/app/controllers/protege/traces_controller.rb +52 -0
- data/app/helpers/protege/application_helper.rb +30 -0
- data/app/helpers/protege/components/badges_helper.rb +59 -0
- data/app/helpers/protege/components/buttons_helper.rb +141 -0
- data/app/helpers/protege/components/cards_helper.rb +48 -0
- data/app/helpers/protege/components/components_helper.rb +30 -0
- data/app/helpers/protege/components/dialogs_helper.rb +36 -0
- data/app/helpers/protege/components/forms_helper.rb +223 -0
- data/app/helpers/protege/components/hotkeys_helper.rb +60 -0
- data/app/helpers/protege/components/icons_helper.rb +41 -0
- data/app/helpers/protege/components/introspection_helper.rb +162 -0
- data/app/helpers/protege/components/layout_helper.rb +169 -0
- data/app/helpers/protege/components/menus_helper.rb +68 -0
- data/app/helpers/protege/components/pagination_helper.rb +100 -0
- data/app/helpers/protege/components/sidebar_helper.rb +211 -0
- data/app/helpers/protege/components/tables_helper.rb +59 -0
- data/app/helpers/protege/components/typography_helper.rb +64 -0
- data/app/helpers/protege/email_domains_helper.rb +22 -0
- data/app/helpers/protege/messages_helper.rb +152 -0
- data/app/helpers/protege/personas_helper.rb +90 -0
- data/app/helpers/protege/responsibilities_helper.rb +63 -0
- data/app/helpers/protege/threads_helper.rb +118 -0
- data/app/helpers/protege/trace_helper.rb +22 -0
- data/app/hooks/protege/event_logger_hook.rb +109 -0
- data/app/jobs/protege/application_job.rb +41 -0
- data/app/jobs/protege/console_inference_job.rb +57 -0
- data/app/jobs/protege/inference_job.rb +60 -0
- data/app/jobs/protege/mail_provisioning_job.rb +21 -0
- data/app/jobs/protege/responsibility_job.rb +70 -0
- data/app/jobs/protege/responsibility_tick_job.rb +19 -0
- data/app/mailboxes/protege/agent_mailbox.rb +123 -0
- data/app/mailers/protege/application_mailer.rb +65 -0
- data/app/models/concerns/protege/broadcastable_email_domain.rb +48 -0
- data/app/models/concerns/protege/broadcastable_message.rb +47 -0
- data/app/models/concerns/protege/broadcastable_persona.rb +50 -0
- data/app/models/concerns/protege/broadcastable_responsibility.rb +51 -0
- data/app/models/concerns/protege/broadcastable_thread.rb +55 -0
- data/app/models/concerns/protege/tool_scoped.rb +88 -0
- data/app/models/protege/access_rule.rb +50 -0
- data/app/models/protege/application_record.rb +18 -0
- data/app/models/protege/current.rb +23 -0
- data/app/models/protege/email_domain.rb +136 -0
- data/app/models/protege/email_thread.rb +81 -0
- data/app/models/protege/message.rb +401 -0
- data/app/models/protege/persona.rb +229 -0
- data/app/models/protege/responsibility.rb +98 -0
- data/app/models/protege/responsibility_run.rb +68 -0
- data/app/models/protege/tool_use.rb +115 -0
- data/app/models/protege/trace.rb +71 -0
- data/app/providers/protege/open_router_provider.rb +317 -0
- data/app/resolvers/README.md +255 -0
- data/app/resolvers/protege/load_file_resolver.rb +86 -0
- data/app/resolvers/protege/load_record_resolver.rb +80 -0
- data/app/resolvers/protege/load_text_resolver.rb +40 -0
- data/app/resolvers/protege/thread_history_resolver.rb +116 -0
- data/app/services/protege/introspection_tool_call_presentation.rb +134 -0
- data/app/services/protege/message_search.rb +114 -0
- data/app/services/protege/trace_search.rb +102 -0
- data/app/subscribers/protege/introspection_broadcaster.rb +324 -0
- data/app/tools/protege/create_file_tool.rb +109 -0
- data/app/tools/protege/read_attachment_tool.rb +101 -0
- data/app/tools/protege/search_emails_tool.rb +129 -0
- data/app/tools/protege/send_email_tool.rb +240 -0
- data/app/tools/protege/web_fetch_tool.rb +224 -0
- data/app/tools/protege/web_search_tool.rb +131 -0
- data/app/views/layouts/protege/application.html.slim +19 -0
- data/app/views/protege/access_rules/create.turbo_stream.slim +6 -0
- data/app/views/protege/email_domains/_email_domain_sidebar_item.html.slim +4 -0
- data/app/views/protege/email_domains/_form.html.slim +10 -0
- data/app/views/protege/email_domains/_sidebar.html.slim +11 -0
- data/app/views/protege/email_domains/index.html.slim +6 -0
- data/app/views/protege/email_domains/new.html.slim +7 -0
- data/app/views/protege/email_domains/show.html.slim +36 -0
- data/app/views/protege/home/show.html.slim +26 -0
- data/app/views/protege/messages/_message.html.slim +19 -0
- data/app/views/protege/messages/_sidebar.html.slim +13 -0
- data/app/views/protege/messages/index.html.slim +20 -0
- data/app/views/protege/personas/_access_rule.html.slim +7 -0
- data/app/views/protege/personas/_access_rule_form.html.slim +8 -0
- data/app/views/protege/personas/_access_rules.html.slim +19 -0
- data/app/views/protege/personas/_actions.html.slim +15 -0
- data/app/views/protege/personas/_form.html.slim +29 -0
- data/app/views/protege/personas/_persona_sidebar_item.html.slim +5 -0
- data/app/views/protege/personas/_sidebar.html.slim +11 -0
- data/app/views/protege/personas/edit.html.slim +7 -0
- data/app/views/protege/personas/index.html.slim +6 -0
- data/app/views/protege/personas/new.html.slim +7 -0
- data/app/views/protege/personas/show.html.slim +37 -0
- data/app/views/protege/responsibilities/_form.html.slim +21 -0
- data/app/views/protege/responsibilities/_responsibility_sidebar_item.html.slim +5 -0
- data/app/views/protege/responsibilities/_sidebar.html.slim +11 -0
- data/app/views/protege/responsibilities/edit.html.slim +7 -0
- data/app/views/protege/responsibilities/index.html.slim +6 -0
- data/app/views/protege/responsibilities/new.html.slim +7 -0
- data/app/views/protege/responsibilities/show.html.slim +22 -0
- data/app/views/protege/responsibility_runs/show.html.slim +13 -0
- data/app/views/protege/shared/_confirm_dialog.html.slim +22 -0
- data/app/views/protege/shared/_header.html.slim +14 -0
- data/app/views/protege/shared/_hotkeys.html.slim +43 -0
- data/app/views/protege/threads/_composer_footer.html.slim +15 -0
- data/app/views/protege/threads/_introspection.html.slim +7 -0
- data/app/views/protege/threads/_reasoning.html.slim +4 -0
- data/app/views/protege/threads/_reply_form.html.slim +7 -0
- data/app/views/protege/threads/_run_marker.html.slim +3 -0
- data/app/views/protege/threads/_sidebar.html.slim +15 -0
- data/app/views/protege/threads/_thread.html.slim +6 -0
- data/app/views/protege/threads/_tool_call.html.slim +8 -0
- data/app/views/protege/threads/index.html.slim +6 -0
- data/app/views/protege/threads/new.html.slim +15 -0
- data/app/views/protege/threads/show.html.slim +25 -0
- data/app/views/protege/traces/_sidebar.html.slim +15 -0
- data/app/views/protege/traces/index.html.slim +11 -0
- data/app/views/protege/traces/searches/_sidebar.html.slim +13 -0
- data/app/views/protege/traces/searches/show.html.slim +22 -0
- data/app/views/protege/traces/show.html.slim +28 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260520000001_create_protege_tables.rb +105 -0
- data/db/migrate/20260706120130_create_protege_traces.rb +30 -0
- data/db/migrate/20260707120000_replace_persona_active_with_archived_at.rb +24 -0
- data/db/migrate/20260708120000_add_disabled_tool_ids_to_personas.rb +22 -0
- data/lib/generators/protege/extension_naming.rb +38 -0
- data/lib/generators/protege/hook/hook_generator.rb +33 -0
- data/lib/generators/protege/hook/templates/hook.rb.tt +36 -0
- data/lib/generators/protege/install/install_generator.rb +170 -0
- data/lib/generators/protege/install/templates/initializer.rb.tt +84 -0
- data/lib/generators/protege/persona/persona_generator.rb +35 -0
- data/lib/generators/protege/persona/templates/persona.rb.tt +31 -0
- data/lib/generators/protege/postfix/postfix_generator.rb +74 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/Dockerfile +43 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/KeyTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/MAIL.md +135 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/README.md +56 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/SigningTable +3 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/TrustedHosts +8 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/entrypoint.sh +118 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/kamal-accessory.example.yml +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/main.cf +68 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendkim.conf +36 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.conf +32 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/opendmarc.ignore.hosts +9 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/policyd-spf.conf +21 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/provision.py +182 -0
- data/lib/generators/protege/postfix/templates/deploy/mail/relay-to-app.sh +28 -0
- data/lib/generators/protege/postfix/templates/github/workflows/build-mail-image.example.yml +50 -0
- data/lib/generators/protege/provider/provider_generator.rb +44 -0
- data/lib/generators/protege/provider/templates/provider.rb.tt +50 -0
- data/lib/generators/protege/resolver/resolver_generator.rb +34 -0
- data/lib/generators/protege/resolver/templates/resolver.rb.tt +31 -0
- data/lib/generators/protege/tool/templates/tool.rb.tt +50 -0
- data/lib/generators/protege/tool/tool_generator.rb +34 -0
- data/lib/protege/configuration.rb +171 -0
- data/lib/protege/engine.rb +103 -0
- data/lib/protege/errors/bad_request_error.rb +8 -0
- data/lib/protege/errors/contract_violation_error.rb +8 -0
- data/lib/protege/errors/error.rb +30 -0
- data/lib/protege/errors/generic_error.rb +7 -0
- data/lib/protege/errors/internal_error.rb +8 -0
- data/lib/protege/errors/invalid_model_error.rb +8 -0
- data/lib/protege/errors/invalid_tool_result_error.rb +9 -0
- data/lib/protege/errors/rate_limited_error.rb +8 -0
- data/lib/protege/errors/resolver_file_not_found_error.rb +9 -0
- data/lib/protege/errors/response_parse_failed_error.rb +8 -0
- data/lib/protege/errors/timeout_error.rb +8 -0
- data/lib/protege/errors/tool_not_available_error.rb +12 -0
- data/lib/protege/errors/tool_not_found_error.rb +9 -0
- data/lib/protege/errors/unauthorized_error.rb +8 -0
- data/lib/protege/errors/unavailable_error.rb +8 -0
- data/lib/protege/errors/unsupported_capability_error.rb +8 -0
- data/lib/protege/events/event.rb +98 -0
- data/lib/protege/events/inference_chunk_event.rb +17 -0
- data/lib/protege/events/inference_completed_event.rb +12 -0
- data/lib/protege/events/inference_failed_event.rb +12 -0
- data/lib/protege/events/inference_generated_event.rb +19 -0
- data/lib/protege/events/inference_max_turns_reached_event.rb +12 -0
- data/lib/protege/events/inference_started_event.rb +11 -0
- data/lib/protege/events/loop_run_completed_event.rb +11 -0
- data/lib/protege/events/loop_run_enqueued_event.rb +12 -0
- data/lib/protege/events/loop_run_failed_event.rb +12 -0
- data/lib/protege/events/loop_run_started_event.rb +11 -0
- data/lib/protege/events/tool_call_completed_event.rb +12 -0
- data/lib/protege/events/tool_call_failed_event.rb +12 -0
- data/lib/protege/events/tool_call_started_event.rb +11 -0
- data/lib/protege/events/tool_calls_received_event.rb +11 -0
- data/lib/protege/extensions/hook.rb +13 -0
- data/lib/protege/extensions/hook_mixin.rb +90 -0
- data/lib/protege/extensions/provider.rb +12 -0
- data/lib/protege/extensions/provider_mixin.rb +124 -0
- data/lib/protege/extensions/resolver.rb +12 -0
- data/lib/protege/extensions/resolver_mixin.rb +58 -0
- data/lib/protege/extensions/tool.rb +12 -0
- data/lib/protege/extensions/tool_mixin.rb +217 -0
- data/lib/protege/gateway/access_control.rb +66 -0
- data/lib/protege/gateway/access_policy.rb +94 -0
- data/lib/protege/gateway/attachment_policy.rb +115 -0
- data/lib/protege/gateway/mail/address.rb +145 -0
- data/lib/protege/gateway/mail/attachment.rb +17 -0
- data/lib/protege/gateway/mail/message_id.rb +104 -0
- data/lib/protege/gateway/mail/outbound.rb +181 -0
- data/lib/protege/gateway/mail/references.rb +107 -0
- data/lib/protege/gateway.rb +398 -0
- data/lib/protege/inference/provider/request.rb +110 -0
- data/lib/protege/inference/provider/response.rb +50 -0
- data/lib/protege/inference/provider/tool_call.rb +52 -0
- data/lib/protege/inference.rb +121 -0
- data/lib/protege/loop/schedule.rb +66 -0
- data/lib/protege/loop/scheduler.rb +42 -0
- data/lib/protege/loop.rb +37 -0
- data/lib/protege/orchestrator/context.rb +65 -0
- data/lib/protege/orchestrator/harness.rb +305 -0
- data/lib/protege/orchestrator/reply_context.rb +21 -0
- data/lib/protege/orchestrator/reply_harness.rb +68 -0
- data/lib/protege/orchestrator/resolver_chain.rb +93 -0
- data/lib/protege/orchestrator/responsibility_context.rb +22 -0
- data/lib/protege/orchestrator/responsibility_harness.rb +60 -0
- data/lib/protege/orchestrator.rb +37 -0
- data/lib/protege/subscribers/hook_dispatcher.rb +140 -0
- data/lib/protege/subscribers/tracing.rb +65 -0
- data/lib/protege/types/document_part.rb +13 -0
- data/lib/protege/types/image_part.rb +11 -0
- data/lib/protege/types/model_message.rb +40 -0
- data/lib/protege/types/result.rb +113 -0
- data/lib/protege/types/stored_file.rb +127 -0
- data/lib/protege/types/text_part.rb +8 -0
- data/lib/protege/types/tool_result.rb +16 -0
- data/lib/protege/version.rb +6 -0
- data/lib/protege.rb +47 -0
- data/lib/tasks/protege_tasks.rake +16 -0
- metadata +494 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Attachment picker for the composer forms — a paperclip button opens the native file dialog, chosen
|
|
2
|
+
// files render as removable badges below the composer, and removing one rebuilds the file input via a
|
|
3
|
+
// DataTransfer. Self-contained and framework-agnostic (like copy.js / forms.js): no importmap, no
|
|
4
|
+
// Stimulus, no build step.
|
|
5
|
+
//
|
|
6
|
+
// Markup (see threads/_composer_footer):
|
|
7
|
+
//
|
|
8
|
+
// <div data-attachment-picker>
|
|
9
|
+
// <input type="file" multiple hidden data-attachment-picker-input>
|
|
10
|
+
// <button type="button" data-attachment-picker-trigger>…paperclip…</button>
|
|
11
|
+
// <ul data-attachment-picker-list></ul>
|
|
12
|
+
// </div>
|
|
13
|
+
//
|
|
14
|
+
// The <input> is the source of truth for what submits; the DataTransfer (kept on the element) lets us
|
|
15
|
+
// accumulate across multiple picks and drop individual files, which a raw FileList cannot do.
|
|
16
|
+
(function () {
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
/** The [data-attachment-picker] container enclosing an element. */
|
|
20
|
+
function rootOf(el) { return el.closest("[data-attachment-picker]"); }
|
|
21
|
+
|
|
22
|
+
/** The file input within a container. */
|
|
23
|
+
function inputOf(root) { return root.querySelector("[data-attachment-picker-input]"); }
|
|
24
|
+
|
|
25
|
+
/** The badge list within a container. */
|
|
26
|
+
function listOf(root) { return root.querySelector("[data-attachment-picker-list]"); }
|
|
27
|
+
|
|
28
|
+
/** The input's backing DataTransfer, created on first use. */
|
|
29
|
+
function transferOf(input) {
|
|
30
|
+
if (!input.pickerTransfer) input.pickerTransfer = new DataTransfer();
|
|
31
|
+
return input.pickerTransfer;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A stable identity for a File, to dedupe repeated picks of the same file. */
|
|
35
|
+
function fileKey(file) { return file.name + ":" + file.size + ":" + file.lastModified; }
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Merge the input's freshly-picked files into its accumulated DataTransfer (deduped), then write the
|
|
39
|
+
* combined set back onto the input — so a second pick adds to, rather than replaces, the selection.
|
|
40
|
+
* @param {HTMLInputElement} input
|
|
41
|
+
* @returns {void}
|
|
42
|
+
*/
|
|
43
|
+
function absorb(input) {
|
|
44
|
+
var transfer = transferOf(input);
|
|
45
|
+
var seen = {};
|
|
46
|
+
var i;
|
|
47
|
+
|
|
48
|
+
for (i = 0; i < transfer.files.length; i++) seen[fileKey(transfer.files[i])] = true;
|
|
49
|
+
|
|
50
|
+
for (i = 0; i < input.files.length; i++) {
|
|
51
|
+
var file = input.files[i];
|
|
52
|
+
if (!seen[fileKey(file)]) {
|
|
53
|
+
transfer.items.add(file);
|
|
54
|
+
seen[fileKey(file)] = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
input.files = transfer.files;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Drop the file at index from the input's DataTransfer and write the remainder back.
|
|
63
|
+
* @param {HTMLInputElement} input
|
|
64
|
+
* @param {number} index
|
|
65
|
+
* @returns {void}
|
|
66
|
+
*/
|
|
67
|
+
function removeAt(input, index) {
|
|
68
|
+
var next = new DataTransfer();
|
|
69
|
+
var files = transferOf(input).files;
|
|
70
|
+
|
|
71
|
+
for (var i = 0; i < files.length; i++) if (i !== index) next.items.add(files[i]);
|
|
72
|
+
|
|
73
|
+
input.pickerTransfer = next;
|
|
74
|
+
input.files = next.files;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Empty an input's selection (used when the form resets after submit). */
|
|
78
|
+
function clear(input) {
|
|
79
|
+
input.pickerTransfer = new DataTransfer();
|
|
80
|
+
input.value = "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Rebuild the badge list to mirror the input's current files, hiding it when empty.
|
|
85
|
+
* @param {HTMLElement} root
|
|
86
|
+
* @returns {void}
|
|
87
|
+
*/
|
|
88
|
+
function render(root) {
|
|
89
|
+
var input = inputOf(root);
|
|
90
|
+
var list = listOf(root);
|
|
91
|
+
if (!input || !list) return;
|
|
92
|
+
|
|
93
|
+
list.innerHTML = "";
|
|
94
|
+
list.hidden = input.files.length === 0;
|
|
95
|
+
|
|
96
|
+
for (var i = 0; i < input.files.length; i++) list.appendChild(badge(input.files[i].name, i));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build one removable filename badge.
|
|
101
|
+
* @param {string} name
|
|
102
|
+
* @param {number} index
|
|
103
|
+
* @returns {HTMLLIElement}
|
|
104
|
+
*/
|
|
105
|
+
function badge(name, index) {
|
|
106
|
+
var li = document.createElement("li");
|
|
107
|
+
li.className = "inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs";
|
|
108
|
+
li.setAttribute("style", "background: var(--surface-subtle); border: 1px solid var(--border)");
|
|
109
|
+
|
|
110
|
+
var label = document.createElement("span");
|
|
111
|
+
label.textContent = name;
|
|
112
|
+
|
|
113
|
+
var remove = document.createElement("button");
|
|
114
|
+
remove.type = "button";
|
|
115
|
+
remove.textContent = "×";
|
|
116
|
+
remove.className = "leading-none opacity-60 hover:opacity-100";
|
|
117
|
+
remove.setAttribute("data-attachment-picker-remove", index);
|
|
118
|
+
remove.setAttribute("aria-label", "Remove " + name);
|
|
119
|
+
|
|
120
|
+
li.appendChild(label);
|
|
121
|
+
li.appendChild(remove);
|
|
122
|
+
return li;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Delegated so it survives Turbo swaps without re-binding.
|
|
126
|
+
document.addEventListener("click", function (event) {
|
|
127
|
+
var trigger = event.target.closest("[data-attachment-picker-trigger]");
|
|
128
|
+
if (trigger) {
|
|
129
|
+
var triggerRoot = rootOf(trigger);
|
|
130
|
+
if (triggerRoot) inputOf(triggerRoot).click();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
var remove = event.target.closest("[data-attachment-picker-remove]");
|
|
135
|
+
if (remove) {
|
|
136
|
+
var removeRoot = rootOf(remove);
|
|
137
|
+
if (removeRoot) {
|
|
138
|
+
removeAt(inputOf(removeRoot), parseInt(remove.getAttribute("data-attachment-picker-remove"), 10));
|
|
139
|
+
render(removeRoot);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
document.addEventListener("change", function (event) {
|
|
145
|
+
var input = event.target.closest("[data-attachment-picker-input]");
|
|
146
|
+
if (!input) return;
|
|
147
|
+
|
|
148
|
+
absorb(input);
|
|
149
|
+
render(rootOf(input));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// The reply composer resets itself after a successful Turbo submit (forms.js). form.reset() clears the
|
|
153
|
+
// native input but not our accumulated DataTransfer or badges, so clear those too.
|
|
154
|
+
document.addEventListener("reset", function (event) {
|
|
155
|
+
if (!(event.target instanceof HTMLFormElement)) return;
|
|
156
|
+
|
|
157
|
+
event.target.querySelectorAll("[data-attachment-picker-input]").forEach(function (input) {
|
|
158
|
+
clear(input);
|
|
159
|
+
var root = rootOf(input);
|
|
160
|
+
if (root) render(root);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
})();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Click-to-copy for any element — self-contained and framework-agnostic (like resize.js / theme.js),
|
|
2
|
+
// so it works regardless of the host app's JavaScript setup: no importmap, no Stimulus, no build step.
|
|
3
|
+
//
|
|
4
|
+
// Markup: mark an element with `data-copy`. Clicking it (or its contents) copies text to the
|
|
5
|
+
// clipboard — the `data-copy` value when set to a real string, otherwise the element's own text.
|
|
6
|
+
//
|
|
7
|
+
// <code data-copy>npm install protege</code>
|
|
8
|
+
// <button data-copy="secret-token">Copy token</button>
|
|
9
|
+
//
|
|
10
|
+
// Pair with the `[data-copy]` cursor rule in application.css for the copy affordance on hover.
|
|
11
|
+
(function () {
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Copy text to the clipboard, preferring the async Clipboard API and falling back to a hidden
|
|
16
|
+
* textarea + execCommand for the non-secure contexts that lack it.
|
|
17
|
+
* @param {string} text - the text to copy
|
|
18
|
+
* @returns {void}
|
|
19
|
+
*/
|
|
20
|
+
function writeClipboard(text) {
|
|
21
|
+
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
22
|
+
navigator.clipboard.writeText(text).catch(function () { fallbackCopy(text); });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
fallbackCopy(text);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Legacy clipboard copy via a hidden, selected textarea — for browsers/contexts without the
|
|
31
|
+
* async Clipboard API.
|
|
32
|
+
* @param {string} text - the text to copy
|
|
33
|
+
* @returns {void}
|
|
34
|
+
*/
|
|
35
|
+
function fallbackCopy(text) {
|
|
36
|
+
var area = document.createElement("textarea");
|
|
37
|
+
area.value = text;
|
|
38
|
+
area.setAttribute("readonly", "");
|
|
39
|
+
area.style.position = "absolute";
|
|
40
|
+
area.style.left = "-9999px";
|
|
41
|
+
document.body.appendChild(area);
|
|
42
|
+
area.select();
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
document.execCommand("copy");
|
|
46
|
+
} catch (e) {
|
|
47
|
+
/* copy unavailable */
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
document.body.removeChild(area);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The text a copy target should place on the clipboard: its `data-copy` value when set to a real
|
|
55
|
+
* string, otherwise its trimmed text content.
|
|
56
|
+
* @param {HTMLElement} target - the `[data-copy]` element
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
function copyText(target) {
|
|
60
|
+
var explicit = target.getAttribute("data-copy");
|
|
61
|
+
if (explicit && explicit !== "true") return explicit;
|
|
62
|
+
|
|
63
|
+
return (target.textContent || "").trim();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Handle a delegated click: copy the nearest copy target's text, if the click landed on one.
|
|
68
|
+
* @param {MouseEvent} event - the delegated click event
|
|
69
|
+
* @returns {void}
|
|
70
|
+
*/
|
|
71
|
+
function onClick(event) {
|
|
72
|
+
var target = event.target.closest("[data-copy]");
|
|
73
|
+
if (!target) return;
|
|
74
|
+
|
|
75
|
+
writeClipboard(copyText(target));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Delegated so it works for any current or future element without re-binding across Turbo swaps.
|
|
79
|
+
document.addEventListener("click", onClick);
|
|
80
|
+
})();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Reset opted-in forms after a successful Turbo submission.
|
|
2
|
+
//
|
|
3
|
+
// Turbo does not clear a form's fields after submit, and a no-navigation response (e.g. the reply
|
|
4
|
+
// composer's 201 Created, whose new message arrives over a Turbo broadcast) leaves the typed text in
|
|
5
|
+
// place. Add data-reset-on-submit to such a form to clear it once the submission succeeds. Self-contained
|
|
6
|
+
// and framework-agnostic (like theme.js / resize.js), so it works regardless of the host app's JS setup.
|
|
7
|
+
(function () {
|
|
8
|
+
"use strict";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reset the submitting form when a Turbo submission succeeds and the form opted in with
|
|
12
|
+
* data-reset-on-submit. Only form controls are reset — inline styles (e.g. a resized height) are left
|
|
13
|
+
* untouched.
|
|
14
|
+
* @param {CustomEvent} event - the turbo:submit-end event (detail.success; target is the form)
|
|
15
|
+
* @returns {void}
|
|
16
|
+
*/
|
|
17
|
+
function resetFormOnSubmitEnd(event) {
|
|
18
|
+
var form = event.target;
|
|
19
|
+
var isInstanceOfHTMLFormElement = form instanceof HTMLFormElement;
|
|
20
|
+
var hasDataResetOnSubmit = form.hasAttribute("data-reset-on-submit");
|
|
21
|
+
|
|
22
|
+
if (event.detail && event.detail.success && isInstanceOfHTMLFormElement && hasDataResetOnSubmit) {
|
|
23
|
+
form.reset();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
document.addEventListener("turbo:submit-end", resetFormOnSubmitEnd);
|
|
28
|
+
})();
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Declarative keyboard shortcuts for any element — self-contained and framework-agnostic (like
|
|
2
|
+
// resize.js / copy.js), so it works regardless of the host app's JavaScript setup: no importmap, no
|
|
3
|
+
// Stimulus, no build step.
|
|
4
|
+
//
|
|
5
|
+
// Markup: put `data-hotkey="<combo>"` on the element a shortcut should activate (a button, link, or
|
|
6
|
+
// form). Two kinds of combo, `+`/space separated, case-insensitive:
|
|
7
|
+
//
|
|
8
|
+
// Modifier combo — fires immediately, even while typing (scoped to the element's form):
|
|
9
|
+
// <button data-hotkey="mod+enter">Send</button> ⌘/Ctrl+Enter submits the form
|
|
10
|
+
// Leader sequence — two bare keys pressed in turn, e.g. "g" then "i" (GitHub/Linear style):
|
|
11
|
+
// <a data-hotkey="g i" href="/inbox">Inbox</a> ignored while typing; conflict-free
|
|
12
|
+
//
|
|
13
|
+
// `mod` is the portable primary modifier — ⌘ on macOS, Ctrl elsewhere. `ctrl`/`meta`/`shift`/`alt`
|
|
14
|
+
// are also accepted; the key is anything `event.key` reports (`enter`, `k`, `/`, …).
|
|
15
|
+
//
|
|
16
|
+
// Scope: a modifier combo inside a <form> fires only while focus is within that same form (so ⌘+Enter
|
|
17
|
+
// in a compose textarea sends *that* form). Leader sequences fire only when NOT typing in a field, so
|
|
18
|
+
// bare letters never clash with the browser. On a match the element is activated: a <form> is
|
|
19
|
+
// submitted, anything else clicked.
|
|
20
|
+
(function () {
|
|
21
|
+
"use strict";
|
|
22
|
+
|
|
23
|
+
var IS_MAC = navigator.platform.indexOf("Mac") === 0 || navigator.platform === "iPhone";
|
|
24
|
+
var LEADER_TIMEOUT = 1000; // ms to press the second key of a sequence
|
|
25
|
+
var leader = null; // the pending leader key of a sequence in progress
|
|
26
|
+
var leaderAt = 0; // when it was pressed
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Parse a modifier combo ("mod+enter") into a spec of required modifiers and key.
|
|
30
|
+
* @param {string} combo
|
|
31
|
+
* @returns {{mod: boolean, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean, key: string}}
|
|
32
|
+
*/
|
|
33
|
+
function parseCombo(combo) {
|
|
34
|
+
var spec = { mod: false, ctrl: false, meta: false, shift: false, alt: false, key: "" };
|
|
35
|
+
|
|
36
|
+
combo.toLowerCase().split("+").forEach(function (part) {
|
|
37
|
+
part = part.trim();
|
|
38
|
+
if (part === "mod" || part === "ctrl" || part === "meta" || part === "shift" || part === "alt") {
|
|
39
|
+
spec[part] = true;
|
|
40
|
+
} else if (part) {
|
|
41
|
+
spec.key = part;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return spec;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether a keydown satisfies a modifier combo. `mod` maps to ⌘ on macOS, Ctrl elsewhere.
|
|
50
|
+
* @param {KeyboardEvent} event
|
|
51
|
+
* @param {object} spec - a parsed combo
|
|
52
|
+
* @returns {boolean}
|
|
53
|
+
*/
|
|
54
|
+
function matches(event, spec) {
|
|
55
|
+
var wantCtrl = spec.ctrl || (spec.mod && !IS_MAC);
|
|
56
|
+
var wantMeta = spec.meta || (spec.mod && IS_MAC);
|
|
57
|
+
|
|
58
|
+
return event.key.toLowerCase() === spec.key &&
|
|
59
|
+
event.ctrlKey === wantCtrl &&
|
|
60
|
+
event.metaKey === wantMeta &&
|
|
61
|
+
event.shiftKey === spec.shift &&
|
|
62
|
+
event.altKey === spec.alt;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Whether an element is a text-entry target (so bare-key sequences must be ignored while it's focused).
|
|
67
|
+
* @param {Element} el
|
|
68
|
+
* @returns {boolean}
|
|
69
|
+
*/
|
|
70
|
+
function isEditable(el) {
|
|
71
|
+
return !!el && (el.isContentEditable || /^(input|textarea|select)$/i.test(el.tagName));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether a modifier-combo element is in scope for the current focus: within its own <form>, or global
|
|
76
|
+
* when it has no enclosing form.
|
|
77
|
+
* @param {HTMLElement} element
|
|
78
|
+
* @returns {boolean}
|
|
79
|
+
*/
|
|
80
|
+
function inScope(element) {
|
|
81
|
+
var form = element.closest("form");
|
|
82
|
+
return !form || form.contains(document.activeElement);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Activate a matched element: submit a form, click anything else.
|
|
87
|
+
* @param {HTMLElement} element
|
|
88
|
+
* @returns {void}
|
|
89
|
+
*/
|
|
90
|
+
function activate(element) {
|
|
91
|
+
if (element.tagName === "FORM") {
|
|
92
|
+
element.requestSubmit();
|
|
93
|
+
} else {
|
|
94
|
+
element.click();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Normalize a hotkey string for comparison (lowercase, single-spaced). */
|
|
99
|
+
function normalize(combo) {
|
|
100
|
+
return combo.toLowerCase().replace(/\s+/g, " ").trim();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Toggle the keyboard-shortcuts overlay open/closed. Purely functional: the overlay's content is a
|
|
105
|
+
* static, hand-authored template (see shared/_hotkeys) — there is nothing to populate. No-op when the
|
|
106
|
+
* page has no overlay.
|
|
107
|
+
* @returns {void}
|
|
108
|
+
*/
|
|
109
|
+
function toggleHelp() {
|
|
110
|
+
var help = document.querySelector("[data-hotkey-help]");
|
|
111
|
+
if (!help) return;
|
|
112
|
+
|
|
113
|
+
if (help.open) {
|
|
114
|
+
help.close();
|
|
115
|
+
} else {
|
|
116
|
+
help.showModal();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fire the first in-scope modifier combo the event matches. Returns true when one handled the event.
|
|
122
|
+
* @param {KeyboardEvent} event
|
|
123
|
+
* @param {NodeList} elements
|
|
124
|
+
* @returns {boolean}
|
|
125
|
+
*/
|
|
126
|
+
function handleCombo(event, elements) {
|
|
127
|
+
for (var i = 0; i < elements.length; i++) {
|
|
128
|
+
var combo = elements[i].getAttribute("data-hotkey");
|
|
129
|
+
if (combo.indexOf("+") !== -1 && matches(event, parseCombo(combo)) && inScope(elements[i])) {
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
activate(elements[i]);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Advance the leader-sequence state machine for a bare keypress. Completes a "leader key" sequence
|
|
140
|
+
* (e.g. "g i") when the second key arrives in time, otherwise arms/clears the pending leader.
|
|
141
|
+
* @param {string} key - the lowercased event key
|
|
142
|
+
* @param {KeyboardEvent} event
|
|
143
|
+
* @param {NodeList} elements
|
|
144
|
+
* @returns {void}
|
|
145
|
+
*/
|
|
146
|
+
function handleSequence(key, event, elements) {
|
|
147
|
+
var armed = leader && (Date.now() - leaderAt) < LEADER_TIMEOUT;
|
|
148
|
+
var target = armed && findSequence(elements, leader + " " + key);
|
|
149
|
+
leader = null;
|
|
150
|
+
|
|
151
|
+
if (target) {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
activate(target);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (isLeader(elements, key)) {
|
|
158
|
+
leader = key;
|
|
159
|
+
leaderAt = Date.now();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The element whose hotkey is exactly this sequence, or null. */
|
|
164
|
+
function findSequence(elements, sequence) {
|
|
165
|
+
for (var i = 0; i < elements.length; i++) {
|
|
166
|
+
if (normalize(elements[i].getAttribute("data-hotkey")) === sequence) return elements[i];
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Whether any hotkey sequence begins with this key (i.e. it's a valid leader). */
|
|
172
|
+
function isLeader(elements, key) {
|
|
173
|
+
for (var i = 0; i < elements.length; i++) {
|
|
174
|
+
if (normalize(elements[i].getAttribute("data-hotkey")).indexOf(key + " ") === 0) return true;
|
|
175
|
+
}
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Handle a delegated keydown: modifier combos first, then (when not typing) bare-key leader sequences.
|
|
181
|
+
* @param {KeyboardEvent} event
|
|
182
|
+
* @returns {void}
|
|
183
|
+
*/
|
|
184
|
+
function onKeydown(event) {
|
|
185
|
+
var elements = document.querySelectorAll("[data-hotkey]");
|
|
186
|
+
|
|
187
|
+
if (handleCombo(event, elements)) return;
|
|
188
|
+
|
|
189
|
+
// Leader sequences and "?" are bare-key and never fire while typing, so they can't clash with the browser.
|
|
190
|
+
if (event.ctrlKey || event.metaKey || event.altKey || isEditable(document.activeElement)) {
|
|
191
|
+
leader = null;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (event.key === "?") {
|
|
196
|
+
event.preventDefault();
|
|
197
|
+
toggleHelp();
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
handleSequence(event.key.toLowerCase(), event, elements);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Delegated so it works for any current or future element without re-binding across Turbo swaps.
|
|
205
|
+
document.addEventListener("keydown", onKeydown);
|
|
206
|
+
|
|
207
|
+
// Expose the help toggle so a visible control (the header's "?" key button) can open the overlay via
|
|
208
|
+
// the same populate-and-open path the "?" keypress uses. Mirrors theme.js's global toggleTheme().
|
|
209
|
+
window.toggleHotkeyHelp = function () {
|
|
210
|
+
toggleHelp();
|
|
211
|
+
};
|
|
212
|
+
})();
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Generic drag-to-resize for any container — self-contained and framework-agnostic.
|
|
2
|
+
//
|
|
3
|
+
// The engine ships and includes this directly (like theme.js), so it works regardless of the host
|
|
4
|
+
// app's JavaScript setup: no importmap, no Stimulus, no build step. It only needs the asset pipeline
|
|
5
|
+
// that already serves the engine's CSS/JS.
|
|
6
|
+
//
|
|
7
|
+
// Markup: mark a container with `data-resize` and give it a handle child with `data-resize-handle`.
|
|
8
|
+
//
|
|
9
|
+
// <aside data-resize data-resize-edge="left"
|
|
10
|
+
// data-resize-min="240" data-resize-max="720" data-resize-key="protege:panel-width">
|
|
11
|
+
// <div data-resize-handle></div>
|
|
12
|
+
// … content …
|
|
13
|
+
// </aside>
|
|
14
|
+
//
|
|
15
|
+
// edge: "left"|"right" resize width, "top"|"bottom" resize height (default "left").
|
|
16
|
+
// key: optional localStorage key — when set, the size is remembered across page loads.
|
|
17
|
+
(function () {
|
|
18
|
+
"use strict";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Whether an edge resizes along the horizontal axis (width) vs the vertical axis (height).
|
|
22
|
+
* @param {string} edge - "left" | "right" | "top" | "bottom"
|
|
23
|
+
* @returns {boolean} true for horizontal (left/right)
|
|
24
|
+
*/
|
|
25
|
+
function isHorizontal(edge) {
|
|
26
|
+
return edge === "left" || edge === "right";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The drag direction (toward the element's interior) that grows the element.
|
|
31
|
+
* @param {string} edge - "left" | "right" | "top" | "bottom"
|
|
32
|
+
* @returns {number} -1 when dragging up/left grows it, otherwise +1
|
|
33
|
+
*/
|
|
34
|
+
function growSign(edge) {
|
|
35
|
+
return edge === "left" || edge === "top" ? -1 : 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Constrain a size to the container's configured min/max.
|
|
40
|
+
* @param {number} value - the desired size in px
|
|
41
|
+
* @param {object} cfg - the resolved container config
|
|
42
|
+
* @returns {number} the clamped size
|
|
43
|
+
*/
|
|
44
|
+
function clampSize(value, cfg) {
|
|
45
|
+
return Math.max(cfg.min, Math.min(cfg.max, value));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Read the resize configuration from a container's data attributes.
|
|
50
|
+
* @param {HTMLElement} panel - the `[data-resize]` container
|
|
51
|
+
* @returns {{edge: string, horizontal: boolean, sign: number, min: number, max: number, key: string}}
|
|
52
|
+
*/
|
|
53
|
+
function readConfig(panel) {
|
|
54
|
+
var edge = panel.getAttribute("data-resize-edge") || "left";
|
|
55
|
+
return {
|
|
56
|
+
edge: edge,
|
|
57
|
+
horizontal: isHorizontal(edge),
|
|
58
|
+
sign: growSign(edge),
|
|
59
|
+
min: parseFloat(panel.getAttribute("data-resize-min")) || 0,
|
|
60
|
+
max: parseFloat(panel.getAttribute("data-resize-max")) || Infinity,
|
|
61
|
+
key: panel.getAttribute("data-resize-key") || ""
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Apply a (clamped) size to the container along its resize axis.
|
|
67
|
+
* @param {HTMLElement} panel - the container
|
|
68
|
+
* @param {object} cfg - the resolved config
|
|
69
|
+
* @param {number} size - the desired size in px
|
|
70
|
+
* @returns {void}
|
|
71
|
+
*/
|
|
72
|
+
function applySize(panel, cfg, size) {
|
|
73
|
+
panel.style[cfg.horizontal ? "width" : "height"] = clampSize(size, cfg) + "px";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Persist a container's current size to localStorage, if a key is configured.
|
|
78
|
+
* @param {HTMLElement} panel - the container
|
|
79
|
+
* @param {object} cfg - the resolved config
|
|
80
|
+
* @returns {void}
|
|
81
|
+
*/
|
|
82
|
+
function persist(panel, cfg) {
|
|
83
|
+
if (!cfg.key) return;
|
|
84
|
+
|
|
85
|
+
var size = cfg.horizontal ? panel.offsetWidth : panel.offsetHeight;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
window.localStorage.setItem(cfg.key, size);
|
|
89
|
+
} catch (e) {
|
|
90
|
+
/* storage unavailable */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Begin a drag from a handle's pointerdown: track the pointer and resize its container until release.
|
|
96
|
+
* @param {PointerEvent} event - the delegated pointerdown event
|
|
97
|
+
* @returns {void}
|
|
98
|
+
*/
|
|
99
|
+
function beginDrag(event) {
|
|
100
|
+
var handle = event.target.closest("[data-resize-handle]");
|
|
101
|
+
if (!handle) return;
|
|
102
|
+
|
|
103
|
+
var panel = handle.closest("[data-resize]");
|
|
104
|
+
if (!panel) return;
|
|
105
|
+
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
var cfg = readConfig(panel);
|
|
108
|
+
var rect = panel.getBoundingClientRect();
|
|
109
|
+
var startPos = cfg.horizontal ? event.clientX : event.clientY;
|
|
110
|
+
var startSize = cfg.horizontal ? rect.width : rect.height;
|
|
111
|
+
|
|
112
|
+
function onMove(e) {
|
|
113
|
+
var pos = cfg.horizontal ? e.clientX : e.clientY;
|
|
114
|
+
applySize(panel, cfg, startSize + cfg.sign * (pos - startPos));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function onUp() {
|
|
118
|
+
document.removeEventListener("pointermove", onMove);
|
|
119
|
+
document.removeEventListener("pointerup", onUp);
|
|
120
|
+
document.body.style.userSelect = "";
|
|
121
|
+
document.body.style.cursor = "";
|
|
122
|
+
persist(panel, cfg);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
document.addEventListener("pointermove", onMove);
|
|
126
|
+
document.addEventListener("pointerup", onUp);
|
|
127
|
+
document.body.style.userSelect = "none";
|
|
128
|
+
document.body.style.cursor = cfg.horizontal ? "col-resize" : "row-resize";
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Restore every resizable container's persisted size. Runs on load and after Turbo navigations.
|
|
133
|
+
* @returns {void}
|
|
134
|
+
*/
|
|
135
|
+
function restoreAll() {
|
|
136
|
+
var panels = document.querySelectorAll("[data-resize][data-resize-key]");
|
|
137
|
+
|
|
138
|
+
Array.prototype.forEach.call(panels, function (panel) {
|
|
139
|
+
var cfg = readConfig(panel);
|
|
140
|
+
var saved = parseFloat(window.localStorage.getItem(cfg.key));
|
|
141
|
+
if (saved) applySize(panel, cfg, saved);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Delegated so it works for any current or future container without re-binding across Turbo swaps.
|
|
146
|
+
document.addEventListener("pointerdown", beginDrag);
|
|
147
|
+
document.addEventListener("DOMContentLoaded", restoreAll);
|
|
148
|
+
document.addEventListener("turbo:load", restoreAll);
|
|
149
|
+
})();
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
|
|
2
|
+
if (localStorage.getItem("protege-theme") === "dark") {
|
|
3
|
+
document.documentElement.classList.add("dark");
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// Called by the toggle button onclick
|
|
7
|
+
window.toggleTheme = function () {
|
|
8
|
+
var isDark = document.documentElement.classList.toggle("dark");
|
|
9
|
+
localStorage.setItem("protege-theme", isDark ? "dark" : "light");
|
|
10
|
+
|
|
11
|
+
var dot = document.querySelector(".toggle-dot");
|
|
12
|
+
if (dot) {
|
|
13
|
+
dot.classList.toggle("translate-x-4", isDark);
|
|
14
|
+
dot.classList.toggle("translate-x-1", !isDark);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Sync toggle dot position on page load
|
|
19
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
20
|
+
var dot = document.querySelector(".toggle-dot");
|
|
21
|
+
if (dot && document.documentElement.classList.contains("dark")) {
|
|
22
|
+
dot.classList.add("translate-x-4");
|
|
23
|
+
dot.classList.remove("translate-x-1");
|
|
24
|
+
}
|
|
25
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
*/
|