feat: add typed signature with font picker to user profile (v0.11.0)

* feat: add typed signature with font picker to user profile

- Add 'Type' tab alongside 'Draw' and 'Upload' in signature/initials modals
- Create typed-signature-form custom element for canvas-based typed signatures
- Persist font preference per user via UserConfig (signature_font/initials_font)
- Pre-select saved font when reopening the modal
- Add i18n keys for 'type' and 'type_signature_here' in all 7 languages

* fix: use options_for_select to satisfy BetterHtml validation

---------

Co-authored-by: Bob Develop <developbob50@gmail.com>
pull/639/head
devin-ai-integration[bot] 2 weeks ago committed by GitHub
parent 7def97ef4a
commit 7029590b32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -4,7 +4,10 @@ class UserInitialsController < ApplicationController
before_action :load_user_config
authorize_resource :user_config
def edit; end
def edit
@font_config =
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::INITIALS_FONT_KEY)
end
def update
file = params[:file]
@ -22,6 +25,7 @@ class UserInitialsController < ApplicationController
)
if @user_config.update(value: attachment.uuid)
save_font_preference(UserConfig::INITIALS_FONT_KEY)
redirect_to settings_profile_index_path, notice: I18n.t('initials_has_been_saved')
else
redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_initials')
@ -40,4 +44,11 @@ class UserInitialsController < ApplicationController
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::INITIALS_KEY)
end
def save_font_preference(key)
return if params[:font].blank?
font_config = UserConfig.find_or_initialize_by(user: current_user, key:)
font_config.update(value: params[:font])
end
end

@ -4,7 +4,10 @@ class UserSignaturesController < ApplicationController
before_action :load_user_config
authorize_resource :user_config
def edit; end
def edit
@font_config =
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::SIGNATURE_FONT_KEY)
end
def update
file = params[:file]
@ -22,6 +25,7 @@ class UserSignaturesController < ApplicationController
)
if @user_config.update(value: attachment.uuid)
save_font_preference(UserConfig::SIGNATURE_FONT_KEY)
redirect_to settings_profile_index_path, notice: I18n.t('signature_has_been_saved')
else
redirect_to settings_profile_index_path, notice: I18n.t('unable_to_save_signature')
@ -40,4 +44,11 @@ class UserSignaturesController < ApplicationController
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::SIGNATURE_KEY)
end
def save_font_preference(key)
return if params[:font].blank?
font_config = UserConfig.find_or_initialize_by(user: current_user, key:)
font_config.update(value: params[:font])
end
end

@ -20,6 +20,7 @@ import AutoresizeTextarea from './elements/autoresize_textarea'
import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import TypedSignatureForm from './elements/typed_signature_form'
import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
@ -111,6 +112,7 @@ safeRegisterElement('autoresize-textarea', AutoresizeTextarea)
safeRegisterElement('submitters-autocomplete', SubmittersAutocomplete)
safeRegisterElement('folder-autocomplete', FolderAutocomplete)
safeRegisterElement('signature-form', SignatureForm)
safeRegisterElement('typed-signature-form', TypedSignatureForm)
safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('prompt-password', PromptPassword)
safeRegisterElement('emails-textarea', EmailsTextarea)

