mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c416dd76e | ||
|
|
1cc4ca6251 | ||
|
|
a27423c845 | ||
|
|
2692b8a1b5 | ||
|
|
7a75a6c887 | ||
|
|
236b882558 | ||
|
|
7ee648dacb | ||
|
|
9bfa6f7f4a | ||
|
|
f9c12b9d78 | ||
|
|
0a3f182e70 | ||
|
|
9c5c1f47f8 | ||
|
|
14c0ad2d16 | ||
|
|
6736d8fe68 | ||
|
|
4ecd830587 | ||
|
|
a0ecc8e823 | ||
|
|
8334b612a8 | ||
|
|
349c0b0537 | ||
|
|
2af09afc66 | ||
|
|
e22a3d9ef3 | ||
|
|
d3c92cecfa | ||
|
|
af4672084d | ||
|
|
b7103cf010 | ||
|
|
13a1835c00 | ||
|
|
c35407d7a9 | ||
|
|
e55ccc0b76 | ||
|
|
b8d00870b4 | ||
|
|
5413fbc248 | ||
|
|
2d2eb7ce4d | ||
|
|
ed2ddbf74e | ||
|
|
b99e5d1262 | ||
|
|
87f6878eda | ||
|
|
99b815eb4b | ||
|
|
e40a29b9ab | ||
|
|
151890bb72 | ||
|
|
78edd87cc1 | ||
|
|
88c99a2b35 | ||
|
|
282b8cfa73 | ||
|
|
e9888c7466 | ||
|
|
0647befb3a | ||
|
|
85aa3d0451 | ||
|
|
6d2b3bb654 | ||
|
|
b394854e9d | ||
|
|
2e586e930e | ||
|
|
8f8f69f0bc | ||
|
|
04663d55eb | ||
|
|
93d0b935e2 | ||
|
|
829b2b2855 | ||
|
|
cf6de444f4 | ||
|
|
126f5d58a2 | ||
|
|
09b93067f4 | ||
|
|
f14458f238 | ||
|
|
445139f05c | ||
|
|
e5e4965f05 | ||
|
|
1c8f7b0316 | ||
|
|
9fe3656652 | ||
|
|
17ae46bd61 | ||
|
|
a036483938 | ||
|
|
3c65b0e4ba | ||
|
|
ced5ab67ab | ||
|
|
28c4177128 | ||
|
|
5d86a08b5f | ||
|
|
a69fb81429 | ||
|
|
e6ac3380d4 | ||
|
|
3a535e7112 | ||
|
|
7464f9cfad | ||
|
|
58e24ef758 | ||
|
|
c1d809f989 | ||
|
|
80b24f0e2a | ||
|
|
2e253904a3 | ||
|
|
a7f381caf0 | ||
|
|
c6a7555f54 | ||
|
|
0772880bbc | ||
|
|
73339d8cd9 | ||
|
|
25504e8cb1 | ||
|
|
63a874528a | ||
|
|
c1c99621ff | ||
|
|
5c168f4a29 | ||
|
|
bef825c4e0 | ||
|
|
a990d22831 | ||
|
|
1687a372ae | ||
|
|
9b267d4a07 | ||
|
|
2189c2e9fe | ||
|
|
1e43fe8969 | ||
|
|
f77a716658 | ||
|
|
a70ac571e3 | ||
|
|
4f5e72f983 | ||
|
|
8f47ec6d4e | ||
|
|
9e1bbf2944 | ||
|
|
aa7d6e9aaa | ||
|
|
57c8be33ce | ||
|
|
ed7109bfc6 | ||
|
|
9e2590489b | ||
|
|
d793f00b37 | ||
|
|
5896c9a2aa | ||
|
|
78b0ce9828 | ||
|
|
e187644716 | ||
|
|
a847e7aa9c | ||
|
|
0905e7d215 | ||
|
|
91147d2f48 | ||
|
|
acce6195b0 | ||
|
|
ce9ab691ec | ||
|
|
4190544edc | ||
|
|
db8e5efbd5 | ||
|
|
a5c6f4e40c | ||
|
|
7537d05d1f |
No files matched your search
@@ -12,7 +12,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 4.0.5
|
||||
ruby-version: 4.0.9
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 4.0.5
|
||||
ruby-version: 4.0.9
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Install Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 4.0.5
|
||||
ruby-version: 4.0.9
|
||||
- name: Cache gems
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
container:
|
||||
image: ruby:4.0.5-alpine
|
||||
image: ruby:4.0.9-alpine
|
||||
|
||||
services:
|
||||
postgres:
|
||||
|
||||
+8
-5
@@ -1,4 +1,4 @@
|
||||
FROM ruby:4.0.5-alpine AS download
|
||||
FROM ruby:4.0.9-alpine AS download
|
||||
|
||||
WORKDIR /fonts
|
||||
|
||||
@@ -17,7 +17,7 @@ RUN apk --no-cache add wget unzip && \
|
||||
mkdir -p /pdfium-linux && \
|
||||
unzip -q pdfium-linux.zip -d /pdfium-linux
|
||||
|
||||
FROM ruby:4.0.5-alpine AS webpack
|
||||
FROM ruby:4.0.9-alpine AS webpack
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV NODE_ENV=production
|
||||
@@ -44,7 +44,7 @@ COPY ./app/views ./app/views
|
||||
|
||||
RUN echo "gem 'shakapacker'" > Gemfile && ./bin/shakapacker
|
||||
|
||||
FROM ruby:4.0.5-alpine AS app
|
||||
FROM ruby:4.0.9-alpine AS app
|
||||
|
||||
ENV RAILS_ENV=production
|
||||
ENV BUNDLE_WITHOUT="development:test"
|
||||
@@ -52,7 +52,8 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \
|
||||
RUN apk upgrade --no-cache ca-certificates-bundle && \
|
||||
apk add --no-cache libpq vips redis onnxruntime leptonica jemalloc font-liberation font-carlito && \
|
||||
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
|
||||
|
||||
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
|
||||
@@ -91,7 +92,7 @@ COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
|
||||
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
|
||||
|
||||
RUN mkdir -p /app/public/fonts && ln -s /fonts/DancingScript-Regular.otf /app/public/fonts/ && \
|
||||
mkdir -p /usr/share/fonts/noto && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/ && fc-cache -f && \
|
||||
mkdir -p /usr/share/fonts/noto/bold && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/bold/ && fc-cache -f && \
|
||||
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
|
||||
chown -R docuseal:docuseal /app/tmp/cache
|
||||
|
||||
@@ -100,6 +101,8 @@ ENV HOME=/home/docuseal
|
||||
ENV WORKDIR=/data/docuseal
|
||||
ENV VIPS_MAX_COORD=17000
|
||||
ENV VIPS_BLOCK_UNTRUSTED=1
|
||||
# ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
|
||||
ENV MALLOC_CONF=dirty_decay_ms:1000,narenas:2,background_thread:true
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '4.0.5'
|
||||
ruby '4.0.9'
|
||||
|
||||
gem 'addressable'
|
||||
gem 'arabic-letter-connector', require: false
|
||||
@@ -45,6 +45,7 @@ gem 'trilogy', require: false
|
||||
gem 'turbo-rails'
|
||||
gem 'twitter_cldr', require: false
|
||||
gem 'tzinfo-data'
|
||||
gem 'wordcraft', require: false, group: :ce
|
||||
gem 'xlsxtream', require: false
|
||||
|
||||
group :development, :test do
|
||||
|
||||
+95
-84
@@ -3,29 +3,29 @@ GEM
|
||||
specs:
|
||||
action_text-trix (2.1.19)
|
||||
railties
|
||||
actioncable (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
actioncable (8.1.4)
|
||||
actionpack (= 8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
actionmailbox (8.1.4)
|
||||
actionpack (= 8.1.4)
|
||||
activejob (= 8.1.4)
|
||||
activerecord (= 8.1.4)
|
||||
activestorage (= 8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
actionmailer (8.1.4)
|
||||
actionpack (= 8.1.4)
|
||||
actionview (= 8.1.4)
|
||||
activejob (= 8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
actionpack (8.1.4)
|
||||
actionview (= 8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
@@ -33,36 +33,36 @@ GEM
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.3.1)
|
||||
actiontext (8.1.4)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
actionpack (= 8.1.4)
|
||||
activerecord (= 8.1.4)
|
||||
activestorage (= 8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
actionview (8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
activejob (8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
activerecord (8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
activemodel (8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
activerecord (8.1.4)
|
||||
activemodel (= 8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
activestorage (8.1.4)
|
||||
actionpack (= 8.1.4)
|
||||
activejob (= 8.1.4)
|
||||
activerecord (= 8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.3.1)
|
||||
activesupport (8.1.4)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
@@ -116,7 +116,7 @@ GEM
|
||||
erubi (~> 1.4)
|
||||
parser (>= 2.4)
|
||||
smart_properties
|
||||
bigdecimal (4.1.2)
|
||||
bigdecimal (4.1.3)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
@@ -149,14 +149,14 @@ GEM
|
||||
crack (1.0.1)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.7)
|
||||
crass (1.0.8)
|
||||
csv (3.3.5)
|
||||
csv-safe (3.3.1)
|
||||
csv (~> 3.0)
|
||||
cuprite (0.17)
|
||||
capybara (~> 3.0)
|
||||
ferrum (~> 0.17.0)
|
||||
date (3.5.1)
|
||||
date (3.6.0)
|
||||
debug (1.11.1)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
@@ -179,7 +179,7 @@ GEM
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
email_typo (0.2.3)
|
||||
erb (6.0.6)
|
||||
erb (6.0.7)
|
||||
erb_lint (0.9.0)
|
||||
activesupport
|
||||
better_html (>= 2.0.1)
|
||||
@@ -195,8 +195,8 @@ GEM
|
||||
railties (>= 6.1.0)
|
||||
faker (3.6.1)
|
||||
i18n (>= 1.8.11, < 2)
|
||||
faraday (2.14.3)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
faraday (2.15.1)
|
||||
faraday-net_http (>= 2.0, < 3.6)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.5.0)
|
||||
@@ -264,14 +264,14 @@ GEM
|
||||
strscan (>= 3.1.2)
|
||||
i18n (1.15.2)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.8.2)
|
||||
io-console (0.9.5)
|
||||
irb (1.18.0)
|
||||
pp (>= 0.6.0)
|
||||
prism (>= 1.3.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jmespath (1.6.2)
|
||||
json (2.21.2)
|
||||
json (3.1.0)
|
||||
jwt (3.2.0)
|
||||
base64
|
||||
language_server-protocol (3.17.0.5)
|
||||
@@ -310,17 +310,17 @@ GEM
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.4)
|
||||
multi_json (1.19.1)
|
||||
multi_json (1.21.2)
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.6)
|
||||
net-imap (0.6.8)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
net-protocol (0.4.0)
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-smtp (0.5.2)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.4-aarch64-linux-gnu)
|
||||
@@ -361,7 +361,7 @@ GEM
|
||||
prettyprint
|
||||
pretender (1.0.0)
|
||||
actionpack (>= 7.2)
|
||||
prettyprint (0.2.0)
|
||||
prettyprint (0.2.1)
|
||||
prism (1.9.0)
|
||||
pry (0.16.0)
|
||||
coderay (~> 1.1)
|
||||
@@ -373,30 +373,30 @@ GEM
|
||||
puma (8.0.2)
|
||||
nio4r (~> 2.0)
|
||||
racc (1.8.1)
|
||||
rack (3.2.6)
|
||||
rack (3.2.7)
|
||||
rack-proxy (0.7.7)
|
||||
rack
|
||||
rack-session (2.1.2)
|
||||
base64 (>= 0.1.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack-test (2.3.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.1.3.1)
|
||||
actioncable (= 8.1.3.1)
|
||||
actionmailbox (= 8.1.3.1)
|
||||
actionmailer (= 8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
actiontext (= 8.1.3.1)
|
||||
actionview (= 8.1.3.1)
|
||||
activejob (= 8.1.3.1)
|
||||
activemodel (= 8.1.3.1)
|
||||
activerecord (= 8.1.3.1)
|
||||
activestorage (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
rails (8.1.4)
|
||||
actioncable (= 8.1.4)
|
||||
actionmailbox (= 8.1.4)
|
||||
actionmailer (= 8.1.4)
|
||||
actionpack (= 8.1.4)
|
||||
actiontext (= 8.1.4)
|
||||
actionview (= 8.1.4)
|
||||
activejob (= 8.1.4)
|
||||
activemodel (= 8.1.4)
|
||||
activerecord (= 8.1.4)
|
||||
activestorage (= 8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.3.1)
|
||||
railties (= 8.1.4)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -407,9 +407,9 @@ GEM
|
||||
rails-i18n (8.1.0)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.1.3.1)
|
||||
actionpack (= 8.1.3.1)
|
||||
activesupport (= 8.1.3.1)
|
||||
railties (8.1.4)
|
||||
actionpack (= 8.1.4)
|
||||
activesupport (= 8.1.4)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
@@ -418,11 +418,11 @@ GEM
|
||||
zeitwerk (~> 2.6)
|
||||
rainbow (3.1.1)
|
||||
rake (13.4.2)
|
||||
rbs (4.1.0)
|
||||
rbs (4.2.0)
|
||||
logger
|
||||
prism (>= 1.6.0)
|
||||
tsort
|
||||
rdoc (8.0.0)
|
||||
rdoc (8.1.0)
|
||||
erb
|
||||
prism (>= 1.6.0)
|
||||
rbs (>= 4.0.0)
|
||||
@@ -430,7 +430,7 @@ GEM
|
||||
redis-client (0.29.0)
|
||||
connection_pool
|
||||
regexp_parser (2.11.3)
|
||||
reline (0.6.3)
|
||||
reline (0.7.0)
|
||||
io-console (~> 0.5)
|
||||
representable (3.2.0)
|
||||
declarative (< 0.1.0)
|
||||
@@ -466,14 +466,14 @@ GEM
|
||||
rspec-mocks (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (>= 3.13.0, < 5.0.0)
|
||||
rspec-support (3.13.7)
|
||||
rubocop (1.86.0)
|
||||
json (~> 2.3)
|
||||
rubocop (1.92.1)
|
||||
json (>= 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
parallel (~> 1.10)
|
||||
parallel (>= 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.9.3, < 3.0)
|
||||
regexp_parser (>= 2.11.3, < 3.0)
|
||||
rubocop-ast (>= 1.49.0, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 4.0)
|
||||
@@ -497,7 +497,7 @@ GEM
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.2)
|
||||
rubyzip (3.7.0)
|
||||
securerandom (0.4.1)
|
||||
semantic_range (3.1.1)
|
||||
shakapacker (9.7.0)
|
||||
@@ -524,11 +524,11 @@ GEM
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
smart_properties (1.17.0)
|
||||
sqlite3 (2.9.5-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.5-aarch64-linux-musl)
|
||||
sqlite3 (2.9.5-arm64-darwin)
|
||||
sqlite3 (2.9.5-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.5-x86_64-linux-musl)
|
||||
sqlite3 (2.9.6-aarch64-linux-gnu)
|
||||
sqlite3 (2.9.6-aarch64-linux-musl)
|
||||
sqlite3 (2.9.6-arm64-darwin)
|
||||
sqlite3 (2.9.6-x86_64-linux-gnu)
|
||||
sqlite3 (2.9.6-x86_64-linux-musl)
|
||||
strip_attributes (2.0.1)
|
||||
activemodel (>= 3.0, < 9.0)
|
||||
strscan (3.1.8)
|
||||
@@ -555,7 +555,7 @@ GEM
|
||||
unicode-emoji (~> 4.1)
|
||||
unicode-emoji (4.2.0)
|
||||
uniform_notifier (1.18.0)
|
||||
uri (1.1.1)
|
||||
uri (1.1.2)
|
||||
useragent (0.16.11)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
@@ -572,12 +572,22 @@ GEM
|
||||
base64
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
wordcraft (0.11.0-aarch64-linux-gnu)
|
||||
ffi (>= 1.15)
|
||||
wordcraft (0.11.0-aarch64-linux-musl)
|
||||
ffi (>= 1.15)
|
||||
wordcraft (0.11.0-arm64-darwin)
|
||||
ffi (>= 1.15)
|
||||
wordcraft (0.11.0-x86_64-linux-gnu)
|
||||
ffi (>= 1.15)
|
||||
wordcraft (0.11.0-x86_64-linux-musl)
|
||||
ffi (>= 1.15)
|
||||
xlsxtream (3.1.0)
|
||||
zip_kit (>= 6.2, < 7)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
yaml (0.4.0)
|
||||
zeitwerk (2.8.2)
|
||||
zeitwerk (2.8.3)
|
||||
zip_kit (6.3.4)
|
||||
|
||||
PLATFORMS
|
||||
@@ -650,10 +660,11 @@ DEPENDENCIES
|
||||
tzinfo-data
|
||||
web-console
|
||||
webmock
|
||||
wordcraft
|
||||
xlsxtream
|
||||
|
||||
RUBY VERSION
|
||||
ruby 4.0.5
|
||||
ruby 4.0.9
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.3
|
||||
@@ -40,7 +40,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
- Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||
- Automatic PDF eSignature
|
||||
- PDF signature verification
|
||||
- Users management
|
||||
- User management
|
||||
- Mobile-optimized
|
||||
- 7 UI languages with signing available in 14 languages
|
||||
- API and Webhooks for integrations
|
||||
|
||||
@@ -9,6 +9,7 @@ Out of scope vulnerabilities:
|
||||
- CSRF
|
||||
- DNSSEC, CAA, CSP headers
|
||||
- DNS or email security related
|
||||
- SSRF
|
||||
- Rate Limiting
|
||||
|
||||
Note: We reserve the right to classify any reported vulnerability as out of scope for the bug bounty program.
|
||||
@@ -54,14 +54,13 @@ class AccountsController < ApplicationController
|
||||
email: true_user.email.sub('@', '+removed@'))
|
||||
true_user.account.update!(archived_at: Time.current)
|
||||
|
||||
# rubocop:disable Layout/LineLength
|
||||
# rubocop:disable-next Layout/LineLength
|
||||
render turbo_stream: turbo_stream.replace(
|
||||
:account_delete_button,
|
||||
html: helpers.tag.p(I18n.t('your_account_will_be_permanently_deleted_within_2_months_please_contact_us_if_you_want_to_keep_your_account')) +
|
||||
helpers.tag.br +
|
||||
helpers.tag.p(I18n.t('your_email_address_has_been_released_immediately_you_can_now_be_added_to_your_company_team_without_waiting_for_the_deletion_period_to_end'))
|
||||
)
|
||||
# rubocop:enable Layout/LineLength
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -11,7 +11,7 @@ module Api
|
||||
before_action :set_noindex_headers
|
||||
before_action :set_security_headers
|
||||
|
||||
# rubocop:disable Metrics
|
||||
# rubocop:disable-next Metrics
|
||||
def show
|
||||
blob_uuid, purp, exp = ApplicationRecord.signed_id_verifier.verified(params[:signed_uuid])
|
||||
|
||||
@@ -53,7 +53,6 @@ module Api
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
|
||||
private
|
||||
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class ActiveStorageBlobsProxyLegacyController < ApiBaseController
|
||||
include ActiveStorage::Streaming
|
||||
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
before_action :set_cors_headers
|
||||
before_action :set_noindex_headers
|
||||
before_action :set_security_headers
|
||||
|
||||
# rubocop:disable Metrics
|
||||
def show
|
||||
Rollbar.info('Blob legacy') if defined?(Rollbar)
|
||||
|
||||
blob = ActiveStorage::Blob.find_signed(params[:signed_blob_id] || params[:signed_id])
|
||||
|
||||
return head :not_found unless blob
|
||||
|
||||
if FilenameUtils.dangerous_extension(blob.filename)
|
||||
Rollbar.error('Dangerous extension') if defined?(Rollbar)
|
||||
|
||||
return head :unprocessable_content
|
||||
end
|
||||
|
||||
is_permitted = blob.attachments.any? do |a|
|
||||
(current_user && a.record.account.id == current_user.account_id) ||
|
||||
a.record.account.account_configs.any? { |e| e.key == 'legacy_blob_proxy' } ||
|
||||
a.name == 'logo'
|
||||
end
|
||||
|
||||
unless is_permitted
|
||||
Rollbar.error("Blob account not found: #{blob.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: 'Not authenticated' }, status: :unauthorized
|
||||
end
|
||||
|
||||
if request.headers['Range'].present?
|
||||
send_blob_byte_range_data blob, request.headers['Range']
|
||||
else
|
||||
http_cache_forever public: true do
|
||||
response.headers['Accept-Ranges'] = 'bytes'
|
||||
response.headers['Content-Length'] = blob.byte_size.to_s
|
||||
|
||||
send_blob_stream blob, disposition: params[:disposition]
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
end
|
||||
@@ -1,74 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class AttachmentsController < ActionController::API
|
||||
include ActionController::Cookies
|
||||
include ActiveStorage::SetCurrent
|
||||
|
||||
COOKIE_STORE_LIMIT = 10
|
||||
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
unless can_upload?(@submitter)
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
file = params[:file]
|
||||
|
||||
if params[:type].in?(%w[initials signature])
|
||||
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
|
||||
|
||||
if ImageUtils.blank?(image)
|
||||
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if ImageUtils.error?(image)
|
||||
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} error, try to sign on another device" },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
|
||||
end
|
||||
|
||||
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
|
||||
|
||||
if params[:remember_signature] == 'true' && @submitter.email.present?
|
||||
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
|
||||
end
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
rescue Submitters::MaliciousFileExtension => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
def can_upload?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at?
|
||||
end
|
||||
|
||||
def build_new_cookie_signatures_json(submitter, attachment)
|
||||
values =
|
||||
begin
|
||||
JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}')
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
values[submitter.email] = attachment.uuid
|
||||
|
||||
values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT
|
||||
|
||||
values.to_json
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -98,7 +98,7 @@ module Api
|
||||
|
||||
render json: build_create_json(submissions)
|
||||
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
|
||||
DownloadUtils::UnableToDownload => e
|
||||
Submitters::SubmitValues::ValidationError, DownloadUtils::UnableToDownload => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class SubmitterEmailClicksController < ActionController::API
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
if params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
|
||||
end
|
||||
|
||||
render json: {}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -36,7 +36,7 @@ module Api
|
||||
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true, params:)
|
||||
end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
# rubocop:disable-next Metrics/MethodLength
|
||||
def update
|
||||
submission = @submitter.submission
|
||||
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']
|
||||
@@ -78,12 +78,12 @@ module Api
|
||||
|
||||
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
|
||||
with_events: false, params:)
|
||||
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
|
||||
rescue Submitters::NormalizeValues::BaseError, Submitters::SubmitValues::ValidationError,
|
||||
DownloadUtils::UnableToDownload => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
||||
def submitter_params
|
||||
submitter_params = params.key?(:submitter) ? params.require(:submitter) : params
|
||||
@@ -178,14 +178,29 @@ module Api
|
||||
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
|
||||
submitters = submitters.where(slug: params[:slug]) if params[:slug].present?
|
||||
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
|
||||
|
||||
if params[:template_id].present?
|
||||
submitters = submitters.joins(:submission).where(submissions: { template_id: params[:template_id] })
|
||||
end
|
||||
submitters = filter_submitters_by_template_id(submitters, params) if params[:template_id].present?
|
||||
|
||||
maybe_filter_by_completed_at(submitters, params)
|
||||
end
|
||||
|
||||
def filter_submitters_by_template_id(submitters, params)
|
||||
return submitters.where(template_id: params[:template_id]) if params[:template_id].is_a?(Array)
|
||||
|
||||
template = Template.find_by(id: params[:template_id])
|
||||
|
||||
return submitters.none if template.nil? || !can?(:read, template)
|
||||
|
||||
if template.account_id == current_user.account_id &&
|
||||
params[:completed_after].blank? && params[:completed_before].blank?
|
||||
account_id_bind = Submitter.predicate_builder.build_bind_attribute('account_id', current_user.account_id)
|
||||
|
||||
submitters = submitters.unscope(where: :account_id)
|
||||
.where((Submitter.arel_table[:account_id] + 0).eq(account_id_bind))
|
||||
end
|
||||
|
||||
submitters.where(template_id: template.id)
|
||||
end
|
||||
|
||||
def assign_external_id(submitter, attrs)
|
||||
submitter.external_id = attrs[:application_key] if attrs.key?(:application_key)
|
||||
submitter.external_id = attrs[:external_id] if attrs.key?(:external_id)
|
||||
|
||||
@@ -118,7 +118,12 @@ class ApplicationController < ActionController::Base
|
||||
end
|
||||
|
||||
def sign_in_for_demo
|
||||
sign_in(User.active.order('random()').take) unless signed_in?
|
||||
return if signed_in?
|
||||
|
||||
account = Account.create!(name: "Demo #{SecureRandom.hex(3)}")
|
||||
|
||||
sign_in(account.users.create!(first_name: 'Demo', last_name: 'User',
|
||||
email: "#{SecureRandom.hex}@docuseal.com", password: SecureRandom.hex))
|
||||
end
|
||||
|
||||
def current_account
|
||||
|
||||
@@ -30,7 +30,7 @@ module Mcp
|
||||
}
|
||||
}.freeze
|
||||
|
||||
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
||||
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
|
||||
def call
|
||||
account = current_user.account
|
||||
|
||||
@@ -87,6 +87,5 @@ module Mcp
|
||||
edit_url: edit_template_url(@template)
|
||||
)
|
||||
end
|
||||
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
||||
end
|
||||
end
|
||||
@@ -66,7 +66,7 @@ module Mcp
|
||||
}
|
||||
}.freeze
|
||||
|
||||
# rubocop:disable Metrics
|
||||
# rubocop:disable-next Metrics
|
||||
def call
|
||||
@template = Template.accessible_by(current_ability).find(mcp_params['template_id'])
|
||||
|
||||
@@ -115,6 +115,5 @@ module Mcp
|
||||
rescue Submissions::CreateFromSubmitters::BaseError => e
|
||||
render_tool_error(e.message)
|
||||
end
|
||||
# rubocop:enable Metrics
|
||||
end
|
||||
end
|
||||
@@ -1,9 +1,8 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class PasswordsController < Devise::PasswordsController
|
||||
# rubocop:disable Rails/LexicallyScopedActionFilter
|
||||
# rubocop:disable-next Rails/LexicallyScopedActionFilter
|
||||
skip_before_action :require_no_authentication, only: %i[edit update]
|
||||
# rubocop:enable Rails/LexicallyScopedActionFilter
|
||||
|
||||
around_action :with_browser_locale
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class PreviewDocumentPageController < ActionController::API
|
||||
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |file|
|
||||
file.flock(File::LOCK_EX)
|
||||
|
||||
# rubocop:disable Style/ZeroLengthPredicate
|
||||
# rubocop:disable-next Style/ZeroLengthPredicate
|
||||
if file.size.zero?
|
||||
cleanup_stale_tempfiles
|
||||
|
||||
@@ -63,7 +63,6 @@ class PreviewDocumentPageController < ActionController::API
|
||||
else
|
||||
FileUtils.touch(file_path)
|
||||
end
|
||||
# rubocop:enable Style/ZeroLengthPredicate
|
||||
|
||||
file.flock(File::LOCK_UN)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ class SendSubmissionEmailController < ApplicationController
|
||||
template = Template.find_by!(slug: params[:template_slug])
|
||||
|
||||
@submitter =
|
||||
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
|
||||
Submitter.completed.where(template_id: template.id).find_by(email: params[:email].to_s.downcase)
|
||||
elsif params[:submission_slug]
|
||||
submission = Submission.find_by!(slug: params[:submission_slug])
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class StartFormController < ApplicationController
|
||||
@submitter = find_or_initialize_submitter(@template, submitter_params)
|
||||
|
||||
if @submitter.completed_at?
|
||||
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank)
|
||||
redirect_to start_form_completed_path(@template.slug, submitter_params.compact_blank.merge(lang:))
|
||||
else
|
||||
if Templates.filter_undefined_submitters(@template.submitters).size > 1 && @submitter.new_record?
|
||||
@error_message = multiple_submitters_error_message
|
||||
@@ -59,7 +59,7 @@ class StartFormController < ApplicationController
|
||||
|
||||
Submitters::StartForm.assign_start_form_cookie(@submitter, request)
|
||||
|
||||
redirect_to submit_form_path(@submitter.slug)
|
||||
redirect_to submit_form_path(@submitter.slug, lang:)
|
||||
else
|
||||
render :show, status: :unprocessable_content
|
||||
end
|
||||
@@ -80,7 +80,7 @@ class StartFormController < ApplicationController
|
||||
raise ActionController::RoutingError, I18n.t('not_found') if required_params.any? { |_, v| v.blank? } ||
|
||||
required_params.except('name').compact_blank.blank?
|
||||
|
||||
@submitter = Submitter.where(submission: @template.submissions)
|
||||
@submitter = Submitter.where(template_id: @template.id)
|
||||
.where.not(completed_at: nil)
|
||||
.find_by!(required_params.except('name'))
|
||||
end
|
||||
@@ -150,7 +150,7 @@ class StartFormController < ApplicationController
|
||||
return render :show, status: :unprocessable_content if submitter.errors.present?
|
||||
|
||||
if Submitters::StartForm.verify_2fa_and_save_submitter(submitter, request, is_new_record: submitter.new_record?)
|
||||
redirect_to submit_form_path(submitter.slug)
|
||||
redirect_to submit_form_path(submitter.slug, lang:)
|
||||
else
|
||||
if defined?(Rollbar) && template.preferences['shared_link_2fa'] != true
|
||||
Rollbar.info("2FA link requested: #{submitter.id}")
|
||||
@@ -167,10 +167,14 @@ class StartFormController < ApplicationController
|
||||
|
||||
render :error, status: :unprocessable_content
|
||||
rescue Submitters::UnableToSendCode, Submitters::InvalidOtp => e
|
||||
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
|
||||
redirect_to start_form_path(template.slug, lang:, params: submitter_params.merge(email_verification: true)),
|
||||
alert: e.message
|
||||
rescue RateLimit::LimitApproached
|
||||
redirect_to start_form_path(template.slug, params: submitter_params.merge(email_verification: true)),
|
||||
redirect_to start_form_path(template.slug, lang:, params: submitter_params.merge(email_verification: true)),
|
||||
alert: I18n.t(:too_many_attempts)
|
||||
end
|
||||
|
||||
def lang
|
||||
params[:lang].presence
|
||||
end
|
||||
end
|
||||
@@ -16,21 +16,21 @@ class StartFormEmail2faSendController < ApplicationController
|
||||
.submitters.new(**submitter_params, account_id: @template.account_id)
|
||||
|
||||
if @submitter.email.blank?
|
||||
return redirect_to start_form_path(@template.slug), alert: I18n.t(:provide_your_email_to_start)
|
||||
return redirect_to start_form_path(@template.slug, lang:), alert: I18n.t(:provide_your_email_to_start)
|
||||
end
|
||||
|
||||
Submitters.send_shared_link_email_verification_code(@submitter, request:)
|
||||
|
||||
redir_params = { notice: I18n.t(:code_has_been_resent) } if params[:resend]
|
||||
|
||||
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
|
||||
redirect_to start_form_path(@template.slug, lang:, params: submitter_params.merge(email_verification: true)),
|
||||
**redir_params
|
||||
rescue Submitters::BouncedEmail => e
|
||||
@error_message = e.message
|
||||
|
||||
render 'start_form/error', status: :unprocessable_content
|
||||
rescue Submitters::UnableToSendCode => e
|
||||
redirect_to start_form_path(@template.slug, params: submitter_params.merge(email_verification: true)),
|
||||
redirect_to start_form_path(@template.slug, lang:, params: submitter_params.merge(email_verification: true)),
|
||||
alert: e.message
|
||||
end
|
||||
|
||||
@@ -66,4 +66,8 @@ class StartFormEmail2faSendController < ApplicationController
|
||||
attrs[:email] = Submissions.normalize_email(attrs[:email])
|
||||
end
|
||||
end
|
||||
|
||||
def lang
|
||||
params[:lang].presence
|
||||
end
|
||||
end
|
||||
@@ -66,6 +66,8 @@ class SubmissionsController < ApplicationController
|
||||
create_submissions(@template, submissions_params, params)
|
||||
end
|
||||
|
||||
raise Submissions::CreateFromSubmitters::BaseError, 'Invalid email address' if submissions.blank?
|
||||
|
||||
WebhookUrls.enqueue_events(submissions, 'submission.created')
|
||||
|
||||
Submissions.send_signature_requests(submissions)
|
||||
|
||||
@@ -25,5 +25,7 @@ class SubmissionsExportController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def new; end
|
||||
def new
|
||||
render layout: turbo_native_android? ? 'plain' : true
|
||||
end
|
||||
end
|
||||
@@ -14,6 +14,6 @@ class SubmissionsFiltersController < ApplicationController
|
||||
def show
|
||||
return head :not_found unless ALLOWED_NAMES.include?(params[:name])
|
||||
|
||||
render params[:name]
|
||||
render params[:name], layout: turbo_native_android? ? 'plain' : true
|
||||
end
|
||||
end
|
||||
@@ -20,7 +20,7 @@ class SubmitFormController < ApplicationController
|
||||
return render :email_2fa unless Submitters::AuthorizedForForm.pass_email_2fa?(@submitter, request)
|
||||
|
||||
if @submitter.completed_at? || submission.completed_at?
|
||||
return redirect_to submit_form_completed_path(@submitter.slug)
|
||||
return redirect_to submit_form_completed_path(@submitter.slug, lang:)
|
||||
end
|
||||
|
||||
@form_configs = Submitters::FormConfigs.call(@submitter, CONFIG_KEYS)
|
||||
@@ -96,7 +96,7 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
redirect_to submit_form_path(params[:submit_form_slug])
|
||||
redirect_to submit_form_path(params[:submit_form_slug], lang:)
|
||||
end
|
||||
|
||||
def success; end
|
||||
@@ -114,7 +114,7 @@ class SubmitFormController < ApplicationController
|
||||
def maybe_require_link_2fa
|
||||
return if Submitters::AuthorizedForForm.pass_link_2fa?(@submitter, current_user, request)
|
||||
|
||||
redirect_to start_form_path(@submitter.submission.template.slug)
|
||||
redirect_to start_form_path(@submitter.submission.template.slug, lang:)
|
||||
end
|
||||
|
||||
def maybe_render_locked_page
|
||||
@@ -144,4 +144,8 @@ class SubmitFormController < ApplicationController
|
||||
ActiveStorage::Attachment.where(record: submission.submitters, name: :attachments)
|
||||
.preload(:blob).index_by(&:uuid)
|
||||
end
|
||||
|
||||
def lang
|
||||
params[:lang].presence
|
||||
end
|
||||
end
|
||||
@@ -10,29 +10,27 @@ class SubmitFormEmail2fasController < ApplicationController
|
||||
|
||||
def create
|
||||
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||
RateLimit.call("verify-2fa-code-1h-#{@submitter.id}", limit: 10, ttl: 1.hour, enabled: true)
|
||||
RateLimit.call("verify-2fa-code-1d-#{@submitter.id}", limit: 20, ttl: 1.day, enabled: true)
|
||||
|
||||
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
|
||||
value = [@submitter.email.downcase.squish, @submitter.slug].join(':')
|
||||
|
||||
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value)
|
||||
if OtpChallenges.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value, purpose: 'form_email_2fa')
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
|
||||
|
||||
cookies.encrypted[:email_2fa_slug] =
|
||||
{ value: @submitter.slug, expires: Submitters::StartForm::COOKIES_TTL.from_now,
|
||||
**Submitters::StartForm::COOKIES_DEFAULTS }
|
||||
|
||||
redirect_to submit_form_path(@submitter.slug)
|
||||
redirect_to submit_form_path(@submitter.slug, lang:)
|
||||
else
|
||||
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:invalid_code)
|
||||
redirect_to submit_form_path(@submitter.slug, status: :error, lang:), alert: I18n.t(:invalid_code)
|
||||
end
|
||||
rescue RateLimit::LimitApproached
|
||||
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts)
|
||||
redirect_to submit_form_path(@submitter.slug, status: :error, lang:), alert: I18n.t(:too_many_attempts)
|
||||
end
|
||||
|
||||
def update
|
||||
if @submitter.submission_events.where(event_type: 'send_2fa_email').exists?(created_at: 15.seconds.ago..)
|
||||
return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:rate_limit_exceeded)
|
||||
return redirect_to submit_form_path(@submitter.slug, status: :error, lang:), alert: I18n.t(:rate_limit_exceeded)
|
||||
end
|
||||
|
||||
RateLimit.call("send-email-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||
@@ -40,19 +38,24 @@ class SubmitFormEmail2fasController < ApplicationController
|
||||
if Docuseal.multitenant? && Submitters.email_bounced_recently?(@submitter.email)
|
||||
Rollbar.warning("Bounced OTP email for submitter: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:verification_email_bounced)
|
||||
return redirect_to submit_form_path(@submitter.slug, status: :error, lang:),
|
||||
alert: I18n.t(:verification_email_bounced)
|
||||
end
|
||||
|
||||
SendSubmitterVerificationEmailJob.perform_async('submitter_id' => @submitter.id, 'locale' => I18n.locale.to_s)
|
||||
|
||||
redir_params = params[:resend] ? { alert: I18n.t(:code_has_been_resent) } : {}
|
||||
|
||||
redirect_to submit_form_path(@submitter.slug, status: :sent), **redir_params
|
||||
redirect_to submit_form_path(@submitter.slug, status: :sent, lang:), **redir_params
|
||||
rescue RateLimit::LimitApproached
|
||||
redirect_to submit_form_path(@submitter.slug, status: :error), alert: I18n.t(:too_many_attempts)
|
||||
redirect_to submit_form_path(@submitter.slug, status: :error, lang:), alert: I18n.t(:too_many_attempts)
|
||||
end
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
end
|
||||
|
||||
def lang
|
||||
params[:lang].presence
|
||||
end
|
||||
end
|
||||
@@ -17,12 +17,12 @@ class SubmitFormMetadataController < ApplicationController
|
||||
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
|
||||
end
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
|
||||
ActiveRecord::Associations::Preloader.new(records: documents, associations: :blob).call
|
||||
|
||||
text_runs = documents.to_h do |document|
|
||||
[
|
||||
document.uuid,
|
||||
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
|
||||
DocumentMetadatas.find_or_create_for_document(document, account_id: submission.account_id).text_runs
|
||||
]
|
||||
end
|
||||
|
||||
|
||||
@@ -4,19 +4,19 @@ class SubmitFormPrefillAttachmentsController < ActionController::API
|
||||
include ActiveStorage::SetCurrent
|
||||
|
||||
def create
|
||||
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
unless can_upload?(submitter)
|
||||
unless can_upload?(@submitter)
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment)
|
||||
|
||||
raise ActiveRecord::RecordNotFound if slug != submitter.slug
|
||||
raise ActiveRecord::RecordNotFound if slug != @submitter.slug
|
||||
|
||||
source_attachment = ActiveStorage::Attachment.find_by!(uuid:)
|
||||
|
||||
attachment = submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id)
|
||||
attachment = @submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id)
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
end
|
||||
|
||||
@@ -34,7 +34,7 @@ class SubmitFormUploadController < ActionController::API
|
||||
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
|
||||
|
||||
if params[:remember_signature] == 'true' && @submitter.email.present?
|
||||
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
|
||||
cookies.permanent.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
|
||||
end
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
|
||||
@@ -81,9 +81,26 @@ class SubmittersController < ApplicationController
|
||||
|
||||
submitter.name = attrs[:name] if attrs.key?(:name)
|
||||
|
||||
if submitter_changed?(submitter)
|
||||
submitter.submitter_versions.new(slug: submitter.slug, email: submitter.email_was,
|
||||
name: submitter.name_was, phone: submitter.phone_was)
|
||||
|
||||
submitter.slug = SecureRandom.base58(14)
|
||||
submitter.values = Submitters.fetch_values_for_delegate(submitter)
|
||||
end
|
||||
|
||||
submitter
|
||||
end
|
||||
|
||||
def submitter_changed?(submitter)
|
||||
if submitter.email_was.blank? && submitter.phone_was.blank?
|
||||
submitter.name_was.present? && submitter.name_changed?
|
||||
else
|
||||
(submitter.email_was.present? && submitter.email_changed?) ||
|
||||
(submitter.phone_was.present? && submitter.phone_changed?)
|
||||
end
|
||||
end
|
||||
|
||||
def submitter_params
|
||||
params.require(:submitter).permit(:email, :name, :phone).transform_values(&:strip)
|
||||
end
|
||||
|
||||
@@ -6,7 +6,17 @@ class TemplateDocumentsController < ApplicationController
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
documents = @template.schema.map do |item|
|
||||
if item['dynamic']
|
||||
dynamic_document = @template.schema_dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
|
||||
DynamicDocuments::EnsureVersionGenerated.call(dynamic_document).document_attachment
|
||||
else
|
||||
@template.schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
end
|
||||
end
|
||||
|
||||
render json: documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -41,7 +41,9 @@ class TemplateFoldersController < ApplicationController
|
||||
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
def edit; end
|
||||
def edit
|
||||
render layout: turbo_native_android? ? 'plain' : true
|
||||
end
|
||||
|
||||
def update
|
||||
if @template_folder != current_account.default_template_folder &&
|
||||
|
||||
@@ -8,7 +8,9 @@ class TemplateSharingsTestingController < ApplicationController
|
||||
end
|
||||
|
||||
def create
|
||||
testing_account = Accounts.find_or_create_testing_user(true_user.account).account
|
||||
account = can?(:manage, :tenants_test) ? current_account : true_user.account
|
||||
|
||||
testing_account = Accounts.find_or_create_testing_user(account).account
|
||||
|
||||
if params[:value] == '1'
|
||||
TemplateSharing.create!(ability: :manage, account: testing_account, template: @template)
|
||||
|
||||
@@ -7,6 +7,8 @@ class TemplatesCloneController < ApplicationController
|
||||
authorize!(:create, Template)
|
||||
|
||||
@template = Template.new(name: "#{@base_template.name} (#{I18n.t('clone')})")
|
||||
|
||||
render layout: turbo_native_android? ? 'plain' : true
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
class TemplatesFoldersController < ApplicationController
|
||||
load_and_authorize_resource :template
|
||||
|
||||
def edit; end
|
||||
def edit
|
||||
render layout: turbo_native_android? ? 'plain' : true
|
||||
end
|
||||
|
||||
def update
|
||||
authorize!(:update, @template)
|
||||
|
||||
@@ -4,7 +4,9 @@ class UserInitialsController < ApplicationController
|
||||
before_action :load_user_config
|
||||
authorize_resource :user_config
|
||||
|
||||
def edit; end
|
||||
def edit
|
||||
render layout: turbo_native_android? ? 'plain' : true
|
||||
end
|
||||
|
||||
def update
|
||||
file = params[:file]
|
||||
|
||||
@@ -4,7 +4,9 @@ class UserSignaturesController < ApplicationController
|
||||
before_action :load_user_config
|
||||
authorize_resource :user_config
|
||||
|
||||
def edit; end
|
||||
def edit
|
||||
render layout: turbo_native_android? ? 'plain' : true
|
||||
end
|
||||
|
||||
def update
|
||||
file = params[:file]
|
||||
|
||||
@@ -31,9 +31,13 @@ class UsersController < ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def new; end
|
||||
def new
|
||||
render layout: turbo_native_android? ? 'plain' : true
|
||||
end
|
||||
|
||||
def edit; end
|
||||
def edit
|
||||
render layout: turbo_native_android? ? 'plain' : true
|
||||
end
|
||||
|
||||
def create
|
||||
existing_user = User.accessible_by(current_ability).find_by(email: @user.email)
|
||||
|
||||
@@ -66,7 +66,7 @@ class WebhookSettingsController < ApplicationController
|
||||
|
||||
SendTestWebhookRequestJob.perform_async(
|
||||
'submitter_id' => submitter.id,
|
||||
'event_uuid' => SecureRandom.uuid,
|
||||
'event_uuid' => SecureRandom.uuid_v7,
|
||||
'webhook_url_id' => @webhook_url.id
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +35,9 @@ import PromptPassword from './elements/prompt_password'
|
||||
import EmailsTextarea from './elements/emails_textarea'
|
||||
import ToggleSubmit from './elements/toggle_submit'
|
||||
import ToggleOnSubmit from './elements/toggle_on_submit'
|
||||
import ToggleOnChecked from './elements/toggle_on_checked'
|
||||
import ToggleSendTitle from './elements/toggle_send_title'
|
||||
import AppendTemplate from './elements/append_template'
|
||||
import CheckOnClick from './elements/check_on_click'
|
||||
import PasswordInput from './elements/password_input'
|
||||
import SearchInput from './elements/search_input'
|
||||
@@ -159,6 +162,9 @@ safeRegisterElement('emails-textarea', EmailsTextarea)
|
||||
safeRegisterElement('toggle-cookies', ToggleCookies)
|
||||
safeRegisterElement('toggle-submit', ToggleSubmit)
|
||||
safeRegisterElement('toggle-on-submit', ToggleOnSubmit)
|
||||
safeRegisterElement('toggle-on-checked', ToggleOnChecked)
|
||||
safeRegisterElement('toggle-send-title', ToggleSendTitle)
|
||||
safeRegisterElement('append-template', AppendTemplate)
|
||||
safeRegisterElement('password-input', PasswordInput)
|
||||
safeRegisterElement('search-input', SearchInput)
|
||||
safeRegisterElement('toggle-attribute', ToggleAttribute)
|
||||
@@ -222,6 +228,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
template,
|
||||
customFields: reactive(JSON.parse(this.dataset.customFields || '[]')),
|
||||
dateFormats: JSON.parse(this.dataset.dateFormats || '[]'),
|
||||
autosave: this.dataset.autosave !== 'false',
|
||||
dynamicDocuments: reactive(JSON.parse(this.dataset.dynamicDocuments || '[]')),
|
||||
backgroundColor: '#faf7f5',
|
||||
locale: this.dataset.locale,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
onClick = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
const target = document.getElementById(this.dataset.targetId)
|
||||
const item = this.querySelector('template').content.firstElementChild.cloneNode(true)
|
||||
|
||||
item.querySelector('a').addEventListener('click', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
item.remove()
|
||||
|
||||
target.classList.toggle('hidden', !target.childElementCount)
|
||||
})
|
||||
|
||||
target.append(item)
|
||||
target.classList.remove('hidden')
|
||||
|
||||
item.querySelector('input').focus()
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { actionable } from '@github/catalyst/lib/actionable'
|
||||
import { targets, targetable } from '@github/catalyst/lib/targetable'
|
||||
|
||||
export default actionable(targetable(class extends HTMLElement {
|
||||
static [targets.static] = ['items']
|
||||
static [targets.static] = ['items', 'groups']
|
||||
|
||||
addItem (e) {
|
||||
e.preventDefault()
|
||||
@@ -24,11 +24,28 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
duplicateItem.querySelectorAll('a.hidden').forEach((button) => button.classList.toggle('hidden'))
|
||||
|
||||
originalItem.parentNode.append(duplicateItem)
|
||||
|
||||
this.toggleMultipleClasses()
|
||||
|
||||
duplicateItem.querySelector("input:not([type='hidden'])")?.focus()
|
||||
}
|
||||
|
||||
removeItem (e) {
|
||||
e.preventDefault()
|
||||
|
||||
this.items.find((item) => item.contains(e.target))?.remove()
|
||||
|
||||
this.toggleMultipleClasses()
|
||||
}
|
||||
|
||||
toggleMultipleClasses () {
|
||||
const isMultiple = this.items.length > 1
|
||||
const elements = [...this.items, ...this.groups]
|
||||
|
||||
elements.forEach((element) => {
|
||||
const classes = element.dataset.multipleClasses || this.dataset.multipleClasses
|
||||
|
||||
classes?.split(' ').forEach((cls) => element.classList.toggle(cls, isMultiple))
|
||||
})
|
||||
}
|
||||
}))
|
||||
@@ -22,7 +22,7 @@ export default class extends HTMLElement {
|
||||
}
|
||||
|
||||
async flashFrom (fetchResponse) {
|
||||
const html = await fetchResponse?.responseHTML
|
||||
const html = await fetchResponse?.responseHTML.catch(() => null)
|
||||
const flash = html && new DOMParser().parseFromString(html, 'text/html').getElementById('native_flash')
|
||||
|
||||
if (flash?.dataset.alert) return { alert: flash.dataset.alert }
|
||||
|
||||
@@ -55,7 +55,7 @@ export default targetable(class extends HTMLElement {
|
||||
|
||||
this.input.files = dataTransfer.files
|
||||
|
||||
if (this.input.webkitEntries.length) {
|
||||
if (this.input.webkitEntries?.length) {
|
||||
this.input.dataset.file = `${dataTransfer.files[0].name}`
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.form.addEventListener('change', this.toggle)
|
||||
|
||||
this.toggle()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.form.removeEventListener('change', this.toggle)
|
||||
}
|
||||
|
||||
toggle = () => {
|
||||
const selector = this.dataset.names.split(' ').map((name) => `[name="${name}"]:checked:enabled`).join(', ')
|
||||
const isChecked = !!this.form.querySelector(selector)
|
||||
|
||||
this.firstElementChild.classList.toggle('hidden', !isChecked)
|
||||
this.lastElementChild.classList.toggle('hidden', isChecked)
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.closest('form')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.events.forEach((event) => this.form.addEventListener(event, this.toggle))
|
||||
|
||||
this.toggle()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.events.forEach((event) => this.form.removeEventListener(event, this.toggle))
|
||||
}
|
||||
|
||||
toggle = (event) => {
|
||||
if (event?.type === 'input' && event.target.matches(this.fieldSelector('name'))) return
|
||||
|
||||
if (event?.type === 'focusin' && !this.isKeepSendUsed && this.isFocusFromName(event)) {
|
||||
this.isKeepSendUsed = true
|
||||
this.keepSendInput = event.target
|
||||
} else if (event?.type === 'focusout' && event.target === this.keepSendInput) {
|
||||
this.keepSendInput = null
|
||||
}
|
||||
|
||||
const isNameOnly = (field) => this.isFilled('name') && !this.isFilled(field) && !this.keepSendInput?.matches(this.fieldSelector(field))
|
||||
|
||||
const isSend = (this.isChecked('send_email') && !isNameOnly('email')) ||
|
||||
(this.isChecked('send_sms') && !isNameOnly('phone'))
|
||||
|
||||
this.firstElementChild.classList.toggle('hidden', !isSend)
|
||||
this.lastElementChild.classList.toggle('hidden', isSend)
|
||||
}
|
||||
|
||||
isChecked (name) {
|
||||
return !!this.form.querySelector(`[name="${name}"]:checked:enabled`)
|
||||
}
|
||||
|
||||
isFilled (field) {
|
||||
return [...this.form.querySelectorAll(this.fieldSelector(field))].some((input) => input.value.trim())
|
||||
}
|
||||
|
||||
isFocusFromName ({ target, relatedTarget }) {
|
||||
return !!relatedTarget?.matches(this.fieldSelector('name')) && !!relatedTarget.value.trim() &&
|
||||
['email', 'phone'].some((field) => target.matches(this.fieldSelector(field)))
|
||||
}
|
||||
|
||||
fieldSelector (field) {
|
||||
return `[name$="[][${field}]"]`
|
||||
}
|
||||
|
||||
get events () {
|
||||
return ['change', 'input', 'linked-input.update', 'focusin', 'focusout']
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.closest('form')
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,9 @@ const ICONS = {
|
||||
user: 'person',
|
||||
users: 'person.2',
|
||||
users_plus: 'person.badge.plus',
|
||||
user_plus: 'person.badge.plus',
|
||||
eye_plus: 'eye',
|
||||
mail_plus: 'envelope',
|
||||
photo: 'photo',
|
||||
settings: 'gearshape',
|
||||
logout: 'rectangle.portrait.and.arrow.right',
|
||||
|
||||
@@ -13,6 +13,7 @@ const en = {
|
||||
must_be_valid_email: 'Must be a valid email address',
|
||||
must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)',
|
||||
must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Must be a valid IBAN',
|
||||
must_contain_numbers_only: 'Must contain numbers only',
|
||||
must_contain_letters_only: 'Must contain letters only',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
|
||||
@@ -132,6 +133,7 @@ const es = {
|
||||
must_be_valid_email: 'Debe ser una dirección de correo electrónico válida',
|
||||
must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)',
|
||||
must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Debe ser un IBAN válido',
|
||||
must_contain_numbers_only: 'Debe contener solo números',
|
||||
must_contain_letters_only: 'Debe contener solo letras',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
|
||||
@@ -251,6 +253,7 @@ const it = {
|
||||
must_be_valid_email: 'Deve essere un indirizzo email valido',
|
||||
must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)',
|
||||
must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Deve essere un IBAN valido',
|
||||
must_contain_numbers_only: 'Deve contenere solo numeri',
|
||||
must_contain_letters_only: 'Deve contenere solo lettere',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
|
||||
@@ -370,6 +373,7 @@ const de = {
|
||||
must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein',
|
||||
must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)',
|
||||
must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Muss eine gültige IBAN sein',
|
||||
must_contain_numbers_only: 'Darf nur Zahlen enthalten',
|
||||
must_contain_letters_only: 'Darf nur Buchstaben enthalten',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
|
||||
@@ -489,6 +493,7 @@ const fr = {
|
||||
must_be_valid_email: 'Doit être une adresse e-mail valide',
|
||||
must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)',
|
||||
must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Doit être un IBAN valide',
|
||||
must_contain_numbers_only: 'Doit contenir uniquement des chiffres',
|
||||
must_contain_letters_only: 'Doit contenir uniquement des lettres',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
|
||||
@@ -608,6 +613,7 @@ const pl = {
|
||||
must_be_valid_email: 'Musi być prawidłowym adresem e-mail',
|
||||
must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)',
|
||||
must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Musi być prawidłowym numerem IBAN',
|
||||
must_contain_numbers_only: 'Może zawierać tylko cyfry',
|
||||
must_contain_letters_only: 'Może zawierać tylko litery',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
|
||||
@@ -727,6 +733,7 @@ const uk = {
|
||||
must_be_valid_email: 'Має бути дійсною адресою електронної пошти',
|
||||
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
|
||||
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Має бути дійсним IBAN',
|
||||
must_contain_numbers_only: 'Має містити лише цифри',
|
||||
must_contain_letters_only: 'Має містити лише літери',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
|
||||
@@ -846,6 +853,7 @@ const cs = {
|
||||
must_be_valid_email: 'Musí být platná e-mailová adresa',
|
||||
must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)',
|
||||
must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Musí být platný IBAN',
|
||||
must_contain_numbers_only: 'Musí obsahovat pouze čísla',
|
||||
must_contain_letters_only: 'Musí obsahovat pouze písmena',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
|
||||
@@ -965,6 +973,7 @@ const pt = {
|
||||
must_be_valid_email: 'Deve ser um endereço de e-mail válido',
|
||||
must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)',
|
||||
must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Deve ser um IBAN válido',
|
||||
must_contain_numbers_only: 'Deve conter apenas números',
|
||||
must_contain_letters_only: 'Deve conter apenas letras',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
|
||||
@@ -1084,6 +1093,7 @@ const he = {
|
||||
must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית',
|
||||
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
|
||||
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'חייב להיות IBAN חוקי',
|
||||
must_contain_numbers_only: 'חייב להכיל מספרים בלבד',
|
||||
must_contain_letters_only: 'חייב להכיל אותיות בלבד',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
|
||||
@@ -1203,6 +1213,7 @@ const nl = {
|
||||
must_be_valid_email: 'Moet een geldig e-mailadres zijn',
|
||||
must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)',
|
||||
must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Moet een geldig IBAN zijn',
|
||||
must_contain_numbers_only: 'Mag alleen cijfers bevatten',
|
||||
must_contain_letters_only: 'Mag alleen letters bevatten',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
|
||||
@@ -1322,6 +1333,7 @@ const ar = {
|
||||
must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
|
||||
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
|
||||
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'يجب أن يكون IBAN صالحًا',
|
||||
must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط',
|
||||
must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
|
||||
@@ -1441,6 +1453,7 @@ const ko = {
|
||||
must_be_valid_email: '유효한 이메일 주소여야 합니다',
|
||||
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
|
||||
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
|
||||
must_be_valid_iban: '유효한 IBAN이어야 합니다',
|
||||
must_contain_numbers_only: '숫자만 포함해야 합니다',
|
||||
must_contain_letters_only: '문자만 포함해야 합니다',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
|
||||
@@ -1560,6 +1573,7 @@ const ja = {
|
||||
must_be_valid_email: '有効なメールアドレスである必要があります',
|
||||
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
|
||||
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
|
||||
must_be_valid_iban: '有効なIBANである必要があります',
|
||||
must_contain_numbers_only: '数字のみを含む必要があります',
|
||||
must_contain_letters_only: '文字のみを含む必要があります',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
|
||||
|
||||
@@ -3,11 +3,19 @@
|
||||
v-if="!error"
|
||||
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
|
||||
>
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
|
||||
<span class="flex items-end">
|
||||
<MarkdownContent
|
||||
v-if="field.title"
|
||||
:string="field.title"
|
||||
/>
|
||||
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
|
||||
<span
|
||||
class="tooltip tooltip-bottom ml-2 mb-1"
|
||||
data-tip="To verify your identity, the details you enter are checked against third-party records, which may include credit bureau data, and used to generate questions based on those records. Only these identity details are shared with the identity verification provider; this document and your tax return information are not shared with it or with any credit reporting company. If the check is recorded on your credit report, it appears as a soft inquiry, which does not affect your credit score and is not shown to lenders. This check does not give the sender of this document or the IRS access to your credit report."
|
||||
>
|
||||
<IconInfoCircle class="w-4 h-4 text-neutral-500" />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="questions"
|
||||
class="float-right font-normal text-neutral-500 mt-1 whitespace-nowrap text-right text-sm"
|
||||
@@ -282,13 +290,14 @@
|
||||
|
||||
<script>
|
||||
import MarkdownContent from './markdown_content'
|
||||
import { IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
import { IconInnerShadowTop, IconInfoCircle } from '@tabler/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'KbaStep',
|
||||
components: {
|
||||
MarkdownContent,
|
||||
IconInnerShadowTop
|
||||
IconInnerShadowTop,
|
||||
IconInfoCircle
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
props: {
|
||||
@@ -483,7 +492,7 @@ export default {
|
||||
this.clearCountdown()
|
||||
|
||||
try {
|
||||
const payload = { ...this.form, submitter_slug: this.submitterSlug }
|
||||
const payload = { ...this.form }
|
||||
|
||||
if (payload.dob) {
|
||||
payload.dob = payload.dob.replace(/-/g, '')
|
||||
@@ -497,7 +506,7 @@ export default {
|
||||
payload.phone = payload.phone.replace(/^\+1/, '')
|
||||
}
|
||||
|
||||
const resp = await fetch(this.baseUrl + '/api/kba', {
|
||||
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
...this.fetchOptions,
|
||||
@@ -548,13 +557,12 @@ export default {
|
||||
}, {})
|
||||
|
||||
try {
|
||||
const resp = await fetch(this.baseUrl + `/api/kba/${this.field.uuid}`, {
|
||||
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba/${this.field.uuid}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
token: this.token,
|
||||
answers: formattedAnswers,
|
||||
reference: this.reference,
|
||||
submitter_slug: this.submitterSlug
|
||||
reference: this.reference
|
||||
}),
|
||||
...this.fetchOptions,
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||
|
||||
@@ -156,7 +156,7 @@ export default {
|
||||
return this.provider === 'paypal'
|
||||
},
|
||||
paymentEndpoint () {
|
||||
return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments'
|
||||
return `/s/${this.submitterSlug}/${this.isPaypal ? 'paypal_payments' : 'stripe_payments'}`
|
||||
},
|
||||
sessionId () {
|
||||
return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token'))
|
||||
@@ -255,9 +255,6 @@ export default {
|
||||
if (this.sessionId) {
|
||||
return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug
|
||||
}),
|
||||
...this.fetchOptions,
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||
}).then(async (resp) => {
|
||||
@@ -294,7 +291,6 @@ export default {
|
||||
fetch(this.baseUrl + this.paymentEndpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
field_uuid: this.field.uuid,
|
||||
check_status: checkStatus,
|
||||
referer: this.normalizedPaymentUrl()
|
||||
|
||||
@@ -285,7 +285,7 @@ export default {
|
||||
}, 1000)
|
||||
},
|
||||
sendVerificationCode () {
|
||||
return fetch(this.baseUrl + '/api/send_phone_verification_code', {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
@@ -293,6 +293,14 @@ export default {
|
||||
phone: this.fullInternationalPhoneValue
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/send_phone_verification_code`, options).then((resp) => {
|
||||
if (resp.status === 404) {
|
||||
return fetch(this.baseUrl + '/api/send_phone_verification_code', options)
|
||||
} else {
|
||||
return resp
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
if ([422, 429].includes(resp.status)) {
|
||||
const data = await resp.json()
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<input
|
||||
v-if="!isTextArea"
|
||||
:id="field.uuid"
|
||||
ref="input"
|
||||
v-model="text"
|
||||
:maxlength="cellsMaxLegth"
|
||||
dir="auto"
|
||||
@@ -46,6 +47,7 @@
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
type="text"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@input="validateValue($event.target)"
|
||||
@focus="$emit('focus')"
|
||||
>
|
||||
<textarea
|
||||
@@ -137,6 +139,9 @@ export default {
|
||||
return null
|
||||
}
|
||||
},
|
||||
isIbanValidation () {
|
||||
return this.patternMessageKeys[this.field.validation?.pattern] === 'must_be_valid_iban'
|
||||
},
|
||||
patternMessageKeys () {
|
||||
return {
|
||||
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn',
|
||||
@@ -144,6 +149,7 @@ export default {
|
||||
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email',
|
||||
'^https?://.*': 'must_be_valid_url',
|
||||
'^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip',
|
||||
'^[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}$': 'must_be_valid_iban',
|
||||
'^[0-9]+$': 'must_contain_numbers_only',
|
||||
'^[a-zA-Z]+$': 'must_contain_letters_only'
|
||||
}
|
||||
@@ -186,8 +192,21 @@ export default {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.validateValue(this.$refs.input)
|
||||
},
|
||||
methods: {
|
||||
validateValue (input) {
|
||||
if (this.isIbanValidation) {
|
||||
input.setCustomValidity(this.isValidIban(input.value) ? '' : this.t('must_be_valid_iban'))
|
||||
}
|
||||
},
|
||||
isValidIban (value) {
|
||||
const iban = value.replace(/\s/g, '')
|
||||
const digits = (iban.slice(4) + iban.slice(0, 4)).replace(/[A-Z]/g, (char) => char.charCodeAt(0) - 55)
|
||||
|
||||
return !iban || digits.split('').reduce((remainder, digit) => (remainder * 10 + parseInt(digit)) % 97, 0) === 1
|
||||
},
|
||||
resizeTextarea () {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
|
||||
@@ -162,10 +162,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
start () {
|
||||
return fetch(this.baseUrl + `/api/identity_verification/${this.field.uuid}`, {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification/${this.field.uuid}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
redirect_url: document.location.href
|
||||
}),
|
||||
...this.fetchOptions,
|
||||
@@ -206,11 +205,8 @@ export default {
|
||||
})
|
||||
},
|
||||
async submit () {
|
||||
const resp = await fetch(this.baseUrl + '/api/identity_verification', {
|
||||
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug
|
||||
}),
|
||||
...this.fetchOptions,
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||
})
|
||||
|
||||
@@ -319,7 +319,7 @@ export default {
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title', 'multi-select'],
|
||||
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title'],
|
||||
data () {
|
||||
return {
|
||||
isContenteditable: false,
|
||||
@@ -798,8 +798,6 @@ export default {
|
||||
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
|
||||
}
|
||||
|
||||
this.$emit('multi-select', e)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -272,7 +272,7 @@ export default {
|
||||
FieldSubmitter,
|
||||
IconX
|
||||
},
|
||||
inject: ['t', 'isMobile', 'nativePlatform'],
|
||||
inject: ['t', 'nativePlatform'],
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
ref="dragContainer"
|
||||
style="max-width: 1600px"
|
||||
:class="nativeView ? (isSheetView ? 'bg-base-100' : 'px-4') : 'mx-auto pl-3 md:pl-4 h-full'"
|
||||
@dragstart="isInternalDrag = true"
|
||||
@dragend="isInternalDrag = false"
|
||||
@dragover="onDragover"
|
||||
@drop="isDragFile = false"
|
||||
@drop="[isDragFile = false, isInternalDrag = false]"
|
||||
>
|
||||
<HoverDropzone
|
||||
v-if="sortedDocuments.length && withUploadButton && editable && !isSheetView"
|
||||
@@ -413,9 +415,9 @@
|
||||
<a
|
||||
:href="`/templates/${template.id}/form`"
|
||||
:data-native-actions="template.submitters.length > 1 ? 'users' : null"
|
||||
data-turbo="false"
|
||||
data-turbo-prefetch="false"
|
||||
class="flex items-center justify-center space-x-2"
|
||||
@click.exact="isPreviewLoading = true"
|
||||
@click.exact="onSaveAndPreviewClick"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isPreviewLoading"
|
||||
@@ -638,6 +640,7 @@
|
||||
:draw-field-type="drawFieldType"
|
||||
:draw-custom-field="drawCustomField"
|
||||
:draw-option="drawOption"
|
||||
:autosave="autosave"
|
||||
@update="onDynamicDocumentUpdate"
|
||||
@draw="clearDrawField"
|
||||
@add-custom-field="addCustomField"
|
||||
@@ -769,7 +772,7 @@
|
||||
class="sticky bottom-0 z-20"
|
||||
>
|
||||
<native-action
|
||||
v-if="nativePlatform && withDocumentsList && (isBreakpointLg || isMobile) && sortedDocuments.length && editable"
|
||||
v-if="nativePlatform && withDocumentsList && (isBreakpointLg || isMobile) && editable"
|
||||
data-placement="drawer"
|
||||
data-side="leading"
|
||||
data-icon="files"
|
||||
@@ -779,7 +782,7 @@
|
||||
data-view="documents"
|
||||
/>
|
||||
<native-action
|
||||
v-if="nativePlatform && withFieldsList && isBreakpointLg && sortedDocuments.length && editable"
|
||||
v-if="nativePlatform && withFieldsList && isBreakpointLg && editable"
|
||||
data-placement="drawer"
|
||||
data-side="trailing"
|
||||
data-icon="plus"
|
||||
@@ -906,7 +909,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20"
|
||||
@click="zoomLevel = 1"
|
||||
@click="resetZoomLevel"
|
||||
>
|
||||
<IconX class="w-4 h-4 stroke-2" />
|
||||
</button>
|
||||
@@ -1007,6 +1010,9 @@ export default {
|
||||
return {
|
||||
template: this.template,
|
||||
save: this.save,
|
||||
pushUndo: this.pushUndo,
|
||||
undo: this.undo,
|
||||
redo: this.redo,
|
||||
t: this.t,
|
||||
assignDropAreaSize: this.assignDropAreaSize,
|
||||
currencies: this.currencies,
|
||||
@@ -1374,6 +1380,7 @@ export default {
|
||||
isPreviewLoading: false,
|
||||
isLoadingBlankPage: false,
|
||||
isSaving: false,
|
||||
hasUnsavedChanges: false,
|
||||
isDetectingPageFields: false,
|
||||
detectFieldsQueue: [],
|
||||
detectingAnalyzingProgress: null,
|
||||
@@ -1387,6 +1394,7 @@ export default {
|
||||
drawOption: null,
|
||||
dragField: null,
|
||||
isDragFile: false,
|
||||
isInternalDrag: false,
|
||||
isMathLoaded: false,
|
||||
isRevisionsModalOpen: false,
|
||||
isDocumentsDrawerOpen: false,
|
||||
@@ -1640,20 +1648,27 @@ export default {
|
||||
this.selectedSubmitter = this.template.submitters[0]
|
||||
},
|
||||
mounted () {
|
||||
this.undoStack = [JSON.stringify(this.template)]
|
||||
this.undoStack = []
|
||||
this.redoStack = []
|
||||
|
||||
this.undoStack.push(this.buildUndoData())
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.onWindowResize()
|
||||
this.resizeObserver = new ResizeObserver(this.onResize)
|
||||
this.resizeObserver.observe(this.$el.getRootNode().querySelector('div[data-v-app]'))
|
||||
})
|
||||
|
||||
document.addEventListener('keyup', this.onKeyUp)
|
||||
document.addEventListener('template-builder:apply-revision', this.onApplyRevisionEvent)
|
||||
window.addEventListener('keydown', this.onKeyDown)
|
||||
|
||||
window.addEventListener('resize', this.onWindowResize)
|
||||
window.addEventListener('dragleave', this.onWindowDragLeave)
|
||||
|
||||
if (!this.autosave) {
|
||||
window.addEventListener('beforeunload', this.onBeforeUnload)
|
||||
document.addEventListener('turbo:before-visit', this.onBeforeVisit)
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (document.location.search?.includes('stripe_connect_success')) {
|
||||
document.querySelector('form[action="/auth/stripe_connect"]')?.closest('.dropdown')?.querySelector('label')?.focus()
|
||||
@@ -1675,8 +1690,13 @@ export default {
|
||||
document.removeEventListener('template-builder:apply-revision', this.onApplyRevisionEvent)
|
||||
window.removeEventListener('keydown', this.onKeyDown)
|
||||
|
||||
window.removeEventListener('resize', this.onWindowResize)
|
||||
this.resizeObserver.disconnect()
|
||||
window.removeEventListener('dragleave', this.onWindowDragLeave)
|
||||
|
||||
if (!this.autosave) {
|
||||
window.removeEventListener('beforeunload', this.onBeforeUnload)
|
||||
document.removeEventListener('turbo:before-visit', this.onBeforeVisit)
|
||||
}
|
||||
},
|
||||
beforeUpdate () {
|
||||
this.documentRefs = []
|
||||
@@ -2034,8 +2054,18 @@ export default {
|
||||
|
||||
ref.x = e.clientX - ref.offsetX
|
||||
ref.y = e.clientY - ref.offsetY
|
||||
} else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid) {
|
||||
this.isDragFile = true
|
||||
} else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid && !this.isInternalDrag) {
|
||||
const items = Array.from(e.dataTransfer.items)
|
||||
|
||||
if (this.dynamicDocuments.length && items.length && items.every((item) => item.type.startsWith('image/'))) {
|
||||
if (!e.defaultPrevented) {
|
||||
e.preventDefault()
|
||||
|
||||
e.dataTransfer.dropEffect = 'none'
|
||||
}
|
||||
} else {
|
||||
this.isDragFile = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onWindowDragLeave (event) {
|
||||
@@ -2223,6 +2253,37 @@ export default {
|
||||
this.insertField(field)
|
||||
}
|
||||
},
|
||||
onSaveAndPreviewClick (event) {
|
||||
this.isPreviewLoading = true
|
||||
|
||||
if (!this.autosave) {
|
||||
event.preventDefault()
|
||||
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
|
||||
dynamicDocumentRefs.forEach((ref) => ref.update())
|
||||
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
|
||||
Promise.all([this.save({ force: true }), ...dynamicDocumentRefs.map((ref) => ref.saveBody())]).then(() => {
|
||||
window.Turbo.visit(`/templates/${this.template.id}/form`)
|
||||
})
|
||||
}
|
||||
},
|
||||
onBeforeUnload (event) {
|
||||
if (this.hasUnsavedChanges) {
|
||||
event.preventDefault()
|
||||
|
||||
event.returnValue = ''
|
||||
|
||||
return ''
|
||||
}
|
||||
},
|
||||
onBeforeVisit (event) {
|
||||
if (this.hasUnsavedChanges && !window.confirm(this.t('close_without_saving_changes'))) {
|
||||
event.preventDefault()
|
||||
}
|
||||
},
|
||||
closeDropdown () {
|
||||
document.activeElement.blur()
|
||||
},
|
||||
@@ -2287,6 +2348,8 @@ export default {
|
||||
|
||||
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
|
||||
|
||||
this.pushUndo()
|
||||
|
||||
dynamicDocumentRefs.forEach((ref) => ref.update())
|
||||
|
||||
this.rebuildVariablesSchema({ disable: false })
|
||||
@@ -2414,42 +2477,85 @@ export default {
|
||||
this.drawOption = null
|
||||
},
|
||||
undo () {
|
||||
this.selectedAreasRef.value = []
|
||||
|
||||
if (this.undoStack.length > 1) {
|
||||
this.undoStack.pop()
|
||||
const stringData = this.undoStack[this.undoStack.length - 1]
|
||||
const currentStringData = JSON.stringify(this.template)
|
||||
const undoData = this.undoStack[this.undoStack.length - 1]
|
||||
const currentUndoData = this.buildUndoData()
|
||||
|
||||
if (stringData && stringData !== currentStringData) {
|
||||
this.redoStack.push(currentStringData)
|
||||
if (!this.isSameUndoData(undoData, currentUndoData)) {
|
||||
this.redoStack.push(currentUndoData)
|
||||
|
||||
Object.assign(this.template, JSON.parse(stringData))
|
||||
|
||||
this.save()
|
||||
this.applyUndoData(undoData, currentUndoData)
|
||||
}
|
||||
}
|
||||
},
|
||||
redo () {
|
||||
const stringData = this.redoStack.pop()
|
||||
this.lastRedoData = stringData
|
||||
const currentStringData = JSON.stringify(this.template)
|
||||
this.selectedAreasRef.value = []
|
||||
|
||||
if (stringData && stringData !== currentStringData) {
|
||||
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) {
|
||||
this.undoStack.push(currentStringData)
|
||||
const undoData = this.redoStack.pop()
|
||||
const currentUndoData = this.buildUndoData()
|
||||
|
||||
if (undoData && !this.isSameUndoData(undoData, currentUndoData)) {
|
||||
if (!this.isSameUndoData(this.undoStack[this.undoStack.length - 1], currentUndoData)) {
|
||||
this.undoStack.push(currentUndoData)
|
||||
}
|
||||
|
||||
Object.assign(this.template, JSON.parse(stringData))
|
||||
this.undoStack.push(undoData)
|
||||
|
||||
this.save()
|
||||
this.applyUndoData(undoData, currentUndoData)
|
||||
}
|
||||
},
|
||||
onWindowResize (e) {
|
||||
buildUndoData () {
|
||||
const template = JSON.stringify(toRaw(this.template))
|
||||
const lastUndoData = this.undoStack[this.undoStack.length - 1]
|
||||
|
||||
return { template: template === lastUndoData?.template ? lastUndoData.template : template, sectionDocs: this.buildSectionDocs() }
|
||||
},
|
||||
buildSectionDocs () {
|
||||
return new Map(this.documentRefs.flatMap((ref) => ref.isDynamic ? ref.getSectionDocs() : []))
|
||||
},
|
||||
isSameUndoData (undoData, currentUndoData) {
|
||||
return undoData.template === currentUndoData.template && this.isSameSectionDocs(undoData, currentUndoData)
|
||||
},
|
||||
isSameSectionDocs (undoData, currentUndoData) {
|
||||
return [...currentUndoData.sectionDocs].every(([sectionKey, doc]) => undoData.sectionDocs.get(sectionKey)?.eq(doc))
|
||||
},
|
||||
applyUndoData (undoData, currentUndoData) {
|
||||
const isTemplateChanged = undoData.template !== currentUndoData.template
|
||||
|
||||
if (isTemplateChanged) {
|
||||
Object.assign(this.template, JSON.parse(undoData.template))
|
||||
}
|
||||
|
||||
this.documentRefs.forEach((ref) => {
|
||||
if (ref.isDynamic) ref.restoreSectionDocs(undoData.sectionDocs)
|
||||
})
|
||||
|
||||
this.buildSectionDocs().forEach((doc, sectionKey) => undoData.sectionDocs.set(sectionKey, doc))
|
||||
|
||||
if (isTemplateChanged) {
|
||||
this.save()
|
||||
|
||||
this.$nextTick(this.syncUndoTemplate)
|
||||
}
|
||||
},
|
||||
syncUndoTemplate () {
|
||||
const lastUndoData = this.undoStack[this.undoStack.length - 1]
|
||||
const template = JSON.stringify(toRaw(this.template))
|
||||
|
||||
if (template !== lastUndoData.template) lastUndoData.template = template
|
||||
},
|
||||
onResize () {
|
||||
const breakpointLg = 1024
|
||||
const breakpointMd = 768
|
||||
const width = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth
|
||||
|
||||
this.isBreakpointLg = width < breakpointLg
|
||||
this.isBreakpointMd = width < breakpointMd
|
||||
if (width) {
|
||||
this.isBreakpointLg = width < breakpointLg
|
||||
this.isBreakpointMd = width < breakpointMd
|
||||
}
|
||||
},
|
||||
onPagesWheel (event) {
|
||||
const isFastScroll = Math.abs(event.deltaY) > 1
|
||||
@@ -2462,23 +2568,33 @@ export default {
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const oldZoom = this.zoomLevel
|
||||
const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006))
|
||||
|
||||
if (nextZoom === oldZoom) return
|
||||
|
||||
this.setZoomLevel(this.zoomLevel - event.deltaY * 0.006, event.clientX, event.clientY)
|
||||
},
|
||||
resetZoomLevel () {
|
||||
const rect = this.$refs.pagesContainer.getBoundingClientRect()
|
||||
const cursorX = event.clientX - rect.left
|
||||
const cursorY = event.clientY - rect.top
|
||||
const ratio = nextZoom / oldZoom
|
||||
const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX
|
||||
const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY
|
||||
|
||||
this.setZoomLevel(1, rect.left + rect.width / 2, (Math.max(rect.top, 0) + Math.min(rect.bottom, window.innerHeight)) / 2)
|
||||
},
|
||||
setZoomLevel (zoomLevel, clientX, clientY) {
|
||||
const nextZoom = Math.max(1, Math.min(3, zoomLevel))
|
||||
|
||||
if (nextZoom === this.zoomLevel) return
|
||||
|
||||
const container = this.$refs.pagesContainer
|
||||
const rect = this.$refs.documents.getBoundingClientRect()
|
||||
|
||||
this.zoomLevel = nextZoom
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.$refs.pagesContainer.scrollLeft = nextScrollLeft
|
||||
this.$refs.pagesContainer.scrollTop = nextScrollTop
|
||||
this.documentRefs.filter((ref) => ref.isDynamic).forEach((ref) => ref.updateContainerWidth())
|
||||
|
||||
this.$nextTick(() => {
|
||||
const nextRect = this.$refs.documents.getBoundingClientRect()
|
||||
const scrollContainer = container.scrollHeight > container.clientHeight ? container : document.scrollingElement
|
||||
|
||||
container.scrollLeft += nextRect.left - clientX + (clientX - rect.left) * nextRect.width / rect.width
|
||||
scrollContainer.scrollTop += nextRect.top - clientY + (clientY - rect.top) * nextRect.height / rect.height
|
||||
})
|
||||
})
|
||||
},
|
||||
setDocumentRefs (el) {
|
||||
@@ -2597,15 +2713,11 @@ export default {
|
||||
event.stopImmediatePropagation()
|
||||
event.preventDefault()
|
||||
|
||||
this.selectedAreasRef.value = []
|
||||
|
||||
this.redo()
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
|
||||
event.stopImmediatePropagation()
|
||||
event.preventDefault()
|
||||
|
||||
this.selectedAreasRef.value = []
|
||||
|
||||
this.undo()
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) {
|
||||
if (this.selectedAreasRef.value.length > 1) {
|
||||
@@ -2980,16 +3092,29 @@ export default {
|
||||
return false
|
||||
}
|
||||
},
|
||||
pushUndo () {
|
||||
const stringData = JSON.stringify(this.template)
|
||||
pushUndo ({ amend = false } = {}) {
|
||||
const undoData = this.buildUndoData()
|
||||
const lastUndoData = this.undoStack[this.undoStack.length - 1]
|
||||
const isTemplateChanged = undoData.template !== lastUndoData.template
|
||||
const isSectionDocsChanged = !this.isSameSectionDocs(lastUndoData, undoData)
|
||||
|
||||
if (this.undoStack[this.undoStack.length - 1] !== stringData) {
|
||||
this.undoStack.push(stringData)
|
||||
if (this.isUndoBatch || (amend && (!isSectionDocsChanged || (!this.redoStack.length && !isTemplateChanged)))) {
|
||||
this.undoStack[this.undoStack.length - 1] = undoData
|
||||
} else if (isTemplateChanged || isSectionDocsChanged) {
|
||||
this.undoStack.push(undoData)
|
||||
|
||||
if (this.lastRedoData !== stringData) {
|
||||
this.redoStack = []
|
||||
}
|
||||
this.redoStack = []
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
this.isUndoBatch = true
|
||||
|
||||
this.$nextTick(() => {
|
||||
this.isUndoBatch = false
|
||||
|
||||
this.syncUndoTemplate()
|
||||
})
|
||||
},
|
||||
setDefaultAreaSize (area, type) {
|
||||
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
||||
@@ -3954,12 +4079,14 @@ export default {
|
||||
}
|
||||
|
||||
if (!this.autosave && !force) {
|
||||
this.hasUnsavedChanges = true
|
||||
|
||||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$el.closest('template-builder')) {
|
||||
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
|
||||
this.$el.closest('template-builder').dataset.template = JSON.stringify(toRaw(this.template))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3976,6 +4103,8 @@ export default {
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}).then(() => {
|
||||
this.hasUnsavedChanges = false
|
||||
|
||||
if (this.onSave) {
|
||||
this.onSave(this.template)
|
||||
}
|
||||
@@ -3992,10 +4121,12 @@ export default {
|
||||
|
||||
this.$nextTick(() => {
|
||||
if (this.$el.closest('template-builder')) {
|
||||
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(this.dynamicDocuments)
|
||||
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(toRaw(this.dynamicDocuments))
|
||||
}
|
||||
})
|
||||
|
||||
this.pushUndo({ amend: true })
|
||||
|
||||
this.save()
|
||||
},
|
||||
rebuildVariablesSchema ({ disable = true } = {}) {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl'">
|
||||
<div :class="inline ? (nativePlatform === 'android' ? 'px-4 pt-1.5 pb-6' : 'px-4 pt-3 pb-6') : 'modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl'">
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
|
||||
@@ -33,7 +33,7 @@
|
||||
>{{ t('available_in_pro') }}</a>
|
||||
</div>
|
||||
<form @submit.prevent="validateSaveAndClose">
|
||||
<div class="my-4">
|
||||
<div :class="inline && nativePlatform === 'android' ? 'mt-1 mb-4' : 'my-4'">
|
||||
<div
|
||||
v-for="(condition, cindex) in conditions"
|
||||
:key="cindex"
|
||||
@@ -168,7 +168,7 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'ConditionModal',
|
||||
inject: ['t', 'template', 'withConditions'],
|
||||
inject: ['t', 'template', 'withConditions', 'nativePlatform'],
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'">
|
||||
<div :class="inline ? (nativePlatform === 'android' ? 'px-4 pt-1.5 pb-6' : 'px-4 pt-3 pb-6') : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'">
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
|
||||
@@ -27,7 +27,7 @@
|
||||
<div>
|
||||
<label
|
||||
dir="auto"
|
||||
class="label text-sm"
|
||||
:class="['label text-sm', { 'pt-0 mt-1': inline && nativePlatform === 'android' }]"
|
||||
for="description_field"
|
||||
>
|
||||
{{ t('description') }}
|
||||
@@ -71,7 +71,7 @@
|
||||
<script>
|
||||
export default {
|
||||
name: 'DescriptionModal',
|
||||
inject: ['t', 'template'],
|
||||
inject: ['t', 'template', 'nativePlatform'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
<CropView
|
||||
v-if="cropPageItem"
|
||||
class="absolute inset-0 z-10 bg-base-100"
|
||||
:style="inline ? { paddingTop: '70px' } : null"
|
||||
:style="inline && nativePlatform === 'ios' ? { paddingTop: '70px' } : null"
|
||||
:template-id="template.id"
|
||||
:page="cropPageItem"
|
||||
:image-url="thumbUrl(cropPageItem)"
|
||||
@@ -47,7 +47,7 @@
|
||||
<RedactView
|
||||
v-else-if="redactPageItem"
|
||||
class="absolute inset-0 z-10 bg-base-100"
|
||||
:style="inline ? { paddingTop: '70px' } : null"
|
||||
:style="inline && nativePlatform === 'ios' ? { paddingTop: '70px' } : null"
|
||||
:template-id="template.id"
|
||||
:page="redactPageItem"
|
||||
:image-url="thumbUrl(redactPageItem)"
|
||||
@@ -63,14 +63,14 @@
|
||||
>
|
||||
<div
|
||||
class="flex-1 overflow-y-auto overflow-x-hidden px-4 md:px-6 py-4 space-y-4"
|
||||
:style="inline ? { paddingTop: '86px' } : null"
|
||||
:style="inline && nativePlatform === 'ios' ? { paddingTop: '86px' } : null"
|
||||
>
|
||||
<div
|
||||
v-for="(doc, docIndex) in layout"
|
||||
:key="doc.attachmentUuid"
|
||||
:data-uuid="doc.attachmentUuid"
|
||||
class="relative"
|
||||
:style="inline ? { scrollMarginTop: '86px' } : null"
|
||||
:style="inline && nativePlatform === 'ios' ? { scrollMarginTop: '86px' } : null"
|
||||
>
|
||||
<div
|
||||
class="flex items-center pb-2"
|
||||
@@ -191,7 +191,7 @@
|
||||
:key="page.id"
|
||||
:data-page-id="page.id"
|
||||
class="relative min-w-0 cursor-pointer group"
|
||||
:style="inline ? { scrollMarginTop: '86px' } : null"
|
||||
:style="inline && nativePlatform === 'ios' ? { scrollMarginTop: '86px' } : null"
|
||||
draggable="true"
|
||||
@click="selectedPageId = page.id"
|
||||
@mousedown="onPageMousedown(page, $event)"
|
||||
@@ -260,7 +260,7 @@
|
||||
</div>
|
||||
<div
|
||||
class="flex-none px-4 border-t pt-3 flex flex-col gap-3 md:w-56 md:border-t-0 md:border-l md:py-4 md:gap-0"
|
||||
:class="{ 'pb-3': !inline }"
|
||||
:class="{ 'pb-3': !inline || nativePlatform === 'android' }"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-2 md:block md:space-y-2">
|
||||
<button
|
||||
@@ -419,7 +419,7 @@ export default {
|
||||
IconEraser,
|
||||
IconCrop
|
||||
},
|
||||
inject: ['t', 'baseFetch'],
|
||||
inject: ['t', 'baseFetch', 'nativePlatform'],
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div
|
||||
class="absolute z-[5] pointer-events-none select-none"
|
||||
:style="{ top: coords.top + 'px', left: coords.left + 'px', width: coords.width + 'px', height: coords.height + 'px' }"
|
||||
>
|
||||
<div
|
||||
class="absolute left-0 bottom-0"
|
||||
:style="{ top: Math.max(coords.top - toolbarHeight - 2, 0) - coords.top + 'px' }"
|
||||
>
|
||||
<div
|
||||
ref="toolbar"
|
||||
class="sticky top-1.5 flex flex-col sm:flex-row gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg pointer-events-auto"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<div
|
||||
v-for="(row, rowIndex) in actionRows"
|
||||
:key="rowIndex"
|
||||
class="flex items-center gap-0.5"
|
||||
>
|
||||
<template
|
||||
v-for="(group, groupIndex) in row"
|
||||
:key="groupIndex"
|
||||
>
|
||||
<div
|
||||
v-if="rowIndex > 0 || groupIndex > 0"
|
||||
class="w-px h-5 bg-neutral-200 mx-1"
|
||||
:class="{ 'hidden sm:block': rowIndex > 0 && groupIndex === 0 }"
|
||||
/>
|
||||
<button
|
||||
v-for="action in group"
|
||||
:key="action.title"
|
||||
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
|
||||
:class="{ 'text-red-600': action.danger, 'bg-neutral-200': action.active, 'hover:bg-neutral-100': !action.active && !isDisabled(action), 'opacity-50 cursor-not-allowed': isDisabled(action) }"
|
||||
:title="action.structural && !coords.isRegular ? t('irregular_table_structure') : t(action.title)"
|
||||
:disabled="isDisabled(action)"
|
||||
@click="$emit('command', action.command, action.arg)"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="size"
|
||||
class="absolute top-0 left-0 border border-dashed border-blue-500"
|
||||
:style="{ width: size.width + 'px', height: size.height + 'px' }"
|
||||
/>
|
||||
<div
|
||||
v-if="coords.type === 'table'"
|
||||
class="absolute w-3 h-3 bg-white border-2 border-blue-500 rounded-sm pointer-events-auto cursor-nwse-resize touch-none before:absolute before:-inset-3"
|
||||
:style="{ left: (size?.width ?? coords.width) - 6 + 'px', top: (size?.height ?? coords.height) - 6 + 'px' }"
|
||||
:title="t('resize')"
|
||||
@pointerdown.prevent.stop="startResize"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { h } from 'vue'
|
||||
import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconRowInsertTop, IconRowInsertBottom, IconColumnInsertLeft, IconColumnInsertRight, IconRowRemove, IconColumnRemove, IconTrash } from '@tabler/icons-vue'
|
||||
|
||||
const IconMergeCells = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
|
||||
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
|
||||
h('path', { d: 'M6 12h4m-2 -2l2 2l-2 2' }),
|
||||
h('path', { d: 'M18 12h-4m2 -2l-2 2l2 2' })
|
||||
])
|
||||
|
||||
const IconSplitCell = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
|
||||
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
|
||||
h('path', { d: 'M12 4v16' }),
|
||||
h('path', { d: 'M10 12h-4m2 -2l-2 2l2 2' }),
|
||||
h('path', { d: 'M14 12h4m-2 -2l2 2l-2 2' })
|
||||
])
|
||||
|
||||
export default {
|
||||
name: 'DynamicBlockMenu',
|
||||
inject: ['t'],
|
||||
props: {
|
||||
coords: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
getResizeLimits: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['command', 'resize'],
|
||||
data () {
|
||||
return {
|
||||
size: null,
|
||||
toolbarHeight: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
actionRows () {
|
||||
const alignActions = [
|
||||
{ command: 'setBlockAlign', arg: 'left', icon: IconAlignLeft, title: 'align_left', active: this.coords.align === 'left' },
|
||||
{ command: 'setBlockAlign', arg: 'center', icon: IconAlignCenter, title: 'align_center', active: this.coords.align === 'center' },
|
||||
{ command: 'setBlockAlign', arg: 'right', icon: IconAlignRight, title: 'align_right', active: this.coords.align === 'right' }
|
||||
]
|
||||
|
||||
if (this.coords.type !== 'table') {
|
||||
return [[alignActions, [{ command: 'deleteSelection', icon: IconTrash, title: 'delete_image', danger: true }]]]
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
alignActions,
|
||||
[
|
||||
{ command: 'addTableRow', arg: false, icon: IconRowInsertTop, title: 'insert_row_above', structural: true },
|
||||
{ command: 'addTableRow', arg: true, icon: IconRowInsertBottom, title: 'insert_row_below', structural: true },
|
||||
{ command: 'addTableColumn', arg: false, icon: IconColumnInsertLeft, title: 'insert_column_left', structural: true },
|
||||
{ command: 'addTableColumn', arg: true, icon: IconColumnInsertRight, title: 'insert_column_right', structural: true }
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
{ command: 'mergeTableCells', icon: IconMergeCells, title: 'merge_cells', structural: true, disabled: !this.coords.canMerge },
|
||||
{ command: 'splitTableCell', icon: IconSplitCell, title: 'split_cell', structural: true, disabled: !this.coords.canSplit }
|
||||
],
|
||||
[
|
||||
{ command: 'deleteTableRow', icon: IconRowRemove, title: 'delete_row', danger: true, structural: true },
|
||||
{ command: 'deleteTableColumn', icon: IconColumnRemove, title: 'delete_column', danger: true, structural: true },
|
||||
{ command: 'deleteTable', icon: IconTrash, title: 'delete_table', danger: true }
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.toolbarHeight = this.$refs.toolbar.offsetHeight
|
||||
},
|
||||
updated () {
|
||||
this.toolbarHeight = this.$refs.toolbar.offsetHeight
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('pointermove', this.onResizeMove)
|
||||
window.removeEventListener('pointerup', this.onResizeEnd)
|
||||
},
|
||||
methods: {
|
||||
isDisabled (action) {
|
||||
return (action.structural && !this.coords.isRegular) || action.disabled
|
||||
},
|
||||
startResize (event) {
|
||||
this.resizeStart = { x: event.clientX, y: event.clientY, width: this.coords.width, height: this.coords.height, ...this.getResizeLimits() }
|
||||
this.size = { width: this.coords.width, height: this.coords.height }
|
||||
|
||||
window.addEventListener('pointermove', this.onResizeMove)
|
||||
window.addEventListener('pointerup', this.onResizeEnd)
|
||||
},
|
||||
onResizeMove (event) {
|
||||
const { x, y, width, height, minHeight, maxWidth } = this.resizeStart
|
||||
this.size = {
|
||||
width: Math.min(Math.max(width + event.clientX - x, 20), maxWidth),
|
||||
height: Math.max(height + event.clientY - y, minHeight)
|
||||
}
|
||||
},
|
||||
onResizeEnd () {
|
||||
window.removeEventListener('pointermove', this.onResizeMove)
|
||||
window.removeEventListener('pointerup', this.onResizeEnd)
|
||||
|
||||
if (this.size.width !== this.coords.width || this.size.height !== this.coords.height) {
|
||||
this.$emit('resize', this.size)
|
||||
}
|
||||
|
||||
this.size = null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -22,6 +22,12 @@
|
||||
v-if="shadow"
|
||||
:to="shadow"
|
||||
>
|
||||
<component
|
||||
:is="'style'"
|
||||
v-if="listsStyle"
|
||||
>
|
||||
{{ listsStyle }}
|
||||
</component>
|
||||
<DynamicSection
|
||||
v-for="section in sections"
|
||||
:ref="setSectionRefs"
|
||||
@@ -44,15 +50,17 @@
|
||||
@draw="$emit('draw', $event)"
|
||||
@add-custom-field="$emit('add-custom-field', $event)"
|
||||
@set-draw="$emit('set-draw', $event)"
|
||||
@add-attachment="document.attachments.push($event)"
|
||||
@add-list="ensureListsStyle"
|
||||
/>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import DynamicSection from './dynamic_section.vue'
|
||||
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
|
||||
import { dynamicStylesheet, tiptapStylesheet, listsCss } from './dynamic_editor.js'
|
||||
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
|
||||
|
||||
export default {
|
||||
@@ -61,6 +69,11 @@ export default {
|
||||
DynamicSection
|
||||
},
|
||||
inject: ['baseFetch', 'template'],
|
||||
provide () {
|
||||
return {
|
||||
documentFonts: computed(() => this.documentFonts)
|
||||
}
|
||||
},
|
||||
props: {
|
||||
document: {
|
||||
type: Object,
|
||||
@@ -71,6 +84,11 @@ export default {
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
autosave: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
},
|
||||
selectedSubmitter: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -107,6 +125,7 @@ export default {
|
||||
return {
|
||||
containerWidth: 1040,
|
||||
isMounted: false,
|
||||
listsStyle: null,
|
||||
sectionRefs: []
|
||||
}
|
||||
},
|
||||
@@ -134,6 +153,40 @@ export default {
|
||||
styles () {
|
||||
return this.headDom.querySelectorAll('style')
|
||||
},
|
||||
documentFonts () {
|
||||
const fonts = []
|
||||
const firstFamily = (value) => value.split(',')[0].trim().replace(/^["']|["']$/g, '')
|
||||
const add = (label, value) => {
|
||||
if (label && !fonts.some((font) => font.label === label)) fonts.push({ label, value })
|
||||
}
|
||||
|
||||
this.styles.forEach((style) => {
|
||||
const css = style.textContent
|
||||
|
||||
for (const type of ['minor', 'major']) {
|
||||
for (const [, name, value] of css.matchAll(new RegExp(`(--[\\w-]+-${type}HAnsi-font):\\s*([^;}]+)`, 'g'))) {
|
||||
add(firstFamily(value), `var(${name})`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [, value] of css.matchAll(/@font-face\s*\{[^}]*?font-family:\s*([^;}]+)/g)) {
|
||||
add(firstFamily(value), value.trim())
|
||||
}
|
||||
})
|
||||
|
||||
const skipFamilies = ['symbol', 'wingdings', 'wingdings 2', 'wingdings 3', 'webdings', 'serif', 'sans-serif', 'monospace', 'inherit', 'initial']
|
||||
const addUsed = (value) => {
|
||||
if (value && !value.startsWith('var(') && !skipFamilies.includes(firstFamily(value).toLowerCase())) add(firstFamily(value), value.trim())
|
||||
}
|
||||
|
||||
this.styles.forEach((style) => {
|
||||
for (const [, value] of style.textContent.replace(/@font-face\s*\{[^}]*\}/g, '').matchAll(/font-family:\s*([^;}]+)/g)) addUsed(value)
|
||||
})
|
||||
|
||||
this.bodyDom.querySelectorAll('[style*="font-family"]').forEach((el) => addUsed(el.style.fontFamily))
|
||||
|
||||
return fonts
|
||||
},
|
||||
shadow () {
|
||||
if (this.isMounted) {
|
||||
return this.$refs.shadow.attachShadow({ mode: 'open' })
|
||||
@@ -142,16 +195,21 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.updatedSectionEditors = new Map()
|
||||
|
||||
this.loadListsStyle()
|
||||
},
|
||||
mounted () {
|
||||
this.isMounted = true
|
||||
|
||||
this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet)
|
||||
|
||||
this.containerWidth = this.$refs.container.clientWidth
|
||||
this.updateContainerWidth()
|
||||
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.$refs.container) {
|
||||
this.containerWidth = this.$refs.container.clientWidth
|
||||
this.updateContainerWidth()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -169,6 +227,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
mergeSchemaProperties,
|
||||
updateContainerWidth () {
|
||||
this.containerWidth = this.$refs.container.clientWidth
|
||||
},
|
||||
removeArea (area) {
|
||||
this.sectionRefs.forEach((sectionRef) => {
|
||||
const pos = sectionRef.findAreaNodePos(area.uuid)
|
||||
@@ -184,8 +245,18 @@ export default {
|
||||
}
|
||||
},
|
||||
reloadContent () {
|
||||
this.loadListsStyle()
|
||||
|
||||
this.sectionRefs.forEach((ref) => ref.reloadContent())
|
||||
},
|
||||
getSectionDocs () {
|
||||
return this.sectionRefs.map((ref) => [ref.sectionKey, ref.editor.state.doc])
|
||||
},
|
||||
restoreSectionDocs (sectionDocs) {
|
||||
this.sectionRefs.forEach((ref) => {
|
||||
if (sectionDocs.has(ref.sectionKey)) ref.restoreDoc(sectionDocs.get(ref.sectionKey))
|
||||
})
|
||||
},
|
||||
onBeforeUnload (event) {
|
||||
if (this.saveTimer) {
|
||||
event.preventDefault()
|
||||
@@ -211,27 +282,49 @@ export default {
|
||||
onSectionUpdate (section, { editor }) {
|
||||
clearTimeout(this.saveTimer)
|
||||
|
||||
this.saveTimer = setTimeout(async () => {
|
||||
await this.updateSectionAndSave(section, editor)
|
||||
this.updatedSectionEditors.set(section.id, editor)
|
||||
|
||||
delete this.saveTimer
|
||||
}, 1000)
|
||||
this.saveTimer = setTimeout(() => this.autosave ? this.updateSectionsAndSave() : this.updateSections(), 1000)
|
||||
},
|
||||
loadListsStyle () {
|
||||
this.listsStyle = this.bodyDom.querySelector('body > style[data-lists]')?.textContent || null
|
||||
},
|
||||
ensureListsStyle () {
|
||||
if (this.listsStyle) return
|
||||
|
||||
const style = this.bodyDom.createElement('style')
|
||||
|
||||
style.dataset.lists = ''
|
||||
style.textContent = listsCss
|
||||
|
||||
this.bodyDom.body.append(style)
|
||||
|
||||
this.listsStyle = listsCss
|
||||
},
|
||||
updateVariablesSchema () {
|
||||
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
|
||||
},
|
||||
updateSectionAndSave (section, editor) {
|
||||
const target = this.bodyDom.getElementById(section.id)
|
||||
updateSections () {
|
||||
delete this.saveTimer
|
||||
|
||||
if (target) {
|
||||
target.innerHTML = this.getHtmlForSave(editor)
|
||||
}
|
||||
this.updatedSectionEditors.forEach((editor, sectionId) => {
|
||||
const target = this.bodyDom.getElementById(sectionId)
|
||||
|
||||
if (target) {
|
||||
target.innerHTML = this.getHtmlForSave(editor)
|
||||
}
|
||||
})
|
||||
|
||||
this.updatedSectionEditors.clear()
|
||||
|
||||
this.document.body = this.bodyDom.body.innerHTML
|
||||
|
||||
this.updateVariablesSchema()
|
||||
|
||||
this.$emit('update', this.document)
|
||||
},
|
||||
updateSectionsAndSave () {
|
||||
this.updateSections()
|
||||
|
||||
return this.saveBody()
|
||||
},
|
||||
@@ -245,6 +338,8 @@ export default {
|
||||
|
||||
delete this.saveTimer
|
||||
|
||||
this.updatedSectionEditors.clear()
|
||||
|
||||
this.sectionRefs.forEach(({ section, editor }) => {
|
||||
const target = this.bodyDom.getElementById(section.id)
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div class="absolute top-7 left-0 min-w-36 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg">
|
||||
<div
|
||||
class="relative"
|
||||
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'field' : null"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
|
||||
:class="{ 'bg-neutral-100': submenu === 'field' }"
|
||||
@click="submenu = submenu === 'field' ? null : 'field'"
|
||||
>
|
||||
<IconForms
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
{{ t('field') }}
|
||||
<IconChevronRight
|
||||
:width="14"
|
||||
:height="14"
|
||||
class="ml-auto"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="submenu === 'field'"
|
||||
class="absolute top-0 left-full min-w-40 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="(icon, type) in fieldTypesList"
|
||||
:key="type"
|
||||
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
|
||||
@click="[$emit('insert-field', type), $emit('close')]"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
{{ fieldNames[type] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative"
|
||||
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'table' : null"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
|
||||
:class="{ 'bg-neutral-100': submenu === 'table' }"
|
||||
@click="submenu = submenu === 'table' ? null : 'table'"
|
||||
>
|
||||
<IconTable
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
{{ t('table') }}
|
||||
<IconChevronRight
|
||||
:width="14"
|
||||
:height="14"
|
||||
class="ml-auto"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="submenu === 'table'"
|
||||
class="absolute top-0 left-full w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
|
||||
>
|
||||
<div
|
||||
v-if="!isMobile"
|
||||
class="grid grid-cols-8 gap-0.5 mb-2"
|
||||
>
|
||||
<template
|
||||
v-for="row in 8"
|
||||
:key="row"
|
||||
>
|
||||
<button
|
||||
v-for="col in 8"
|
||||
:key="col"
|
||||
class="w-4 h-4 border rounded-sm"
|
||||
:class="row <= tableSize.rows && col <= tableSize.cols ? 'bg-neutral-200 border-neutral-400' : 'bg-white border-neutral-200'"
|
||||
:aria-label="`${row} × ${col}`"
|
||||
@pointerenter="$event.pointerType === 'mouse' ? tableSize = { rows: row, cols: col } : null"
|
||||
@click="[$emit('insert-table', { rows: row, cols: col }), $emit('close')]"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-1 text-xs">
|
||||
<template
|
||||
v-for="key in ['rows', 'cols']"
|
||||
:key="key"
|
||||
>
|
||||
<span v-if="key === 'cols'">×</span>
|
||||
<div class="flex items-center border border-neutral-200 rounded-md">
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
|
||||
:aria-label="t(`decrease_${key}`)"
|
||||
@click="tableSize[key] = Math.max(tableSize[key] - 1, 1)"
|
||||
>
|
||||
<IconMinus
|
||||
:width="12"
|
||||
:height="12"
|
||||
/>
|
||||
</button>
|
||||
<span class="w-5 text-center">{{ tableSize[key] }}</span>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
|
||||
:aria-label="t(`increase_${key}`)"
|
||||
@click="tableSize[key] = Math.min(tableSize[key] + 1, 20)"
|
||||
>
|
||||
<IconPlus
|
||||
:width="12"
|
||||
:height="12"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-if="isMobile || tableSize.rows > 8 || tableSize.cols > 8"
|
||||
class="w-full mt-2 px-2 py-1 rounded-md bg-neutral-100 hover:bg-neutral-200"
|
||||
@click="[$emit('insert-table', { ...tableSize }), $emit('close')]"
|
||||
>
|
||||
{{ t('insert') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
|
||||
@mouseenter="submenu = null"
|
||||
@click="[$emit('insert-variable'), $emit('close')]"
|
||||
>
|
||||
<IconBracketsContain
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
{{ t('variable') }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
|
||||
@mouseenter="submenu = null"
|
||||
@click="[$emit('insert-image'), $emit('close')]"
|
||||
>
|
||||
<IconPhoto
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
{{ t('image') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconPlus, IconMinus, IconForms, IconTable, IconPhoto, IconChevronRight, IconBracketsContain } from '@tabler/icons-vue'
|
||||
import FieldType from './field_type.vue'
|
||||
|
||||
export default {
|
||||
name: 'DynamicInsertMenu',
|
||||
components: {
|
||||
IconPlus,
|
||||
IconMinus,
|
||||
IconForms,
|
||||
IconTable,
|
||||
IconPhoto,
|
||||
IconChevronRight,
|
||||
IconBracketsContain
|
||||
},
|
||||
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 't', 'fieldTypes', 'isMobile'],
|
||||
emits: ['insert-field', 'insert-table', 'insert-variable', 'insert-image', 'close'],
|
||||
data () {
|
||||
return {
|
||||
submenu: null,
|
||||
tableSize: { rows: 3, cols: 3 }
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldIcons: FieldType.computed.fieldIcons,
|
||||
skipTypes: FieldType.computed.skipTypes,
|
||||
fieldIconsSorted: FieldType.computed.fieldIconsSorted,
|
||||
fieldTypesList () {
|
||||
return Object.fromEntries(Object.entries(this.fieldIconsSorted).filter(([type]) => {
|
||||
return this.fieldTypes.includes(type) || ((this.withPhone || type !== 'phone') && (this.withPayment || type !== 'payment') && (this.withVerification || type !== 'verification') && (this.withKba || type !== 'kba'))
|
||||
}))
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
document.addEventListener('mousedown', this.onDocumentMouseDown)
|
||||
},
|
||||
beforeUnmount () {
|
||||
document.removeEventListener('mousedown', this.onDocumentMouseDown)
|
||||
|
||||
this.$emit('close')
|
||||
},
|
||||
methods: {
|
||||
onDocumentMouseDown (event) {
|
||||
if (!event.composedPath().includes(this.$el.parentElement)) {
|
||||
this.$emit('close')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,107 +1,360 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="absolute z-10 flex items-center gap-0.5 px-1.5 py-1 bg-white border border-base-300 rounded-lg shadow select-none"
|
||||
:style="{ top: (coords.top - 42) + 'px', left: coords.left + 'px', transform: 'translateX(-50%)' }"
|
||||
ref="menu"
|
||||
class="absolute z-10 flex flex-col gap-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg select-none text-sm"
|
||||
:style="{ top: coords.top + 'px', left: menuLeft + 'px' }"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isBold ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Bold"
|
||||
@click="toggleBold"
|
||||
>
|
||||
<IconBold
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isItalic ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Italic"
|
||||
@click="toggleItalic"
|
||||
>
|
||||
<IconItalic
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isUnderline ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Underline"
|
||||
@click="toggleUnderline"
|
||||
>
|
||||
<IconUnderline
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
|
||||
:class="isStrike ? 'bg-base-200' : 'bg-transparent'"
|
||||
title="Strikethrough"
|
||||
@click="toggleStrike"
|
||||
>
|
||||
<IconStrikethrough
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-base-300 mx-1" />
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
|
||||
title="Wrap in variable"
|
||||
@click="wrapVariable"
|
||||
>
|
||||
<IconBracketsContain
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span class="px-0.5">
|
||||
Variable
|
||||
</span>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-base-300 mx-1" />
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
|
||||
title="Wrap in condition"
|
||||
@click="wrapCondition"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="tabler-icon tabler-icon-brackets-contain"
|
||||
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" />
|
||||
<text
|
||||
x="12"
|
||||
y="16.5"
|
||||
text-anchor="middle"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
font-size="14"
|
||||
font-weight="600"
|
||||
font-family="ui-sans-serif, system-ui, sans-serif"
|
||||
>if</text>
|
||||
</svg>
|
||||
<span class="px-0.5">
|
||||
Condition
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center justify-between gap-1 w-24 sm:w-36 h-7 px-2 border border-neutral-200 rounded-md hover:bg-neutral-100"
|
||||
:title="t('font')"
|
||||
@click="toggleDropdown('font')"
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
:style="{ fontFamily }"
|
||||
>{{ fontFamily }}</span>
|
||||
<IconChevronDown
|
||||
:width="14"
|
||||
:height="14"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="dropdown === 'font'"
|
||||
ref="dropdown"
|
||||
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
|
||||
class="absolute z-10 top-full mt-1 w-48 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-64 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="font in fonts"
|
||||
:key="font.label"
|
||||
class="block w-full text-left px-2 py-1 rounded-md truncate"
|
||||
:class="font.label === fontFamily ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
|
||||
:style="{ fontFamily: font.value }"
|
||||
@click="setSpanStyle('font-family', font.value)"
|
||||
>
|
||||
{{ font.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center justify-between gap-1 w-12 sm:w-16 h-7 px-2 border border-neutral-200 rounded-md hover:bg-neutral-100"
|
||||
:title="t('font_size')"
|
||||
@click="toggleDropdown('size')"
|
||||
>
|
||||
<span>{{ fontSize }}</span>
|
||||
<IconChevronDown
|
||||
:width="14"
|
||||
:height="14"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="dropdown === 'size'"
|
||||
ref="dropdown"
|
||||
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
|
||||
class="absolute z-10 top-full mt-1 w-16 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-64 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="size in fontSizes"
|
||||
:key="size"
|
||||
class="block w-full text-left px-2 py-1 rounded-md"
|
||||
:class="size === fontSize ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
|
||||
@click="setFontSize(size)"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
|
||||
:title="t('increase_font_size')"
|
||||
@click="setFontSize(fontSizes.find((size) => size > fontSize) || fontSize)"
|
||||
>
|
||||
<IconTextIncrease
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
|
||||
:title="t('decrease_font_size')"
|
||||
@click="setFontSize([...fontSizes].reverse().find((size) => size < fontSize) || fontSize)"
|
||||
>
|
||||
<IconTextDecrease
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-neutral-200 mx-1" />
|
||||
<div class="relative">
|
||||
<button
|
||||
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
|
||||
:title="t('line_spacing')"
|
||||
@click="toggleDropdown('lineHeight')"
|
||||
>
|
||||
<IconLineHeight
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
<IconChevronDown
|
||||
:width="12"
|
||||
:height="12"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="dropdown === 'lineHeight'"
|
||||
ref="dropdown"
|
||||
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
|
||||
class="absolute z-10 top-full mt-1 w-20 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="value in lineHeights"
|
||||
:key="value"
|
||||
class="block w-full text-left px-2 py-1 rounded-md"
|
||||
:class="value === lineHeight ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
|
||||
@click="setBlockStyle('line-height', `calc(${value} * 1.15)`)"
|
||||
>
|
||||
{{ value }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
|
||||
:title="t('align')"
|
||||
@click="toggleDropdown('align')"
|
||||
>
|
||||
<component
|
||||
:is="alignIcons[textAlign]"
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
<IconChevronDown
|
||||
:width="12"
|
||||
:height="12"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="dropdown === 'align'"
|
||||
ref="dropdown"
|
||||
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
|
||||
class="absolute z-10 top-full mt-1 w-max flex gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="(icon, align) in alignIcons"
|
||||
:key="align"
|
||||
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
|
||||
:class="align === textAlign ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
|
||||
:title="t(align)"
|
||||
@click="setBlockStyle('text-align', align)"
|
||||
>
|
||||
<component
|
||||
:is="icon"
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<button
|
||||
class="inline-flex items-center justify-center h-7 px-1 rounded-md"
|
||||
:class="listStyle ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
|
||||
:title="t('bullet_list')"
|
||||
@click="toggleDropdown('list')"
|
||||
>
|
||||
<IconList
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
<IconChevronDown
|
||||
:width="12"
|
||||
:height="12"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="dropdown === 'list'"
|
||||
ref="dropdown"
|
||||
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
|
||||
class="absolute z-10 top-full mt-1 w-28 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
|
||||
>
|
||||
<button
|
||||
v-for="({ label, marker }, style) in listStyles"
|
||||
:key="style"
|
||||
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
|
||||
:class="style === listStyle ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
|
||||
@click="toggleList(style)"
|
||||
>
|
||||
<span class="w-4 text-center">{{ marker }}</span>
|
||||
{{ label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
|
||||
:class="isBold ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
|
||||
:title="t('bold')"
|
||||
@click="toggleBold"
|
||||
>
|
||||
<IconBold
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
|
||||
:class="isItalic ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
|
||||
:title="t('italic')"
|
||||
@click="toggleItalic"
|
||||
>
|
||||
<IconItalic
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
|
||||
:class="isUnderline ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
|
||||
:title="t('underline')"
|
||||
@click="toggleUnderline"
|
||||
>
|
||||
<IconUnderline
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
|
||||
:class="isStrike ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
|
||||
:title="t('strikethrough')"
|
||||
@click="toggleStrike"
|
||||
>
|
||||
<IconStrikethrough
|
||||
:width="16"
|
||||
:height="16"
|
||||
/>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-neutral-200 mx-1" />
|
||||
<div
|
||||
v-for="colorProperty in ['color', 'background-color']"
|
||||
:key="colorProperty"
|
||||
class="relative"
|
||||
>
|
||||
<button
|
||||
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
|
||||
:title="colorProperty === 'color' ? t('text_color') : t('background_color')"
|
||||
@click="toggleDropdown(colorProperty)"
|
||||
>
|
||||
<span class="flex flex-col items-center">
|
||||
<component
|
||||
:is="colorProperty === 'color' ? 'IconLetterA' : 'IconHighlight'"
|
||||
:width="16"
|
||||
:height="14"
|
||||
/>
|
||||
<span
|
||||
class="w-4 h-1 rounded-sm border border-neutral-200"
|
||||
:style="{ backgroundColor: colorProperty === 'color' ? color : backgroundColor }"
|
||||
/>
|
||||
</span>
|
||||
<IconChevronDown
|
||||
:width="12"
|
||||
:height="12"
|
||||
/>
|
||||
</button>
|
||||
<div
|
||||
v-if="dropdown === colorProperty"
|
||||
ref="dropdown"
|
||||
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
|
||||
class="absolute z-10 top-full mt-1 w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-1 w-full mb-2 px-2 py-1 rounded-md hover:bg-neutral-100"
|
||||
@click="setSpanStyle(colorProperty, null)"
|
||||
>
|
||||
<IconX
|
||||
:width="14"
|
||||
:height="14"
|
||||
/>
|
||||
{{ colorProperty === 'color' ? t('default') : t('none') }}
|
||||
</button>
|
||||
<div class="grid grid-cols-10 gap-1">
|
||||
<button
|
||||
v-for="value in colors"
|
||||
:key="value"
|
||||
class="w-4 h-4 rounded-sm border border-neutral-200 hover:scale-125 transition-transform"
|
||||
:style="{ backgroundColor: value }"
|
||||
:title="value"
|
||||
@click="setSpanStyle(colorProperty, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-px h-5 bg-neutral-200 mx-1 sm:mx-0.5" />
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-xs h-7 px-1 rounded-md"
|
||||
:class="isCellSelection ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
|
||||
:title="t('create_variable')"
|
||||
:disabled="isCellSelection"
|
||||
@click="wrapVariable"
|
||||
>
|
||||
<IconBracketsContain
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<span class="hidden sm:inline px-0.5 font-mono">
|
||||
{{ t('variable') }}
|
||||
</span>
|
||||
</button>
|
||||
<div class="w-px h-5 bg-neutral-200 mx-1 sm:mx-0.5" />
|
||||
<button
|
||||
class="inline-flex items-center justify-center text-xs h-7 px-1 rounded-md"
|
||||
:class="isCellSelection ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
|
||||
:title="t('create_condition')"
|
||||
:disabled="isCellSelection"
|
||||
@click="wrapCondition"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="tabler-icon tabler-icon-brackets-contain"
|
||||
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" />
|
||||
<text
|
||||
x="12"
|
||||
y="16.5"
|
||||
text-anchor="middle"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
font-size="14"
|
||||
font-weight="600"
|
||||
font-family="ui-sans-serif, system-ui, sans-serif"
|
||||
>if</text>
|
||||
</svg>
|
||||
<span class="hidden sm:inline px-0.5 font-mono">
|
||||
{{ t('condition') }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain } from '@tabler/icons-vue'
|
||||
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain, IconChevronDown, IconTextIncrease, IconTextDecrease, IconLineHeight, IconAlignLeft, IconAlignCenter, IconAlignRight, IconAlignJustified, IconLetterA, IconHighlight, IconList, IconX } from '@tabler/icons-vue'
|
||||
import { CellSelection } from '@tiptap/pm/tables'
|
||||
import { findNumbering, numberingListStyle } from './dynamic_editor.js'
|
||||
|
||||
export default {
|
||||
name: 'DynamicMenu',
|
||||
@@ -110,8 +363,17 @@ export default {
|
||||
IconItalic,
|
||||
IconUnderline,
|
||||
IconStrikethrough,
|
||||
IconBracketsContain
|
||||
IconBracketsContain,
|
||||
IconChevronDown,
|
||||
IconTextIncrease,
|
||||
IconTextDecrease,
|
||||
IconLineHeight,
|
||||
IconLetterA,
|
||||
IconHighlight,
|
||||
IconList,
|
||||
IconX
|
||||
},
|
||||
inject: ['t', 'documentFonts'],
|
||||
props: {
|
||||
editor: {
|
||||
type: Object,
|
||||
@@ -123,23 +385,90 @@ export default {
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['add-variable', 'add-condition'],
|
||||
emits: ['add-variable', 'add-condition', 'add-list'],
|
||||
data () {
|
||||
return {
|
||||
isMouseDown: false,
|
||||
isBold: this.editor.isActive('bold'),
|
||||
isItalic: this.editor.isActive('italic'),
|
||||
isUnderline: this.editor.isActive('underline'),
|
||||
isStrike: this.editor.isActive('strike')
|
||||
dropdown: null,
|
||||
isDropdownAlignedRight: false,
|
||||
menuWidth: 0,
|
||||
isBold: false,
|
||||
isItalic: false,
|
||||
isUnderline: false,
|
||||
isStrike: false,
|
||||
fontFamily: '',
|
||||
fontSize: 12,
|
||||
color: '',
|
||||
backgroundColor: '',
|
||||
lineHeight: '',
|
||||
textAlign: 'left',
|
||||
listStyle: null,
|
||||
isCellSelection: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
visible () {
|
||||
return !!this.coords && !this.isMouseDown
|
||||
},
|
||||
menuLeft () {
|
||||
return Math.max(4, Math.min(this.coords.left - this.menuWidth / 2, this.coords.width - this.menuWidth - 4))
|
||||
},
|
||||
standardFonts () {
|
||||
return [
|
||||
{ label: 'Arial', value: 'Arial' },
|
||||
{ label: 'Times New Roman', value: "'Times New Roman'" },
|
||||
{ label: 'Courier New', value: "'Courier New'" }
|
||||
]
|
||||
},
|
||||
fonts () {
|
||||
return [
|
||||
...this.documentFonts.filter((font) => !this.standardFonts.some((standardFont) => standardFont.label === font.label)),
|
||||
...this.standardFonts
|
||||
]
|
||||
},
|
||||
fontSizes () {
|
||||
return [8, 9, 10, 10.5, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72]
|
||||
},
|
||||
lineHeights () {
|
||||
return ['1', '1.15', '1.5', '2', '2.5', '3']
|
||||
},
|
||||
alignIcons () {
|
||||
return {
|
||||
left: IconAlignLeft,
|
||||
center: IconAlignCenter,
|
||||
right: IconAlignRight,
|
||||
justify: IconAlignJustified
|
||||
}
|
||||
},
|
||||
listStyles () {
|
||||
return {
|
||||
disc: { label: this.t('dots'), marker: '•' },
|
||||
dash: { label: this.t('dash'), marker: '–' },
|
||||
decimal: { label: this.t('numbers'), marker: '1.' }
|
||||
}
|
||||
},
|
||||
colors () {
|
||||
return [
|
||||
'#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#efefef', '#f3f3f3', '#ffffff',
|
||||
'#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff',
|
||||
'#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc',
|
||||
'#cc4125', '#e06666', '#f6b26b', '#ffd966', '#93c47d', '#76a5af', '#6d9eeb', '#6fa8dc', '#8e7cc3', '#c27ba0',
|
||||
'#85200c', '#990000', '#b45f06', '#bf9000', '#38761d', '#134f5c', '#1155cc', '#0b5394', '#351c75', '#741b47'
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
visible (value) {
|
||||
this.dropdown = null
|
||||
|
||||
if (value) {
|
||||
this.readState()
|
||||
this.$nextTick(this.measure)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.editor.view.dom.addEventListener('mousedown', this.onMouseDown)
|
||||
this.editor.view.dom.parentElement.addEventListener('mousedown', this.onMouseDown)
|
||||
|
||||
document.addEventListener('mouseup', this.onMouseUp)
|
||||
|
||||
@@ -147,18 +476,79 @@ export default {
|
||||
},
|
||||
beforeUnmount () {
|
||||
if (!this.editor.isDestroyed) {
|
||||
this.editor.view.dom.removeEventListener('mousedown', this.onMouseDown)
|
||||
this.editor.view.dom.parentElement.removeEventListener('mousedown', this.onMouseDown)
|
||||
this.editor.off('transaction', this.onTransaction)
|
||||
}
|
||||
|
||||
document.removeEventListener('mouseup', this.onMouseUp)
|
||||
},
|
||||
methods: {
|
||||
measure () {
|
||||
if (this.$refs.menu) {
|
||||
this.menuWidth = this.$refs.menu.offsetWidth
|
||||
}
|
||||
},
|
||||
toggleDropdown (name) {
|
||||
this.dropdown = this.dropdown === name ? null : name
|
||||
this.isDropdownAlignedRight = false
|
||||
|
||||
this.$nextTick(() => {
|
||||
const dropdownEl = [this.$refs.dropdown].flat()[0]
|
||||
|
||||
if (!dropdownEl) return
|
||||
|
||||
const dropdownRect = dropdownEl.getBoundingClientRect()
|
||||
const containerRect = this.$el.parentElement.getBoundingClientRect()
|
||||
|
||||
this.isDropdownAlignedRight = dropdownRect.right > containerRect.right
|
||||
})
|
||||
},
|
||||
toggleBold () {
|
||||
this.editor.chain().focus().toggleBold().run()
|
||||
this.toggleStyledMark('bold', 'font-weight', this.isBold, (style) => parseInt(style.fontWeight) >= 600)
|
||||
},
|
||||
toggleItalic () {
|
||||
this.editor.chain().focus().toggleItalic().run()
|
||||
this.toggleStyledMark('italic', 'font-style', this.isItalic, (style) => style.fontStyle === 'italic')
|
||||
},
|
||||
toggleStyledMark (markName, property, isActive, isStyled) {
|
||||
const isSegmentStyled = ({ from }) => isStyled(getComputedStyle(this.textElementAt(from)))
|
||||
|
||||
if (isActive) {
|
||||
this.editor.chain().focus().unsetMark(markName).setListMarkerStyle(property, null).run()
|
||||
|
||||
if (this.selectedTextSegments().some(isSegmentStyled)) {
|
||||
this.editor.chain().setSpanStyle(property, 'normal').run()
|
||||
}
|
||||
} else {
|
||||
this.editor.chain().focus().setSpanStyle(property, null).run()
|
||||
|
||||
const { state, view } = this.editor
|
||||
const tr = state.tr
|
||||
|
||||
this.selectedTextSegments().forEach((segment) => {
|
||||
if (!isSegmentStyled(segment)) tr.addMark(segment.from, segment.to, state.schema.marks[markName].create())
|
||||
})
|
||||
|
||||
if (tr.docChanged) view.dispatch(tr)
|
||||
|
||||
this.editor.commands.setListMarkerStyle(property, markName)
|
||||
}
|
||||
},
|
||||
selectedTextSegments () {
|
||||
const { state } = this.editor
|
||||
const segments = []
|
||||
|
||||
state.selection.ranges.forEach(({ $from, $to }) => state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
|
||||
if (node.isText) segments.push({ from: Math.max(pos, $from.pos), to: Math.min(pos + node.nodeSize, $to.pos) })
|
||||
}))
|
||||
|
||||
return segments
|
||||
},
|
||||
textElementAt (pos) {
|
||||
const { node, offset } = this.editor.view.domAtPos(pos, 1)
|
||||
|
||||
if (node.nodeType === Node.TEXT_NODE) return node.parentElement
|
||||
|
||||
return node.childNodes[offset]?.nodeType === Node.ELEMENT_NODE ? node.childNodes[offset] : node
|
||||
},
|
||||
toggleUnderline () {
|
||||
this.editor.chain().focus().toggleUnderline().run()
|
||||
@@ -166,6 +556,30 @@ export default {
|
||||
toggleStrike () {
|
||||
this.editor.chain().focus().toggleStrike().run()
|
||||
},
|
||||
setSpanStyle (property, value) {
|
||||
this.dropdown = null
|
||||
|
||||
const chain = this.editor.chain().focus().setSpanStyle(property, value)
|
||||
|
||||
if (property !== 'background-color') chain.setListMarkerStyle(property, value)
|
||||
|
||||
chain.run()
|
||||
},
|
||||
setFontSize (size) {
|
||||
this.setSpanStyle('font-size', `${size}pt`)
|
||||
},
|
||||
setBlockStyle (property, value) {
|
||||
this.dropdown = null
|
||||
|
||||
this.editor.chain().focus().setBlockStyle(property, value).run()
|
||||
},
|
||||
toggleList (style) {
|
||||
this.dropdown = null
|
||||
|
||||
if (style !== this.listStyle) this.$emit('add-list')
|
||||
|
||||
this.editor.chain().focus().toggleList(style).run()
|
||||
},
|
||||
wrapVariable () {
|
||||
const { from, to } = this.editor.state.selection
|
||||
const replacement = '[[variable]]'
|
||||
@@ -201,10 +615,62 @@ export default {
|
||||
}, 1)
|
||||
},
|
||||
onTransaction () {
|
||||
this.isBold = this.editor.isActive('bold')
|
||||
this.isItalic = this.editor.isActive('italic')
|
||||
this.isUnderline = this.editor.isActive('underline')
|
||||
this.isStrike = this.editor.isActive('strike')
|
||||
if (this.visible) {
|
||||
this.readState()
|
||||
}
|
||||
},
|
||||
readState () {
|
||||
const { view, state } = this.editor
|
||||
const { ranges } = state.selection
|
||||
const selectionFrom = Math.min(...ranges.map((range) => range.$from.pos))
|
||||
const selectionTo = Math.max(...ranges.map((range) => range.$to.pos))
|
||||
let from = null
|
||||
|
||||
state.doc.nodesBetween(selectionFrom, selectionTo, (node, pos) => {
|
||||
if (from === null && node.isText) from = Math.max(pos, selectionFrom)
|
||||
})
|
||||
|
||||
if (from === null) from = selectionFrom
|
||||
|
||||
const $from = state.doc.resolve(from)
|
||||
const el = this.textElementAt(from)
|
||||
const style = getComputedStyle(el)
|
||||
const blockDom = $from.parent.isTextblock ? view.nodeDOM($from.before()) : view.dom
|
||||
const decorations = []
|
||||
|
||||
this.isCellSelection = state.selection instanceof CellSelection
|
||||
this.isBold = parseInt(style.fontWeight) >= 600
|
||||
this.isItalic = style.fontStyle === 'italic'
|
||||
|
||||
this.fontFamily = style.fontFamily.split(',')[0].replace(/["']/g, '').trim()
|
||||
this.fontSize = Math.round(parseFloat(style.fontSize) * 0.75 * 2) / 2
|
||||
this.color = style.color
|
||||
this.backgroundColor = 'transparent'
|
||||
|
||||
for (let inlineEl = el; inlineEl && view.dom.contains(inlineEl); inlineEl = inlineEl.parentElement) {
|
||||
const { textDecorationLine, backgroundColor } = getComputedStyle(inlineEl)
|
||||
|
||||
decorations.push(textDecorationLine)
|
||||
|
||||
if (inlineEl === blockDom) break
|
||||
|
||||
if (this.backgroundColor === 'transparent' && backgroundColor !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'transparent') {
|
||||
this.backgroundColor = backgroundColor
|
||||
}
|
||||
}
|
||||
|
||||
this.isUnderline = this.editor.isActive('underline') || decorations.some((line) => line.includes('underline'))
|
||||
this.isStrike = this.editor.isActive('strike') || decorations.some((line) => line.includes('line-through'))
|
||||
|
||||
if ($from.parent.isTextblock) {
|
||||
const blockStyle = getComputedStyle(blockDom)
|
||||
const align = blockStyle.textAlign.replace('start', 'left').replace('end', 'right')
|
||||
|
||||
this.textAlign = this.alignIcons[align] ? align : 'left'
|
||||
this.lineHeight = String(Math.round(parseFloat(blockStyle.lineHeight) / parseFloat(blockStyle.fontSize) / 1.15 * 100) / 100)
|
||||
}
|
||||
|
||||
this.listStyle = findNumbering($from.parent) ? numberingListStyle(view, $from.before()) : null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
<div
|
||||
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
|
||||
:class="{ 'cursor-crosshair': isDrawMode && editable }"
|
||||
@mousedown="onMarginMouseDown"
|
||||
>
|
||||
<div
|
||||
v-if="isDrawMode && editable && cursorHighlightCoords"
|
||||
class="absolute pointer-events-none z-10 bg-black"
|
||||
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
|
||||
/>
|
||||
<div :style="{ zoom: containerWidth / sectionWidthPx }">
|
||||
<div
|
||||
:style="{ zoom, '--zoom': zoom }"
|
||||
:data-zoomed-out="zoom < 0.75 || null"
|
||||
>
|
||||
<section
|
||||
:id="section.id"
|
||||
ref="editorElement"
|
||||
@@ -17,6 +21,13 @@
|
||||
:style="section.style.cssText"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref="imageInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="hidden"
|
||||
@change="onImageSelected"
|
||||
>
|
||||
<Teleport
|
||||
v-if="editor"
|
||||
:to="container"
|
||||
@@ -30,6 +41,7 @@
|
||||
:area="selectedArea"
|
||||
:field="selectedField"
|
||||
:editable="editable"
|
||||
:is-mobile="isMobile"
|
||||
:template="template"
|
||||
:selected-areas-ref="selectedAreasRef"
|
||||
:get-field-type-index="getFieldTypeIndex"
|
||||
@@ -41,9 +53,50 @@
|
||||
v-if="editable"
|
||||
v-show="!selectedAreasRef.value.length"
|
||||
:editor="editor"
|
||||
:coords="dynamicMenuCoords"
|
||||
:coords="isInsertMenuOpen ? null : dynamicMenuCoords"
|
||||
@add-variable="dynamicMenuCoords = null"
|
||||
@add-condition="dynamicMenuCoords = null"
|
||||
@add-list="$emit('add-list')"
|
||||
/>
|
||||
<div
|
||||
v-if="editable && insertMenuCoords && !isDrawMode"
|
||||
class="absolute z-10 select-none text-sm"
|
||||
:style="{ top: insertMenuCoords.top - 12 + 'px', left: insertMenuCoords.left - 12 + 'px' }"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<button
|
||||
class="flex items-center justify-center w-6 h-6 rounded-md border border-neutral-200"
|
||||
:class="isInsertMenuOpen ? 'bg-neutral-100 text-base-content' : 'bg-white text-base-content/40 hover:bg-neutral-100 hover:text-base-content'"
|
||||
:title="t('insert')"
|
||||
@click="isInsertMenuOpen = !isInsertMenuOpen"
|
||||
>
|
||||
<IconX
|
||||
v-if="isInsertMenuOpen"
|
||||
:width="14"
|
||||
:height="14"
|
||||
/>
|
||||
<IconPlus
|
||||
v-else
|
||||
:width="14"
|
||||
:height="14"
|
||||
/>
|
||||
</button>
|
||||
<DynamicInsertMenu
|
||||
v-if="isInsertMenuOpen"
|
||||
:key="insertMenuCoords.top"
|
||||
@close="isInsertMenuOpen = false"
|
||||
@insert-field="onInsertField"
|
||||
@insert-table="onInsertTable"
|
||||
@insert-variable="editor.chain().focus().insertContent('[[variable]]').run()"
|
||||
@insert-image="$refs.imageInput.click()"
|
||||
/>
|
||||
</div>
|
||||
<DynamicBlockMenu
|
||||
v-if="editable && blockMenuCoords && !isDrawMode"
|
||||
:coords="blockMenuCoords"
|
||||
:get-resize-limits="getBlockResizeLimits"
|
||||
@command="onBlockCommand"
|
||||
@resize="onBlockResize"
|
||||
/>
|
||||
<FieldContextMenu
|
||||
v-if="contextMenu && contextMenuField"
|
||||
@@ -64,20 +117,29 @@
|
||||
<script>
|
||||
import { shallowRef } from 'vue'
|
||||
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
|
||||
import { Selection, TextSelection } from '@tiptap/pm/state'
|
||||
import { findTable, CellSelection } from '@tiptap/pm/tables'
|
||||
import { v4 } from 'uuid'
|
||||
import { IconPlus, IconX } from '@tabler/icons-vue'
|
||||
import FieldContextMenu from './field_context_menu.vue'
|
||||
import AreaTitle from './area_title.vue'
|
||||
import DynamicMenu from './dynamic_menu.vue'
|
||||
import { buildEditor } from './dynamic_editor.js'
|
||||
import DynamicInsertMenu from './dynamic_insert_menu.vue'
|
||||
import DynamicBlockMenu from './dynamic_block_menu.vue'
|
||||
import { buildEditor, isRegularTable, resizeTable, tableRowMinHeights } from './dynamic_editor.js'
|
||||
|
||||
export default {
|
||||
name: 'DynamicSection',
|
||||
components: {
|
||||
DynamicMenu,
|
||||
DynamicInsertMenu,
|
||||
DynamicBlockMenu,
|
||||
FieldContextMenu,
|
||||
AreaTitle
|
||||
AreaTitle,
|
||||
IconPlus,
|
||||
IconX
|
||||
},
|
||||
inject: ['template', 'save', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'],
|
||||
inject: ['template', 'save', 'pushUndo', 'undo', 'redo', 'baseFetch', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor', 'isMobile'],
|
||||
props: {
|
||||
section: {
|
||||
type: Object,
|
||||
@@ -147,12 +209,15 @@ export default {
|
||||
default: null
|
||||
}
|
||||
},
|
||||
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
|
||||
emits: ['update', 'draw', 'set-draw', 'add-custom-field', 'add-attachment', 'add-list'],
|
||||
data () {
|
||||
return {
|
||||
isAreaDrag: false,
|
||||
areaToolbarCoords: null,
|
||||
dynamicMenuCoords: null,
|
||||
insertMenuCoords: null,
|
||||
isInsertMenuOpen: false,
|
||||
blockMenuCoords: null,
|
||||
contextMenu: null,
|
||||
cursorHighlightCoords: null
|
||||
}
|
||||
@@ -196,6 +261,9 @@ export default {
|
||||
editor () {
|
||||
return this.editorRef.value
|
||||
},
|
||||
sectionKey () {
|
||||
return `${this.attachmentUuid}:${this.section.id}`
|
||||
},
|
||||
sectionWidthPx () {
|
||||
const pt = parseFloat(this.section.style.width)
|
||||
|
||||
@@ -235,9 +303,19 @@ export default {
|
||||
if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) {
|
||||
this.$nextTick(() => this.setDynamicMenuCoords(this.editor))
|
||||
}
|
||||
|
||||
if (this.insertMenuCoords && this.editor) {
|
||||
this.$nextTick(() => this.setInsertMenuCoords(this.editor))
|
||||
}
|
||||
|
||||
if (this.blockMenuCoords && this.editor) {
|
||||
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.lastUpdateTime = 0
|
||||
|
||||
this.editorRef.value = buildEditor({
|
||||
dynamicAreaProps: {
|
||||
template: this.template,
|
||||
@@ -250,17 +328,36 @@ export default {
|
||||
onAreaResize: this.onAreaResize,
|
||||
onAreaDragStart: this.onAreaDragStart
|
||||
},
|
||||
attachmentsIndex: this.attachmentsIndex,
|
||||
getAttachmentsIndex: () => this.attachmentsIndex,
|
||||
onFieldDrop: this.onFieldDrop,
|
||||
onFieldDestroy: this.onFieldDestroy,
|
||||
onUndo: this.undo,
|
||||
onRedo: this.redo,
|
||||
renderHtmlForSaveRef: this.renderHtmlForSaveRef,
|
||||
editorOptions: {
|
||||
element: this.$refs.editorElement,
|
||||
editable: this.editable,
|
||||
content: this.section.innerHTML,
|
||||
onUpdate: (event) => this.$emit('update', event),
|
||||
onUpdate: (event) => {
|
||||
this.$emit('update', event)
|
||||
|
||||
if (event.transaction.getMeta('addToHistory') !== false) {
|
||||
this.pushUndo({ amend: event.transaction.time - this.lastUpdateTime < 500 })
|
||||
|
||||
this.lastUpdateTime = event.transaction.time
|
||||
}
|
||||
|
||||
if (this.blockMenuCoords) {
|
||||
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
|
||||
}
|
||||
},
|
||||
onSelectionUpdate: this.onSelectionUpdate,
|
||||
onBlur: () => { this.dynamicMenuCoords = null }
|
||||
onFocus: this.onSelectionUpdate,
|
||||
onBlur: () => {
|
||||
this.dynamicMenuCoords = null
|
||||
this.insertMenuCoords = null
|
||||
this.blockMenuCoords = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -269,6 +366,8 @@ export default {
|
||||
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
|
||||
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
|
||||
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
|
||||
|
||||
this.$nextTick(() => this.pushUndo({ amend: true }))
|
||||
},
|
||||
beforeUnmount () {
|
||||
if (this.editor) {
|
||||
@@ -284,6 +383,19 @@ export default {
|
||||
reloadContent () {
|
||||
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false, parseOptions: this.editor.options.parseOptions })
|
||||
},
|
||||
restoreDoc (doc) {
|
||||
const { state, view } = this.editor
|
||||
const { content } = doc.type.schema === state.schema ? doc : state.schema.nodeFromJSON(doc.toJSON())
|
||||
const diffEnd = content.findDiffEnd(state.doc.content)
|
||||
|
||||
if (diffEnd) {
|
||||
const tr = state.tr.replaceWith(0, state.doc.content.size, content).setMeta('addToHistory', false)
|
||||
|
||||
view.dispatch(tr.setSelection(Selection.near(tr.doc.resolve(diffEnd.a))).scrollIntoView())
|
||||
|
||||
this.lastUpdateTime = 0
|
||||
}
|
||||
},
|
||||
findAreaNodePos (areaUuid) {
|
||||
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
|
||||
|
||||
@@ -402,16 +514,163 @@ export default {
|
||||
this.selectedAreasRef.value = [area]
|
||||
}
|
||||
}
|
||||
|
||||
this.blockMenuCoords = null
|
||||
} else {
|
||||
this.areaToolbarCoords = null
|
||||
this.selectedAreasRef.value = []
|
||||
|
||||
if (editor.state.selection.empty) {
|
||||
this.setBlockMenuCoords(editor)
|
||||
|
||||
if (selection.node || selection.empty || !editor.isFocused) {
|
||||
this.dynamicMenuCoords = null
|
||||
} else {
|
||||
this.setDynamicMenuCoords(editor)
|
||||
}
|
||||
}
|
||||
|
||||
if (selection instanceof CellSelection) {
|
||||
this.insertMenuCoords = null
|
||||
} else if (editor.isFocused) {
|
||||
this.setInsertMenuCoords(editor)
|
||||
}
|
||||
},
|
||||
setInsertMenuCoords (editor) {
|
||||
const coords = editor.view.coordsAtPos(editor.state.selection.head)
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
const sectionEl = this.$refs.editorElement
|
||||
const sectionRect = sectionEl.getBoundingClientRect()
|
||||
const paddingLeft = parseFloat(getComputedStyle(sectionEl).paddingLeft) * this.zoom
|
||||
|
||||
this.insertMenuCoords = {
|
||||
top: (coords.top + coords.bottom) / 2 - containerRect.top,
|
||||
left: sectionRect.left - containerRect.left + Math.max(paddingLeft / 2, 14)
|
||||
}
|
||||
},
|
||||
setBlockMenuCoords (editor) {
|
||||
const { selection } = editor.state
|
||||
const isImage = selection.node?.type.name === 'image'
|
||||
const table = isImage ? null : findTable(selection.$from)
|
||||
|
||||
if (!isImage && !table) {
|
||||
this.blockMenuCoords = null
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const pos = isImage ? selection.from : table.pos
|
||||
const dom = editor.view.nodeDOM(pos)
|
||||
const rect = dom.getBoundingClientRect()
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
const coords = {
|
||||
type: isImage ? 'image' : 'table',
|
||||
pos,
|
||||
align: 'left',
|
||||
top: rect.top - containerRect.top,
|
||||
left: rect.left - containerRect.left,
|
||||
width: rect.width,
|
||||
height: rect.height
|
||||
}
|
||||
|
||||
if (isImage) {
|
||||
coords.align = getComputedStyle(dom.parentElement).textAlign.replace('start', 'left').replace('end', 'right')
|
||||
} else {
|
||||
const { marginLeft, marginRight, float } = dom.style
|
||||
|
||||
if (float === 'left' || float === 'right') {
|
||||
coords.align = float
|
||||
} else if (marginLeft === 'auto') {
|
||||
coords.align = marginRight === 'auto' ? 'center' : 'right'
|
||||
} else if (dom.getAttribute('align')) {
|
||||
coords.align = dom.getAttribute('align')
|
||||
}
|
||||
|
||||
coords.isRegular = isRegularTable(table.node)
|
||||
coords.canMerge = editor.can().mergeTableCells()
|
||||
coords.canSplit = editor.can().splitTableCell()
|
||||
}
|
||||
|
||||
this.blockMenuCoords = coords
|
||||
},
|
||||
getBlockResizeLimits () {
|
||||
const dom = this.editor.view.nodeDOM(this.blockMenuCoords.pos)
|
||||
const rect = dom.getBoundingClientRect()
|
||||
|
||||
return {
|
||||
minHeight: tableRowMinHeights(dom, this.zoom, false).reduce((acc, value) => acc + value, 0),
|
||||
maxWidth: Math.max(this.$refs.editorElement.getBoundingClientRect().right - rect.left, rect.width)
|
||||
}
|
||||
},
|
||||
onBlockResize ({ width, height }) {
|
||||
resizeTable(this.editor.view, this.blockMenuCoords.pos, { width: width / this.zoom, height: height / this.zoom }, this.zoom)
|
||||
|
||||
this.editor.commands.focus()
|
||||
|
||||
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
|
||||
},
|
||||
onBlockCommand (command, arg) {
|
||||
this.editor.chain().focus()[command](arg).run()
|
||||
|
||||
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
|
||||
},
|
||||
onInsertField (type) {
|
||||
const { from, to } = this.editor.state.selection
|
||||
|
||||
this.insertFieldAtRange({ sourceField: { type }, from, to })
|
||||
},
|
||||
onInsertTable ({ rows, cols }) {
|
||||
const $pos = this.editor.state.tr.deleteSelection().selection.$head
|
||||
const cell = '<td style="border: 1px solid #000000; padding: 4pt; vertical-align: top"><p style="margin: 0"></p></td>'
|
||||
const html = `<table style="width: 100%; border-collapse: collapse"><tbody>${`<tr>${cell.repeat(cols)}</tr>`.repeat(rows)}</tbody></table>`
|
||||
const isEmpty = $pos.parent.isTextblock && !$pos.parent.content.size
|
||||
const from = !$pos.parent.isTextblock ? $pos.pos : isEmpty ? $pos.before() : $pos.after()
|
||||
const to = isEmpty ? $pos.after() : from
|
||||
|
||||
this.editor.chain().focus()
|
||||
.deleteSelection()
|
||||
.insertContentAt({ from, to }, html)
|
||||
.setTextSelection(from + 4)
|
||||
.run()
|
||||
},
|
||||
async onImageSelected (event) {
|
||||
const file = event.target.files[0]
|
||||
|
||||
event.target.value = ''
|
||||
|
||||
if (!file) return
|
||||
|
||||
const image = await this.uploadImage(file)
|
||||
|
||||
if (image) {
|
||||
this.editor.chain().focus().insertContent(image).run()
|
||||
}
|
||||
},
|
||||
async insertImageFiles (files, pos) {
|
||||
const images = (await Promise.all(files.map(this.uploadImage))).filter(Boolean)
|
||||
|
||||
if (images.length) {
|
||||
this.editor.chain().focus().insertContentAt(pos, images).run()
|
||||
}
|
||||
},
|
||||
async uploadImage (file) {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await this.baseFetch(`/templates/${this.template.id}/dynamic_documents/${this.attachmentUuid}/attachments`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) return alert(data.error)
|
||||
|
||||
this.$emit('add-attachment', data)
|
||||
|
||||
await this.$nextTick()
|
||||
|
||||
return { type: 'image', attrs: { htmlAttrs: { src: `blob:${data.uuid}`, style: 'max-width: 100%' } } }
|
||||
},
|
||||
setDynamicMenuCoords (editor) {
|
||||
const { from, to } = editor.state.selection
|
||||
@@ -419,11 +678,11 @@ export default {
|
||||
const start = view.coordsAtPos(from)
|
||||
const end = view.coordsAtPos(to)
|
||||
const containerRect = this.container.getBoundingClientRect()
|
||||
const left = (start.left + end.right) / 2 - containerRect.left
|
||||
|
||||
this.dynamicMenuCoords = {
|
||||
top: Math.min(start.top, end.top) - containerRect.top,
|
||||
left: Math.max(80, Math.min(left, containerRect.width - 80))
|
||||
top: Math.max(start.bottom, end.bottom) - containerRect.top + 8,
|
||||
left: (start.left + end.right) / 2 - containerRect.left,
|
||||
width: containerRect.width
|
||||
}
|
||||
},
|
||||
onFieldDestroy (node) {
|
||||
@@ -493,6 +752,8 @@ export default {
|
||||
if (event.key === 'Escape') {
|
||||
this.editor.chain().setNodeSelection(0).blur().run()
|
||||
this.deselectArea()
|
||||
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
onEditorMouseMove (event) {
|
||||
@@ -608,25 +869,23 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const newField = {
|
||||
const newField = payload.parentField || {
|
||||
...field,
|
||||
uuid: v4(),
|
||||
submitter_uuid: this.selectedSubmitter.uuid,
|
||||
areas: [area]
|
||||
areas: []
|
||||
}
|
||||
|
||||
newField.areas.push(area)
|
||||
|
||||
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
|
||||
const oldOptionUuid = area.option_uuid
|
||||
const optionsMap = {}
|
||||
const oldOptionIndex = field.options.findIndex((opt) => opt.uuid === area.option_uuid)
|
||||
|
||||
newField.options = field.options.map((opt) => {
|
||||
const newUuid = v4()
|
||||
optionsMap[opt.uuid] = newUuid
|
||||
if (!payload.parentField) {
|
||||
newField.options = field.options.map((opt) => ({ ...opt, uuid: v4() }))
|
||||
}
|
||||
|
||||
return { ...opt, uuid: newUuid }
|
||||
})
|
||||
|
||||
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
|
||||
area.option_uuid = (newField.options[oldOptionIndex] || newField.options[0]).uuid
|
||||
}
|
||||
|
||||
return { field: newField, area }
|
||||
@@ -642,6 +901,8 @@ export default {
|
||||
const text = clipboardData.getData('text/plain')
|
||||
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
|
||||
|
||||
if (/class="[^"]*doc-list-/.test(html)) this.$emit('add-list')
|
||||
|
||||
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
|
||||
return
|
||||
}
|
||||
@@ -662,10 +923,13 @@ export default {
|
||||
|
||||
let lastArea = null
|
||||
|
||||
const parentFieldsIndex = {}
|
||||
|
||||
fieldNodes.forEach((fieldNode) => {
|
||||
const fieldValue = fieldNode.dataset.field
|
||||
const areaValue = fieldNode.dataset.area
|
||||
const templateId = fieldNode.dataset.templateId
|
||||
const sourceFieldUuid = fieldNode.getAttribute('uuid')
|
||||
|
||||
if (!fieldValue || !areaValue) {
|
||||
return
|
||||
@@ -674,11 +938,16 @@ export default {
|
||||
const { field, area } = this.buildCopiedField({
|
||||
field: JSON.parse(fieldValue),
|
||||
area: JSON.parse(areaValue),
|
||||
templateId: Number(templateId)
|
||||
templateId: Number(templateId),
|
||||
parentField: parentFieldsIndex[sourceFieldUuid]
|
||||
})
|
||||
|
||||
this.insertFieldInTemplate(field, insertIndex)
|
||||
insertIndex += 1
|
||||
if (!parentFieldsIndex[sourceFieldUuid]) {
|
||||
parentFieldsIndex[sourceFieldUuid] = field
|
||||
|
||||
this.insertFieldInTemplate(field, insertIndex)
|
||||
insertIndex += 1
|
||||
}
|
||||
|
||||
fieldNode.setAttribute('uuid', field.uuid)
|
||||
fieldNode.setAttribute('area-uuid', area.uuid)
|
||||
@@ -718,7 +987,7 @@ export default {
|
||||
onSelectedAreaChange () {
|
||||
this.save()
|
||||
},
|
||||
onFieldDrop (view, event, _slice, moved) {
|
||||
onFieldDrop (view, event, slice, moved) {
|
||||
this.isAreaDrag = false
|
||||
|
||||
if (moved) {
|
||||
@@ -726,8 +995,9 @@ export default {
|
||||
}
|
||||
|
||||
const draggedField = this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField
|
||||
const imageFiles = slice.size ? [] : Array.from(event.dataTransfer.files).filter((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (!draggedField) return false
|
||||
if (!draggedField && !imageFiles.length) return false
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
@@ -735,17 +1005,63 @@ export default {
|
||||
|
||||
if (!pos) return false
|
||||
|
||||
this.insertFieldAtRange({
|
||||
sourceField: draggedField,
|
||||
existingField: this.fieldsDragFieldRef?.value,
|
||||
from: pos.pos
|
||||
})
|
||||
if (draggedField) {
|
||||
this.insertFieldAtRange({
|
||||
sourceField: draggedField,
|
||||
existingField: this.fieldsDragFieldRef?.value,
|
||||
from: pos.pos
|
||||
})
|
||||
|
||||
this.fieldsDragFieldRef.value = null
|
||||
this.customDragFieldRef.value = null
|
||||
this.fieldsDragFieldRef.value = null
|
||||
this.customDragFieldRef.value = null
|
||||
} else {
|
||||
this.insertImageFiles(imageFiles, pos.pos)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
closestPosAtCoords ({ clientX, clientY }) {
|
||||
const view = this.editor.view
|
||||
const rect = view.dom.getBoundingClientRect()
|
||||
const left = Math.min(Math.max(clientX, rect.left + 1), rect.right - 1)
|
||||
const top = Math.min(Math.max(clientY, rect.top + 1), rect.bottom - 1)
|
||||
const caret = document.caretPositionFromPoint?.(left, top, { shadowRoots: [view.root] })
|
||||
|
||||
if (caret && view.dom.contains(caret.offsetNode)) {
|
||||
return view.posAtDOM(caret.offsetNode, caret.offset)
|
||||
} else {
|
||||
return view.posAtCoords({ left, top }).pos
|
||||
}
|
||||
},
|
||||
onMarginMouseDown (event) {
|
||||
const view = this.editor.view
|
||||
|
||||
if (event.button !== 0 || !this.editable || this.isDrawMode || view.dom.contains(event.target)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
const anchor = this.closestPosAtCoords(event)
|
||||
|
||||
const selectTo = (e) => {
|
||||
const { doc, tr } = view.state
|
||||
|
||||
view.dispatch(tr.setSelection(TextSelection.between(doc.resolve(anchor), doc.resolve(this.closestPosAtCoords(e)))))
|
||||
}
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', selectTo)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
selectTo(event)
|
||||
|
||||
view.focus()
|
||||
|
||||
window.addEventListener('mousemove', selectTo)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
},
|
||||
onEditorPointerDown (event) {
|
||||
if (!this.isDrawMode || !this.editable || this.isDraggingField) {
|
||||
return
|
||||
|
||||
@@ -19,4 +19,5 @@
|
||||
|
||||
:host {
|
||||
all: initial;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
@@ -608,10 +608,10 @@ export default {
|
||||
return !['stamp', 'heading', 'strikethrough'].includes(this.field.type)
|
||||
},
|
||||
showCondition () {
|
||||
return this.withCondition && (this.withConditions || this.nativePlatform !== 'ios') && !['stamp', 'heading'].includes(this.field.type)
|
||||
return this.withCondition && (this.withConditions || !this.nativePlatform) && !['stamp', 'heading'].includes(this.field.type)
|
||||
},
|
||||
showFormula () {
|
||||
return (this.field.type === 'number' || this.field.type === 'date' || this.field.type === 'payment' || !!this.field.preferences?.formula) && (this.withFormula || this.nativePlatform !== 'ios')
|
||||
return (this.field.type === 'number' || this.field.type === 'date' || this.field.type === 'payment' || !!this.field.preferences?.formula) && (this.withFormula || !this.nativePlatform)
|
||||
},
|
||||
showRequired () {
|
||||
return this.withRequired && !['phone', 'stamp', 'verification', 'strikethrough', 'heading'].includes(this.field.type)
|
||||
|
||||
@@ -484,7 +484,7 @@
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="withCondition && (withConditions || nativePlatform !== 'ios') && field.type != 'stamp' && field.type != 'heading'"
|
||||
v-if="withCondition && (withConditions || !nativePlatform) && field.type != 'stamp' && field.type != 'heading'"
|
||||
class="field-settings-condition"
|
||||
>
|
||||
<label
|
||||
@@ -500,7 +500,7 @@
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="(field.type == 'number' || field.type == 'date' || field.preferences?.formula) && (withFormula || nativePlatform !== 'ios')"
|
||||
v-if="(field.type == 'number' || field.type == 'date' || field.preferences?.formula) && (withFormula || !nativePlatform)"
|
||||
class="field-settings-formula"
|
||||
>
|
||||
<label
|
||||
@@ -745,6 +745,7 @@ export default {
|
||||
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email',
|
||||
'^https?://.*': 'url',
|
||||
'^[0-9]{5}(?:-[0-9]{4})?$': 'zip',
|
||||
'^[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}$': 'iban',
|
||||
'^[0-9]+$': 'numbers_only',
|
||||
'^[a-zA-Z]+$': 'letters_only'
|
||||
}
|
||||
|
||||
@@ -275,7 +275,7 @@
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
v-else-if="type == 'phone' && nativePlatform !== 'ios' && (fieldTypes.length === 0 || fieldTypes.includes(type))"
|
||||
v-else-if="type == 'phone' && !nativePlatform && (fieldTypes.length === 0 || fieldTypes.includes(type))"
|
||||
class="tooltip tooltip-bottom flex"
|
||||
:class="{'tooltip-bottom-end': withPayment, 'tooltip-bottom': !withPayment }"
|
||||
:data-tip="t('unlock_sms_verified_phone_number_field_with_paid_plan_use_text_field_for_phone_numbers_without_verification')"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'">
|
||||
<div :class="inline ? (nativePlatform === 'android' ? 'px-4 pt-1.5 pb-6' : 'px-4 pt-3 pb-6') : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'">
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
|
||||
@@ -21,7 +21,7 @@
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<div :class="inline && nativePlatform === 'android' ? 'mt-1' : 'mt-4'">
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span>
|
||||
@@ -200,7 +200,7 @@ export default {
|
||||
components: {
|
||||
IconChevronDown
|
||||
},
|
||||
inject: ['t', 'template'],
|
||||
inject: ['t', 'template', 'nativePlatform'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'">
|
||||
<div :class="inline ? (nativePlatform === 'android' ? 'px-4 pt-1.5 pb-6' : 'px-4 pt-3 pb-6') : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'">
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
|
||||
@@ -32,7 +32,7 @@
|
||||
class="link"
|
||||
>{{ t('available_in_pro') }}</a>
|
||||
</div>
|
||||
<div class="flex flex-wrap mb-2 gap-y-1 pt-1">
|
||||
<div :class="['flex flex-wrap mb-2 gap-y-1', inline && nativePlatform === 'android' ? 'mt-1' : 'pt-1']">
|
||||
<button
|
||||
v-for="f in fields"
|
||||
:key="f.uuid"
|
||||
@@ -152,7 +152,7 @@ export default {
|
||||
IconCodePlus,
|
||||
IconMathFunction
|
||||
},
|
||||
inject: ['t', 'template', 'withFormula'],
|
||||
inject: ['t', 'template', 'withFormula', 'nativePlatform'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
|
||||
@@ -70,6 +70,7 @@ const en = {
|
||||
remove_condition: 'Remove condition',
|
||||
add_condition: 'Add condition',
|
||||
are_you_sure_: 'Are you sure?',
|
||||
close_without_saving_changes: 'Close without saving changes?',
|
||||
sign_yourself: 'Sign Yourself',
|
||||
set_signing_date: 'Set signing date',
|
||||
signing_date: 'Signing Date',
|
||||
@@ -210,6 +211,7 @@ const en = {
|
||||
email: 'Email',
|
||||
url: 'URL',
|
||||
zip: 'ZIP',
|
||||
iban: 'IBAN',
|
||||
custom: 'Custom',
|
||||
numbers_only: 'Numbers only',
|
||||
letters_only: 'Letters only',
|
||||
@@ -248,7 +250,44 @@ const en = {
|
||||
no_revisions_yet: 'No revisions yet',
|
||||
viewing_revision_from: 'Viewing revision from {date}',
|
||||
connect_google_drive: 'Connect Google Drive',
|
||||
submitting: 'Submitting'
|
||||
submitting: 'Submitting',
|
||||
font_size: 'Font size',
|
||||
increase_font_size: 'Increase font size',
|
||||
decrease_font_size: 'Decrease font size',
|
||||
line_spacing: 'Line spacing',
|
||||
justify: 'Justify',
|
||||
bullet_list: 'List',
|
||||
dots: 'Dots',
|
||||
dash: 'Dash',
|
||||
numbers: 'Numbers',
|
||||
bold: 'Bold',
|
||||
italic: 'Italic',
|
||||
underline: 'Underline',
|
||||
strikethrough: 'Strikethrough',
|
||||
text_color: 'Text color',
|
||||
background_color: 'Background color',
|
||||
variable: 'Variable',
|
||||
create_variable: 'Wrap in variable',
|
||||
create_condition: 'Wrap in condition',
|
||||
insert: 'Insert',
|
||||
field: 'Field',
|
||||
table: 'Table',
|
||||
decrease_rows: 'Fewer rows',
|
||||
increase_rows: 'More rows',
|
||||
decrease_cols: 'Fewer columns',
|
||||
increase_cols: 'More columns',
|
||||
align_center: 'Align Center',
|
||||
insert_row_above: 'Insert row above',
|
||||
insert_row_below: 'Insert row below',
|
||||
insert_column_left: 'Insert column left',
|
||||
insert_column_right: 'Insert column right',
|
||||
merge_cells: 'Merge cells',
|
||||
split_cell: 'Split cell',
|
||||
delete_row: 'Delete row',
|
||||
delete_column: 'Delete column',
|
||||
delete_table: 'Delete table',
|
||||
delete_image: 'Delete image',
|
||||
irregular_table_structure: 'Rows and columns can\'t be edited: irregular table layout'
|
||||
}
|
||||
|
||||
const es = {
|
||||
@@ -333,6 +372,7 @@ const es = {
|
||||
down: 'Abajo',
|
||||
set_signing_date: 'Establecer fecha de firma',
|
||||
are_you_sure_: '¿Estás seguro?',
|
||||
close_without_saving_changes: '¿Cerrar sin guardar los cambios?',
|
||||
sign_yourself: 'Firma tú mismo',
|
||||
checked: 'Marcado',
|
||||
current_date: 'Fecha actual',
|
||||
@@ -463,6 +503,7 @@ const es = {
|
||||
email: 'Correo electrónico',
|
||||
url: 'URL',
|
||||
zip: 'ZIP',
|
||||
iban: 'IBAN',
|
||||
custom: 'Personalizado',
|
||||
numbers_only: 'Solo números',
|
||||
letters_only: 'Solo letras',
|
||||
@@ -501,7 +542,44 @@ const es = {
|
||||
no_revisions_yet: 'Aún no hay revisiones',
|
||||
viewing_revision_from: 'Viendo revisión del {date}',
|
||||
connect_google_drive: 'Conectar Google Drive',
|
||||
submitting: 'Enviando'
|
||||
submitting: 'Enviando',
|
||||
font_size: 'Tamaño',
|
||||
increase_font_size: 'Aumentar tamaño',
|
||||
decrease_font_size: 'Reducir tamaño',
|
||||
line_spacing: 'Interlineado',
|
||||
justify: 'Justificar',
|
||||
bullet_list: 'Lista',
|
||||
dots: 'Puntos',
|
||||
dash: 'Guiones',
|
||||
numbers: 'Números',
|
||||
bold: 'Negrita',
|
||||
italic: 'Cursiva',
|
||||
underline: 'Subrayado',
|
||||
strikethrough: 'Tachado',
|
||||
text_color: 'Color de texto',
|
||||
background_color: 'Color de fondo',
|
||||
variable: 'Variable',
|
||||
create_variable: 'Crear variable',
|
||||
create_condition: 'Crear condición',
|
||||
insert: 'Insertar',
|
||||
field: 'Campo',
|
||||
table: 'Tabla',
|
||||
decrease_rows: 'Menos filas',
|
||||
increase_rows: 'Más filas',
|
||||
decrease_cols: 'Menos columnas',
|
||||
increase_cols: 'Más columnas',
|
||||
align_center: 'Centrar',
|
||||
insert_row_above: 'Fila arriba',
|
||||
insert_row_below: 'Fila abajo',
|
||||
insert_column_left: 'Columna izquierda',
|
||||
insert_column_right: 'Columna derecha',
|
||||
merge_cells: 'Combinar celdas',
|
||||
split_cell: 'Dividir celda',
|
||||
delete_row: 'Eliminar fila',
|
||||
delete_column: 'Eliminar columna',
|
||||
delete_table: 'Eliminar tabla',
|
||||
delete_image: 'Eliminar imagen',
|
||||
irregular_table_structure: 'No se pueden editar filas ni columnas: tabla irregular'
|
||||
}
|
||||
|
||||
const it = {
|
||||
@@ -576,6 +654,7 @@ const it = {
|
||||
remove_condition: 'Rimuovi condizione',
|
||||
add_condition: 'Aggiungi condizione',
|
||||
are_you_sure_: 'Sei sicuro?',
|
||||
close_without_saving_changes: 'Chiudere senza salvare le modifiche?',
|
||||
sign_yourself: 'Firma te stesso',
|
||||
set_signing_date: 'Imposta data di firma',
|
||||
signing_date: 'Data di firma',
|
||||
@@ -716,6 +795,7 @@ const it = {
|
||||
email: 'Email',
|
||||
url: 'URL',
|
||||
zip: 'CAP',
|
||||
iban: 'IBAN',
|
||||
custom: 'Personalizzato',
|
||||
numbers_only: 'Solo numeri',
|
||||
letters_only: 'Solo lettere',
|
||||
@@ -754,7 +834,44 @@ const it = {
|
||||
no_revisions_yet: 'Nessuna revisione ancora',
|
||||
viewing_revision_from: 'Visualizzazione revisione del {date}',
|
||||
connect_google_drive: 'Connetti Google Drive',
|
||||
submitting: 'Invio'
|
||||
submitting: 'Invio',
|
||||
font_size: 'Dimensione',
|
||||
increase_font_size: 'Aumenta dimensione',
|
||||
decrease_font_size: 'Riduci dimensione',
|
||||
line_spacing: 'Interlinea',
|
||||
justify: 'Giustifica',
|
||||
bullet_list: 'Elenco',
|
||||
dots: 'Punti',
|
||||
dash: 'Trattini',
|
||||
numbers: 'Numeri',
|
||||
bold: 'Grassetto',
|
||||
italic: 'Corsivo',
|
||||
underline: 'Sottolineato',
|
||||
strikethrough: 'Barrato',
|
||||
text_color: 'Colore testo',
|
||||
background_color: 'Colore sfondo',
|
||||
variable: 'Variabile',
|
||||
create_variable: 'Crea variabile',
|
||||
create_condition: 'Crea condizione',
|
||||
insert: 'Inserisci',
|
||||
field: 'Campo',
|
||||
table: 'Tabella',
|
||||
decrease_rows: 'Meno righe',
|
||||
increase_rows: 'Più righe',
|
||||
decrease_cols: 'Meno colonne',
|
||||
increase_cols: 'Più colonne',
|
||||
align_center: 'Centra',
|
||||
insert_row_above: 'Riga sopra',
|
||||
insert_row_below: 'Riga sotto',
|
||||
insert_column_left: 'Colonna a sinistra',
|
||||
insert_column_right: 'Colonna a destra',
|
||||
merge_cells: 'Unisci celle',
|
||||
split_cell: 'Dividi cella',
|
||||
delete_row: 'Elimina riga',
|
||||
delete_column: 'Elimina colonna',
|
||||
delete_table: 'Elimina tabella',
|
||||
delete_image: 'Elimina immagine',
|
||||
irregular_table_structure: 'Righe e colonne non modificabili: tabella irregolare'
|
||||
}
|
||||
|
||||
const pt = {
|
||||
@@ -839,6 +956,7 @@ const pt = {
|
||||
down: 'Para baixo',
|
||||
set_signing_date: 'Definir data de assinatura',
|
||||
are_you_sure_: 'Tem certeza?',
|
||||
close_without_saving_changes: 'Fechar sem salvar as alterações?',
|
||||
sign_yourself: 'Assine você mesmo',
|
||||
checked: 'Marcado',
|
||||
current_date: 'Data atual',
|
||||
@@ -969,6 +1087,7 @@ const pt = {
|
||||
email: 'Email',
|
||||
url: 'URL',
|
||||
zip: 'ZIP',
|
||||
iban: 'IBAN',
|
||||
custom: 'Personalizado',
|
||||
numbers_only: 'Somente números',
|
||||
letters_only: 'Somente letras',
|
||||
@@ -1007,7 +1126,44 @@ const pt = {
|
||||
no_revisions_yet: 'Nenhuma revisão ainda',
|
||||
viewing_revision_from: 'Visualizando revisão de {date}',
|
||||
connect_google_drive: 'Conectar Google Drive',
|
||||
submitting: 'Enviando'
|
||||
submitting: 'Enviando',
|
||||
font_size: 'Tamanho',
|
||||
increase_font_size: 'Aumentar tamanho',
|
||||
decrease_font_size: 'Diminuir tamanho',
|
||||
line_spacing: 'Espaçamento',
|
||||
justify: 'Justificar',
|
||||
bullet_list: 'Lista',
|
||||
dots: 'Pontos',
|
||||
dash: 'Traços',
|
||||
numbers: 'Números',
|
||||
bold: 'Negrito',
|
||||
italic: 'Itálico',
|
||||
underline: 'Sublinhado',
|
||||
strikethrough: 'Tachado',
|
||||
text_color: 'Cor do texto',
|
||||
background_color: 'Cor de fundo',
|
||||
variable: 'Variável',
|
||||
create_variable: 'Criar variável',
|
||||
create_condition: 'Criar condição',
|
||||
insert: 'Inserir',
|
||||
field: 'Campo',
|
||||
table: 'Tabela',
|
||||
decrease_rows: 'Menos linhas',
|
||||
increase_rows: 'Mais linhas',
|
||||
decrease_cols: 'Menos colunas',
|
||||
increase_cols: 'Mais colunas',
|
||||
align_center: 'Centralizar',
|
||||
insert_row_above: 'Linha acima',
|
||||
insert_row_below: 'Linha abaixo',
|
||||
insert_column_left: 'Coluna à esquerda',
|
||||
insert_column_right: 'Coluna à direita',
|
||||
merge_cells: 'Mesclar células',
|
||||
split_cell: 'Dividir célula',
|
||||
delete_row: 'Excluir linha',
|
||||
delete_column: 'Excluir coluna',
|
||||
delete_table: 'Excluir tabela',
|
||||
delete_image: 'Excluir imagem',
|
||||
irregular_table_structure: 'Linhas e colunas não editáveis: tabela irregular'
|
||||
}
|
||||
|
||||
const fr = {
|
||||
@@ -1082,6 +1238,7 @@ const fr = {
|
||||
remove_condition: 'Supprimer la condition',
|
||||
add_condition: 'Ajouter une condition',
|
||||
are_you_sure_: 'Êtes-vous sûr ?',
|
||||
close_without_saving_changes: 'Fermer sans enregistrer les modifications ?',
|
||||
sign_yourself: 'Signer vous-même',
|
||||
set_signing_date: 'Définir la date de signature',
|
||||
signing_date: 'Date de signature',
|
||||
@@ -1222,6 +1379,7 @@ const fr = {
|
||||
email: 'E-mail',
|
||||
url: 'URL',
|
||||
zip: 'ZIP',
|
||||
iban: 'IBAN',
|
||||
custom: 'Personnalisé',
|
||||
numbers_only: 'Chiffres uniquement',
|
||||
letters_only: 'Lettres uniquement',
|
||||
@@ -1260,7 +1418,44 @@ const fr = {
|
||||
no_revisions_yet: 'Aucune révision pour le moment',
|
||||
viewing_revision_from: 'Affichage de la révision du {date}',
|
||||
connect_google_drive: 'Connecter Google Drive',
|
||||
submitting: 'Soumission'
|
||||
submitting: 'Soumission',
|
||||
font_size: 'Taille',
|
||||
increase_font_size: 'Augmenter la taille',
|
||||
decrease_font_size: 'Réduire la taille',
|
||||
line_spacing: 'Interligne',
|
||||
justify: 'Justifier',
|
||||
bullet_list: 'Liste',
|
||||
dots: 'Points',
|
||||
dash: 'Tirets',
|
||||
numbers: 'Numéros',
|
||||
bold: 'Gras',
|
||||
italic: 'Italique',
|
||||
underline: 'Souligné',
|
||||
strikethrough: 'Barré',
|
||||
text_color: 'Couleur du texte',
|
||||
background_color: 'Couleur de fond',
|
||||
variable: 'Variable',
|
||||
create_variable: 'Créer une variable',
|
||||
create_condition: 'Créer une condition',
|
||||
insert: 'Insérer',
|
||||
field: 'Champ',
|
||||
table: 'Tableau',
|
||||
decrease_rows: 'Moins de lignes',
|
||||
increase_rows: 'Plus de lignes',
|
||||
decrease_cols: 'Moins de colonnes',
|
||||
increase_cols: 'Plus de colonnes',
|
||||
align_center: 'Centrer',
|
||||
insert_row_above: 'Ligne au-dessus',
|
||||
insert_row_below: 'Ligne en dessous',
|
||||
insert_column_left: 'Colonne à gauche',
|
||||
insert_column_right: 'Colonne à droite',
|
||||
merge_cells: 'Fusionner',
|
||||
split_cell: 'Scinder',
|
||||
delete_row: 'Supprimer la ligne',
|
||||
delete_column: 'Supprimer la colonne',
|
||||
delete_table: 'Supprimer le tableau',
|
||||
delete_image: 'Supprimer l\'image',
|
||||
irregular_table_structure: 'Lignes et colonnes non modifiables : tableau irrégulier'
|
||||
}
|
||||
|
||||
const de = {
|
||||
@@ -1335,6 +1530,7 @@ const de = {
|
||||
remove_condition: 'Bedingung entfernen',
|
||||
add_condition: 'Bedingung hinzufügen',
|
||||
are_you_sure_: 'Sind Sie sicher?',
|
||||
close_without_saving_changes: 'Ohne Speichern der Änderungen schließen?',
|
||||
sign_yourself: 'Selbst unterschreiben',
|
||||
set_signing_date: 'Unterzeichnungsdatum festlegen',
|
||||
signing_date: 'Unterzeichnungsdatum',
|
||||
@@ -1475,6 +1671,7 @@ const de = {
|
||||
email: 'E-Mail',
|
||||
url: 'URL',
|
||||
zip: 'ZIP',
|
||||
iban: 'IBAN',
|
||||
custom: 'Benutzerdefiniert',
|
||||
numbers_only: 'Nur Zahlen',
|
||||
letters_only: 'Nur Buchstaben',
|
||||
@@ -1513,7 +1710,44 @@ const de = {
|
||||
no_revisions_yet: 'Noch keine Revisionen',
|
||||
viewing_revision_from: 'Ansicht der Revision vom {date}',
|
||||
connect_google_drive: 'Google Drive verbinden',
|
||||
submitting: 'Übermittlung'
|
||||
submitting: 'Übermittlung',
|
||||
font_size: 'Schriftgröße',
|
||||
increase_font_size: 'Schrift vergrößern',
|
||||
decrease_font_size: 'Schrift verkleinern',
|
||||
line_spacing: 'Zeilenabstand',
|
||||
justify: 'Blocksatz',
|
||||
bullet_list: 'Liste',
|
||||
dots: 'Punkte',
|
||||
dash: 'Striche',
|
||||
numbers: 'Zahlen',
|
||||
bold: 'Fett',
|
||||
italic: 'Kursiv',
|
||||
underline: 'Unterstrichen',
|
||||
strikethrough: 'Durchgestrichen',
|
||||
text_color: 'Textfarbe',
|
||||
background_color: 'Hintergrundfarbe',
|
||||
variable: 'Variable',
|
||||
create_variable: 'Variable erstellen',
|
||||
create_condition: 'Bedingung erstellen',
|
||||
insert: 'Einfügen',
|
||||
field: 'Feld',
|
||||
table: 'Tabelle',
|
||||
decrease_rows: 'Weniger Zeilen',
|
||||
increase_rows: 'Mehr Zeilen',
|
||||
decrease_cols: 'Weniger Spalten',
|
||||
increase_cols: 'Mehr Spalten',
|
||||
align_center: 'Zentrieren',
|
||||
insert_row_above: 'Zeile darüber',
|
||||
insert_row_below: 'Zeile darunter',
|
||||
insert_column_left: 'Spalte links',
|
||||
insert_column_right: 'Spalte rechts',
|
||||
merge_cells: 'Zellen verbinden',
|
||||
split_cell: 'Zelle teilen',
|
||||
delete_row: 'Zeile löschen',
|
||||
delete_column: 'Spalte löschen',
|
||||
delete_table: 'Tabelle löschen',
|
||||
delete_image: 'Bild löschen',
|
||||
irregular_table_structure: 'Zeilen und Spalten nicht bearbeitbar: unregelmäßige Tabelle'
|
||||
}
|
||||
|
||||
const nl = {
|
||||
@@ -1588,6 +1822,7 @@ const nl = {
|
||||
remove_condition: 'Voorwaarde verwijderen',
|
||||
add_condition: 'Voorwaarde toevoegen',
|
||||
are_you_sure_: 'Weet u het zeker?',
|
||||
close_without_saving_changes: 'Sluiten zonder wijzigingen op te slaan?',
|
||||
sign_yourself: 'Zelf ondertekenen',
|
||||
set_signing_date: 'Ondertekeningsdatum instellen',
|
||||
signing_date: 'Ondertekeningsdatum',
|
||||
@@ -1728,6 +1963,7 @@ const nl = {
|
||||
email: 'E-mail',
|
||||
url: 'URL',
|
||||
zip: 'Postcode',
|
||||
iban: 'IBAN',
|
||||
custom: 'Aangepast',
|
||||
numbers_only: 'Alleen cijfers',
|
||||
letters_only: 'Alleen letters',
|
||||
@@ -1766,7 +2002,44 @@ const nl = {
|
||||
no_revisions_yet: 'Nog geen revisies',
|
||||
viewing_revision_from: 'Revisie van {date} bekijken',
|
||||
connect_google_drive: 'Verbind Google Drive',
|
||||
submitting: 'Indienen'
|
||||
submitting: 'Indienen',
|
||||
font_size: 'Tekstgrootte',
|
||||
increase_font_size: 'Tekst vergroten',
|
||||
decrease_font_size: 'Tekst verkleinen',
|
||||
line_spacing: 'Regelafstand',
|
||||
justify: 'Uitvullen',
|
||||
bullet_list: 'Lijst',
|
||||
dots: 'Punten',
|
||||
dash: 'Streepjes',
|
||||
numbers: 'Nummers',
|
||||
bold: 'Vet',
|
||||
italic: 'Cursief',
|
||||
underline: 'Onderstrepen',
|
||||
strikethrough: 'Doorhalen',
|
||||
text_color: 'Tekstkleur',
|
||||
background_color: 'Achtergrondkleur',
|
||||
variable: 'Variabele',
|
||||
create_variable: 'Maak variabele',
|
||||
create_condition: 'Maak voorwaarde',
|
||||
insert: 'Invoegen',
|
||||
field: 'Veld',
|
||||
table: 'Tabel',
|
||||
decrease_rows: 'Minder rijen',
|
||||
increase_rows: 'Meer rijen',
|
||||
decrease_cols: 'Minder kolommen',
|
||||
increase_cols: 'Meer kolommen',
|
||||
align_center: 'Centreren',
|
||||
insert_row_above: 'Rij erboven',
|
||||
insert_row_below: 'Rij eronder',
|
||||
insert_column_left: 'Kolom links',
|
||||
insert_column_right: 'Kolom rechts',
|
||||
merge_cells: 'Cellen samenvoegen',
|
||||
split_cell: 'Cel splitsen',
|
||||
delete_row: 'Rij verwijderen',
|
||||
delete_column: 'Kolom verwijderen',
|
||||
delete_table: 'Tabel verwijderen',
|
||||
delete_image: 'Afbeelding verwijderen',
|
||||
irregular_table_structure: 'Rijen en kolommen niet bewerkbaar: onregelmatige tabel'
|
||||
}
|
||||
|
||||
export { en, es, it, pt, fr, de, nl }
|
||||
@@ -57,7 +57,6 @@
|
||||
@scroll-to="$emit('scroll-to', $event)"
|
||||
@add-custom-field="$emit('add-custom-field', $event)"
|
||||
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
|
||||
@multi-select="openMultiSelectContextMenu"
|
||||
@click-title="closeContextMenu"
|
||||
/>
|
||||
<FieldArea
|
||||
@@ -450,13 +449,6 @@ export default {
|
||||
areas: this.selectedAreasRef.value
|
||||
}
|
||||
},
|
||||
openMultiSelectContextMenu (event) {
|
||||
if (this.selectedAreasRef.value.length >= 2) {
|
||||
this.openSelectionContextMenu(event)
|
||||
} else {
|
||||
this.closeContextMenu()
|
||||
}
|
||||
},
|
||||
handleSelectionCopy () {
|
||||
this.$emit('copy-selected-areas')
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
>{{ t('learn_more') }}</a>
|
||||
</div>
|
||||
<li
|
||||
v-if="withFormula || nativePlatform !== 'ios'"
|
||||
v-if="withFormula || !nativePlatform"
|
||||
class="field-settings-formula mb-1"
|
||||
>
|
||||
<label
|
||||
@@ -233,7 +233,7 @@
|
||||
</label>
|
||||
</li>
|
||||
<li
|
||||
v-if="withCondition && (withConditions || nativePlatform !== 'ios')"
|
||||
v-if="withCondition && (withConditions || !nativePlatform)"
|
||||
class="field-settings-condition mt-1"
|
||||
>
|
||||
<label
|
||||
@@ -414,7 +414,7 @@ export default {
|
||||
checkStripeStatus () {
|
||||
this.isLoading = true
|
||||
|
||||
fetch('/api/stripe_connect').then(async (resp) => {
|
||||
fetch('/stripe_connect').then(async (resp) => {
|
||||
const { status } = await resp.json()
|
||||
|
||||
if (status === 'connected') {
|
||||
@@ -427,7 +427,7 @@ export default {
|
||||
})
|
||||
},
|
||||
checkPaypalStatus () {
|
||||
fetch('/api/paypal_connect').then(async (resp) => {
|
||||
fetch('/paypal_connect').then(async (resp) => {
|
||||
const { status } = await resp.json()
|
||||
|
||||
if (status === 'connected') {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 mt-20 w-full'">
|
||||
<div :class="inline ? (nativePlatform === 'android' ? 'px-4 pt-1.5 pb-6' : 'px-4 pt-3 pb-6') : 'modal-box pt-4 pb-6 mt-20 w-full'">
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="flex justify-between items-center border-b pb-2 mb-3 font-medium"
|
||||
@@ -63,7 +63,7 @@ import { IconUser, IconInnerShadowTop } from '@tabler/icons-vue'
|
||||
export default {
|
||||
name: 'RevisionsModal',
|
||||
components: { IconUser, IconInnerShadowTop },
|
||||
inject: ['t', 'baseFetch'],
|
||||
inject: ['t', 'baseFetch', 'nativePlatform'],
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
|
||||
@@ -259,7 +259,7 @@ export default {
|
||||
return true
|
||||
},
|
||||
showCondition () {
|
||||
return this.withCondition && (this.withConditions || this.nativePlatform !== 'ios')
|
||||
return this.withCondition && (this.withConditions || !this.nativePlatform)
|
||||
},
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels
|
||||
|
||||
@@ -100,7 +100,7 @@ class ProcessSubmitterCompletionJob
|
||||
|
||||
WebhookUrls.for_account_id(submitter.account_id, %w[form.completed submission.completed]).each do |webhook|
|
||||
if webhook.events.include?('form.completed')
|
||||
event_uuids['form.completed'] ||= SecureRandom.uuid
|
||||
event_uuids['form.completed'] ||= SecureRandom.uuid_v7
|
||||
|
||||
SendFormCompletedWebhookRequestJob.perform_async('submitter_id' => submitter.id,
|
||||
'event_uuid' => event_uuids['form.completed'],
|
||||
@@ -109,7 +109,7 @@ class ProcessSubmitterCompletionJob
|
||||
|
||||
next unless webhook.events.include?('submission.completed') && is_last
|
||||
|
||||
event_uuids['submission.completed'] ||= SecureRandom.uuid
|
||||
event_uuids['submission.completed'] ||= SecureRandom.uuid_v7
|
||||
|
||||
SendSubmissionCompletedWebhookRequestJob.perform_async('submission_id' => submitter.submission_id,
|
||||
'event_uuid' => event_uuids['submission.completed'],
|
||||
|
||||
@@ -30,14 +30,16 @@ class SendTestWebhookRequestJob
|
||||
raise LocalhostError, "Can't send to localhost." if uri.host.in?(SendWebhookRequest::LOCALHOSTS)
|
||||
end
|
||||
|
||||
Faraday.post(webhook_url.url,
|
||||
{
|
||||
event_type: 'form.completed',
|
||||
timestamp: Time.current.iso8601,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json,
|
||||
body_json = {
|
||||
event_type: 'form.completed',
|
||||
timestamp: Time.current.iso8601,
|
||||
data: Submitters::SerializeForWebhook.call(submitter)
|
||||
}.to_json
|
||||
|
||||
Faraday.post(webhook_url.url, body_json,
|
||||
'Content-Type' => 'application/json',
|
||||
'User-Agent' => USER_AGENT,
|
||||
'X-Docuseal-Signature' => WebhookUrls::Signatures.sign(webhook_url.hmac_secret, body: body_json),
|
||||
**webhook_url.secret.to_h)
|
||||
end
|
||||
end
|
||||
@@ -181,12 +181,21 @@ class SubmitterMailer < ApplicationMailer
|
||||
def otp_verification_email(submitter, locale: nil)
|
||||
@current_account = submitter.account
|
||||
@submitter = submitter
|
||||
@otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':'))
|
||||
@otp_code = OtpChallenges.generate([submitter.email.downcase.squish, submitter.slug].join(':'),
|
||||
purpose: 'form_email_2fa')
|
||||
|
||||
assign_message_metadata('otp_verification_email', submitter)
|
||||
|
||||
from =
|
||||
if Docuseal.multitenant? &&
|
||||
AccountConfig.exists?(account_id: submitter.account_id, key: 'custom_otp_email', value: true)
|
||||
from_address_for_submitter(submitter)
|
||||
else
|
||||
default_params[:from]
|
||||
end
|
||||
|
||||
I18n.with_locale(locale || submitter.account.locale) do
|
||||
mail(to: submitter.email, subject: I18n.t('email_verification'))
|
||||
mail(to: submitter.email, from:, subject: I18n.t('email_verification'))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -5,10 +5,20 @@ class TemplateMailer < ApplicationMailer
|
||||
@current_account = template.account
|
||||
@template = template
|
||||
|
||||
@otp_code = EmailVerificationCodes.generate([email.downcase.strip, template.slug].join(':'))
|
||||
@otp_code = OtpChallenges.generate([email.downcase.squish, template.slug].join(':'), purpose: 'form_email_2fa')
|
||||
|
||||
assign_message_metadata('otp_verification_email', template)
|
||||
|
||||
mail(to: email, subject: I18n.t('email_verification'))
|
||||
from =
|
||||
if Docuseal.multitenant? &&
|
||||
AccountConfig.exists?(account_id: template.account_id, key: 'custom_otp_email', value: true)
|
||||
put_metadata('from_user_id' => template.author_id)
|
||||
|
||||
template.author.friendly_name
|
||||
else
|
||||
default_params[:from]
|
||||
end
|
||||
|
||||
mail(to: email, from:, subject: I18n.t('email_verification'))
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,8 @@
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_accounts_on_uuid (uuid) UNIQUE
|
||||
# index_accounts_on_created_at (created_at)
|
||||
# index_accounts_on_uuid (uuid) UNIQUE
|
||||
#
|
||||
class Account < ApplicationRecord
|
||||
attribute :uuid, :string, default: -> { SecureRandom.uuid }
|
||||
|
||||
@@ -61,6 +61,7 @@ class AccountConfig < ApplicationRecord
|
||||
DOCUMENT_FILENAME_FORMAT_KEY = 'document_filename_format'
|
||||
TEMPLATE_CUSTOM_FIELDS_KEY = 'template_custom_fields'
|
||||
TEMPLATE_DATE_FORMATS_KEY = 'template_date_formats'
|
||||
TEMPLATE_BUILDER_AUTOSAVE_KEY = 'template_builder_autosave'
|
||||
POLICY_LINKS_KEY = 'policy_links'
|
||||
ENABLE_MCP_KEY = 'enable_mcp'
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: otp_challenges
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# attempts :integer default(0), not null
|
||||
# expire_at :datetime not null
|
||||
# key :string not null
|
||||
# nonce :string not null
|
||||
# verified_at :datetime
|
||||
# version :integer not null
|
||||
# created_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_otp_challenges_on_key_and_version (key,version) UNIQUE
|
||||
#
|
||||
class OtpChallenge < ApplicationRecord
|
||||
end
|
||||
@@ -25,15 +25,17 @@
|
||||
# account_id :bigint not null
|
||||
# external_id :string
|
||||
# submission_id :bigint not null
|
||||
# template_id :bigint
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_submitters_on_account_id_and_completed_at (account_id,completed_at) WHERE (completed_at IS NOT NULL)
|
||||
# index_submitters_on_account_id_and_id (account_id,id)
|
||||
# index_submitters_on_email (email)
|
||||
# index_submitters_on_email_and_template_id (email,template_id) WHERE (email IS NOT NULL)
|
||||
# index_submitters_on_external_id (external_id)
|
||||
# index_submitters_on_slug (slug) UNIQUE
|
||||
# index_submitters_on_submission_id (submission_id)
|
||||
# index_submitters_on_template_id_and_id (template_id,id)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
@@ -68,6 +70,8 @@ class Submitter < ApplicationRecord
|
||||
|
||||
scope :completed, -> { where.not(completed_at: nil) }
|
||||
|
||||
before_validation :set_template_id, on: :create
|
||||
|
||||
after_destroy :anonymize_email_events, if: -> { Docuseal.multitenant? }
|
||||
|
||||
def status
|
||||
@@ -124,6 +128,10 @@ class Submitter < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def set_template_id
|
||||
self.template_id = submission&.template_id
|
||||
end
|
||||
|
||||
def anonymize_email_events
|
||||
email_events.each do |event|
|
||||
event.update!(email: Digest::MD5.base64digest(event.email))
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
# index_webhook_events_on_webhook_url_id_and_id (webhook_url_id,id)
|
||||
#
|
||||
class WebhookEvent < ApplicationRecord
|
||||
attribute :uuid, :string, default: -> { SecureRandom.uuid }
|
||||
attribute :uuid, :string, default: -> { SecureRandom.uuid_v7 }
|
||||
|
||||
belongs_to :webhook_url, optional: true
|
||||
belongs_to :account, optional: true
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DECLINE_KEY) %>
|
||||
<% if can?(:manage, account_config) && (!turbo_native_ios? || !Docuseal.multitenant? || can?(:manage, :disable_decline)) %>
|
||||
<% if can?(:manage, account_config) && (!turbo_native_app? || !Docuseal.multitenant? || can?(:manage, :disable_decline)) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
@@ -156,7 +156,7 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DELEGATE_KEY) %>
|
||||
<% if can?(:manage, account_config) && (!turbo_native_ios? || !Docuseal.multitenant? || can?(:manage, :delegate_form)) %>
|
||||
<% if can?(:manage, account_config) && (!turbo_native_app? || !Docuseal.multitenant? || can?(:manage, :delegate_form)) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
@@ -196,7 +196,7 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY) %>
|
||||
<% if can?(:manage, account_config) %>
|
||||
<% if !Docuseal.demo? && can?(:manage, account_config) %>
|
||||
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
|
||||
<%= f.hidden_field :key %>
|
||||
<div class="flex items-center justify-between gap-4 py-2.5">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<% unless turbo_native_ios? %>
|
||||
<% unless turbo_native_app? %>
|
||||
<tr class="group">
|
||||
<td>
|
||||
<div class="flex items-center space-x-1">
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
|
||||
<path d="M12 18c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
|
||||
<path d="M16 19h6" />
|
||||
<path d="M19 16v6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 460 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 19h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v5.5" />
|
||||
<path d="M16 19h6" />
|
||||
<path d="M19 16v6" />
|
||||
<path d="M3 7l9 6l9 -6" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 441 B |
@@ -1,8 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M5 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" />
|
||||
<path d="M3 21v-2a4 4 0 0 1 4 -4h4c.96 0 1.84 .338 2.53 .901" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" />
|
||||
<path d="M16 19h6" />
|
||||
<path d="M19 16v6" />
|
||||
<path d="M6 21v-2a4 4 0 0 1 4 -4h4" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 488 B After Width: | Height: | Size: 421 B |
@@ -7,4 +7,21 @@
|
||||
-webkit-user-drag: none;
|
||||
-webkit-touch-callout: none;
|
||||
}
|
||||
|
||||
<% if turbo_native_android? %>
|
||||
input[type="date"] {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
input[type="date"]::-webkit-calendar-picker-indicator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
div:has(> #account_settings_menu) + * {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
}
|
||||
<% end %>
|
||||
</style>
|
||||
@@ -33,10 +33,10 @@
|
||||
<% end %>
|
||||
<turbo-frame id="drawer"></turbo-frame>
|
||||
<%= render turbo_native_app? ? 'shared/native_navbar' : 'shared/navbar' %>
|
||||
<% if flash.present? %><%= render 'shared/flash' %><% end %>
|
||||
<div class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
|
||||
<main class="max-w-6xl mx-auto px-4 md:px-2 mb-8">
|
||||
<%= yield %>
|
||||
</div>
|
||||
</main>
|
||||
<% if flash.present? %><%= render 'shared/flash' %><% end %>
|
||||
<%= render 'shared/body_scripts' %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,6 +13,8 @@
|
||||
<%= stylesheet_pack_tag 'form', media: 'all', data: { turbo_track: 'dynamic' } %>
|
||||
</head>
|
||||
<body>
|
||||
<%= yield %>
|
||||
<main>
|
||||
<%= yield %>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -13,8 +13,10 @@
|
||||
<%= stylesheet_pack_tag 'application', media: 'all', data: { turbo_track: 'dynamic' } %>
|
||||
</head>
|
||||
<body>
|
||||
<% if flash.present? %><%= render 'shared/flash' %><% end %>
|
||||
<%= yield %>
|
||||
<main>
|
||||
<%= yield %>
|
||||
</main>
|
||||
<turbo-frame id="modal"></turbo-frame>
|
||||
<% if flash.present? %><%= render 'shared/flash' %><% end %>
|
||||
</body>
|
||||
</html>
|
||||
@@ -22,7 +22,7 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render 'bcc_form', config: @bcc_config %>
|
||||
<% unless can?(:manage, :personalization_restricted) || (turbo_native_ios? && !can?(:manage, :email_reminders)) %>
|
||||
<% unless can?(:manage, :personalization_restricted) || (turbo_native_app? && !can?(:manage, :email_reminders)) %>
|
||||
<div class="flex justify-between items-end mb-4 mt-8">
|
||||
<h2 class="text-2xl md:text-3xl font-bold">
|
||||
<%= t('sign_request_email_reminders') %>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<%= render 'documents_copy_email_form' %>
|
||||
<%= render 'submitter_completed_email_form' %>
|
||||
</div>
|
||||
<% if !turbo_native_ios? || can?(:manage, :custom_logo) %>
|
||||
<% if !turbo_native_app? || can?(:manage, :custom_logo) %>
|
||||
<p class="text-3xl md:text-4xl font-bold mb-4 mt-8">
|
||||
<%= t('company_logo') %>
|
||||
</p>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="w-full text-center">
|
||||
<span class="font-bold"><%= t('demo_environment') %></span>
|
||||
<br>
|
||||
<%= t('create_a_new_template_document_form_or_submit_the_existing_one_html', new_template_link: new_template_path, start_form_link: start_form_url(slug: ::Template.first&.slug)) %> 😊
|
||||
<%= t('create_a_new_template_document_form_html', new_template_link: new_template_path) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,11 +1,13 @@
|
||||
<script type="application/json" id="native_path_configuration"><%= render partial: 'layouts/native_path_configuration', formats: :json %></script>
|
||||
<% if signed_in? %>
|
||||
<native-action data-placement="profile" data-icon="user">
|
||||
<%= link_to t('profile'), settings_profile_index_path %>
|
||||
</native-action>
|
||||
<native-action data-placement="profile" data-icon="settings">
|
||||
<%= link_to t('settings'), settings_path %>
|
||||
</native-action>
|
||||
<% unless turbo_native_android? %>
|
||||
<native-action data-placement="profile" data-icon="user">
|
||||
<%= link_to t('profile'), settings_profile_index_path %>
|
||||
</native-action>
|
||||
<native-action data-placement="profile" data-icon="settings">
|
||||
<%= link_to t('settings'), settings_path %>
|
||||
</native-action>
|
||||
<% end %>
|
||||
<% if current_account.testing? && (can?(:manage, EncryptedConfig) || current_user != true_user) %>
|
||||
<native-action data-placement="test_mode" data-icon="code" data-label="<%= t('exit_test_mode') %>">
|
||||
<%= button_to '', testing_account_path, method: :delete, form: { data: { turbo_action: 'replace' } } %>
|
||||
@@ -22,13 +24,13 @@
|
||||
<% end %>
|
||||
<% if can?(:create, ::Template) && controller_name.in?(%w[templates_dashboard submissions_dashboard template_folders templates_archived submissions_archived]) %>
|
||||
<% folder_name = @template_folder&.full_name %>
|
||||
<native-action data-placement="create" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>"></native-action>
|
||||
<native-action data-placement="create" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>"></native-action>
|
||||
<native-action data-placement="create" data-icon="scan" data-native="scan" data-label="<%= t('scan') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>"></native-action>
|
||||
<native-action data-placement="create" data-icon="pencil_plus">
|
||||
<%= link_to t('create'), new_template_path(folder_name: folder_name), data: { turbo_frame: :modal, native_modal: :medium } %>
|
||||
</native-action>
|
||||
<% unless controller_name.in?(%w[templates_archived submissions_archived]) %>
|
||||
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>"></native-action>
|
||||
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>"></native-action>
|
||||
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="scan" data-native="scan" data-label="<%= t('scan') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>"></native-action>
|
||||
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="pencil_plus">
|
||||
<%= link_to t('create'), new_template_path(folder_name: folder_name), data: { turbo_frame: :modal, native_modal: :medium } %>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<%= render 'shared/navbar_warning' %>
|
||||
<div class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between">
|
||||
<nav class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">
|
||||
<%= render 'shared/title' %>
|
||||
@@ -106,4 +106,4 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -43,7 +43,7 @@
|
||||
<%= link_to t('storage'), settings_storage_index_path, class: 'text-base hover:bg-base-300', data: nav_link_data %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if can?(:read, EncryptedConfig.new(key: 'submitter_invitation_sms', account: current_account)) && true_user == current_user && (!turbo_native_ios? || can?(:manage, :sms_verification)) %>
|
||||
<% if can?(:read, EncryptedConfig.new(key: 'submitter_invitation_sms', account: current_account)) && true_user == current_user && (!turbo_native_app? || can?(:manage, :sms_verification)) %>
|
||||
<li>
|
||||
<%= link_to 'SMS', settings_sms_path, class: 'text-base hover:bg-base-300', data: nav_link_data %>
|
||||
</li>
|
||||
@@ -84,7 +84,7 @@
|
||||
</li>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% if !Docuseal.demo? && !turbo_native_ios? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %>
|
||||
<% if !Docuseal.demo? && !turbo_native_app? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.linked_account_account) %>
|
||||
<li>
|
||||
<%= content_for(:pro_link) || link_to(Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premises" }.to_query}", class: 'text-base hover:bg-base-300', data: { turbo: false }) do %>
|
||||
<%= t('plans') %>
|
||||
@@ -93,21 +93,21 @@
|
||||
</li>
|
||||
<% end %>
|
||||
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %>
|
||||
<% if Docuseal.multitenant? || !turbo_native_ios? %>
|
||||
<% unless turbo_native_app? %>
|
||||
<li>
|
||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
|
||||
<% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if Docuseal.multitenant? %>
|
||||
<% if Docuseal.multitenant? && !turbo_native_app? %>
|
||||
<li>
|
||||
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
|
||||
<%= t('embedding') %>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
<% if (!Docuseal.multitenant? || can?(:manage, :saml_sso)) && can?(:read, EncryptedConfig.new(key: 'saml_configs', account: current_account)) && true_user == current_user && (!turbo_native_ios? || can?(:manage, :saml_sso)) %>
|
||||
<% if (!Docuseal.multitenant? || can?(:manage, :saml_sso)) && can?(:read, EncryptedConfig.new(key: 'saml_configs', account: current_account)) && true_user == current_user && (!turbo_native_app? || can?(:manage, :saml_sso)) %>
|
||||
<li>
|
||||
<%= link_to 'SSO', settings_sso_index_path, class: 'text-base hover:bg-base-300', data: nav_link_data %>
|
||||
</li>
|
||||
|
||||
Loaded 100 of 195 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user