···2020// --- Hit testing ---
21212222/**
2323- * Hit-test: is a point inside a shape's bounding box?
2323+ * Hit-test: is a point inside a shape's (possibly rotated) bounding box?
2424+ *
2525+ * When the shape has a non-zero `rotation` (radians), the test point is
2626+ * rotated by -rotation around the shape's center into the shape's local
2727+ * coordinate space before the axis-aligned bounds check.
2428 */
2529export function hitTestShape(shape: Shape, px: number, py: number): boolean {
2626- return px >= shape.x && px <= shape.x + shape.width &&
2727- py >= shape.y && py <= shape.y + shape.height;
3030+ const cx = shape.x + shape.width / 2;
3131+ const cy = shape.y + shape.height / 2;
3232+3333+ let localX = px;
3434+ let localY = py;
3535+3636+ if (shape.rotation) {
3737+ const cos = Math.cos(-shape.rotation);
3838+ const sin = Math.sin(-shape.rotation);
3939+ const dx = px - cx;
4040+ const dy = py - cy;
4141+ localX = cx + dx * cos - dy * sin;
4242+ localY = cy + dx * sin + dy * cos;
4343+ }
4444+4545+ return localX >= shape.x && localX <= shape.x + shape.width &&
4646+ localY >= shape.y && localY <= shape.y + shape.height;
2847}
29483049/**
+10
src/forms/form-builder.ts
···242242243243/**
244244 * Validate an entire form submission.
245245+ *
246246+ * When `visibleQuestionIds` is provided, only questions in that set are
247247+ * validated. Hidden questions (e.g. hidden by conditional logic) are
248248+ * skipped even if they are marked as required.
245249 */
246250export function validateSubmission(
247251 form: FormSchema,
248252 answers: Map<string, unknown>,
253253+ visibleQuestionIds?: Set<string> | string[],
249254): Map<string, string> {
255255+ const visibleSet = visibleQuestionIds
256256+ ? (visibleQuestionIds instanceof Set ? visibleQuestionIds : new Set(visibleQuestionIds))
257257+ : null;
258258+250259 const errors = new Map<string, string>();
251260 for (const q of form.questions) {
261261+ if (visibleSet && !visibleSet.has(q.id)) continue;
252262 const error = validateAnswer(q, answers.get(q.id));
253263 if (error) errors.set(q.id, error);
254264 }
+3-3
src/lib/share-dialog.ts
···40404141/** Build a share URL for a document. */
4242export function buildShareUrl(baseUrl: string, docType: string, docId: string, keyString: string, mode: ShareMode): string {
4343- const url = `${baseUrl}/${docType}/${docId}#${keyString}`;
4343+ const base = `${baseUrl}/${docType}/${docId}`;
4444 if (mode === 'view') {
4545- return url + '?mode=view';
4545+ return `${base}?mode=view#${keyString}`;
4646 }
4747- return url;
4747+ return `${base}#${keyString}`;
4848}
49495050/** Check if current URL indicates view-only mode. */
+27-3
src/sheets/formula-date.ts
···1515 case 'MONTH': { const md = new Date(args[0] as string | number | Date); return isNaN(md.getTime()) ? '#VALUE!' : md.getMonth() + 1; }
1616 case 'DAY': { const dd = new Date(args[0] as string | number | Date); return isNaN(dd.getTime()) ? '#VALUE!' : dd.getDate(); }
17171818- case 'HOUR': return new Date(args[0] as string | number | Date).getHours();
1919- case 'MINUTE': return new Date(args[0] as string | number | Date).getMinutes();
2020- case 'SECOND': return new Date(args[0] as string | number | Date).getSeconds();
1818+ case 'HOUR': {
1919+ const hVal = args[0];
2020+ if (typeof hVal === 'string') {
2121+ const hMatch = hVal.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
2222+ if (hMatch) return parseInt(hMatch[1], 10);
2323+ }
2424+ const hd = new Date(hVal as string | number | Date);
2525+ return isNaN(hd.getTime()) ? '#VALUE!' : hd.getHours();
2626+ }
2727+ case 'MINUTE': {
2828+ const mVal = args[0];
2929+ if (typeof mVal === 'string') {
3030+ const mMatch = mVal.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
3131+ if (mMatch) return parseInt(mMatch[2], 10);
3232+ }
3333+ const md = new Date(mVal as string | number | Date);
3434+ return isNaN(md.getTime()) ? '#VALUE!' : md.getMinutes();
3535+ }
3636+ case 'SECOND': {
3737+ const sVal = args[0];
3838+ if (typeof sVal === 'string') {
3939+ const sMatch = sVal.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
4040+ if (sMatch) return sMatch[3] !== undefined ? parseInt(sMatch[3], 10) : 0;
4141+ }
4242+ const sd = new Date(sVal as string | number | Date);
4343+ return isNaN(sd.getTime()) ? '#VALUE!' : sd.getSeconds();
4444+ }
2145 case 'WEEKDAY': {
2246 const wdDate = new Date(args[0] as string | number | Date);
2347 const wdType = args[1] !== undefined ? toNum(args[1]) : 1;
+63
tests/diagrams-bugs.test.ts
···736736});
737737738738// ---------------------------------------------------------------------------
739739+// hitTestShape rotation support
740740+// ---------------------------------------------------------------------------
741741+742742+describe('hitTestShape with rotation', () => {
743743+ it('unrotated hit still works (regression)', () => {
744744+ const shape = makeShape({ id: 's1', kind: 'rectangle', x: 0, y: 0, width: 100, height: 50, rotation: 0 });
745745+ expect(hitTestShape(shape, 50, 25)).toBe(true);
746746+ expect(hitTestShape(shape, 101, 25)).toBe(false);
747747+ });
748748+749749+ it('45-degree rotated rect hit at visual corner', () => {
750750+ // 100x100 rect centered at (100, 100), rotated 45 degrees
751751+ // The visual top corner after rotation is at approximately (100, 100 - 70.7)
752752+ // = (100, 29.3). A point near that visual corner should hit.
753753+ const shape = makeShape({
754754+ id: 's1', kind: 'rectangle',
755755+ x: 50, y: 50, width: 100, height: 100,
756756+ rotation: Math.PI / 4,
757757+ });
758758+ // Center of shape is (100, 100). The visual top vertex after 45deg rotation
759759+ // of the corner (50,50) around center (100,100) is approx (100, 29.3).
760760+ // A point at (100, 35) is inside the rotated diamond.
761761+ expect(hitTestShape(shape, 100, 35)).toBe(true);
762762+ });
763763+764764+ it('miss at AABB-only corner of rotated rect', () => {
765765+ // 100x100 rect at (50,50), center at (100, 100), rotated 45 degrees.
766766+ // After rotation it becomes a diamond. The AABB corner (30, 30) would
767767+ // be inside an unrotated AABB but is outside the actual rotated shape.
768768+ const shape = makeShape({
769769+ id: 's1', kind: 'rectangle',
770770+ x: 50, y: 50, width: 100, height: 100,
771771+ rotation: Math.PI / 4,
772772+ });
773773+ // Center of shape is at (100, 100).
774774+ // (100, 100) center always hits
775775+ expect(hitTestShape(shape, 100, 100)).toBe(true);
776776+ // (30, 30) is in the axis-aligned bounding box but outside the rotated diamond.
777777+ // Un-rotating (30,30) by -45deg around (100,100): dx=-70, dy=-70
778778+ // localX = 100 + (-70*cos(-45) - (-70)*sin(-45)) = 100 + (-49.5 - 49.5) = 1
779779+ // localY = 100 + (-70*sin(-45) + (-70)*cos(-45)) = 100 + (49.5 - 49.5) = 100
780780+ // local (1, 100) is NOT inside [50..150, 50..150], so it misses.
781781+ expect(hitTestShape(shape, 30, 30)).toBe(false);
782782+ });
783783+784784+ it('point inside 90-degree rotated wide rectangle', () => {
785785+ // 200x50 rect at (0,0), center at (100, 25), rotated 90 degrees.
786786+ // After rotation, it becomes a tall 50x200 shape visually.
787787+ const shape = makeShape({
788788+ id: 's1', kind: 'rectangle',
789789+ x: 0, y: 0, width: 200, height: 50,
790790+ rotation: Math.PI / 2,
791791+ });
792792+ // Center (100, 25). After 90deg rotation, the shape extends
793793+ // vertically. A point at (100, -60) should be inside the rotated shape
794794+ // (it's within the rotated height of 200).
795795+ expect(hitTestShape(shape, 100, -60)).toBe(true);
796796+ // A point far to the right should miss (width is only 50 after rotation)
797797+ expect(hitTestShape(shape, 200, 25)).toBe(false);
798798+ });
799799+});
800800+801801+// ---------------------------------------------------------------------------
739802// COVERAGE GAP: snapPoint edge cases
740803// ---------------------------------------------------------------------------
741804
+52
tests/form-builder.test.ts
···632632 expect(errors.size).toBe(1);
633633 expect(errors.has(form.questions[0].id)).toBe(true);
634634 });
635635+636636+ it('skips validation for hidden required fields', () => {
637637+ let form = createForm('Test');
638638+ form = addQuestion(form, 'short_text', 'Visible', { required: true });
639639+ form = addQuestion(form, 'short_text', 'Hidden', { required: true });
640640+ const visibleIds = new Set([form.questions[0].id]);
641641+ const answers = new Map<string, unknown>();
642642+ const errors = validateSubmission(form, answers, visibleIds);
643643+ // Only the visible required field should produce an error
644644+ expect(errors.size).toBe(1);
645645+ expect(errors.has(form.questions[0].id)).toBe(true);
646646+ expect(errors.has(form.questions[1].id)).toBe(false);
647647+ });
648648+649649+ it('visible required field fails without answer', () => {
650650+ let form = createForm('Test');
651651+ form = addQuestion(form, 'short_text', 'Name', { required: true });
652652+ const visibleIds = new Set([form.questions[0].id]);
653653+ const answers = new Map<string, unknown>();
654654+ const errors = validateSubmission(form, answers, visibleIds);
655655+ expect(errors.size).toBe(1);
656656+ expect(errors.get(form.questions[0].id)).toBe('This field is required');
657657+ });
658658+659659+ it('hidden required field passes without answer', () => {
660660+ let form = createForm('Test');
661661+ form = addQuestion(form, 'short_text', 'Name', { required: true });
662662+ const visibleIds = new Set<string>(); // nothing visible
663663+ const answers = new Map<string, unknown>();
664664+ const errors = validateSubmission(form, answers, visibleIds);
665665+ expect(errors.size).toBe(0);
666666+ });
667667+668668+ it('accepts array of visible IDs as well as Set', () => {
669669+ let form = createForm('Test');
670670+ form = addQuestion(form, 'short_text', 'Q1', { required: true });
671671+ form = addQuestion(form, 'short_text', 'Q2', { required: true });
672672+ const visibleIds = [form.questions[0].id]; // array, not Set
673673+ const answers = new Map<string, unknown>();
674674+ const errors = validateSubmission(form, answers, visibleIds);
675675+ expect(errors.size).toBe(1);
676676+ expect(errors.has(form.questions[0].id)).toBe(true);
677677+ });
678678+679679+ it('validates all questions when visibleQuestionIds is omitted (backward compat)', () => {
680680+ let form = createForm('Test');
681681+ form = addQuestion(form, 'short_text', 'Q1', { required: true });
682682+ form = addQuestion(form, 'short_text', 'Q2', { required: true });
683683+ const answers = new Map<string, unknown>();
684684+ const errors = validateSubmission(form, answers);
685685+ expect(errors.size).toBe(2);
686686+ });
635687});
636688637689// --- questionCount / requiredCount ---