@ -0,0 +1,137 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { cropCanvasAndExportToPNG } from '../submission_form/crop_canvas'
const SIGNATURE_FONTS = {
'Dancing Script': 'DancingScript-Regular.otf',
'Great Vibes': 'GreatVibes-Regular.ttf',
Pacifico: 'Pacifico-Regular.ttf',
Caveat: 'Caveat-Regular.ttf',
'Homemade Apple': 'HomemadeApple-Regular.ttf',
'Mrs Saint Delafield': 'MrsSaintDelafield-Regular.ttf',
'Shadows Into Light': 'ShadowsIntoLight-Regular.ttf',
'Alex Brush': 'AlexBrush-Regular.ttf',
Kalam: 'Kalam-Regular.ttf',
Sacramento: 'Sacramento-Regular.ttf',
'Herr Von Muellerhoff': 'HerrVonMuellerhoff-Regular.ttf'
}
const fontLoadPromises = {}
const scale = 3
export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'textInput', 'fontSelect', 'input', 'button', 'fontHidden']
async connectedCallback () {
this.setCanvasSize()
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
this.setCanvasSize()
this.updateCanvas()
})
})
this.resizeObserver.observe(this.canvas.parentNode)
this.textInput.addEventListener('input', () => this.updateCanvas())
this.fontSelect.addEventListener('change', async () => {
this.fontHidden.value = this.fontSelect.value
await this.loadFont(this.fontSelect.value)
this.updateCanvas()
})
this.button.addEventListener('click', (e) => {
e.preventDefault()
if (!this.textInput.value.trim()) return
this.button.disabled = true
this.submit()
})
await this.loadFont(this.fontSelect.value)
if (this.textInput.value) {
this.updateCanvas()
}
}
disconnectedCallback () {
if (this.resizeObserver) {
this.resizeObserver.disconnect()
}
}
setCanvasSize () {
const width = this.canvas.parentNode.clientWidth
const height = width / 2.5
if (this.canvas.width !== width * scale || this.canvas.height !== height * scale) {
this.canvas.width = width * scale
this.canvas.height = height * scale
this.canvas.getContext('2d').scale(scale, scale)
}
}
loadFont (fontName) {
const file = SIGNATURE_FONTS[fontName]
if (!file) return Promise.resolve()
if (!fontLoadPromises[fontName]) {
const ext = file.endsWith('.otf') ? 'opentype' : 'truetype'
const font = new FontFace(fontName, `url(/fonts/${file}) format("${ext}")`)
fontLoadPromises[fontName] = font.load().then((loadedFont) => {
document.fonts.add(loadedFont)
}).catch((error) => {
console.error('Font loading failed:', error)
})
}
return fontLoadPromises[fontName]
}
updateCanvas () {
const context = this.canvas.getContext('2d')
const text = this.textInput.value
const fontFamily = this.fontSelect.value
const initialFontSize = 44
const setFontSize = (size) => {
context.font = `italic ${size}px "${fontFamily}"`
}
const maxWidth = this.canvas.width / scale
let size = initialFontSize
setFontSize(size)
while (context.measureText(text).width > maxWidth && size > 1) {
size -= 1
setFontSize(size)
}
context.textAlign = 'center'
context.clearRect(0, 0, this.canvas.width / scale, this.canvas.height / scale)
context.fillText(text, this.canvas.width / 2 / scale, this.canvas.height / 2 / scale + 11)
}
async submit () {
const blob = await cropCanvasAndExportToPNG(this.canvas)
const file = new File([blob], 'signature.png', { type: 'image/png' })
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
this.input.files = dataTransfer.files
if (this.input.webkitEntries.length) {
this.input.dataset.file = `${dataTransfer.files[0].name}`
}
this.closest('form').requestSubmit()
}
})

@ -23,6 +23,8 @@
class UserConfig < ApplicationRecord
SIGNATURE_KEY = 'signature'
INITIALS_KEY = 'initials'
SIGNATURE_FONT_KEY = 'signature_font'
INITIALS_FONT_KEY = 'initials_font'
RECEIVE_COMPLETED_EMAIL = 'receive_completed_email'
RECEIVE_DECLINED_EMAIL = 'receive_declined_email'
SHOW_APP_TOUR = 'show_app_tour'

@ -1,5 +1,7 @@
<% signature_fonts = ['Dancing Script', 'Great Vibes', 'Pacifico', 'Caveat', 'Homemade Apple', 'Mrs Saint Delafield', 'Shadows Into Light', 'Alex Brush', 'Kalam', 'Sacramento', 'Herr Von Muellerhoff'] %>
<% saved_font = @font_config&.value || 'Dancing Script' %>
<%= render 'shared/turbo_modal', title: t('update_initials') do %>
<% options = [[t('draw'), 'draw'], [t('upload'), 'upload']] %>
<% options = [[t('draw'), 'draw'], [t('type'), 'type'], [t('upload'), 'upload']] %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block">
<div class="join">
<% options.each_with_index do |(label, value), index| %>
@ -24,6 +26,22 @@
</signature-form>
<% end %>
</div>
<div id="type" class="hidden mt-3">
<%= form_for @user_config, url: user_initials_path, method: :put, data: { turbo_frame: :_top }, html: { autocomplete: :off, enctype: 'multipart/form-data' } do |f| %>
<typed-signature-form class="relative block">
<canvas data-target="typed-signature-form.canvas" class="bg-white border border-base-300 w-full rounded"></canvas>
<input name="file" class="hidden" data-target="typed-signature-form.input" type="file" accept="image/png,image/jpeg,image/jpg">
<input type="text" data-target="typed-signature-form.textInput" class="base-input !text-2xl w-full mt-4" placeholder="<%= t('type_signature_here') %>...">
<select data-target="typed-signature-form.fontSelect" class="base-input mt-2 text-sm">
<%= options_for_select(signature_fonts.map { |f| [f, f] }, saved_font) %>
</select>
<input type="hidden" name="font" value="<%= saved_font %>" data-target="typed-signature-form.fontHidden">
<div class="form-control mt-4">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button', data: { target: 'typed-signature-form.button' } %>
</div>
</typed-signature-form>
<% end %>
</div>
<div id="upload" class="hidden mt-3">
<%= form_for @user_config, url: user_initials_path, method: :put, data: { turbo_frame: :_top }, html: { autocomplete: :off, enctype: 'multipart/form-data' } do |f| %>
<file-dropzone data-submit-on-upload="true" class="w-full">

