-
Notifications
You must be signed in to change notification settings - Fork 2
Add developer doc examples from #se_team_code_share #53
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v2
Are you sure you want to change the base?
Changes from all commits
2fbac37
eb523ee
9875393
a7d6b32
abba8fc
3348331
19cc34a
284143c
9578f74
d605c4d
f93351a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| --- | ||
| title: Add a Text Caption to a Photo | ||
| excerpt: An App Extension that lets field users upload a photo, type a caption, and render the captioned image on an HTML5 canvas — useful for annotating site photos, adding measurement labels, or branding field images before submission. | ||
| --- | ||
|
|
||
| This App Extension uses the browser's HTML5 Canvas API to composite a text caption over a user-selected photo entirely in the browser, with no server round-trip. The result can be screenshotted or extended to upload the annotated image back to Fulcrum. | ||
|
|
||
| ## App Extension Code | ||
|
|
||
| ```html | ||
| <!DOCTYPE html> | ||
| <html lang="en"> | ||
| <head> | ||
| <meta charset="UTF-8" /> | ||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||
| <title>Photo Caption Tool</title> | ||
| <style> | ||
| body { | ||
| font-family: Arial, sans-serif; | ||
| padding: 16px; | ||
| max-width: 800px; | ||
| margin: 0 auto; | ||
| } | ||
| .controls { | ||
| display: flex; | ||
| flex-wrap: wrap; | ||
| gap: 8px; | ||
| margin-bottom: 12px; | ||
| align-items: center; | ||
| } | ||
| input[type="file"] { flex: 1 1 180px; } | ||
| input[type="text"] { flex: 2 1 240px; padding: 6px; font-size: 14px; } | ||
| input[type="range"] { flex: 1 1 120px; } | ||
| button { | ||
| padding: 8px 16px; | ||
| background: #0066cc; | ||
| color: white; | ||
| border: none; | ||
| border-radius: 4px; | ||
| cursor: pointer; | ||
| font-size: 14px; | ||
| } | ||
| button:hover { background: #0055aa; } | ||
| canvas { | ||
| max-width: 100%; | ||
| border: 1px solid #ccc; | ||
| display: block; | ||
| } | ||
| label { font-size: 13px; color: #555; } | ||
| </style> | ||
| </head> | ||
| <body> | ||
|
|
||
| <div class="controls"> | ||
| <input type="file" id="upload" accept="image/*" /> | ||
| <input type="text" id="captionInput" placeholder="Enter caption text" /> | ||
| <div> | ||
| <label>Font size: <span id="sizeLabel">30</span>px</label> | ||
| <input type="range" id="fontSize" min="10" max="80" value="30" | ||
| oninput="document.getElementById('sizeLabel').textContent = this.value" /> | ||
| </div> | ||
| <button id="addCaptionButton">Add Caption</button> | ||
| </div> | ||
|
|
||
| <canvas id="captionCanvas"></canvas> | ||
|
|
||
| <script> | ||
| const canvas = document.getElementById('captionCanvas'); | ||
| const ctx = canvas.getContext('2d'); | ||
| const upload = document.getElementById('upload'); | ||
| const input = document.getElementById('captionInput'); | ||
| const button = document.getElementById('addCaptionButton'); | ||
| const sizeEl = document.getElementById('fontSize'); | ||
|
|
||
| let img = new Image(); | ||
|
|
||
| // Load photo to canvas when a file is selected | ||
| upload.addEventListener('change', (event) => { | ||
| const file = event.target.files[0]; | ||
| if (!file) return; | ||
| const reader = new FileReader(); | ||
| reader.onload = (e) => { img.src = e.target.result; }; | ||
| reader.readAsDataURL(file); | ||
| }); | ||
|
|
||
| // Draw image to canvas once loaded | ||
| img.onload = () => { | ||
| canvas.width = img.width; | ||
| canvas.height = img.height; | ||
| ctx.drawImage(img, 0, 0); | ||
| }; | ||
|
|
||
| // Composite the caption over the image on button click | ||
| button.addEventListener('click', () => { | ||
| const text = input.value.trim(); | ||
| const fontSize = parseInt(sizeEl.value, 10); | ||
|
|
||
| if (!text || !img.src) return; | ||
|
|
||
| // Redraw the original image (clears any previous caption attempt) | ||
| ctx.drawImage(img, 0, 0); | ||
|
|
||
| // Position caption centered near the bottom of the image | ||
| const x = canvas.width / 2; | ||
| const y = canvas.height - fontSize * 1.5; | ||
|
|
||
| ctx.font = `bold ${fontSize}px Arial`; | ||
| ctx.textAlign = 'center'; | ||
| ctx.strokeStyle = 'black'; | ||
| ctx.lineWidth = fontSize / 8; | ||
| ctx.fillStyle = 'white'; | ||
|
|
||
| // Draw stroke first for a legible outline, then fill | ||
| ctx.strokeText(text, x, y); | ||
| ctx.fillText(text, x, y); | ||
| }); | ||
| </script> | ||
| </body> | ||
| </html> | ||
| ``` | ||
|
|
||
| ## How It Works | ||
|
|
||
| `FileReader.readAsDataURL()` reads the selected image file into a base64 data URL, which is then assigned to an `Image` object's `src`. Once the image fires its `onload` event, the canvas is resized to match the image dimensions and `drawImage()` renders it. | ||
|
|
||
| When the user clicks "Add Caption", the canvas is redrawn (to reset any previous caption), and the caption text is drawn at the horizontal center, positioned near the bottom of the image. The text is rendered twice: first with a thick black stroke for contrast, then with a white fill for legibility. | ||
|
|
||
| ## Customization | ||
|
|
||
| **Caption position:** Change the `y` calculation to place the caption at the top (`y = fontSize * 1.5`) or at a fixed pixel offset from any edge. | ||
|
|
||
| **Font and color:** Swap `ctx.font` and `ctx.fillStyle` for different typefaces, weights, or colors. Use `ctx.fillStyle = 'rgba(0,0,0,0.5)'` to draw a semi-transparent background bar behind the text. | ||
|
|
||
| **Download the captioned image:** Add a download button that calls `canvas.toDataURL('image/jpeg')` and triggers a link click: | ||
|
|
||
| ```javascript | ||
| document.getElementById('downloadBtn').addEventListener('click', () => { | ||
| const link = document.createElement('a'); | ||
| link.download = 'captioned-photo.jpg'; | ||
| link.href = canvas.toDataURL('image/jpeg', 0.92); | ||
| link.click(); | ||
| }); | ||
| ``` | ||
|
|
||
| **Multiple captions / drag and drop:** Track caption objects in an array and redraw them all on each render pass to support repositionable annotations. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,218 @@ | ||||||||||||||||
| --- | ||||||||||||||||
| title: Date picker with blackout dates | ||||||||||||||||
| excerpt: >- | ||||||||||||||||
| This App Extension opens a popup calendar that prevents users from selecting | ||||||||||||||||
| blacked-out date ranges. Blackout dates are loaded dynamically from a | ||||||||||||||||
| separate Fulcrum app using LOADRECORDS, so administrators can manage blocked | ||||||||||||||||
| dates without touching the data event code. | ||||||||||||||||
| deprecated: false | ||||||||||||||||
| hidden: false | ||||||||||||||||
| metadata: | ||||||||||||||||
| title: '' | ||||||||||||||||
| description: '' | ||||||||||||||||
| robots: noindex | ||||||||||||||||
| next: | ||||||||||||||||
| description: '' | ||||||||||||||||
| --- | ||||||||||||||||
|
|
||||||||||||||||
| # Date picker with blackout dates | ||||||||||||||||
|
|
||||||||||||||||
| This example uses an App Extension to display a Flatpickr-powered calendar popup that restricts users from selecting certain dates. Blackout date ranges are stored in a separate Fulcrum app and loaded at runtime via `LOADRECORDS`, so administrators can add or remove blocked dates without updating the Data Event. | ||||||||||||||||
|
|
||||||||||||||||
| When the user clicks a button on the form, `OPENEXTENSION` launches the calendar. The selected date is returned to the form and written to a date field. | ||||||||||||||||
|
|
||||||||||||||||
| ## How it works | ||||||||||||||||
|
|
||||||||||||||||
| 1. A separate **blackout dates app** stores date range records (start date / end date). | ||||||||||||||||
| 2. On `load-record`, the Data Event fetches those ranges via `LOADRECORDS` and stores them in a variable. | ||||||||||||||||
| 3. When the user clicks the calendar button, `OPENEXTENSION` opens `calendar_picker.html` — a self-contained HTML page that uses [Flatpickr](https://flatpickr.js.org/) to render the calendar. | ||||||||||||||||
| 4. The blackout ranges are passed to the HTML page via the `data` option. Flatpickr disables those date ranges in the calendar. | ||||||||||||||||
| 5. When the user picks a date, the HTML page sends it back via `Fulcrum.finish()`, and the Data Event writes it to an appointment date field. | ||||||||||||||||
|
|
||||||||||||||||
| ## Setup | ||||||||||||||||
|
|
||||||||||||||||
| 1. Create a **blackout dates app** with two Date fields: | ||||||||||||||||
| - `start` — the first day of the blocked range | ||||||||||||||||
| - `end` — the last day of the blocked range (inclusive) | ||||||||||||||||
| Note the **App ID** and the field keys for `start` and `end`. | ||||||||||||||||
| 2. Upload `calendar_picker.html` (below) as a **Reference File** in your Fulcrum org, or attach it directly to your app. | ||||||||||||||||
| 3. In your data collection app, add: | ||||||||||||||||
| - A **Date** field for the appointment result (e.g. data name: `appointment_date`) | ||||||||||||||||
| - A **Button** field to trigger the calendar (e.g. data name: `open_calendar`) | ||||||||||||||||
| 4. Add the Data Event code below to the app and update the configuration constants. | ||||||||||||||||
|
|
||||||||||||||||
| > **Note:** The Flatpickr calendar requires an internet connection when using the CDN version below. For fully offline use, replace the CDN links with a locally hosted or embedded copy of Flatpickr. | ||||||||||||||||
|
|
||||||||||||||||
| ## Data Event Code | ||||||||||||||||
|
|
||||||||||||||||
| ```js | ||||||||||||||||
| // ─── Configuration ─────────────────────────────────────────────────────────── | ||||||||||||||||
|
|
||||||||||||||||
| // App ID of the blackout dates app | ||||||||||||||||
| const BLACKOUT_FORM_ID = 'YOUR-BLACKOUT-DATES-APP-ID-HERE'; | ||||||||||||||||
|
|
||||||||||||||||
| // Field key of the start date field in the blackout app | ||||||||||||||||
| const START_DATE_KEY = 'start'; | ||||||||||||||||
|
|
||||||||||||||||
| // Field key of the end date field in the blackout app | ||||||||||||||||
| const END_DATE_KEY = 'end'; | ||||||||||||||||
|
|
||||||||||||||||
| // Data name of the date field to write the selected appointment date to | ||||||||||||||||
| const APPOINTMENT_FIELD = 'appointment_date'; | ||||||||||||||||
|
|
||||||||||||||||
| // Data name of the button field that opens the calendar | ||||||||||||||||
| const CALENDAR_BUTTON = 'open_calendar'; | ||||||||||||||||
|
|
||||||||||||||||
| // ─── Load blackout ranges on record open ───────────────────────────────────── | ||||||||||||||||
|
|
||||||||||||||||
| let blackoutRanges = []; | ||||||||||||||||
|
|
||||||||||||||||
| ON('load-record', () => { | ||||||||||||||||
| LOADRECORDS({ form_id: BLACKOUT_FORM_ID }, (err, result) => { | ||||||||||||||||
| if (err) { | ||||||||||||||||
| console.log('Error loading blackout dates:', INSPECT(err)); | ||||||||||||||||
| return; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| // Build an array of { start, end } objects from the loaded records | ||||||||||||||||
| blackoutRanges = (result.records || []).map(rec => ({ | ||||||||||||||||
| start: rec.form_values[START_DATE_KEY] || '', | ||||||||||||||||
| end: rec.form_values[END_DATE_KEY] || '' | ||||||||||||||||
| })).filter(range => range.start && range.end); | ||||||||||||||||
| }); | ||||||||||||||||
| }); | ||||||||||||||||
|
|
||||||||||||||||
| // ─── Open the calendar extension ───────────────────────────────────────────── | ||||||||||||||||
|
|
||||||||||||||||
| ON('click', CALENDAR_BUTTON, () => { | ||||||||||||||||
| if (!blackoutRanges.length) { | ||||||||||||||||
| // Allow the calendar to open even if no blackout dates loaded yet | ||||||||||||||||
| console.log('No blackout ranges loaded; opening calendar without restrictions.'); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| OPENEXTENSION({ | ||||||||||||||||
| url: 'attachment://calendar_picker.html', | ||||||||||||||||
| title: 'Select Appointment Date', | ||||||||||||||||
| width: 400, | ||||||||||||||||
| height: 500, | ||||||||||||||||
| data: { | ||||||||||||||||
| blackoutRanges: blackoutRanges, | ||||||||||||||||
| today: new Date().toISOString().split('T')[0] | ||||||||||||||||
|
||||||||||||||||
| today: new Date().toISOString().split('T')[0] | |
| today: (function (d) { | |
| const year = d.getFullYear(); | |
| const month = String(d.getMonth() + 1).padStart(2, '0'); | |
| const day = String(d.getDate()).padStart(2, '0'); | |
| return year + '-' + month + '-' + day; | |
| })(new Date()) |
Copilot
AI
Mar 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This logic likely disables one extra day beyond the intended end date. You already set end to the end of the end-day (23:59:59.999), so adding +1 day extends the disabled range. Remove the extra day bump (or keep the bump but set the end time to the start of day) so the disabled window matches the inclusive end date described.
| end.setHours(23, 59, 59, 999); | |
| end.setDate(end.getDate() + 1); // include the full end day | |
| end.setHours(23, 59, 59, 999); // include the full end day |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These constants are described as 'field key', but the sample values (
'start','end') look likedata_names. Since the code readsrec.form_values[START_DATE_KEY], this will only work if those strings are actual form-value keys. Either change the documentation to say these aredata_names and read values accordingly, or update the placeholders to look like real Fulcrum field keys and keep the current access pattern.