@ -1,5 +1,7 @@
<% signature_fonts = ['Dancing Script', 'Great Vibes', 'Pacifico', 'Caveat', 'Homemade Apple', 'Mrs Saint Delafield', 'Shadows Into Light', 'Alex Brush', 'Kalam', 'Sacramento', 'Herr Von Muellerhoff'] %>
<% saved_font = @font_config&.value || 'Dancing Script' %>
<%= render 'shared/turbo_modal', title: t('update_signature') do %>
<% options = [[t('draw'), 'draw'], [t('upload'), 'upload']] %>
<% options = [[t('draw'), 'draw'], [t('type'), 'type'], [t('upload'), 'upload']] %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block">
<div class="join">
<% options.each_with_index do |(label, value), index| %>
@ -24,6 +26,22 @@
</signature-form>
<% end %>
</div>
<div id="type" class="hidden mt-3">
<%= form_for @user_config, url: user_signature_path, method: :put, data: { turbo_frame: :_top }, html: { autocomplete: :off, enctype: 'multipart/form-data' } do |f| %>
<typed-signature-form class="relative block">
<canvas data-target="typed-signature-form.canvas" class="bg-white border border-base-300 w-full rounded"></canvas>
<input name="file" class="hidden" data-target="typed-signature-form.input" type="file" accept="image/png,image/jpeg,image/jpg">
<input type="text" data-target="typed-signature-form.textInput" class="base-input !text-2xl w-full mt-4" placeholder="<%= t('type_signature_here') %>...">
<select data-target="typed-signature-form.fontSelect" class="base-input mt-2 text-sm">
<%= options_for_select(signature_fonts.map { |f| [f, f] }, saved_font) %>
</select>
<input type="hidden" name="font" value="<%= saved_font %>" data-target="typed-signature-form.fontHidden">
<div class="form-control mt-4">
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button', data: { target: 'typed-signature-form.button' } %>
</div>
</typed-signature-form>
<% end %>
</div>
<div id="upload" class="hidden mt-3">
<%= form_for @user_config, url: user_signature_path, method: :put, data: { turbo_frame: :_top }, html: { autocomplete: :off, enctype: 'multipart/form-data' } do |f| %>
<file-dropzone data-submit-on-upload="true" class="w-full">

@ -527,6 +527,8 @@ en: &en
processing: Processing
upload_initials: Upload Initials
draw: Draw
type: Type
type_signature_here: Type signature here
upload_signature: Upload Signature
integration: Integration
admin: Admin
@ -1570,6 +1572,8 @@ es: &es
processing: Procesando
upload_initials: Subir iniciales
draw: Dibujar
type: Escribir
type_signature_here: Escribe la firma aquí
upload_signature: Subir firma
integration: Integración
admin: Administrador
@ -2610,6 +2614,8 @@ it: &it
processing: Elaborazione in corso
upload_initials: Carica iniziali
draw: Disegna
type: Digitare
type_signature_here: Scrivi la firma qui
upload_signature: Carica firma
integration: Integrazione
admin: Amministratore
@ -3654,6 +3660,8 @@ fr: &fr
processing: Traitement en cours
upload_initials: Téléverser les initiales
draw: Dessiner
type: Saisir
type_signature_here: Saisissez la signature ici
upload_signature: Téléverser la signature
integration: Intégration
admin: Administrateur
@ -4691,6 +4699,8 @@ pt: &pt
processing: Processando
upload_initials: Enviar iniciais
draw: Desenhar
type: Digitar
type_signature_here: Digite a assinatura aqui
upload_signature: Enviar assinatura
integration: Integração
admin: Administrador
@ -5731,6 +5741,8 @@ de: &de
processing: Verarbeitung
upload_initials: Initialen hochladen
draw: Zeichnen
type: Eingeben
type_signature_here: Unterschrift hier eingeben
upload_signature: Unterschrift hochladen
integration: Integration
admin: Administrator
@ -7176,6 +7188,8 @@ nl: &nl
processing: Verwerken
upload_initials: Initialen uploaden
draw: Tekenen
type: Typen
type_signature_here: Typ hier uw handtekening
upload_signature: Handtekening uploaden
integration: Integratie
admin: Beheerder

Loading…
Cancel
Save